Implement apix-registry with IoT sunset/decommission lifecycle and full BDD suite

- REST API: register, patch, O-level, replacements, history, search endpoints
- IoT lifecycle validations: future sunset, lock-before-release, sunset-passed-before-decommission
- DB schema: Liquibase changesets 001–008 (services, versions, replacements, sunset-at column)
- @ColumnTransformer(write="?::jsonb") on bsm_payload fields to avoid JDBC varchar→jsonb rejection
- Jandex plugin on apix-common + quarkus.index-dependency so @NotBlank validators resolve at runtime
- quarkus-logging-json extension added; quarkus.log.console.json=false is now a recognised key
- Fix requireSunsetBeforeLockRelease: Boolean.TRUE.equals instead of !Boolean.FALSE.equals (null guard)
- BDD suite: 27 scenarios / 213 steps across 5 feature files (sunset-lock, decommission, replacement, discovery, anonymity)
- Test infrastructure: JDBC TRUNCATE in @Before for DB isolation, Arc.container() for clock control — no test endpoints in production code
- sunsetAt truncated to microseconds in BDD steps to match Postgres timestamptz precision
- Cucumber step fixes: singular/plural candidate(s), lastResponse propagation in replacementsReturnsNCandidates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-08 09:13:26 +02:00
commit b2a16a8be7
71 changed files with 5480 additions and 0 deletions
@@ -0,0 +1,25 @@
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 java.time.Instant;
import java.util.List;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ReplacementsResponse(
Boolean locked,
Instant sunsetAt,
List<Candidate> candidates
) {
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Candidate(
UUID id,
String name,
String endpoint,
OLevel oLevel,
ServiceStage serviceStage
) {}
}
@@ -0,0 +1,34 @@
package org.botstandards.apix.registry.dto;
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 java.time.Instant;
import java.util.List;
import java.util.UUID;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ServicePatchRequest(
String name,
String description,
String endpoint,
List<String> capabilities,
String registrantEmail,
String registrantName,
String registrantJurisdiction,
OrgType registrantOrgType,
String registrantLei,
String openApiSpecUrl,
String mcpSpecUrl,
String policyUrl,
String securityContactUrl,
BsmPayload.Pricing pricing,
String bsmVersion,
ServiceStage serviceStage,
Boolean locked,
Instant sunsetAt,
String migrationGuideUrl,
List<UUID> replacesServiceIds
) {}
@@ -0,0 +1,71 @@
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 java.time.Instant;
import java.util.List;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ServiceResponse(
UUID id,
String name,
String description,
String endpoint,
List<String> capabilities,
String registrantEmail,
String registrantName,
String registrantJurisdiction,
OrgType registrantOrgType,
String registrantLei,
String openApiSpecUrl,
String mcpSpecUrl,
String policyUrl,
String securityContactUrl,
BsmPayload.Pricing pricing,
String bsmVersion,
OLevel oLevel,
LivenessStatus livenessStatus,
ServiceStage serviceStage,
RegistryStatus registryStatus,
Boolean locked,
Instant sunsetAt,
String migrationGuideUrl,
List<UUID> replacesServiceIds,
Instant registeredAt,
Instant lastUpdatedAt
) {
public static ServiceResponse from(ServiceEntity e) {
BsmPayload b = e.bsmPayload;
return new ServiceResponse(
e.id,
b.name(),
b.description(),
b.endpoint(),
b.capabilities(),
b.registrantEmail(),
b.registrantName(),
b.registrantJurisdiction(),
e.registrantOrgType,
b.registrantLei(),
b.openApiSpecUrl(),
b.mcpSpecUrl(),
b.policyUrl(),
b.securityContactUrl(),
b.pricing(),
b.bsmVersion(),
e.olevel,
e.livenessStatus,
e.serviceStage,
e.registryStatus,
e.locked,
e.sunsetAt,
e.migrationGuideUrl,
b.replacesServiceIds(),
e.registeredAt,
e.lastUpdatedAt
);
}
}
@@ -0,0 +1,14 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record VersionHistoryEntry(
UUID id,
String type,
String previousValue,
String newValue,
String createdAt
) {}
@@ -0,0 +1,64 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.persistence.BsmPayloadConverter;
import org.hibernate.annotations.ColumnTransformer;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "services")
public class ServiceEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@Column(name = "endpoint_url", nullable = false, unique = true)
public String endpointUrl;
@Convert(converter = BsmPayloadConverter.class)
@Column(name = "bsm_payload", columnDefinition = "jsonb", nullable = false)
@ColumnTransformer(write = "?::jsonb")
public BsmPayload bsmPayload;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
public OLevel olevel = OLevel.UNVERIFIED;
@Enumerated(EnumType.STRING)
@Column(name = "liveness_status", nullable = false)
public LivenessStatus livenessStatus = LivenessStatus.PENDING;
@Column(name = "registered_at", nullable = false)
public Instant registeredAt;
@Enumerated(EnumType.STRING)
@Column(name = "registrant_org_type", nullable = false)
public OrgType registrantOrgType = OrgType.INDIVIDUAL;
@Enumerated(EnumType.STRING)
@Column(name = "service_stage", nullable = false)
public ServiceStage serviceStage = ServiceStage.DEVELOPMENT;
@Enumerated(EnumType.STRING)
@Column(name = "registry_status", nullable = false)
public RegistryStatus registryStatus = RegistryStatus.ACTIVE;
@Column(nullable = false)
public int version = 1;
@Column(name = "last_updated_at")
public Instant lastUpdatedAt;
@Column(name = "locked")
public Boolean locked;
@Column(name = "sunset_at")
public Instant sunsetAt;
@Column(name = "migration_guide_url")
public String migrationGuideUrl;
}
@@ -0,0 +1,27 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "service_replacements")
public class ServiceReplacementEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@Column(name = "deprecated_service_id", nullable = false)
public UUID deprecatedServiceId;
@Column(name = "replacement_service_id", nullable = false)
public UUID replacementServiceId;
@Column(name = "declared_at", nullable = false)
public Instant declaredAt;
@Column(name = "compatibility_notes")
public String compatibilityNotes;
}
@@ -0,0 +1,55 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.persistence.BsmPayloadConverter;
import org.hibernate.annotations.ColumnTransformer;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "service_versions")
public class ServiceVersionEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@Column(name = "service_id", nullable = false)
public UUID serviceId;
@Column(nullable = false)
public int version;
@Column(name = "recorded_at", nullable = false)
public Instant recordedAt;
@Enumerated(EnumType.STRING)
@Column(name = "change_type", nullable = false)
public ChangeType changeType;
@Convert(converter = BsmPayloadConverter.class)
@Column(name = "bsm_payload", columnDefinition = "jsonb", nullable = false)
@ColumnTransformer(write = "?::jsonb")
public BsmPayload bsmPayload;
@Enumerated(EnumType.STRING)
@Column(name = "registrant_org_type", nullable = false)
public OrgType registrantOrgType;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
public OLevel olevel;
@Enumerated(EnumType.STRING)
@Column(name = "service_stage", nullable = false)
public ServiceStage serviceStage;
@Enumerated(EnumType.STRING)
@Column(name = "registry_status", nullable = false)
public RegistryStatus registryStatus;
@Column(name = "note")
public String note;
}
@@ -0,0 +1,38 @@
package org.botstandards.apix.registry.persistence;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import org.botstandards.apix.common.BsmPayload;
@Converter(autoApply = true)
public class BsmPayloadConverter implements AttributeConverter<BsmPayload, String> {
private static final JsonMapper MAPPER = JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
@Override
public String convertToDatabaseColumn(BsmPayload payload) {
if (payload == null) return null;
try {
return MAPPER.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Cannot serialize BsmPayload", e);
}
}
@Override
public BsmPayload convertToEntityAttribute(String json) {
if (json == null) return null;
try {
return MAPPER.readValue(json, BsmPayload.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Cannot deserialize BsmPayload", e);
}
}
}
@@ -0,0 +1,101 @@
package org.botstandards.apix.registry.resource;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.botstandards.apix.common.BsmPayload;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.registry.dto.ReplacementsResponse;
import org.botstandards.apix.registry.dto.ServicePatchRequest;
import org.botstandards.apix.registry.dto.ServiceResponse;
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
import org.botstandards.apix.registry.service.RegistryService;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Path("/services")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ServiceResource {
@Inject
RegistryService registryService;
@ConfigProperty(name = "apix.api-key")
String apiKey;
@POST
public Response register(@Valid BsmPayload payload, @HeaderParam("X-Api-Key") String key) {
requireKey(key);
var service = registryService.register(payload);
return Response.created(URI.create("/services/" + service.id))
.entity(Map.of("id", service.id.toString()))
.build();
}
@GET
@Path("/{id}")
public ServiceResponse getById(@PathParam("id") UUID id) {
return ServiceResponse.from(registryService.requireById(id));
}
@PATCH
@Path("/{id}")
public ServiceResponse patch(@PathParam("id") UUID id,
ServicePatchRequest req,
@HeaderParam("X-Api-Key") String key) {
requireKey(key);
return ServiceResponse.from(registryService.patch(id, req));
}
@GET
public List<ServiceResponse> search(@QueryParam("capability") String capability,
@QueryParam("stage") String stage) {
if (capability == null || capability.isBlank()) {
throw new BadRequestException("capability query parameter is required");
}
return registryService.search(capability, stage).stream()
.map(ServiceResponse::from)
.toList();
}
@PATCH
@Path("/{id}/olevel")
public ServiceResponse setOLevel(@PathParam("id") UUID id,
@QueryParam("level") String level,
@HeaderParam("X-Api-Key") String key) {
requireKey(key);
OLevel oLevel = OLevel.valueOf(level.toUpperCase());
return ServiceResponse.from(registryService.setOLevel(id, oLevel));
}
@GET
@Path("/{id}/replacements")
public Response getReplacements(@PathParam("id") UUID id,
@QueryParam("minOLevel") String minOLevel) {
ReplacementsResponse body = registryService.getReplacements(id, minOLevel);
return Response.ok(body)
.header("Cache-Control", "public, max-age=60")
.build();
}
@GET
@Path("/{id}/history")
public List<VersionHistoryEntry> getHistory(@PathParam("id") UUID id) {
return registryService.getHistory(id);
}
private void requireKey(String provided) {
if (!apiKey.equals(provided)) {
throw new NotAuthorizedException(
Response.status(401)
.entity(Map.of("message", "Invalid or missing API key"))
.build());
}
}
}
@@ -0,0 +1,23 @@
package org.botstandards.apix.registry.service;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.Instant;
@ApplicationScoped
public class ClockService {
private volatile Instant override = null;
public Instant now() {
return override != null ? override : Instant.now();
}
public void advance(Instant instant) {
this.override = instant;
}
public void reset() {
this.override = null;
}
}
@@ -0,0 +1,340 @@
package org.botstandards.apix.registry.service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
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.entity.ServiceEntity;
import org.botstandards.apix.registry.entity.ServiceReplacementEntity;
import org.botstandards.apix.registry.entity.ServiceVersionEntity;
import java.time.Instant;
import java.util.*;
@ApplicationScoped
public class RegistryService {
@Inject
EntityManager em;
@Inject
ClockService clockService;
@Transactional
public ServiceEntity register(BsmPayload payload) {
long existing = ((Number) em.createNativeQuery(
"SELECT COUNT(*) FROM services WHERE endpoint_url = :url")
.setParameter("url", payload.endpoint())
.getSingleResult()).longValue();
if (existing > 0) {
throw new WebApplicationException(
Response.status(409)
.entity(Map.of("message", "A service with this endpoint is already registered"))
.build());
}
Instant now = Instant.now();
ServiceEntity service = new ServiceEntity();
service.id = UUID.randomUUID();
service.endpointUrl = payload.endpoint();
service.bsmPayload = payload;
service.olevel = OLevel.UNVERIFIED;
service.livenessStatus = LivenessStatus.PENDING;
service.registeredAt = now;
service.registrantOrgType = payload.registrantOrgType() != null ? payload.registrantOrgType() : OrgType.INDIVIDUAL;
service.serviceStage = payload.serviceStage() != null ? payload.serviceStage() : ServiceStage.DEVELOPMENT;
service.registryStatus = RegistryStatus.ACTIVE;
service.version = 1;
service.locked = payload.locked();
service.sunsetAt = payload.sunsetAt();
service.migrationGuideUrl = payload.migrationGuideUrl();
em.persist(service);
em.persist(snapshot(service, ChangeType.REGISTERED, now));
if (payload.replacesServiceIds() != null) {
for (UUID deprecatedId : payload.replacesServiceIds()) {
upsertReplacement(deprecatedId, service.id, now);
}
}
return service;
}
public ServiceEntity requireById(UUID id) {
ServiceEntity e = em.find(ServiceEntity.class, id);
if (e == null) throw new NotFoundException("Service not found: " + id);
return e;
}
@Transactional
public ServiceEntity setOLevel(UUID id, OLevel level) {
ServiceEntity e = requireById(id);
e.olevel = level;
e.lastUpdatedAt = Instant.now();
return e;
}
@Transactional
public ServiceEntity patch(UUID id, ServicePatchRequest req) {
ServiceEntity service = requireById(id);
// ── IoT validations ───────────────────────────────────────────────────
Instant now = clockService.now();
requireFutureSunset(req.sunsetAt(), now);
requireSunsetBeforeLockRelease(req.locked(), service.locked,
req.sunsetAt() != null ? req.sunsetAt() : service.sunsetAt);
requireSunsetPassedBeforeDecommission(req.serviceStage(), service.sunsetAt, now);
if (req.replacesServiceIds() != null) {
for (UUID deprecatedId : req.replacesServiceIds()) {
validateReplacementTarget(deprecatedId);
}
}
ServiceStage stageBefore = service.serviceStage;
Boolean lockedBefore = service.locked;
applyPatch(service, req);
ChangeType changeType = detectChangeType(stageBefore, lockedBefore, req);
service.version++;
service.lastUpdatedAt = Instant.now();
em.persist(snapshot(service, changeType, service.lastUpdatedAt));
if (req.replacesServiceIds() != null) {
syncReplacements(service.id, req.replacesServiceIds(), service.lastUpdatedAt);
}
return service;
}
@SuppressWarnings("unchecked")
public List<ServiceEntity> search(String capability, String stage) {
ServiceStage targetStage = stage != null
? ServiceStage.valueOf(stage.toUpperCase())
: ServiceStage.PRODUCTION;
return em.createNativeQuery(
"SELECT s.* FROM services s " +
"WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " +
"AND s.registry_status = 'ACTIVE' " +
"AND s.service_stage = :stage",
ServiceEntity.class)
.setParameter("cap", capability)
.setParameter("stage", targetStage.name())
.getResultList();
}
@SuppressWarnings("unchecked")
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr) {
ServiceEntity deprecated = requireById(deprecatedId);
// Locked services that are not yet DECOMMISSIONED block replacement discovery
if (Boolean.TRUE.equals(deprecated.locked) && deprecated.serviceStage != ServiceStage.DECOMMISSIONED) {
return new ReplacementsResponse(deprecated.locked, deprecated.sunsetAt, List.of());
}
List<ServiceEntity> candidates = em.createNativeQuery(
"SELECT s.* FROM services s " +
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id " +
"WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'",
ServiceEntity.class)
.setParameter("depId", deprecatedId)
.getResultList();
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
return new ReplacementsResponse(
deprecated.locked,
deprecated.sunsetAt,
candidates.stream()
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
.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))
.toList()
);
}
public List<VersionHistoryEntry> getHistory(UUID id) {
requireById(id);
List<ServiceVersionEntity> versions = em.createQuery(
"FROM ServiceVersionEntity v WHERE v.serviceId = :id ORDER BY v.version ASC",
ServiceVersionEntity.class)
.setParameter("id", id)
.getResultList();
List<VersionHistoryEntry> result = new ArrayList<>();
for (int i = 0; i < versions.size(); i++) {
result.add(toHistoryEntry(versions.get(i), i > 0 ? versions.get(i - 1) : null));
}
return result;
}
// ── Package-private static helpers — unit-testable without DB ─────────────
static ChangeType detectChangeType(ServiceStage stageBefore, Boolean lockedBefore, ServicePatchRequest req) {
if (req.locked() != null && Boolean.FALSE.equals(req.locked()) && Boolean.TRUE.equals(lockedBefore)) {
return ChangeType.LOCK_RELEASED;
}
if (req.serviceStage() != null && req.serviceStage() != stageBefore) {
if (req.sunsetAt() != null && req.serviceStage() == ServiceStage.DEPRECATED) {
return ChangeType.SUNSET_DECLARED;
}
return ChangeType.STAGE_CHANGED;
}
if (req.replacesServiceIds() != null) {
return ChangeType.REPLACEMENT_DECLARED;
}
return ChangeType.BSM_UPDATED;
}
// sunsetAt must be strictly after now — exclusive boundary means now==sunsetAt is already "past"
static void requireFutureSunset(Instant sunsetAt, Instant now) {
if (sunsetAt != null && !sunsetAt.isAfter(now)) {
throw unprocessable("sunset_at must be a future moment");
}
}
static void requireSunsetBeforeLockRelease(Boolean newLocked, Boolean existingLocked, Instant effectiveSunsetAt) {
if (Boolean.FALSE.equals(newLocked) && Boolean.TRUE.equals(existingLocked) && effectiveSunsetAt == null) {
throw unprocessable("sunset_at required before lock release");
}
}
// Exclusive boundary: now >= sunsetAt means the sunset moment has arrived
static void requireSunsetPassedBeforeDecommission(ServiceStage newStage, Instant sunsetAt, Instant now) {
if (newStage == ServiceStage.DECOMMISSIONED) {
if (sunsetAt == null || now.isBefore(sunsetAt)) {
throw unprocessable("sunset_at has not passed");
}
}
}
// ── Private helpers ───────────────────────────────────────────────────────
private void validateReplacementTarget(UUID deprecatedId) {
ServiceEntity target = em.find(ServiceEntity.class, deprecatedId);
if (target == null) throw unprocessable("target service not found");
if (target.serviceStage != ServiceStage.DEPRECATED && target.serviceStage != ServiceStage.DECOMMISSIONED) {
throw unprocessable("target service is not deprecated");
}
if (target.serviceStage == ServiceStage.DEPRECATED && Boolean.TRUE.equals(target.locked)) {
throw unprocessable("target service lock has not been released");
}
}
private void applyPatch(ServiceEntity e, ServicePatchRequest r) {
BsmPayload old = e.bsmPayload;
e.bsmPayload = new BsmPayload(
r.name() != null ? r.name() : old.name(),
r.description() != null ? r.description() : old.description(),
r.endpoint() != null ? r.endpoint() : old.endpoint(),
r.capabilities() != null ? r.capabilities() : old.capabilities(),
r.registrantEmail() != null ? r.registrantEmail() : old.registrantEmail(),
r.registrantName() != null ? r.registrantName() : old.registrantName(),
r.registrantJurisdiction() != null ? r.registrantJurisdiction() : old.registrantJurisdiction(),
r.registrantOrgType() != null ? r.registrantOrgType() : old.registrantOrgType(),
r.registrantLei() != null ? r.registrantLei() : old.registrantLei(),
r.openApiSpecUrl() != null ? r.openApiSpecUrl() : old.openApiSpecUrl(),
r.mcpSpecUrl() != null ? r.mcpSpecUrl() : old.mcpSpecUrl(),
r.policyUrl() != null ? r.policyUrl() : old.policyUrl(),
r.securityContactUrl() != null ? r.securityContactUrl() : old.securityContactUrl(),
r.pricing() != null ? r.pricing() : old.pricing(),
r.bsmVersion() != null ? r.bsmVersion() : old.bsmVersion(),
r.serviceStage() != null ? r.serviceStage() : old.serviceStage(),
r.locked() != null ? r.locked() : old.locked(),
r.sunsetAt() != null ? r.sunsetAt() : old.sunsetAt(),
r.migrationGuideUrl() != null ? r.migrationGuideUrl() : old.migrationGuideUrl(),
r.replacesServiceIds() != null ? r.replacesServiceIds() : old.replacesServiceIds()
);
if (r.endpoint() != null) e.endpointUrl = r.endpoint();
if (r.registrantOrgType() != null) e.registrantOrgType = r.registrantOrgType();
if (r.serviceStage() != null) e.serviceStage = r.serviceStage();
if (r.locked() != null) e.locked = r.locked();
if (r.sunsetAt() != null) e.sunsetAt = r.sunsetAt();
if (r.migrationGuideUrl() != null) e.migrationGuideUrl = r.migrationGuideUrl();
}
private ServiceVersionEntity snapshot(ServiceEntity e, ChangeType changeType, Instant at) {
ServiceVersionEntity v = new ServiceVersionEntity();
v.id = UUID.randomUUID();
v.serviceId = e.id;
v.version = e.version;
v.recordedAt = at;
v.changeType = changeType;
v.bsmPayload = e.bsmPayload;
v.registrantOrgType = e.registrantOrgType;
v.olevel = e.olevel;
v.serviceStage = e.serviceStage;
v.registryStatus = e.registryStatus;
return v;
}
private void syncReplacements(UUID providerId, List<UUID> deprecatedIds, Instant now) {
em.createNativeQuery("DELETE FROM service_replacements WHERE replacement_service_id = :id")
.setParameter("id", providerId)
.executeUpdate();
for (UUID deprecatedId : deprecatedIds) {
upsertReplacement(deprecatedId, providerId, now);
}
}
private void upsertReplacement(UUID deprecatedId, UUID replacementId, Instant now) {
long count = ((Number) em.createNativeQuery(
"SELECT COUNT(*) FROM service_replacements " +
"WHERE deprecated_service_id = :dep AND replacement_service_id = :rep")
.setParameter("dep", deprecatedId)
.setParameter("rep", replacementId)
.getSingleResult()).longValue();
if (count == 0) {
// Record event in the deprecated service's timeline
ServiceEntity deprecated = em.find(ServiceEntity.class, deprecatedId);
if (deprecated != null) {
deprecated.version++;
deprecated.lastUpdatedAt = now;
em.persist(snapshot(deprecated, ChangeType.REPLACEMENT_DECLARED, now));
}
ServiceReplacementEntity r = new ServiceReplacementEntity();
r.id = UUID.randomUUID();
r.deprecatedServiceId = deprecatedId;
r.replacementServiceId = replacementId;
r.declaredAt = now;
em.persist(r);
}
}
private VersionHistoryEntry toHistoryEntry(ServiceVersionEntity cur, ServiceVersionEntity prev) {
String previousValue = null;
String newValue = null;
switch (cur.changeType) {
case STAGE_CHANGED, SUNSET_DECLARED -> {
previousValue = prev != null ? prev.serviceStage.name() : null;
newValue = cur.serviceStage.name();
}
case LOCK_RELEASED -> {
previousValue = prev != null && prev.bsmPayload.locked() != null
? prev.bsmPayload.locked().toString() : null;
newValue = cur.bsmPayload.locked() != null ? cur.bsmPayload.locked().toString() : null;
}
default -> {}
}
return new VersionHistoryEntry(cur.id, cur.changeType.name(), previousValue, newValue,
cur.recordedAt.toString());
}
private static WebApplicationException unprocessable(String message) {
return new WebApplicationException(
Response.status(422).entity(Map.of("message", message)).build());
}
}
@@ -0,0 +1,35 @@
# ── Jandex — include apix-common in the index so bean validation constraints work ──
quarkus.index-dependency.apix-common.group-id=org.botstandards
quarkus.index-dependency.apix-common.artifact-id=apix-common
# ── Datasource ────────────────────────────────────────────────────────────────
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=${QUARKUS_DATASOURCE_JDBC_URL:jdbc:postgresql://localhost:5432/apix}
quarkus.datasource.username=${QUARKUS_DATASOURCE_USERNAME:apix}
quarkus.datasource.password=${QUARKUS_DATASOURCE_PASSWORD:apix}
# ── ORM ───────────────────────────────────────────────────────────────────────
# Liquibase owns schema creation; Hibernate must not touch DDL
quarkus.hibernate-orm.database.generation=none
# ── Liquibase ─────────────────────────────────────────────────────────────────
quarkus.liquibase.migrate-at-start=true
quarkus.liquibase.change-log=db/changelog/db.changelog-master.xml
# ── HTTP ──────────────────────────────────────────────────────────────────────
quarkus.http.port=8180
# ── Security — API key for write endpoints ───────────────────────────────────
apix.api-key=${APIX_API_KEY:dev-insecure-key-change-in-prod}
# ── Verification ──────────────────────────────────────────────────────────────
apix.gleif.api-url=${GLEIF_API_URL:https://api.gleif.org/api/v1}
apix.opencorporates.api-key=${OPENCORPORATES_API_KEY:}
apix.sanctions.cache-path=${SANCTIONS_CACHE_PATH:./sanctions-cache}
# ── Logging ───────────────────────────────────────────────────────────────────
quarkus.log.level=${LOG_LEVEL:DEBUG}
quarkus.log.console.json=false
# ── Health ────────────────────────────────────────────────────────────────────
quarkus.smallrye-health.root-path=/q/health
@@ -0,0 +1,36 @@
<?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="001" author="apix">
<createTable tableName="services">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="endpoint_url" type="text">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_services_endpoint_url"/>
</column>
<!-- Full BSM document stored as JSONB for flexible querying -->
<column name="bsm_payload" type="jsonb">
<constraints nullable="false"/>
</column>
<column name="olevel" type="varchar(50)" defaultValue="UNVERIFIED">
<constraints nullable="false"/>
</column>
<column name="slevel" type="varchar(50)"/>
<column name="liveness_status" type="varchar(50)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="registered_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<!-- GIN index on bsm_payload for capability search (@> operator) -->
<sql>CREATE INDEX idx_services_bsm_payload_gin ON services USING gin (bsm_payload);</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,18 @@
<?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="002" author="apix">
<addColumn tableName="services">
<column name="verification_status" type="varchar(50)"/>
<column name="olevel_checked_at" type="timestamptz"/>
<!-- null = not yet screened; false = hit; true = clear -->
<column name="sanctions_cleared" type="boolean"/>
<column name="gleif_lei" type="text"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,17 @@
<?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="003" author="apix">
<addColumn tableName="services">
<column name="last_checked_at" type="timestamptz"/>
<column name="uptime_30d_percent" type="numeric(5,2)"/>
<column name="avg_response_ms" type="integer"/>
<column name="consecutive_failures" type="integer" defaultValueNumeric="0"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?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="004" author="apix">
<addColumn tableName="services">
<!-- Registrant type — top-level column, not buried in JSONB, for direct filtering -->
<column name="registrant_org_type" type="varchar(50)" defaultValue="INDIVIDUAL">
<constraints nullable="false"/>
</column>
<!-- Registrant-declared lifecycle stage — controls search visibility -->
<column name="service_stage" type="varchar(50)" defaultValue="DEVELOPMENT">
<constraints nullable="false"/>
</column>
<!-- BSF-controlled administrative state — always applied before stage filter -->
<column name="registry_status" type="varchar(50)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<!-- Monotonically increasing per-service counter; links to service_versions -->
<column name="version" type="integer" defaultValueNumeric="1">
<constraints nullable="false"/>
</column>
<!-- Timestamp of last non-liveness update (liveness writes go to 003 columns) -->
<column name="last_updated_at" type="timestamptz"/>
</addColumn>
<!-- service_stage is the primary search filter dimension after capability -->
<createIndex tableName="services" indexName="idx_services_service_stage">
<column name="service_stage"/>
</createIndex>
<!-- registry_status is applied to every public query; index keeps it cheap -->
<createIndex tableName="services" indexName="idx_services_registry_status">
<column name="registry_status"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,64 @@
<?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="005" author="apix">
<!-- Append-only snapshot table. Rows are never updated or deleted.
Each row captures the complete service state at the moment of a meaningful change.
Diffs are computed from adjacent versions at read time, not stored. -->
<createTable tableName="service_versions">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="service_id" type="uuid">
<constraints nullable="false"
foreignKeyName="fk_sv_service_id"
references="services(id)"/>
</column>
<column name="version" type="integer">
<constraints nullable="false"/>
</column>
<column name="recorded_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
<!-- REGISTERED | BSM_UPDATED | ORG_TYPE_CHANGED | OLEVEL_CHANGED |
STAGE_CHANGED | OWNERSHIP_TRANSFERRED | REGISTRY_STATUS_CHANGED -->
<column name="change_type" type="varchar(50)">
<constraints nullable="false"/>
</column>
<!-- Full BSM at this version — enables complete before/after comparison -->
<column name="bsm_payload" type="jsonb">
<constraints nullable="false"/>
</column>
<column name="registrant_org_type" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="olevel" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="service_stage" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="registry_status" type="varchar(50)">
<constraints nullable="false"/>
</column>
<!-- Optional human-readable context for the change, e.g.
"Incorporated as GmbH; transitioning from individual registration" -->
<column name="note" type="text"/>
</createTable>
<addUniqueConstraint
tableName="service_versions"
columnNames="service_id, version"
constraintName="uq_sv_service_version"/>
<!-- Primary access pattern: all versions for a given service, ordered by version -->
<createIndex tableName="service_versions" indexName="idx_sv_service_id">
<column name="service_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,33 @@
<?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="006" author="apix">
<addColumn tableName="services">
<!-- null = lock concept not applicable (non-IoT service)
true = device locked to this template owner; migration blocked
false = lock released; device owner may migrate freely
Default null so existing records are not incorrectly treated as locked. -->
<column name="locked" type="boolean"/>
<!-- ISO date when the service goes permanently offline.
Set together with service_stage = DEPRECATED. -->
<column name="sunset_date" type="date"/>
<!-- Provider-hosted migration documentation URL. -->
<column name="migration_guide_url" type="text"/>
</addColumn>
<!-- Targeted index: only rows with a sunset date (small, DEPRECATED subset) -->
<sql>CREATE INDEX idx_services_sunset_date ON services (sunset_date)
WHERE sunset_date IS NOT NULL;</sql>
<!-- Targeted index: only rows where locked is explicitly set -->
<sql>CREATE INDEX idx_services_locked ON services (locked)
WHERE locked IS NOT NULL;</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,49 @@
<?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="007" author="apix">
<!-- Declared compatibility index.
Replacement providers list deprecated service IDs in BSM.replacesServiceIds[].
The registry extracts those declarations here for efficient lookup.
These rows are derived from BSM content and re-synced on every BSM update —
never edit directly. -->
<createTable tableName="service_replacements">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- The deprecated service being replaced -->
<column name="deprecated_service_id" type="uuid">
<constraints nullable="false"
foreignKeyName="fk_sr_deprecated"
references="services(id)"/>
</column>
<!-- The service declaring it can replace the deprecated one -->
<column name="replacement_service_id" type="uuid">
<constraints nullable="false"
foreignKeyName="fk_sr_replacement"
references="services(id)"/>
</column>
<column name="declared_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
<!-- Optional human-readable compatibility notes from the replacement provider -->
<column name="compatibility_notes" type="text"/>
</createTable>
<!-- A replacement service can declare compatibility with each deprecated service once -->
<addUniqueConstraint
tableName="service_replacements"
columnNames="deprecated_service_id, replacement_service_id"
constraintName="uq_sr_deprecated_replacement"/>
<!-- Primary query path: GET /services/{deprecatedId}/replacements -->
<createIndex tableName="service_replacements" indexName="idx_sr_deprecated_service_id">
<column name="deprecated_service_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,24 @@
<?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="008" author="apix">
<!-- Drop old date-only index before renaming the column -->
<sql>DROP INDEX IF EXISTS idx_services_sunset_date;</sql>
<!-- Rename and retype: date → timestamptz (exclusive boundary, UTC).
Existing date values are cast to midnight UTC of that date. -->
<sql>ALTER TABLE services RENAME COLUMN sunset_date TO sunset_at;</sql>
<sql>ALTER TABLE services
ALTER COLUMN sunset_at TYPE timestamptz
USING sunset_at::timestamptz;</sql>
<!-- Recreate targeted index under new column name -->
<sql>CREATE INDEX idx_services_sunset_at ON services (sunset_at)
WHERE sunset_at IS NOT NULL;</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,17 @@
<?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">
<include file="changes/001-initial-schema.xml" relativeToChangelogFile="true"/>
<include file="changes/002-verification-columns.xml" relativeToChangelogFile="true"/>
<include file="changes/003-liveness-metrics.xml" relativeToChangelogFile="true"/>
<include file="changes/004-org-stage-status.xml" relativeToChangelogFile="true"/>
<include file="changes/005-version-history.xml" relativeToChangelogFile="true"/>
<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"/>
</databaseChangeLog>