handleFilterCompilationException(
+ FilterCompilationException ex, WebRequest request) {
+
+ String errorId = generateErrorId();
+
+ if (ex.origin() == FilterCompilationException.Origin.REQUEST) {
+ log.debug(
+ "Rejected caller filter, error_id={}, path={}: {}",
+ errorId,
+ request.getDescription(false),
+ ex.getMessage());
+ ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), ex.getMessage(), errorId);
+ return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
+ }
+
+ log.error(
+ "Filter attribute configuration defect, error_id={}, path={}: {}",
+ errorId,
+ request.getDescription(false),
+ ex.getMessage(),
+ ex);
+ ErrorResponse errorResponse = new ErrorResponse(
+ HttpStatus.INTERNAL_SERVER_ERROR.value(), "An internal server error occurred", errorId);
+ return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+
/**
* Handles RuntimeException.
*
diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/Combinator.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/Combinator.java
new file mode 100644
index 0000000..e3344df
--- /dev/null
+++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/Combinator.java
@@ -0,0 +1,39 @@
+/*
+ * 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.filter;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import java.util.Arrays;
+import java.util.Locale;
+
+/** How the children of a {@link FilterNode.Group} combine. */
+public enum Combinator {
+ AND("and"),
+ OR("or");
+
+ private final String wireName;
+
+ Combinator(String wireName) {
+ this.wireName = wireName;
+ }
+
+ @JsonValue
+ public String wireName() {
+ return wireName;
+ }
+
+ @JsonCreator
+ public static Combinator fromWireName(String value) {
+ String normalised = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
+ return Arrays.stream(values())
+ .filter(combinator -> combinator.wireName.equals(normalised))
+ .findFirst()
+ .orElseThrow(() ->
+ new IllegalArgumentException("Unsupported combinator '" + value + "'; supported: [and, or]"));
+ }
+}
diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/ComparisonOperator.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/ComparisonOperator.java
new file mode 100644
index 0000000..61c93d2
--- /dev/null
+++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/ComparisonOperator.java
@@ -0,0 +1,73 @@
+/*
+ * 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.filter;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import java.util.Arrays;
+import java.util.Locale;
+
+/**
+ * The closed comparison vocabulary a {@link FilterNode.Comparison} may use. Anything a caller
+ * names outside this set is rejected rather than interpreted, which is what keeps the
+ * translation from a caller filter to a database predicate total and auditable.
+ */
+public enum ComparisonOperator {
+ EQ("eq", Arity.SINGLE),
+ NEQ("neq", Arity.SINGLE),
+ IN("in", Arity.ANY),
+ NOT_IN("not_in", Arity.ANY),
+ LT("lt", Arity.SINGLE),
+ LTE("lte", Arity.SINGLE),
+ GT("gt", Arity.SINGLE),
+ GTE("gte", Arity.SINGLE),
+ /** Case-insensitive substring match. */
+ CONTAINS("contains", Arity.SINGLE);
+
+ /** How many operands the operator accepts. */
+ public enum Arity {
+ /** Exactly one value. */
+ SINGLE,
+ /** Zero or more values. */
+ ANY
+ }
+
+ private final String wireName;
+ private final Arity arity;
+
+ ComparisonOperator(String wireName, Arity arity) {
+ this.wireName = wireName;
+ this.arity = arity;
+ }
+
+ @JsonValue
+ public String wireName() {
+ return wireName;
+ }
+
+ public Arity arity() {
+ return arity;
+ }
+
+ /** {@code true} for operators that require a totally ordered operand type. */
+ public boolean isOrdering() {
+ return this == LT || this == LTE || this == GT || this == GTE;
+ }
+
+ @JsonCreator
+ public static ComparisonOperator fromWireName(String value) {
+ String normalised = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
+ return Arrays.stream(values())
+ .filter(operator -> operator.wireName.equals(normalised))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Unsupported comparison operator '" + value
+ + "'; supported operators are "
+ + Arrays.stream(values())
+ .map(ComparisonOperator::wireName)
+ .toList()));
+ }
+}
diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/FilterCompilationException.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/FilterCompilationException.java
new file mode 100644
index 0000000..413ee91
--- /dev/null
+++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/FilterCompilationException.java
@@ -0,0 +1,39 @@
+/*
+ * 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.filter;
+
+/**
+ * Raised when a {@link FilterNode} cannot be compiled into a database query predicate.
+ *
+ * {@link Origin} decides the HTTP response: a malformed caller filter is a client error,
+ * whereas an attribute definition this system's own configuration cannot honour (an
+ * unrecognised {@code data_type}, or a stored value that fails to cast to its declared type) is
+ * an internal fault. The latter must never degrade into "apply what could be understood" - a
+ * partially applied filter is indistinguishable from a data leak - so both cases abort the
+ * request rather than return a partial or unfiltered result.
+ */
+public class FilterCompilationException extends RuntimeException {
+
+ /** Which trust domain produced the offending predicate. */
+ public enum Origin {
+ /** A caller-supplied filter is malformed, unknown, or type-mismatched. Maps to 400. */
+ REQUEST,
+ /** This system's own attribute configuration or stored data is inconsistent. Maps to 500. */
+ POLICY
+ }
+
+ private final Origin origin;
+
+ public FilterCompilationException(Origin origin, String message) {
+ super(message);
+ this.origin = origin;
+ }
+
+ public Origin origin() {
+ return origin;
+ }
+}
diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/FilterNode.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/FilterNode.java
new file mode 100644
index 0000000..28911b7
--- /dev/null
+++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/filter/FilterNode.java
@@ -0,0 +1,69 @@
+/*
+ * 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.filter;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A database-agnostic predicate tree carrying no SQL, no column names, and no operators beyond
+ * {@link ComparisonOperator} - a caller-supplied filter can never express anything the
+ * {@code SpecificationPredicateCompiler} cannot bind as a parameter.
+ */
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = FilterNode.Group.class, name = "group"),
+ @JsonSubTypes.Type(value = FilterNode.Comparison.class, name = "comparison")
+})
+public sealed interface FilterNode {
+
+ /**
+ * A conjunction or disjunction of child predicates. An empty {@code AND} is true and an
+ * empty {@code OR} is false, matching the identity element of each operation - neither case
+ * silently widens a result set.
+ */
+ record Group(@NotNull Combinator combinator, @NotNull List nodes) implements FilterNode {
+
+ public Group {
+ nodes = nodes == null ? List.of() : List.copyOf(nodes);
+ }
+
+ public static Group and(List nodes) {
+ return new Group(Combinator.AND, nodes);
+ }
+
+ public static Group or(List nodes) {
+ return new Group(Combinator.OR, nodes);
+ }
+ }
+
+ /**
+ * A comparison of one resource attribute against one or more literal operands.
+ *
+ * @param attribute logical attribute name, resolved against the resource attribute registry
+ * - never a column name and never interpolated into a query
+ * @param operator the comparison to apply
+ * @param values operands, still in their JSON representation; coerced to the attribute's
+ * declared type at compile time
+ */
+ record Comparison(@NotBlank String attribute, @NotNull ComparisonOperator operator, @NotNull List