producers) {
*/
private boolean isValidProvider(ProductConsumerDTO provider) {
- if (provider.getValidity() == null || provider.getValidity().equals(BigDecimal.ZERO)) return true;
+ if (provider.getValidity() == null || provider.getValidity().compareTo(BigDecimal.ZERO) == 0) return true;
return isValidGrantedTs(provider.getGrantedTs(), provider.getValidity());
}
diff --git a/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql b/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql
new file mode 100644
index 0000000..2034188
--- /dev/null
+++ b/src/main/resources/db/migration/V20260902120000__add_attribute_schema_tables.sql
@@ -0,0 +1,124 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+-- Which core entity types may carry dynamic attributes, and the table entity_id resolves against.
+CREATE TABLE attribute_scope (
+ id BIGSERIAL PRIMARY KEY,
+ code VARCHAR(50) NOT NULL,
+ table_name VARCHAR(150) NOT NULL,
+ description VARCHAR(500),
+ CONSTRAINT uq_attribute_scope__code UNIQUE (code)
+);
+
+-- Attribute vocabulary: name/type/validation metadata, independent of which scope(s) it applies to.
+CREATE TABLE attribute_definition (
+ id BIGSERIAL PRIMARY KEY,
+ namespace VARCHAR(150) NOT NULL,
+ name VARCHAR(150) NOT NULL,
+ display_name VARCHAR(255),
+ description TEXT NOT NULL,
+ data_type VARCHAR(50) NOT NULL,
+ multi_valued BOOLEAN NOT NULL DEFAULT FALSE,
+ allowed_values JSONB,
+ validation_pattern VARCHAR(500),
+ classification JSONB,
+ sensitive BOOLEAN NOT NULL DEFAULT FALSE,
+ is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ created_by VARCHAR(255) NOT NULL,
+ updated_at TIMESTAMP,
+ updated_by VARCHAR(255),
+ CONSTRAINT uq_attribute_definition__namespace_name UNIQUE (namespace, name)
+);
+
+-- Which scopes a definition is valid on, whether required there, and its default.
+CREATE TABLE attribute_definition_scope (
+ id BIGSERIAL PRIMARY KEY,
+ attribute_definition_id BIGINT NOT NULL,
+ attribute_scope_id BIGINT NOT NULL,
+ required BOOLEAN NOT NULL DEFAULT FALSE,
+ default_value JSONB,
+ is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ created_by VARCHAR(255) NOT NULL,
+ updated_at TIMESTAMP,
+ updated_by VARCHAR(255),
+ CONSTRAINT fk_attribute_definition_scope__attribute_definition_id
+ FOREIGN KEY (attribute_definition_id) REFERENCES attribute_definition (id),
+ CONSTRAINT fk_attribute_definition_scope__attribute_scope_id
+ FOREIGN KEY (attribute_scope_id) REFERENCES attribute_scope (id),
+ CONSTRAINT uq_attribute_definition_scope__definition_scope
+ UNIQUE (attribute_definition_id, attribute_scope_id)
+);
+CREATE INDEX idx_attribute_definition_scope__attribute_definition_id
+ ON attribute_definition_scope (attribute_definition_id);
+CREATE INDEX idx_attribute_definition_scope__attribute_scope_id
+ ON attribute_definition_scope (attribute_scope_id);
+
+-- Actual values. entity_id is polymorphic: PK of the row in attribute_scope.table_name for that pairing's
+-- scope, not a declared FK — enforced by the soft-delete trigger below, not by the database schema.
+CREATE TABLE attribute_value (
+ id BIGSERIAL PRIMARY KEY,
+ attribute_definition_scope_id BIGINT NOT NULL,
+ entity_id BIGINT NOT NULL,
+ value JSONB NOT NULL,
+ is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ created_by VARCHAR(255) NOT NULL,
+ updated_at TIMESTAMP,
+ updated_by VARCHAR(255),
+ CONSTRAINT fk_attribute_value__attribute_definition_scope_id
+ FOREIGN KEY (attribute_definition_scope_id) REFERENCES attribute_definition_scope (id)
+);
+CREATE INDEX idx_attribute_value__entity_id ON attribute_value (entity_id);
+-- Backs the PEP's EXISTS sub-queries per constraint (attribute_definition_scope_id, entity_id, value).
+CREATE UNIQUE INDEX uq_attr_value_live
+ ON attribute_value (attribute_definition_scope_id, entity_id, value)
+ WHERE is_deleted = FALSE;
+
+INSERT INTO attribute_scope (code, table_name, description) VALUES
+ ('ORGANISATION', 'organisation', 'Attributes carried by an organisation'),
+ ('CONSUMER', 'consumer', 'Attributes carried by a consumer'),
+ ('PRODUCER', 'producer', 'Attributes carried by a producer'),
+ ('PRODUCT', 'product', 'Attributes carried by a product'),
+ ('SUBSCRIPTION', 'product_consumer', 'Attributes carried by a product/consumer subscription');
+
+-- Soft-delete any live attribute_value rows for the entity being removed, scoped to the deleted table.
+CREATE FUNCTION fn_attribute_value_soft_delete_on_entity_delete() RETURNS TRIGGER AS $$
+BEGIN
+ UPDATE attribute_value av
+ SET is_deleted = TRUE,
+ updated_at = now(),
+ updated_by = 'trigger:' || TG_TABLE_NAME
+ FROM attribute_definition_scope ads
+ JOIN attribute_scope asc_ ON asc_.id = ads.attribute_scope_id
+ WHERE av.attribute_definition_scope_id = ads.id
+ AND asc_.table_name = TG_TABLE_NAME
+ AND av.entity_id = OLD.id
+ AND av.is_deleted = FALSE;
+ RETURN OLD;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_organisation_attribute_value_soft_delete
+ AFTER DELETE ON organisation
+ FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete();
+
+CREATE TRIGGER trg_consumer_attribute_value_soft_delete
+ AFTER DELETE ON consumer
+ FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete();
+
+CREATE TRIGGER trg_producer_attribute_value_soft_delete
+ AFTER DELETE ON producer
+ FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete();
+
+CREATE TRIGGER trg_product_attribute_value_soft_delete
+ AFTER DELETE ON product
+ FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete();
+
+CREATE TRIGGER trg_product_consumer_attribute_value_soft_delete
+ AFTER DELETE ON product_consumer
+ FOR EACH ROW EXECUTE FUNCTION fn_attribute_value_soft_delete_on_entity_delete();
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java
new file mode 100644
index 0000000..df49d13
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AbstractPostgresRepositoryTest.java
@@ -0,0 +1,53 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * Base class for repository tests that need the real Flyway migrations and real
+ * Postgres behaviour (partial unique indexes, {@code plpgsql} triggers) that the
+ * project's shared H2 test profile ({@code src/test/resources/application.yml}) cannot
+ * provide. Points the Spring context at a shared Postgres container and re-enables
+ * Flyway (disabled in the shared profile) so migrations apply for real.
+ *
+ * The container is started eagerly in a static initializer rather than left to the
+ * {@code @Testcontainers}/{@code @Container} JUnit extension. With multiple concrete
+ * subclasses - each getting its own Spring context - relying on the extension's
+ * per-class {@code beforeAll} to start (or no-op past) the container raced against
+ * context refresh on CI and on some local Docker setups: the first class or two would
+ * see the container "started" but not yet accepting TCP connections, and every test in
+ * that class would time out. Starting synchronously here, before any JUnit lifecycle
+ * callback runs for any subclass, removes that race. Testcontainers' Ryuk reaper still
+ * cleans the container up at JVM exit; it is not tied to the JUnit5 extension.
+ */
+@DataJpaTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+public abstract class AbstractPostgresRepositoryTest {
+
+ static final PostgreSQLContainer> POSTGRES =
+ new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"));
+
+ static {
+ POSTGRES.start();
+ }
+
+ @DynamicPropertySource
+ static void datasourceProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName);
+ registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect");
+ registry.add("spring.flyway.enabled", () -> "true");
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java
new file mode 100644
index 0000000..1ebdfb4
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionRepositoryTest.java
@@ -0,0 +1,64 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition;
+
+class AttributeDefinitionRepositoryTest extends AbstractPostgresRepositoryTest {
+
+ @Autowired
+ private AttributeDefinitionRepository attributeDefinitionRepository;
+
+ private static AttributeDefinition newDefinition(String namespace, String name) {
+ AttributeDefinition definition = new AttributeDefinition();
+ definition.setNamespace(namespace);
+ definition.setName(name);
+ definition.setDescription("Test attribute definition");
+ definition.setDataType("STRING");
+ definition.setCreatedAt(Timestamp.from(Instant.now()));
+ definition.setCreatedBy("test");
+ return definition;
+ }
+
+ @Test
+ void findByNamespaceAndName_returnsPersistedDefinition() {
+ attributeDefinitionRepository.saveAndFlush(newDefinition("policy", "risk-tier"));
+
+ Optional found =
+ attributeDefinitionRepository.findByNamespaceAndName("policy", "risk-tier");
+
+ assertThat(found).isPresent();
+ assertThat(found.get().getDataType()).isEqualTo("STRING");
+ assertThat(found.get().getMultiValued()).isFalse();
+ assertThat(found.get().getSensitive()).isFalse();
+ }
+
+ @Test
+ void findByNamespaceAndName_returnsEmptyForUnknownPair() {
+ Optional found = attributeDefinitionRepository.findByNamespaceAndName("nope", "nope");
+
+ assertThat(found).isEmpty();
+ }
+
+ @Test
+ void save_rejectsDuplicateNamespaceAndName() {
+ attributeDefinitionRepository.saveAndFlush(newDefinition("policy", "duplicate-check"));
+ AttributeDefinition duplicate = newDefinition("policy", "duplicate-check");
+
+ assertThrows(
+ DataIntegrityViolationException.class, () -> attributeDefinitionRepository.saveAndFlush(duplicate));
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java
new file mode 100644
index 0000000..605dcb4
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeDefinitionScopeRepositoryTest.java
@@ -0,0 +1,88 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope;
+
+class AttributeDefinitionScopeRepositoryTest extends AbstractPostgresRepositoryTest {
+
+ @Autowired
+ private AttributeDefinitionRepository attributeDefinitionRepository;
+
+ @Autowired
+ private AttributeScopeRepository attributeScopeRepository;
+
+ @Autowired
+ private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository;
+
+ private AttributeDefinition persistDefinition(String name) {
+ AttributeDefinition definition = new AttributeDefinition();
+ definition.setNamespace("policy");
+ definition.setName(name);
+ definition.setDescription("Test attribute definition");
+ definition.setDataType("STRING");
+ definition.setCreatedAt(Timestamp.from(Instant.now()));
+ definition.setCreatedBy("test");
+ return attributeDefinitionRepository.saveAndFlush(definition);
+ }
+
+ private static AttributeDefinitionScope newBinding(
+ AttributeDefinition definition, AttributeScope scope, boolean required) {
+ AttributeDefinitionScope binding = new AttributeDefinitionScope();
+ binding.setAttributeDefinition(definition);
+ binding.setAttributeScope(scope);
+ binding.setRequired(required);
+ binding.setCreatedAt(Timestamp.from(Instant.now()));
+ binding.setCreatedBy("test");
+ return binding;
+ }
+
+ @Test
+ void findByAttributeDefinitionId_returnsAllBoundScopes() {
+ AttributeDefinition definition = persistDefinition("multi-scope-attr");
+ AttributeScope productScope =
+ attributeScopeRepository.findByCode("PRODUCT").orElseThrow();
+ AttributeScope consumerScope =
+ attributeScopeRepository.findByCode("CONSUMER").orElseThrow();
+
+ attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, productScope, true));
+ attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, consumerScope, false));
+
+ List bindings =
+ attributeDefinitionScopeRepository.findByAttributeDefinitionId(definition.getId());
+
+ assertThat(bindings).hasSize(2);
+ assertThat(bindings)
+ .extracting(b -> b.getAttributeScope().getId())
+ .containsExactlyInAnyOrder(productScope.getId(), consumerScope.getId());
+ }
+
+ @Test
+ void save_rejectsDuplicateDefinitionScopePair() {
+ AttributeDefinition definition = persistDefinition("duplicate-binding-attr");
+ AttributeScope productScope =
+ attributeScopeRepository.findByCode("PRODUCT").orElseThrow();
+ attributeDefinitionScopeRepository.saveAndFlush(newBinding(definition, productScope, false));
+
+ AttributeDefinitionScope duplicate = newBinding(definition, productScope, true);
+
+ assertThrows(
+ DataIntegrityViolationException.class,
+ () -> attributeDefinitionScopeRepository.saveAndFlush(duplicate));
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java
new file mode 100644
index 0000000..a76c944
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeScopeRepositoryTest.java
@@ -0,0 +1,47 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope;
+
+class AttributeScopeRepositoryTest extends AbstractPostgresRepositoryTest {
+
+ @Autowired
+ private AttributeScopeRepository attributeScopeRepository;
+
+ @Test
+ void findByCode_returnsSeededScope() {
+ Optional found = attributeScopeRepository.findByCode("PRODUCT");
+
+ assertThat(found).isPresent();
+ assertThat(found.get().getTableName()).isEqualTo("product");
+ }
+
+ @Test
+ void findByCode_returnsEmptyForUnknownCode() {
+ Optional found = attributeScopeRepository.findByCode("DOES_NOT_EXIST");
+
+ assertThat(found).isEmpty();
+ }
+
+ @Test
+ void save_rejectsDuplicateCode() {
+ AttributeScope duplicate = new AttributeScope();
+ duplicate.setCode("PRODUCT");
+ duplicate.setTableName("product");
+ duplicate.setDescription("Duplicate of the seeded PRODUCT scope");
+
+ assertThrows(DataIntegrityViolationException.class, () -> attributeScopeRepository.saveAndFlush(duplicate));
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java
new file mode 100644
index 0000000..2a24d25
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueRepositoryTest.java
@@ -0,0 +1,141 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue;
+
+class AttributeValueRepositoryTest extends AbstractPostgresRepositoryTest {
+
+ @Autowired
+ private AttributeDefinitionRepository attributeDefinitionRepository;
+
+ @Autowired
+ private AttributeScopeRepository attributeScopeRepository;
+
+ @Autowired
+ private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository;
+
+ @Autowired
+ private AttributeValueRepository attributeValueRepository;
+
+ private AttributeDefinitionScope persistProductScopedBinding(String attributeName) {
+ AttributeDefinition definition = new AttributeDefinition();
+ definition.setNamespace("policy");
+ definition.setName(attributeName);
+ definition.setDescription("Test attribute definition");
+ definition.setDataType("STRING");
+ definition.setCreatedAt(Timestamp.from(Instant.now()));
+ definition.setCreatedBy("test");
+ definition = attributeDefinitionRepository.saveAndFlush(definition);
+
+ AttributeScope productScope =
+ attributeScopeRepository.findByCode("PRODUCT").orElseThrow();
+
+ AttributeDefinitionScope binding = new AttributeDefinitionScope();
+ binding.setAttributeDefinition(definition);
+ binding.setAttributeScope(productScope);
+ binding.setRequired(false);
+ binding.setCreatedAt(Timestamp.from(Instant.now()));
+ binding.setCreatedBy("test");
+ return attributeDefinitionScopeRepository.saveAndFlush(binding);
+ }
+
+ private static AttributeValue newValue(AttributeDefinitionScope binding, Long entityId, String json) {
+ AttributeValue value = new AttributeValue();
+ value.setAttributeDefinitionScope(binding);
+ value.setEntityId(entityId);
+ value.setValue(json);
+ value.setCreatedAt(Timestamp.from(Instant.now()));
+ value.setCreatedBy("test");
+ return value;
+ }
+
+ @Test
+ void findLiveValue_returnsNonDeletedValue() {
+ AttributeDefinitionScope binding = persistProductScopedBinding("live-value-attr");
+ attributeValueRepository.saveAndFlush(newValue(binding, 1001L, "\"gold\""));
+
+ List live =
+ attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse(
+ binding.getId(), 1001L);
+
+ assertThat(live).hasSize(1);
+ assertThat(live.get(0).getValue()).isEqualTo("\"gold\"");
+ }
+
+ @Test
+ void findLiveValue_excludesSoftDeletedValue() {
+ AttributeDefinitionScope binding = persistProductScopedBinding("soft-deleted-attr");
+ AttributeValue value = newValue(binding, 1002L, "\"silver\"");
+ value.setIsDeleted(true);
+ attributeValueRepository.saveAndFlush(value);
+
+ List live =
+ attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse(
+ binding.getId(), 1002L);
+
+ assertThat(live).isEmpty();
+ }
+
+ @Test
+ void save_rejectsExactDuplicateLiveValue() {
+ AttributeDefinitionScope binding = persistProductScopedBinding("duplicate-value-attr");
+ attributeValueRepository.saveAndFlush(newValue(binding, 1003L, "\"gold\""));
+
+ AttributeValue duplicate = newValue(binding, 1003L, "\"gold\"");
+
+ assertThrows(DataIntegrityViolationException.class, () -> attributeValueRepository.saveAndFlush(duplicate));
+ }
+
+ @Test
+ void save_acceptsDistinctValueForSameBindingAndEntity() {
+ // uq_attr_value_live keys on (scope, entity, value) - it is an idempotency guard against exact
+ // duplicates, not a single-valuedness constraint, so a different value is allowed. See design.md.
+ AttributeDefinitionScope binding = persistProductScopedBinding("multi-valued-attr");
+ attributeValueRepository.saveAndFlush(newValue(binding, 1004L, "\"gold\""));
+
+ attributeValueRepository.saveAndFlush(newValue(binding, 1004L, "\"silver\""));
+
+ List live =
+ attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse(
+ binding.getId(), 1004L);
+ assertThat(live)
+ .hasSize(2)
+ .extracting(AttributeValue::getValue)
+ .containsExactlyInAnyOrder("\"gold\"", "\"silver\"");
+ }
+
+ @Test
+ void save_allowsSameValueAgainAfterPriorDuplicateIsSoftDeleted() {
+ AttributeDefinitionScope binding = persistProductScopedBinding("resurrected-attr");
+ AttributeValue first = newValue(binding, 1005L, "\"gold\"");
+ first = attributeValueRepository.saveAndFlush(first);
+ first.setIsDeleted(true);
+ attributeValueRepository.saveAndFlush(first);
+
+ AttributeValue resurrected = newValue(binding, 1005L, "\"gold\"");
+ attributeValueRepository.saveAndFlush(resurrected);
+
+ List live =
+ attributeValueRepository.findByAttributeDefinitionScopeIdAndEntityIdAndIsDeletedFalse(
+ binding.getId(), 1005L);
+ assertThat(live).hasSize(1);
+ assertThat(live.get(0).getValue()).isEqualTo("\"gold\"");
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java
new file mode 100644
index 0000000..c8ffe58
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/AttributeValueSoftDeleteTriggerTest.java
@@ -0,0 +1,235 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally
+ * attributed to the Department for Business and Trade (UK) as the governing entity.
+ */
+
+package uk.gov.dbt.ndtp.ia.node.management.persistency.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinition;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeDefinitionScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeScope;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.AttributeValue;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product;
+import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer;
+
+/**
+ * Verifies the migration's five {@code AFTER DELETE} triggers, which soft-delete
+ * {@code attribute_value} rows scoped to the deleted owning entity rather than
+ * leaving them orphaned.
+ */
+class AttributeValueSoftDeleteTriggerTest extends AbstractPostgresRepositoryTest {
+
+ @Autowired
+ private OrganisationRepository organisationRepository;
+
+ @Autowired
+ private ProducerRepository producerRepository;
+
+ @Autowired
+ private ConsumerRepository consumerRepository;
+
+ @Autowired
+ private ProductRepository productRepository;
+
+ @Autowired
+ private ProductConsumerRepository productConsumerRepository;
+
+ @Autowired
+ private AttributeScopeRepository attributeScopeRepository;
+
+ @Autowired
+ private AttributeDefinitionRepository attributeDefinitionRepository;
+
+ @Autowired
+ private AttributeDefinitionScopeRepository attributeDefinitionScopeRepository;
+
+ @Autowired
+ private AttributeValueRepository attributeValueRepository;
+
+ @Autowired
+ private TestEntityManager testEntityManager;
+
+ private AttributeDefinitionScope bindingFor(String scopeCode, String attributeName) {
+ AttributeDefinition definition = new AttributeDefinition();
+ definition.setNamespace("policy");
+ definition.setName(attributeName);
+ definition.setDescription("Trigger test attribute definition");
+ definition.setDataType("STRING");
+ definition.setCreatedAt(Timestamp.from(Instant.now()));
+ definition.setCreatedBy("test");
+ definition = attributeDefinitionRepository.saveAndFlush(definition);
+
+ AttributeScope scope = attributeScopeRepository.findByCode(scopeCode).orElseThrow();
+
+ AttributeDefinitionScope binding = new AttributeDefinitionScope();
+ binding.setAttributeDefinition(definition);
+ binding.setAttributeScope(scope);
+ binding.setRequired(false);
+ binding.setCreatedAt(Timestamp.from(Instant.now()));
+ binding.setCreatedBy("test");
+ return attributeDefinitionScopeRepository.saveAndFlush(binding);
+ }
+
+ private Long persistLiveValue(AttributeDefinitionScope binding, Long entityId) {
+ AttributeValue value = new AttributeValue();
+ value.setAttributeDefinitionScope(binding);
+ value.setEntityId(entityId);
+ value.setValue("\"trigger-test-value\"");
+ value.setCreatedAt(Timestamp.from(Instant.now()));
+ value.setCreatedBy("test");
+ return attributeValueRepository.saveAndFlush(value).getId();
+ }
+
+ private Organisation persistOrganisation() {
+ Organisation organisation = new Organisation();
+ organisation.setName("Trigger Test Org");
+ return organisationRepository.saveAndFlush(organisation);
+ }
+
+ private Consumer persistConsumer(Organisation organisation) {
+ Consumer consumer = new Consumer();
+ consumer.setName("Trigger Test Consumer");
+ consumer.setOrg(organisation);
+ consumer.setIdpClientId("trigger-test-consumer");
+ consumer.setScheduleType("cron");
+ return consumerRepository.saveAndFlush(consumer);
+ }
+
+ private Producer persistProducer(Organisation organisation) {
+ Producer producer = new Producer();
+ producer.setName("Trigger Test Producer");
+ producer.setDescription("Trigger test producer");
+ producer.setOrg(organisation);
+ producer.setActive(true);
+ producer.setHost("localhost");
+ producer.setPort(BigDecimal.valueOf(8080));
+ producer.setTls(true);
+ producer.setIdpClientId("trigger-test-producer");
+ return producerRepository.saveAndFlush(producer);
+ }
+
+ private Product persistProduct(Producer producer) {
+ Product product = new Product();
+ product.setName("Trigger Test Product");
+ product.setTopic("topic.trigger-test");
+ product.setProducer(producer);
+ return productRepository.saveAndFlush(product);
+ }
+
+ private ProductConsumer persistProductConsumer(Product product, Consumer consumer) {
+ ProductConsumer productConsumer = new ProductConsumer();
+ productConsumer.setProduct(product);
+ productConsumer.setConsumer(consumer);
+ productConsumer.setGrantedTs(Timestamp.from(Instant.now()));
+ productConsumer.setValidity(BigDecimal.valueOf(30));
+ productConsumer.setScheduleType("cron");
+ return productConsumerRepository.saveAndFlush(productConsumer);
+ }
+
+ @Test
+ void deletingOrganisation_softDeletesItsAttributeValues() {
+ Organisation organisation = persistOrganisation();
+ AttributeDefinitionScope binding = bindingFor("ORGANISATION", "org-trigger-attr");
+ Long valueId = persistLiveValue(binding, organisation.getId());
+
+ organisationRepository.delete(organisation);
+ organisationRepository.flush();
+ testEntityManager.clear();
+
+ assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat(
+ v.getIsDeleted())
+ .isTrue());
+ }
+
+ @Test
+ void deletingConsumer_softDeletesItsAttributeValues() {
+ Organisation organisation = persistOrganisation();
+ Consumer consumer = persistConsumer(organisation);
+ AttributeDefinitionScope binding = bindingFor("CONSUMER", "consumer-trigger-attr");
+ Long valueId = persistLiveValue(binding, consumer.getId());
+
+ consumerRepository.delete(consumer);
+ consumerRepository.flush();
+ testEntityManager.clear();
+
+ assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat(
+ v.getIsDeleted())
+ .isTrue());
+ }
+
+ @Test
+ void deletingProducer_softDeletesItsAttributeValues() {
+ Organisation organisation = persistOrganisation();
+ Producer producer = persistProducer(organisation);
+ AttributeDefinitionScope binding = bindingFor("PRODUCER", "producer-trigger-attr");
+ Long valueId = persistLiveValue(binding, producer.getId());
+
+ producerRepository.delete(producer);
+ producerRepository.flush();
+ testEntityManager.clear();
+
+ assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat(
+ v.getIsDeleted())
+ .isTrue());
+ }
+
+ @Test
+ void deletingProduct_softDeletesItsAttributeValues() {
+ Organisation organisation = persistOrganisation();
+ Producer producer = persistProducer(organisation);
+ Product product = persistProduct(producer);
+ AttributeDefinitionScope binding = bindingFor("PRODUCT", "product-trigger-attr");
+ Long valueId = persistLiveValue(binding, product.getId());
+
+ productRepository.delete(product);
+ productRepository.flush();
+ testEntityManager.clear();
+
+ assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat(
+ v.getIsDeleted())
+ .isTrue());
+ }
+
+ @Test
+ void deletingProductConsumer_softDeletesItsAttributeValues() {
+ Organisation organisation = persistOrganisation();
+ Producer producer = persistProducer(organisation);
+ Product product = persistProduct(producer);
+ Consumer consumer = persistConsumer(organisation);
+ ProductConsumer productConsumer = persistProductConsumer(product, consumer);
+ AttributeDefinitionScope binding = bindingFor("SUBSCRIPTION", "subscription-trigger-attr");
+ Long valueId = persistLiveValue(binding, productConsumer.getId());
+
+ productConsumerRepository.delete(productConsumer);
+ productConsumerRepository.flush();
+ testEntityManager.clear();
+
+ assertThat(attributeValueRepository.findById(valueId)).isPresent().get().satisfies(v -> assertThat(
+ v.getIsDeleted())
+ .isTrue());
+ }
+
+ @Test
+ void deletingEntityWithNoAttributeValues_succeedsAndLeavesAttributeValueTableUntouched() {
+ Organisation organisation = persistOrganisation();
+ long countBefore = attributeValueRepository.count();
+
+ organisationRepository.delete(organisation);
+ organisationRepository.flush();
+
+ assertThat(organisationRepository.existsById(organisation.getId())).isFalse();
+ assertThat(attributeValueRepository.count()).isEqualTo(countBefore);
+ }
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java
index 27a429e..aead7da 100644
--- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java
@@ -159,8 +159,7 @@ void getConsumerConfigByClientId_whenNoValidProducts_clearsAllProducerProducts()
void getConsumerConfigByClientId_withConsumerIdFilter_appliesFilter_andRemovesNullProductIds() {
String clientId = "clientC";
ConsumerDTO c1 = consumer(3L, clientId, "c3", "CRON", "@daily");
- ConsumerDTO cOther = consumer(99L, clientId, "other", "CRON", "@minutely");
- when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1, cOther));
+ when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1));
ProductConsumerDTO pc = productConsumer(300L, 3L, null, null);
when(productConsumerService.findByConsumerId(3L)).thenReturn(List.of(pc));
@@ -272,8 +271,8 @@ void getProducerConfigByClientId_withValidValidity_includesConsumer() {
void getConsumerConfigByClientId_withConsumerId_filtersByConsumerId() {
String clientId = "clientA";
ConsumerDTO c1 = consumer(1L, clientId, "c1", "CRON", "@hourly");
- ConsumerDTO c2 = consumer(2L, clientId, "c2", "CRON", "@daily");
- when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1, c2));
+
+ when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1));
ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(1L));
@@ -284,11 +283,9 @@ void getConsumerConfigByClientId_withConsumerId_filtersByConsumerId() {
void getProducerConfigByClientId_withProducerId_filtersByProducerId() {
String clientId = "producerClient";
ProductDTO p1 = product(100L, "p1");
- ProductDTO p2 = product(101L, "p2");
ProducerDTO pr1 = producer(1L, true, p1);
- ProducerDTO pr2 = producer(2L, true, p2);
- when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1, pr2));
+ when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1));
ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(1L));