feat(registry): add IoT device connection profile (iot_profiles table)

New optional 1:1 extension to services: providers can declare how IoT
devices connect to their hub (hub_url, protocols, provisioning endpoint,
device classes, firmware compatibility) via PATCH /services/{id}.

- New entity IotProfileEntity + Liquibase changeset 009 (GIN indexes for
  jsonb device_classes and protocols arrays)
- IotProtocol / CredentialFormat enums; IotProfileRequest / IotProfileResponse DTOs
- JsonStringListConverter for jsonb List<String> persistence
- GET /services/{id}/replacements extended with iotProfile per candidate
  and new filter params: iotReady=true, deviceClass=..., protocol=...
- 6 new BDD scenarios (IotProfileCucumberTest) covering profile creation,
  candidate enrichment, iotReady / deviceClass filtering, partial update,
  and missing-field 422 validation
- All 57 tests green (6 new + 27 existing transition + 24 unit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-08 16:12:13 +02:00
parent b2a16a8be7
commit 5156089152
19 changed files with 537 additions and 12 deletions
@@ -0,0 +1,8 @@
package org.botstandards.apix.registry.dto;
public enum CredentialFormat {
JWT,
PSK,
X509,
NONE
}
@@ -0,0 +1,19 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record IotProfileRequest(
String hubUrl,
List<IotProtocol> protocols,
String tlsMinVersion,
String provisioningEndpoint,
CredentialFormat credentialFormat,
String deviceIdField,
List<String> deviceClasses,
String minFirmwareVersion,
String firmwareUpdateUrl,
Integer estimatedMigrationMinutes
) {}
@@ -0,0 +1,36 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.registry.entity.IotProfileEntity;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record IotProfileResponse(
String hubUrl,
List<String> protocols,
String tlsMinVersion,
String provisioningEndpoint,
String credentialFormat,
String deviceIdField,
List<String> deviceClasses,
String minFirmwareVersion,
String firmwareUpdateUrl,
Integer estimatedMigrationMinutes
) {
public static IotProfileResponse from(IotProfileEntity e) {
if (e == null) return null;
return new IotProfileResponse(
e.hubUrl,
e.protocols,
e.tlsMinVersion,
e.provisioningEndpoint,
e.credentialFormat != null ? e.credentialFormat.name() : null,
e.deviceIdField,
e.deviceClasses,
e.minFirmwareVersion,
e.firmwareUpdateUrl,
e.estimatedMigrationMinutes
);
}
}
@@ -0,0 +1,10 @@
package org.botstandards.apix.registry.dto;
public enum IotProtocol {
MQTT_3_1_1,
MQTT_5_0,
WEBSOCKET,
COAP,
HTTP_1_1,
AMQP
}
@@ -3,6 +3,7 @@ package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.ServiceStage;
import org.botstandards.apix.registry.dto.IotProfileResponse;
import java.time.Instant;
import java.util.List;
@@ -20,6 +21,7 @@ public record ReplacementsResponse(
String name,
String endpoint,
OLevel oLevel,
ServiceStage serviceStage
ServiceStage serviceStage,
IotProfileResponse iotProfile
) {}
}
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.botstandards.apix.common.BsmPayload;
import org.botstandards.apix.common.OrgType;
import org.botstandards.apix.common.ServiceStage;
import org.botstandards.apix.registry.dto.IotProfileRequest;
import java.time.Instant;
import java.util.List;
@@ -30,5 +31,6 @@ public record ServicePatchRequest(
Boolean locked,
Instant sunsetAt,
String migrationGuideUrl,
List<UUID> replacesServiceIds
List<UUID> replacesServiceIds,
IotProfileRequest iotProfile
) {}
@@ -3,6 +3,7 @@ package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.entity.ServiceEntity;
import org.botstandards.apix.registry.dto.IotProfileResponse;
import java.time.Instant;
import java.util.List;
@@ -35,7 +36,8 @@ public record ServiceResponse(
String migrationGuideUrl,
List<UUID> replacesServiceIds,
Instant registeredAt,
Instant lastUpdatedAt
Instant lastUpdatedAt,
IotProfileResponse iotProfile
) {
public static ServiceResponse from(ServiceEntity e) {
BsmPayload b = e.bsmPayload;
@@ -65,7 +67,8 @@ public record ServiceResponse(
e.migrationGuideUrl,
b.replacesServiceIds(),
e.registeredAt,
e.lastUpdatedAt
e.lastUpdatedAt,
IotProfileResponse.from(e.iotProfile)
);
}
}
@@ -0,0 +1,64 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import org.botstandards.apix.registry.dto.CredentialFormat;
import org.botstandards.apix.registry.persistence.JsonStringListConverter;
import org.hibernate.annotations.ColumnTransformer;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "iot_profiles")
public class IotProfileEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "service_id", nullable = false, unique = true)
public ServiceEntity service;
@Column(name = "hub_url", nullable = false, length = 512)
public String hubUrl;
@Convert(converter = JsonStringListConverter.class)
@Column(name = "protocols", columnDefinition = "jsonb", nullable = false)
@ColumnTransformer(write = "?::jsonb")
public List<String> protocols;
@Column(name = "tls_min_version", length = 10, nullable = false)
public String tlsMinVersion = "1.2";
@Column(name = "provisioning_endpoint", length = 512)
public String provisioningEndpoint;
@Enumerated(EnumType.STRING)
@Column(name = "credential_format", length = 30)
public CredentialFormat credentialFormat;
@Column(name = "device_id_field", length = 100)
public String deviceIdField;
@Convert(converter = JsonStringListConverter.class)
@Column(name = "device_classes", columnDefinition = "jsonb", nullable = false)
@ColumnTransformer(write = "?::jsonb")
public List<String> deviceClasses;
@Column(name = "min_firmware_version", length = 50)
public String minFirmwareVersion;
@Column(name = "firmware_update_url", length = 512)
public String firmwareUpdateUrl;
@Column(name = "estimated_migration_minutes")
public Integer estimatedMigrationMinutes;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
@Column(name = "updated_at", nullable = false)
public Instant updatedAt;
}
@@ -4,6 +4,8 @@ import jakarta.persistence.*;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.persistence.BsmPayloadConverter;
import org.hibernate.annotations.ColumnTransformer;
import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode;
import java.time.Instant;
import java.util.UUID;
@@ -61,4 +63,11 @@ public class ServiceEntity {
@Column(name = "migration_guide_url")
public String migrationGuideUrl;
// EAGER so the profile is available without an explicit @Transactional on read paths.
// iot_profiles rows are sparse — Hibernate uses a LEFT JOIN for em.find()/JPQL,
// and a secondary SELECT per entity for native queries. Both are acceptable at registry scale.
@OneToOne(mappedBy = "service", fetch = FetchType.EAGER, optional = true)
@Fetch(FetchMode.SELECT)
public IotProfileEntity iotProfile;
}
@@ -0,0 +1,34 @@
package org.botstandards.apix.registry.persistence;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.json.JsonMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.List;
@Converter
public class JsonStringListConverter implements AttributeConverter<List<String>, String> {
private static final JsonMapper MAPPER = JsonMapper.builder().build();
@Override
public String convertToDatabaseColumn(List<String> attribute) {
if (attribute == null) return null;
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException("Cannot serialize string list to JSON", e);
}
}
@Override
public List<String> convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
try {
return MAPPER.readValue(dbData, new TypeReference<>() {});
} catch (Exception e) {
throw new IllegalStateException("Cannot deserialize JSON to string list", e);
}
}
}
@@ -77,8 +77,11 @@ public class ServiceResource {
@GET
@Path("/{id}/replacements")
public Response getReplacements(@PathParam("id") UUID id,
@QueryParam("minOLevel") String minOLevel) {
ReplacementsResponse body = registryService.getReplacements(id, minOLevel);
@QueryParam("minOLevel") String minOLevel,
@QueryParam("iotReady") Boolean iotReady,
@QueryParam("deviceClass") String deviceClass,
@QueryParam("protocol") String protocol) {
ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol);
return Response.ok(body)
.header("Cache-Control", "public, max-age=60")
.build();
@@ -11,12 +11,17 @@ import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.dto.ReplacementsResponse;
import org.botstandards.apix.registry.dto.ServicePatchRequest;
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
import org.botstandards.apix.registry.dto.IotProfileRequest;
import org.botstandards.apix.registry.dto.IotProfileResponse;
import org.botstandards.apix.registry.dto.IotProtocol;
import org.botstandards.apix.registry.entity.IotProfileEntity;
import org.botstandards.apix.registry.entity.ServiceEntity;
import org.botstandards.apix.registry.entity.ServiceReplacementEntity;
import org.botstandards.apix.registry.entity.ServiceVersionEntity;
import java.time.Instant;
import java.util.*;
import java.util.stream.Stream;
@ApplicationScoped
public class RegistryService {
@@ -113,6 +118,9 @@ public class RegistryService {
if (req.replacesServiceIds() != null) {
syncReplacements(service.id, req.replacesServiceIds(), service.lastUpdatedAt);
}
if (req.iotProfile() != null) {
upsertIotProfile(service, req.iotProfile(), service.lastUpdatedAt);
}
return service;
}
@@ -135,7 +143,8 @@ public class RegistryService {
}
@SuppressWarnings("unchecked")
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr) {
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr,
Boolean iotReady, String deviceClass, String protocol) {
ServiceEntity deprecated = requireById(deprecatedId);
// Locked services that are not yet DECOMMISSIONED block replacement discovery
@@ -153,14 +162,30 @@ public class RegistryService {
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
Stream<ServiceEntity> stream = candidates.stream()
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal());
if (Boolean.TRUE.equals(iotReady)) {
stream = stream.filter(c -> c.iotProfile != null);
}
if (deviceClass != null && !deviceClass.isBlank()) {
stream = stream.filter(c -> c.iotProfile != null
&& c.iotProfile.deviceClasses != null
&& c.iotProfile.deviceClasses.contains(deviceClass));
}
if (protocol != null && !protocol.isBlank()) {
stream = stream.filter(c -> c.iotProfile != null
&& c.iotProfile.protocols != null
&& c.iotProfile.protocols.contains(protocol));
}
return new ReplacementsResponse(
deprecated.locked,
deprecated.sunsetAt,
candidates.stream()
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
stream
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
.map(c -> new ReplacementsResponse.Candidate(
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage))
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,
IotProfileResponse.from(c.iotProfile)))
.toList()
);
}
@@ -290,6 +315,35 @@ public class RegistryService {
}
}
private void upsertIotProfile(ServiceEntity service, IotProfileRequest req, Instant now) {
IotProfileEntity p = service.iotProfile;
boolean isNew = (p == null);
if (isNew) {
if (req.hubUrl() == null) throw unprocessable("iotProfile.hubUrl is required");
if (req.protocols() == null || req.protocols().isEmpty()) throw unprocessable("iotProfile.protocols is required");
if (req.deviceClasses() == null || req.deviceClasses().isEmpty()) throw unprocessable("iotProfile.deviceClasses is required");
p = new IotProfileEntity();
p.id = UUID.randomUUID();
p.service = service;
p.createdAt = now;
}
if (req.hubUrl() != null) p.hubUrl = req.hubUrl();
if (req.protocols() != null) p.protocols = req.protocols().stream().map(IotProtocol::name).toList();
if (req.tlsMinVersion() != null) p.tlsMinVersion = req.tlsMinVersion();
if (req.provisioningEndpoint() != null) p.provisioningEndpoint = req.provisioningEndpoint();
if (req.credentialFormat() != null) p.credentialFormat = req.credentialFormat();
if (req.deviceIdField() != null) p.deviceIdField = req.deviceIdField();
if (req.deviceClasses() != null) p.deviceClasses = req.deviceClasses();
if (req.minFirmwareVersion() != null) p.minFirmwareVersion = req.minFirmwareVersion();
if (req.firmwareUpdateUrl() != null) p.firmwareUpdateUrl = req.firmwareUpdateUrl();
if (req.estimatedMigrationMinutes() != null) p.estimatedMigrationMinutes = req.estimatedMigrationMinutes();
p.updatedAt = now;
if (isNew) {
service.iotProfile = p;
em.persist(p);
}
}
private void upsertReplacement(UUID deprecatedId, UUID replacementId, Instant now) {
long count = ((Number) em.createNativeQuery(
"SELECT COUNT(*) FROM service_replacements " +
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="009" author="apix">
<!-- Optional IoT device-connection profile attached to a registered service.
A service without an iot_profiles row is a generic API service with no
declared device-onboarding support. A service with a row is discoverable
by IoT devices polling for a cloud-sunset replacement target. -->
<createTable tableName="iot_profiles">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- Owning FK — one profile per service, deleted with the service -->
<column name="service_id" type="uuid">
<constraints nullable="false" unique="true"
foreignKeyName="fk_iot_profile_service"
references="services(id)"
deleteCascade="true"/>
</column>
<!-- Device connection -->
<!-- WebSocket / MQTT broker / CoAP endpoint the device connects to -->
<column name="hub_url" type="varchar(512)">
<constraints nullable="false"/>
</column>
<!-- JSONB array of IotProtocol enum values, e.g. ["MQTT_5_0","WEBSOCKET"] -->
<column name="protocols" type="jsonb">
<constraints nullable="false"/>
</column>
<column name="tls_min_version" type="varchar(10)" defaultValue="1.2">
<constraints nullable="false"/>
</column>
<!-- Provisioning — device calls this URL directly; APIX never sees device identity -->
<column name="provisioning_endpoint" type="varchar(512)"/>
<!-- CredentialFormat enum: JWT | PSK | X509 | NONE -->
<column name="credential_format" type="varchar(30)"/>
<!-- Which device field is presented at provisioning (serialNumber, mac, deviceEui) -->
<column name="device_id_field" type="varchar(100)"/>
<!-- Device class targeting — same capability taxonomy as GET /services?capability -->
<!-- JSONB array of class identifiers, e.g. ["device.class.smart-home-hub"] -->
<column name="device_classes" type="jsonb">
<constraints nullable="false"/>
</column>
<!-- Migration aid -->
<column name="min_firmware_version" type="varchar(50)"/>
<column name="firmware_update_url" type="varchar(512)"/>
<!-- Estimated wall-clock minutes to complete the migration (for UX display) -->
<column name="estimated_migration_minutes" type="integer"/>
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
<column name="updated_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<!-- Liquibase createIndex does not support PostgreSQL-specific index types;
GIN must be declared via raw SQL for jsonb containment queries. -->
<sql>CREATE INDEX idx_iot_device_classes ON iot_profiles USING gin(device_classes)</sql>
<sql>CREATE INDEX idx_iot_protocols ON iot_profiles USING gin(protocols)</sql>
</changeSet>
</databaseChangeLog>
@@ -13,5 +13,6 @@
<include file="changes/006-sunset-lock.xml" relativeToChangelogFile="true"/>
<include file="changes/007-service-replacements.xml" relativeToChangelogFile="true"/>
<include file="changes/008-sunset-at.xml" relativeToChangelogFile="true"/>
<include file="changes/009-iot-profiles.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,27 @@
package org.botstandards.apix.registry.bdd;
import io.cucumber.core.cli.Main;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Runs BDD scenarios for the IoT device connection profile feature.
* Shares step definitions with IotTransitionCucumberTest via the same glue package.
*/
@QuarkusTest
public class IotProfileCucumberTest {
@Test
public void run() {
byte exitCode = Main.run(
"--glue", "org.botstandards.apix.registry.bdd",
"--plugin", "pretty",
"--plugin", "json:target/cucumber-report-iot-profile.json",
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
"classpath:features/iot-profile"
);
assertEquals(0, exitCode, "One or more IoT profile Cucumber scenarios failed");
}
}
@@ -783,4 +783,93 @@ public class IotTransitionSteps {
.doesNotContain("user-agent");
}
}
// ── IoT Profile — Given (setup) ──────────────────────────────────────────
@Given("{string} has IoT profile hubUrl {string} protocols {string} deviceClasses {string}")
public void hasIotProfile(String name, String hubUrl, String protocols, String deviceClasses) {
asTemplateOwner()
.body(Map.of("iotProfile", Map.of(
"hubUrl", hubUrl,
"protocols", List.of(protocols),
"deviceClasses", List.of(deviceClasses)
)))
.patch("/services/" + serviceIds.get(name))
.then().statusCode(200);
}
// ── IoT Profile — When ───────────────────────────────────────────────────
@When("{string} sets its IoT profile with hubUrl {string} protocols {string} deviceClasses {string}")
public void setsIotProfile(String name, String hubUrl, String protocols, String deviceClasses) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("iotProfile", Map.of(
"hubUrl", hubUrl,
"protocols", List.of(protocols),
"deviceClasses", List.of(deviceClasses)
)))
.patch("/services/" + serviceIds.get(name));
currentServiceId = serviceIds.get(name);
}
@When("{string} updates its IoT profile hubUrl to {string}")
public void updatesIotProfileHubUrl(String name, String hubUrl) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("iotProfile", Map.of("hubUrl", hubUrl)))
.patch("/services/" + serviceIds.get(name));
currentServiceId = serviceIds.get(name);
}
@When("{string} patches iotProfile with only hubUrl {string}")
public void patchesIotProfileWithOnlyHubUrl(String name, String hubUrl) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("iotProfile", Map.of("hubUrl", hubUrl)))
.patch("/services/" + serviceIds.get(name));
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$")
public void getReplacementsIotReady() {
actionPhase = true;
lastResponse = given().get(
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?iotReady=true");
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$")
public void getReplacementsFilteredByDeviceClass(String deviceClass) {
actionPhase = true;
lastResponse = given().get(
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?deviceClass=" + deviceClass);
}
// ── IoT Profile — Then ───────────────────────────────────────────────────
@Then("^GET /services/\\{openHubId\\} includes an iotProfile$")
public void getOpenHubIncludesIotProfile() {
given().get("/services/" + serviceIds.get("OpenHub"))
.then().statusCode(200)
.body("iotProfile", notNullValue());
}
@Then("the iotProfile.hubUrl is {string}")
public void iotProfileHubUrlIs(String expected) {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("iotProfile.hubUrl", equalTo(expected));
}
@Then("the iotProfile.protocols contains {string}")
public void iotProfileProtocolsContains(String protocol) {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("iotProfile.protocols", hasItem(protocol));
}
@Then("the candidate {string} has an iotProfile with hubUrl {string}")
public void candidateHasIotProfileWithHubUrl(String name, String hubUrl) {
lastResponse.then().statusCode(200)
.body("candidates.find { it.name == '" + name + "' }.iotProfile.hubUrl", equalTo(hubUrl));
}
}
@@ -79,7 +79,7 @@ class RegistryServiceTest {
var req = new ServicePatchRequest(
null, null, null, List.of("new.capability"),
null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null);
null, null, null, null, null, null);
assertThat(RegistryService.detectChangeType(ServiceStage.PRODUCTION, null, req))
.isEqualTo(ChangeType.BSM_UPDATED);
}
@@ -188,6 +188,6 @@ class RegistryServiceTest {
private static ServicePatchRequest patch(ServiceStage stage, Boolean locked, Instant sunsetAt, List<UUID> replaces) {
return new ServicePatchRequest(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
stage, locked, sunsetAt, null, replaces);
stage, locked, sunsetAt, null, replaces, null);
}
}
@@ -0,0 +1,55 @@
Feature: IoT device connection profile
As a service provider targeting IoT device portability
I want to declare my device connection profile in the APIX registry
So that migrating IoT devices can discover how to connect to my service
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false
And a registered service "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED"
And a registered service "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED"
Scenario: Provider adds an IoT profile to their service
When "OpenHub" sets its IoT profile with hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
Then the response is HTTP 200
And GET /services/{openHubId} includes an iotProfile
And the iotProfile.hubUrl is "wss://hub.openhub.io/v2"
And the iotProfile.protocols contains "MQTT_5_0"
Scenario: IoT profile is included in replacement candidates
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements is called
Then the response is HTTP 200
And the candidate "OpenHub" has an iotProfile with hubUrl "wss://hub.openhub.io/v2"
Scenario: iotReady=true excludes candidates without a profile
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "CloudBridge" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements?iotReady=true is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "CloudBridge"
Scenario: deviceClass filter narrows replacement candidates
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "MQTT_5_0" deviceClasses "device.class.industrial-sensor"
And "CloudBridge" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements?deviceClass=device.class.smart-home-hub is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "OpenHub"
Scenario: IoT profile fields are partially updated on subsequent PATCH
Given "OpenHub" has IoT profile hubUrl "wss://old.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
When "OpenHub" updates its IoT profile hubUrl to "wss://new.openhub.io"
Then the response is HTTP 200
And GET /services/{openHubId} includes an iotProfile
And the iotProfile.hubUrl is "wss://new.openhub.io"
And the iotProfile.protocols contains "MQTT_5_0"
Scenario: Missing required fields on first IoT profile PATCH returns 422
When "OpenHub" patches iotProfile with only hubUrl "wss://hub.openhub.io"
Then the response is HTTP 422
And the error message contains "iotProfile.protocols is required"
@@ -0,0 +1,38 @@
# ADR-015: IoT Device Transition — Pull-Only Anonymity Model
**Status:** Accepted
**Date:** 2026-05-06
## Context
When a cloud service declares sunset and releases its device lock, IoT device owners need to discover compatible replacement services. APIX facilitates this discovery. The design question is how APIX interacts with device owners during this process without compromising their anonymity.
Device owner anonymity is a hard requirement: APIX must not store any artefact that links a query to a specific device or owner, including under legal compulsion.
## Alternatives
| Alt | Description | Pros | Cons |
|---|---|---|---|
| **A — Pull-only polling** | Devices poll `GET /services/{id}` and `GET /services/{id}/replacements` periodically. No registration. | Zero identity exposure; no per-device APIX state; no single point of notification failure | Device must be online and polling; no guaranteed delivery; detection latency depends on poll interval |
| **B — Anonymous token registration** | Device generates a one-time token and posts it to APIX for push callbacks on status change | Lower detection latency; no active polling required | Token + callback URL creates a device linkage; legally compellable even if hashed; APIX becomes notification infrastructure |
| **C — Email / contact registration** | Device owner registers email with APIX for sunset alerts | Familiar UX for human operators | Directly identifies owner; incompatible with anonymity requirement |
| **D — Third-party privacy relay** | Device owner registers with a privacy relay (e.g. anonymising alias service) which forwards APIX push events | Plausible deniability for owner identity | External dependency; relay is a single point of failure; APIX still stores a callback endpoint linkable to the relay account |
## Decision
**Alternative A — Pull-only polling.**
## Reason
Only pull-only provides a genuine anonymity guarantee. Alternatives B, C, and D all require APIX to store an artefact (token, address, or endpoint) that is linkable to a device or owner under legal compulsion, regardless of hashing or relay indirection. APIX's anonymity guarantee is only credible if there is no stored linkage at all.
Device owners requiring active push notification must implement it themselves using their own tooling pointed at the APIX poll endpoints. The poll API is designed to be cache-friendly and bandwidth-minimal.
## Consequences
- `GET /services/{id}` must return `locked`, `sunset_date`, and `service_stage` without authentication.
- `GET /services/{id}/replacements` must be publicly accessible without authentication.
- Both endpoints are included in the Caddy cache configuration (same cache policy as the default production service query).
- IP addresses from polling requests must not be persisted beyond standard access log rotation (no analytics pipeline on these endpoints).
- BSF terms of registration require template owners to release the device lock within 90 days of sunset declaration, making the polling window predictable for device owners.
- APIX must never log request parameters or headers that could identify a polling device.