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,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>