diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/BackendRegistry.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/BackendRegistry.java
new file mode 100644
index 0000000..71cebd9
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/BackendRegistry.java
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import com.codeheadsystems.velocity.spi.VelocityBackend;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * An immutable registry of the backends the engine can dispatch to, keyed by the backend name a
+ * feature definition binds to (FR-16).
+ *
+ *
Injected at engine construction (no Dagger here — DI lives in the service tier). A feature
+ * bound to an unknown backend is a configuration error surfaced immediately by {@link #backend}.
+ */
+public final class BackendRegistry {
+
+ private final Map backends;
+
+ /**
+ * Creates a registry from a name → backend map.
+ *
+ * @param backends the backends by name; copied defensively, must be non-empty with non-blank keys
+ */
+ public BackendRegistry(final Map backends) {
+ Objects.requireNonNull(backends, "backends");
+ if (backends.isEmpty()) {
+ throw new IllegalArgumentException("backend registry must not be empty");
+ }
+ backends.forEach(
+ (name, backend) -> {
+ Objects.requireNonNull(name, "backend name");
+ Objects.requireNonNull(backend, "backend for '" + name + "'");
+ if (name.isBlank()) {
+ throw new IllegalArgumentException("backend name must not be blank");
+ }
+ });
+ this.backends = Map.copyOf(backends);
+ }
+
+ /**
+ * Looks up a backend by name.
+ *
+ * @param name the backend name a definition binds to
+ * @return the backend
+ * @throws IllegalArgumentException if no backend is registered under {@code name}
+ */
+ public VelocityBackend backend(final String name) {
+ Objects.requireNonNull(name, "name");
+ final VelocityBackend backend = backends.get(name);
+ if (backend == null) {
+ throw new IllegalArgumentException(
+ "no backend registered under name '" + name + "'; known backends: " + backends.keySet());
+ }
+ return backend;
+ }
+
+ /**
+ * The registered backend names.
+ *
+ * @return the immutable set of names
+ */
+ public Set names() {
+ return backends.keySet();
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/CapabilityValidator.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/CapabilityValidator.java
new file mode 100644
index 0000000..38d9d64
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/CapabilityValidator.java
@@ -0,0 +1,90 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import com.codeheadsystems.velocity.core.model.CapabilityValidationResult;
+import com.codeheadsystems.velocity.core.model.CapabilityValidationResult.Violation;
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.spi.model.AggregationType;
+import com.codeheadsystems.velocity.spi.model.BackendCapabilities;
+import com.codeheadsystems.velocity.spi.model.Window;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Validates feature definitions against their target backend's declared capabilities (FR-29).
+ *
+ * The engine rejects an import/reload unless every definition is valid (atomic with FR-17); this
+ * validator returns all violations rather than throwing on the first, so the rejection can
+ * carry a complete, precise list. Fast-reject, not silent degradation (NFR-18): the engine never
+ * pretends a backend can serve a window/aggregation/retention it did not declare.
+ *
+ *
Checks, per definition, that: the aggregation is supported; every window is supported (matches
+ * a declared {@code WindowSpec}); the backend's {@code maxRetention} covers the largest window
+ * (FR-22a); and DISTINCT is only used where DISTINCT is supported.
+ */
+public final class CapabilityValidator {
+
+ /** Creates a capability validator. */
+ public CapabilityValidator() {}
+
+ /**
+ * Validates one definition against a backend's capabilities.
+ *
+ * @param definition the definition to validate
+ * @param capabilities the target backend's declared capabilities
+ * @return the collected violations (empty when valid)
+ */
+ public CapabilityValidationResult validate(
+ final FeatureDefinition definition, final BackendCapabilities capabilities) {
+ Objects.requireNonNull(definition, "definition");
+ Objects.requireNonNull(capabilities, "capabilities");
+ final List violations = new ArrayList<>();
+ collect(definition, capabilities, violations);
+ return new CapabilityValidationResult(violations);
+ }
+
+ private void collect(
+ final FeatureDefinition definition,
+ final BackendCapabilities capabilities,
+ final List violations) {
+ final AggregationType type = definition.aggregation().type();
+ if (!capabilities.supportsAggregation(type)) {
+ violations.add(
+ new Violation(
+ definition.name(),
+ "aggregation "
+ + type
+ + " is not supported by backend '"
+ + definition.backend()
+ + "'"));
+ }
+ for (final Window window : definition.windows()) {
+ if (!capabilities.supportsWindow(window)) {
+ violations.add(
+ new Violation(
+ definition.name(),
+ "window "
+ + window.type()
+ + " "
+ + window.duration()
+ + " is not supported by backend '"
+ + definition.backend()
+ + "'"));
+ }
+ }
+ final var largest = definition.largestWindow();
+ if (largest.compareTo(capabilities.maxRetention()) > 0) {
+ violations.add(
+ new Violation(
+ definition.name(),
+ "largest window "
+ + largest
+ + " exceeds backend '"
+ + definition.backend()
+ + "' maxRetention "
+ + capabilities.maxRetention()
+ + " (FR-22a)"));
+ }
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/DefinitionVersionHasher.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/DefinitionVersionHasher.java
new file mode 100644
index 0000000..6808c30
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/DefinitionVersionHasher.java
@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.core.model.SubjectSource;
+import com.codeheadsystems.velocity.spi.model.Aggregation;
+import com.codeheadsystems.velocity.spi.model.Window;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Computes the deterministic {@code versionHash} stamped onto a {@code FeatureDefinitions} snapshot
+ * (FR-40).
+ *
+ * The hash is a SHA-256 over a canonical, order-independent string form of the definitions:
+ * definitions are sorted by name and each is rendered to a stable field-delimited line, so the same
+ * set of definitions always hashes to the same value regardless of list order, and any change to
+ * any field changes the hash. A caller that sees a value stamped with a different hash than it last
+ * read knows the definition changed under it (FR-40, §15 R12).
+ */
+public final class DefinitionVersionHasher {
+
+ private DefinitionVersionHasher() {}
+
+ /**
+ * Computes the version hash of a definition set.
+ *
+ * @param definitions the definitions to hash (any order)
+ * @return a lowercase hex SHA-256 of the canonical form
+ */
+ public static String hash(final List definitions) {
+ Objects.requireNonNull(definitions, "definitions");
+ final List lines = new ArrayList<>(definitions.size());
+ for (final FeatureDefinition definition : definitions) {
+ lines.add(canonical(definition));
+ }
+ lines.sort(String::compareTo);
+ final String canonical = String.join("\n", lines);
+ return sha256Hex(canonical.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String canonical(final FeatureDefinition definition) {
+ final StringBuilder builder = new StringBuilder();
+ builder
+ .append(definition.name())
+ .append('|')
+ .append(definition.subjectType())
+ .append('|')
+ .append(subjectSource(definition.subjectSource()))
+ .append('|')
+ .append(definition.backend())
+ .append('|')
+ .append(aggregation(definition.aggregation()))
+ .append('|')
+ .append(definition.distinctThreshold())
+ .append('|');
+ final List windows = new ArrayList<>();
+ for (final Window window : definition.windows()) {
+ windows.add(window.type() + ":" + window.duration());
+ }
+ windows.sort(String::compareTo);
+ builder.append(String.join(",", windows));
+ return builder.toString();
+ }
+
+ private static String subjectSource(final SubjectSource source) {
+ return switch (source) {
+ case SubjectSource.Primary ignored -> "PRIMARY";
+ case SubjectSource.FromDimension fromDimension ->
+ "FROM_DIMENSION:" + fromDimension.dimensionName();
+ };
+ }
+
+ private static String aggregation(final Aggregation aggregation) {
+ return aggregation.type()
+ + (aggregation.dimension() == null ? "" : ":" + aggregation.dimension());
+ }
+
+ private static String sha256Hex(final byte[] bytes) {
+ final MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance("SHA-256");
+ } catch (final NoSuchAlgorithmException e) {
+ // SHA-256 is a mandated JCA algorithm; its absence is an unrecoverable environment fault.
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ final byte[] hashed = digest.digest(bytes);
+ final StringBuilder hex = new StringBuilder(hashed.length * 2);
+ for (final byte b : hashed) {
+ hex.append(Character.forDigit((b >> 4) & 0xF, 16));
+ hex.append(Character.forDigit(b & 0xF, 16));
+ }
+ return hex.toString();
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/DimensionHasher.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/DimensionHasher.java
new file mode 100644
index 0000000..59d121d
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/DimensionHasher.java
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import com.codeheadsystems.velocity.spi.model.DistinctMember;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.util.Objects;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Hashes a raw DISTINCT dimension value into an opaque, fixed-width {@link DistinctMember} so raw
+ * PII (IPs, tokens, device IDs) never reaches a backend (FR-38, §15 R11).
+ *
+ * The token is {@code HMAC-SHA256(salt, value)} — 32 bytes — with the per-namespace salt drawn
+ * from a {@link NamespaceSaltProvider}. The mapping is deterministic per {@code (namespace, value)}
+ * (so the same value counts once) and different values produce different members (so cardinality is
+ * preserved), while the backend only ever stores and counts the opaque token. Keyed hashing (not a
+ * bare digest) is what makes a low-entropy dimension resistant to offline brute-force when the
+ * salt lives in a separate trust domain (§15 R11); see {@link NamespaceSaltProvider}.
+ */
+public final class DimensionHasher {
+
+ private static final String HMAC_SHA256 = "HmacSHA256";
+
+ private final NamespaceSaltProvider saltProvider;
+
+ /**
+ * Creates a hasher backed by the given salt provider.
+ *
+ * @param saltProvider the per-namespace salt source
+ */
+ public DimensionHasher(final NamespaceSaltProvider saltProvider) {
+ this.saltProvider = Objects.requireNonNull(saltProvider, "saltProvider");
+ }
+
+ /**
+ * Hashes a raw dimension value into an opaque distinct member.
+ *
+ * @param namespace the namespace (selects the salt)
+ * @param value the raw dimension value
+ * @return the 32-byte keyed-hash member
+ */
+ public DistinctMember hash(final Namespace namespace, final String value) {
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(value, "value");
+ final Mac mac = newMac(saltProvider.salt(namespace));
+ final byte[] token = mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
+ return new DistinctMember(token);
+ }
+
+ private static Mac newMac(final byte[] salt) {
+ try {
+ final Mac mac = Mac.getInstance(HMAC_SHA256);
+ mac.init(new SecretKeySpec(salt, HMAC_SHA256));
+ return mac;
+ } catch (final java.security.NoSuchAlgorithmException e) {
+ // HmacSHA256 is a mandated JCA algorithm; its absence is an unrecoverable environment fault.
+ throw new IllegalStateException("HmacSHA256 not available", e);
+ } catch (final InvalidKeyException e) {
+ throw new IllegalStateException("invalid HMAC salt", e);
+ }
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/FanOutResolver.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/FanOutResolver.java
new file mode 100644
index 0000000..8533161
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/FanOutResolver.java
@@ -0,0 +1,166 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import com.codeheadsystems.velocity.core.model.FanOutResult;
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.core.model.FeatureDefinitions;
+import com.codeheadsystems.velocity.core.model.SubjectSource;
+import com.codeheadsystems.velocity.spi.model.Aggregation;
+import com.codeheadsystems.velocity.spi.model.ApplyStatus;
+import com.codeheadsystems.velocity.spi.model.FailureCode;
+import com.codeheadsystems.velocity.spi.model.Feature;
+import com.codeheadsystems.velocity.spi.model.FeatureResult;
+import com.codeheadsystems.velocity.spi.model.Intent;
+import com.codeheadsystems.velocity.spi.model.Intent.CountIntent;
+import com.codeheadsystems.velocity.spi.model.Intent.DistinctIntent;
+import com.codeheadsystems.velocity.spi.model.Intent.SumIntent;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import com.codeheadsystems.velocity.spi.model.PerFeature;
+import com.codeheadsystems.velocity.spi.model.Subject;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Resolves one {@code record()} event into backend-neutral write intents grouped by backend
+ * (FR-18).
+ *
+ *
For each definition in the namespace's snapshot the resolver decides the target {@link
+ * Subject} (the event's own subject for a {@link SubjectSource.Primary primary} source when the
+ * type matches; a dimension-derived subject for a {@link SubjectSource.FromDimension} source when
+ * that dimension is present), builds the SPI {@link Feature} and the aggregation-specific {@link
+ * Intent}, keyed-hashing DISTINCT dimension values via the {@link DimensionHasher} (FR-38). A
+ * single event therefore fans out across multiple subjects and backends. A definition that applies
+ * to the event's subject but cannot be written — a SUM with no value, or a DISTINCT whose dimension
+ * is absent — becomes a {@code SKIPPED} outcome rather than being silently dropped.
+ */
+public final class FanOutResolver {
+
+ private final DimensionHasher dimensionHasher;
+
+ /**
+ * Creates a resolver.
+ *
+ * @param dimensionHasher the keyed hasher for DISTINCT dimension values (FR-38)
+ */
+ public FanOutResolver(final DimensionHasher dimensionHasher) {
+ this.dimensionHasher = Objects.requireNonNull(dimensionHasher, "dimensionHasher");
+ }
+
+ /**
+ * Resolves the fan-out of one event.
+ *
+ * @param namespace the namespace (selects DISTINCT salt)
+ * @param primarySubject the event's own subject
+ * @param dimensions the event's dimensions (used for dimension-derived subjects and DISTINCT)
+ * @param valueCents the event's SUM value in integer cents, or null if absent
+ * @param definitions the namespace's current definition snapshot
+ * @return the intents grouped by backend plus any skipped-with-reason outcomes
+ */
+ public FanOutResult resolve(
+ final Namespace namespace,
+ final Subject primarySubject,
+ final Map dimensions,
+ final @Nullable BigDecimal valueCents,
+ final FeatureDefinitions definitions) {
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(primarySubject, "primarySubject");
+ Objects.requireNonNull(dimensions, "dimensions");
+ Objects.requireNonNull(definitions, "definitions");
+
+ final Map> byBackend = new LinkedHashMap<>();
+ final List skipped = new ArrayList<>();
+
+ for (final FeatureDefinition definition : definitions.definitions()) {
+ final Subject subject = targetSubject(definition, primarySubject, dimensions);
+ if (subject == null) {
+ continue; // The definition does not apply to this event (FR-4 no-op).
+ }
+ final Feature feature =
+ new Feature(definition.name(), definition.aggregation(), definition.windows());
+ final Intent intent =
+ buildIntent(namespace, definition, feature, subject, dimensions, valueCents, skipped);
+ if (intent != null) {
+ byBackend.computeIfAbsent(definition.backend(), unused -> new ArrayList<>()).add(intent);
+ }
+ }
+ return new FanOutResult(byBackend, skipped);
+ }
+
+ private static @Nullable Subject targetSubject(
+ final FeatureDefinition definition,
+ final Subject primarySubject,
+ final Map dimensions) {
+ return switch (definition.subjectSource()) {
+ case SubjectSource.Primary ignored ->
+ primarySubject.type().equals(definition.subjectType()) ? primarySubject : null;
+ case SubjectSource.FromDimension fromDimension -> {
+ final String value = dimensions.get(fromDimension.dimensionName());
+ yield present(value) ? new Subject(definition.subjectType(), value) : null;
+ }
+ };
+ }
+
+ private @Nullable Intent buildIntent(
+ final Namespace namespace,
+ final FeatureDefinition definition,
+ final Feature feature,
+ final Subject subject,
+ final Map dimensions,
+ final @Nullable BigDecimal valueCents,
+ final List skipped) {
+ final Aggregation aggregation = definition.aggregation();
+ return switch (aggregation.type()) {
+ case COUNT -> new CountIntent(feature, subject);
+ case SUM -> {
+ if (valueCents == null) {
+ skipped.add(
+ skip(feature, "SUM feature '" + feature.name() + "' requires an event value"));
+ yield null;
+ }
+ if (valueCents.scale() != 0) {
+ skipped.add(
+ skip(
+ feature,
+ "SUM feature '"
+ + feature.name()
+ + "' requires integer cents (scale 0), was scale "
+ + valueCents.scale()));
+ yield null;
+ }
+ yield new SumIntent(feature, subject, valueCents);
+ }
+ case DISTINCT -> {
+ // The aggregation's dimension is the value counted; may differ from a FromDimension
+ // subject.
+ final String dimensionName = Objects.requireNonNull(aggregation.dimension());
+ final String value = dimensions.get(dimensionName);
+ if (!present(value)) {
+ skipped.add(
+ skip(
+ feature,
+ "DISTINCT feature '"
+ + feature.name()
+ + "' requires dimension '"
+ + dimensionName
+ + "'"));
+ yield null;
+ }
+ yield new DistinctIntent(feature, subject, dimensionHasher.hash(namespace, value));
+ }
+ };
+ }
+
+ private static PerFeature skip(final Feature feature, final String reason) {
+ return new PerFeature(
+ feature, ApplyStatus.SKIPPED, FeatureResult.failure(FailureCode.VALIDATION, reason));
+ }
+
+ private static boolean present(final @Nullable String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/FeatureDefinitionProvider.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/FeatureDefinitionProvider.java
new file mode 100644
index 0000000..5c6c01a
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/FeatureDefinitionProvider.java
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import com.codeheadsystems.velocity.core.model.FeatureDefinitions;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+
+/**
+ * Supplies the current {@link FeatureDefinitions} snapshot for a namespace (FR-16/FR-17).
+ *
+ * The engine reads a whole snapshot per request. Hot-reload is expressed as an atomic swap of
+ * the snapshot behind this interface, so a reader never observes a half-applied definition set
+ * (FR-17). A namespace with no configured definitions returns an empty snapshot, not {@code null}
+ * (FR-4).
+ */
+public interface FeatureDefinitionProvider {
+
+ /**
+ * The current snapshot for a namespace.
+ *
+ * @param namespace the namespace
+ * @return the current snapshot; empty (never null) for an unconfigured namespace
+ */
+ FeatureDefinitions definitions(Namespace namespace);
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/FeatureDefinitionYaml.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/FeatureDefinitionYaml.java
new file mode 100644
index 0000000..191c5cc
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/FeatureDefinitionYaml.java
@@ -0,0 +1,182 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.core.model.SubjectSource;
+import com.codeheadsystems.velocity.core.model.yaml.FeatureDefinitionDoc;
+import com.codeheadsystems.velocity.core.model.yaml.FeatureDefinitionDoc.AggregationDoc;
+import com.codeheadsystems.velocity.core.model.yaml.FeatureDefinitionDoc.SubjectSourceDoc;
+import com.codeheadsystems.velocity.core.model.yaml.FeatureDefinitionDoc.WindowDoc;
+import com.codeheadsystems.velocity.core.model.yaml.FeatureDefinitionsDoc;
+import com.codeheadsystems.velocity.spi.model.Aggregation;
+import com.codeheadsystems.velocity.spi.model.AggregationType;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import com.codeheadsystems.velocity.spi.model.Window;
+import com.codeheadsystems.velocity.spi.model.WindowType;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import org.jspecify.annotations.Nullable;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.dataformat.yaml.YAMLMapper;
+
+/**
+ * Imports and exports feature definitions as round-trippable YAML (FR-28).
+ *
+ *
The YAML document is the canonical external representation of a namespace's feature
+ * configuration, so it can be versioned in source control and moved between environments. The wire
+ * shape ({@link FeatureDefinitionsDoc}) is a plain Jackson DTO kept separate from the domain, so
+ * the domain stays serialization-neutral; this class maps between the two. Unknown fields are
+ * tolerated on read for forward compatibility.
+ *
+ *
Shape (durations are ISO-8601, e.g. {@code PT1H}):
+ *
+ *
{@code
+ * namespace: acme
+ * definitions:
+ * - name: card.count.1h
+ * subjectType: card
+ * subjectSource: { kind: PRIMARY }
+ * backend: memory
+ * aggregation: { type: COUNT }
+ * windows:
+ * - { duration: PT1H, type: TUMBLING }
+ * - name: ip.count.1h
+ * subjectType: ip
+ * subjectSource: { kind: FROM_DIMENSION, dimension: ip }
+ * backend: memory
+ * aggregation: { type: COUNT }
+ * windows:
+ * - { duration: PT1M, type: TUMBLING }
+ * }
+ */
+public final class FeatureDefinitionYaml {
+
+ private final YAMLMapper mapper;
+
+ /** Creates a YAML codec with a forward-compatible mapper. */
+ public FeatureDefinitionYaml() {
+ this.mapper =
+ YAMLMapper.builder()
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+ .build();
+ }
+
+ /**
+ * Exports a namespace's definitions to YAML.
+ *
+ * @param namespace the namespace to stamp on the document
+ * @param definitions the definitions to export
+ * @return the YAML document
+ */
+ public String export(final Namespace namespace, final List definitions) {
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(definitions, "definitions");
+ final List docs = new ArrayList<>(definitions.size());
+ for (final FeatureDefinition definition : definitions) {
+ docs.add(toDoc(definition));
+ }
+ return mapper.writeValueAsString(new FeatureDefinitionsDoc(namespace.value(), docs));
+ }
+
+ /**
+ * Imports the definitions from a YAML document.
+ *
+ * @param yaml the YAML document
+ * @return the domain definitions
+ */
+ public List importDefinitions(final String yaml) {
+ Objects.requireNonNull(yaml, "yaml");
+ final FeatureDefinitionsDoc doc = mapper.readValue(yaml, FeatureDefinitionsDoc.class);
+ final List definitions =
+ doc.definitions() == null ? List.of() : doc.definitions();
+ final List result = new ArrayList<>(definitions.size());
+ for (final FeatureDefinitionDoc definition : definitions) {
+ result.add(fromDoc(definition));
+ }
+ return result;
+ }
+
+ /**
+ * The namespace declared in a YAML document, if any.
+ *
+ * @param yaml the YAML document
+ * @return the namespace, or empty if the document does not declare one
+ */
+ public @Nullable Namespace importNamespace(final String yaml) {
+ Objects.requireNonNull(yaml, "yaml");
+ final FeatureDefinitionsDoc doc = mapper.readValue(yaml, FeatureDefinitionsDoc.class);
+ final String namespace = doc.namespace();
+ return namespace == null || namespace.isBlank() ? null : new Namespace(namespace);
+ }
+
+ private static FeatureDefinitionDoc toDoc(final FeatureDefinition definition) {
+ final List windows = new ArrayList<>(definition.windows().size());
+ for (final Window window : definition.windows()) {
+ windows.add(new WindowDoc(window.duration().toString(), window.type().name()));
+ }
+ final Aggregation aggregation = definition.aggregation();
+ return new FeatureDefinitionDoc(
+ definition.name(),
+ definition.subjectType(),
+ toSubjectSourceDoc(definition.subjectSource()),
+ new AggregationDoc(aggregation.type().name(), aggregation.dimension()),
+ windows,
+ definition.backend(),
+ definition.distinctThreshold());
+ }
+
+ private static SubjectSourceDoc toSubjectSourceDoc(final SubjectSource source) {
+ return switch (source) {
+ case SubjectSource.Primary ignored -> new SubjectSourceDoc("PRIMARY", null);
+ case SubjectSource.FromDimension fromDimension ->
+ new SubjectSourceDoc("FROM_DIMENSION", fromDimension.dimensionName());
+ };
+ }
+
+ private static FeatureDefinition fromDoc(final FeatureDefinitionDoc doc) {
+ final AggregationDoc aggregationDoc =
+ Objects.requireNonNull(doc.aggregation(), "aggregation is required");
+ final List windowDocs =
+ Objects.requireNonNull(doc.windows(), "windows are required");
+ final List windows = new ArrayList<>(windowDocs.size());
+ for (final WindowDoc windowDoc : windowDocs) {
+ windows.add(
+ new Window(
+ Duration.parse(Objects.requireNonNull(windowDoc.duration(), "window duration")),
+ WindowType.valueOf(Objects.requireNonNull(windowDoc.type(), "window type"))));
+ }
+ return new FeatureDefinition(
+ Objects.requireNonNull(doc.name(), "name is required"),
+ Objects.requireNonNull(doc.subjectType(), "subjectType is required"),
+ fromSubjectSourceDoc(doc.subjectSource()),
+ fromAggregationDoc(aggregationDoc),
+ windows,
+ Objects.requireNonNull(doc.backend(), "backend is required"),
+ doc.distinctThreshold());
+ }
+
+ private static SubjectSource fromSubjectSourceDoc(final @Nullable SubjectSourceDoc doc) {
+ if (doc == null || doc.kind() == null || "PRIMARY".equals(doc.kind())) {
+ return SubjectSource.primary();
+ }
+ if ("FROM_DIMENSION".equals(doc.kind())) {
+ return SubjectSource.fromDimension(
+ Objects.requireNonNull(doc.dimension(), "FROM_DIMENSION requires a dimension"));
+ }
+ throw new IllegalArgumentException("unknown subjectSource kind '" + doc.kind() + "'");
+ }
+
+ private static Aggregation fromAggregationDoc(final AggregationDoc doc) {
+ final AggregationType type =
+ AggregationType.valueOf(Objects.requireNonNull(doc.type(), "aggregation type is required"));
+ return switch (type) {
+ case COUNT -> Aggregation.count();
+ case SUM -> Aggregation.sum();
+ case DISTINCT ->
+ Aggregation.distinct(
+ Objects.requireNonNull(doc.dimension(), "DISTINCT requires a dimension"));
+ };
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/InMemoryNamespaceSaltProvider.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/InMemoryNamespaceSaltProvider.java
new file mode 100644
index 0000000..ec0c9f5
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/InMemoryNamespaceSaltProvider.java
@@ -0,0 +1,70 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * A development/test {@link NamespaceSaltProvider} that derives a per-namespace salt from a single
+ * in-process master secret.
+ *
+ * Not for production (§15 R11). The master secret is co-resident with the
+ * process (and, in a test, with the distinct sets), which is exactly the custody posture R11
+ * forbids. Production MUST use a KMS/secret-store-backed provider so the key lives in a separate
+ * trust domain. The salt is {@code SHA-256(master || namespace)}, so it is deterministic per
+ * namespace for a given master (reproducible hashing within a run) and distinct across namespaces.
+ */
+public final class InMemoryNamespaceSaltProvider implements NamespaceSaltProvider {
+
+ private final byte[] master;
+ private final ConcurrentMap cache = new ConcurrentHashMap<>();
+
+ /** Creates a provider with a random 32-byte master secret (distinct per instance). */
+ public InMemoryNamespaceSaltProvider() {
+ this(randomMaster());
+ }
+
+ /**
+ * Creates a provider with an explicit master secret — use a fixed secret for reproducible hashing
+ * across runs in tests.
+ *
+ * @param master the master secret bytes; copied defensively, must be non-empty
+ */
+ public InMemoryNamespaceSaltProvider(final byte[] master) {
+ Objects.requireNonNull(master, "master");
+ if (master.length == 0) {
+ throw new IllegalArgumentException("master secret must not be empty");
+ }
+ this.master = master.clone();
+ }
+
+ @Override
+ public byte[] salt(final Namespace namespace) {
+ Objects.requireNonNull(namespace, "namespace");
+ return cache.computeIfAbsent(namespace, this::derive).clone();
+ }
+
+ private byte[] derive(final Namespace namespace) {
+ final MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance("SHA-256");
+ } catch (final NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ digest.update(master);
+ digest.update(namespace.value().getBytes(StandardCharsets.UTF_8));
+ return digest.digest();
+ }
+
+ private static byte[] randomMaster() {
+ final byte[] bytes = new byte[32];
+ new SecureRandom().nextBytes(bytes);
+ return bytes;
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/MutableFeatureDefinitionProvider.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/MutableFeatureDefinitionProvider.java
new file mode 100644
index 0000000..50597a7
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/MutableFeatureDefinitionProvider.java
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.core.model.FeatureDefinitions;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * The default in-memory {@link FeatureDefinitionProvider}: a concurrent map of namespace → snapshot
+ * whose per-namespace snapshot is swapped atomically on {@link #reload} (FR-17).
+ *
+ * Because each namespace's value is a single immutable {@link FeatureDefinitions} reference, a
+ * {@link #definitions} reader either sees the old snapshot in full or the new one in full — never a
+ * half-applied set — even while a concurrent {@code reload} runs. Loading definitions from an
+ * external store or file watcher (a non-primary hot-reload source, OQ-C) is deferred; such a source
+ * would drive {@link #reload}.
+ */
+public final class MutableFeatureDefinitionProvider implements FeatureDefinitionProvider {
+
+ private final ConcurrentMap snapshots = new ConcurrentHashMap<>();
+
+ /** Creates an empty provider with no definitions configured for any namespace. */
+ public MutableFeatureDefinitionProvider() {}
+
+ @Override
+ public FeatureDefinitions definitions(final Namespace namespace) {
+ Objects.requireNonNull(namespace, "namespace");
+ return snapshots.computeIfAbsent(namespace, MutableFeatureDefinitionProvider::emptySnapshot);
+ }
+
+ /**
+ * Atomically replaces a namespace's definition set with a fresh snapshot (FR-17), recomputing the
+ * version hash (FR-40).
+ *
+ * @param namespace the namespace to reload
+ * @param definitions the new definition set (validated by the engine before this is called)
+ * @return the newly installed snapshot
+ */
+ public FeatureDefinitions reload(
+ final Namespace namespace, final List definitions) {
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(definitions, "definitions");
+ final FeatureDefinitions snapshot =
+ new FeatureDefinitions(namespace, definitions, DefinitionVersionHasher.hash(definitions));
+ snapshots.put(namespace, snapshot);
+ return snapshot;
+ }
+
+ private static FeatureDefinitions emptySnapshot(final Namespace namespace) {
+ return new FeatureDefinitions(namespace, List.of(), DefinitionVersionHasher.hash(List.of()));
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/NamespaceSaltProvider.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/NamespaceSaltProvider.java
new file mode 100644
index 0000000..6d6dcfe
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/NamespaceSaltProvider.java
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import com.codeheadsystems.velocity.spi.model.Namespace;
+
+/**
+ * Supplies the per-namespace HMAC salt used to keyed-hash DISTINCT dimension values at rest (FR-38,
+ * §15 R11).
+ *
+ * Key custody (§15 R11). In production the salt/key MUST live in a separate
+ * trust domain (a KMS or secret store), not co-resident with the distinct sets: a
+ * low-entropy dimension like an IPv4 (2³² space) is brute-forceable offline after a single
+ * datastore dump if the salt is dumped alongside it, defeating the "no raw PII at rest" guarantee.
+ * A KMS-backed implementation is a deferred follow-up; {@link InMemoryNamespaceSaltProvider} is a
+ * development/test default only.
+ */
+public interface NamespaceSaltProvider {
+
+ /**
+ * The salt bytes for a namespace.
+ *
+ * @param namespace the namespace
+ * @return the salt; must be stable per namespace and never empty
+ */
+ byte[] salt(Namespace namespace);
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/VelocityEngine.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/VelocityEngine.java
new file mode 100644
index 0000000..79d3420
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/VelocityEngine.java
@@ -0,0 +1,394 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import com.codeheadsystems.velocity.core.model.CapabilityValidationResult;
+import com.codeheadsystems.velocity.core.model.CapabilityValidationResult.Violation;
+import com.codeheadsystems.velocity.core.model.FanOutResult;
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.core.model.FeatureDefinitions;
+import com.codeheadsystems.velocity.spi.CountStore;
+import com.codeheadsystems.velocity.spi.DistinctStore;
+import com.codeheadsystems.velocity.spi.SumStore;
+import com.codeheadsystems.velocity.spi.VelocityBackend;
+import com.codeheadsystems.velocity.spi.model.Aggregation;
+import com.codeheadsystems.velocity.spi.model.ApplyContext;
+import com.codeheadsystems.velocity.spi.model.ApplyResult;
+import com.codeheadsystems.velocity.spi.model.ApplyStatus;
+import com.codeheadsystems.velocity.spi.model.BackendCapabilities;
+import com.codeheadsystems.velocity.spi.model.FailureCode;
+import com.codeheadsystems.velocity.spi.model.FeatureResult;
+import com.codeheadsystems.velocity.spi.model.FeatureValue;
+import com.codeheadsystems.velocity.spi.model.Intent;
+import com.codeheadsystems.velocity.spi.model.Intent.CountIntent;
+import com.codeheadsystems.velocity.spi.model.Intent.DistinctIntent;
+import com.codeheadsystems.velocity.spi.model.Intent.SumIntent;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import com.codeheadsystems.velocity.spi.model.PerFeature;
+import com.codeheadsystems.velocity.spi.model.QueryContext;
+import com.codeheadsystems.velocity.spi.model.QueryTuple;
+import com.codeheadsystems.velocity.spi.model.Subject;
+import com.codeheadsystems.velocity.spi.model.Window;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The embeddable engine and public API: turns a {@code record()} into backend-neutral intents, fans
+ * it out across every matching feature and backend, and merges the outcomes into one result stamped
+ * with the definition version it was computed under (FR-40).
+ *
+ *
The engine owns fan-out and routing only; windowing lives entirely in the backend (ADR 0003).
+ * It is constructed with a {@link BackendRegistry}, a {@link FeatureDefinitionProvider}, a {@link
+ * DimensionHasher}, and a {@link CapabilityValidator} (no Dagger — DI lives in the service tier).
+ *
+ *
Read-your-write rides on the backend: because the in-process backends return an apply's
+ * post-write value, {@link #record}'s result already reflects the write (ADR 0007). Every returned
+ * value is re-stamped with the current snapshot's {@code versionHash} (FR-40).
+ */
+public final class VelocityEngine {
+
+ private final BackendRegistry backends;
+ private final FeatureDefinitionProvider definitionProvider;
+ private final CapabilityValidator capabilityValidator;
+ private final FanOutResolver fanOutResolver;
+
+ /**
+ * Creates an engine.
+ *
+ * @param backends the registry of backends features bind to
+ * @param definitionProvider the source of per-namespace definition snapshots (atomic hot-reload)
+ * @param dimensionHasher the keyed hasher for DISTINCT dimension values (FR-38)
+ * @param capabilityValidator the validator run on reload (FR-29)
+ */
+ public VelocityEngine(
+ final BackendRegistry backends,
+ final FeatureDefinitionProvider definitionProvider,
+ final DimensionHasher dimensionHasher,
+ final CapabilityValidator capabilityValidator) {
+ this.backends = Objects.requireNonNull(backends, "backends");
+ this.definitionProvider = Objects.requireNonNull(definitionProvider, "definitionProvider");
+ this.capabilityValidator = Objects.requireNonNull(capabilityValidator, "capabilityValidator");
+ this.fanOutResolver =
+ new FanOutResolver(Objects.requireNonNull(dimensionHasher, "dimensionHasher"));
+ }
+
+ /**
+ * Records an event, fanning it out to every matching feature across every affected subject and
+ * backend, and returns the aggregate post-write outcome (FR-18, ADR 0009).
+ *
+ *
An event that matches no definition is a no-op with an empty result, never an error (FR-4).
+ * Each per-feature entry is stamped with the current snapshot's definition version (FR-40).
+ *
+ * @param namespace the tenancy scope
+ * @param subject the event's primary subject
+ * @param dimensions the event's dimensions (empty if none)
+ * @param valueCents the event's SUM value in integer cents, or null if the event carries no value
+ * @return one {@link PerFeature} outcome per touched {@code (feature × window)} plus any skipped
+ * features
+ */
+ public ApplyResult record(
+ final Namespace namespace,
+ final Subject subject,
+ final Map dimensions,
+ final @Nullable BigDecimal valueCents) {
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(subject, "subject");
+ Objects.requireNonNull(dimensions, "dimensions");
+ final FeatureDefinitions definitions = definitionProvider.definitions(namespace);
+ final FanOutResult fanOut =
+ fanOutResolver.resolve(namespace, subject, dimensions, valueCents, definitions);
+ final ApplyContext ctx = new ApplyContext(namespace, null);
+ final List outcomes = new ArrayList<>();
+ for (final Map.Entry> group : fanOut.intentsByBackend().entrySet()) {
+ final VelocityBackend backend = backends.backend(group.getKey());
+ outcomes.addAll(applyGroup(ctx, backend, group.getValue()));
+ }
+ outcomes.addAll(fanOut.skipped());
+ return new ApplyResult(stampAll(outcomes, definitions.versionHash()));
+ }
+
+ private List applyGroup(
+ final ApplyContext ctx, final VelocityBackend backend, final List intents) {
+ final List counts = new ArrayList<>();
+ final List sums = new ArrayList<>();
+ final List distincts = new ArrayList<>();
+ for (final Intent intent : intents) {
+ switch (intent) {
+ case CountIntent count -> counts.add(count);
+ case SumIntent sum -> sums.add(sum);
+ case DistinctIntent distinct -> distincts.add(distinct);
+ }
+ }
+ final List outcomes = new ArrayList<>();
+ if (!counts.isEmpty()) {
+ if (backend instanceof CountStore store) {
+ outcomes.addAll(store.applyCount(ctx, counts).perFeature());
+ } else {
+ outcomes.addAll(unsupported(counts, "COUNT"));
+ }
+ }
+ if (!sums.isEmpty()) {
+ if (backend instanceof SumStore store) {
+ outcomes.addAll(store.applySum(ctx, sums).perFeature());
+ } else {
+ outcomes.addAll(unsupported(sums, "SUM"));
+ }
+ }
+ if (!distincts.isEmpty()) {
+ if (backend instanceof DistinctStore store) {
+ outcomes.addAll(store.applyDistinct(ctx, distincts).perFeature());
+ } else {
+ outcomes.addAll(unsupported(distincts, "DISTINCT"));
+ }
+ }
+ return outcomes;
+ }
+
+ private static List unsupported(
+ final List extends Intent> intents, final String aggregation) {
+ final List failed = new ArrayList<>(intents.size());
+ for (final Intent intent : intents) {
+ failed.add(
+ new PerFeature(
+ intent.feature(),
+ ApplyStatus.FAILED,
+ FeatureResult.failure(
+ FailureCode.INTERNAL,
+ "backend does not implement the "
+ + aggregation
+ + " mix-in for feature '"
+ + intent.feature().name()
+ + "'")));
+ }
+ return failed;
+ }
+
+ /**
+ * Queries a single feature's value over each of its windows for a subject.
+ *
+ * @param namespace the tenancy scope
+ * @param subject the subject to read
+ * @param featureName the feature to read
+ * @return one result per the feature's windows, in declaration order (each stamped with the
+ * current definition version)
+ * @throws IllegalArgumentException if the namespace has no such feature
+ */
+ public List query(
+ final Namespace namespace, final Subject subject, final String featureName) {
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(subject, "subject");
+ Objects.requireNonNull(featureName, "featureName");
+ final FeatureDefinitions definitions = definitionProvider.definitions(namespace);
+ final FeatureDefinition definition =
+ definitions
+ .definition(featureName)
+ .orElseThrow(
+ () ->
+ new IllegalArgumentException(
+ "no feature '"
+ + featureName
+ + "' in namespace "
+ + namespace.value()
+ + " (version "
+ + definitions.versionHash()
+ + ")"));
+ return queryDefinition(namespace, subject, definition, definitions.versionHash());
+ }
+
+ /**
+ * Queries several features for a subject in one call.
+ *
+ * @param namespace the tenancy scope
+ * @param subject the subject to read
+ * @param featureNames the features to read
+ * @return a map of feature name → per-window results, in the request's iteration order
+ * @throws IllegalArgumentException if the namespace has no such feature
+ */
+ public Map> query(
+ final Namespace namespace, final Subject subject, final List featureNames) {
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(subject, "subject");
+ Objects.requireNonNull(featureNames, "featureNames");
+ final Map> results = new LinkedHashMap<>();
+ for (final String featureName : featureNames) {
+ results.put(featureName, query(namespace, subject, featureName));
+ }
+ return results;
+ }
+
+ private List queryDefinition(
+ final Namespace namespace,
+ final Subject subject,
+ final FeatureDefinition definition,
+ final String versionHash) {
+ final VelocityBackend backend = backends.backend(definition.backend());
+ final Aggregation aggregation = definition.aggregation();
+ final List tuples = new ArrayList<>(definition.windows().size());
+ for (final Window window : definition.windows()) {
+ tuples.add(new QueryTuple(subject, aggregation, window));
+ }
+ final QueryContext ctx = new QueryContext(namespace, null);
+ final List raw =
+ switch (aggregation.type()) {
+ case COUNT ->
+ backend instanceof CountStore store
+ ? store.queryCount(ctx, tuples)
+ : queryUnsupported(tuples, "COUNT");
+ case SUM ->
+ backend instanceof SumStore store
+ ? store.querySum(ctx, tuples)
+ : queryUnsupported(tuples, "SUM");
+ case DISTINCT ->
+ backend instanceof DistinctStore store
+ ? store.queryDistinct(ctx, tuples)
+ : queryUnsupported(tuples, "DISTINCT");
+ };
+ return stampResults(raw, versionHash);
+ }
+
+ private static List queryUnsupported(
+ final List tuples, final String aggregation) {
+ final List failed = new ArrayList<>(tuples.size());
+ for (int i = 0; i < tuples.size(); i++) {
+ failed.add(
+ FeatureResult.failure(
+ FailureCode.INTERNAL, "backend does not implement the " + aggregation + " mix-in"));
+ }
+ return failed;
+ }
+
+ /**
+ * The declared capabilities of a registered backend (passthrough).
+ *
+ * @param backendName the backend name
+ * @return the backend's capabilities
+ */
+ public BackendCapabilities capabilities(final String backendName) {
+ Objects.requireNonNull(backendName, "backendName");
+ return backends.backend(backendName).capabilities();
+ }
+
+ /**
+ * Administratively purges stored state on every registered backend (FR-23).
+ *
+ * @param namespace the namespace to erase within
+ * @param subject the subject to erase; null erases the whole namespace
+ */
+ public void purge(final Namespace namespace, final @Nullable Subject subject) {
+ Objects.requireNonNull(namespace, "namespace");
+ for (final String name : backends.names()) {
+ backends.backend(name).purge(namespace, subject);
+ }
+ }
+
+ /**
+ * Validates a candidate definition set against its target backends' capabilities (FR-29) without
+ * installing it.
+ *
+ * @param definitions the candidate definitions
+ * @return the collected violations (empty when the set is valid)
+ */
+ public CapabilityValidationResult validateReload(final List definitions) {
+ Objects.requireNonNull(definitions, "definitions");
+ final List violations = new ArrayList<>();
+ for (final FeatureDefinition definition : definitions) {
+ final VelocityBackend backend =
+ backends.names().contains(definition.backend())
+ ? backends.backend(definition.backend())
+ : null;
+ if (backend == null) {
+ violations.add(
+ new Violation(
+ definition.name(),
+ "unknown backend '"
+ + definition.backend()
+ + "'; known backends: "
+ + backends.names()));
+ continue;
+ }
+ violations.addAll(
+ capabilityValidator.validate(definition, backend.capabilities()).violations());
+ }
+ return new CapabilityValidationResult(violations);
+ }
+
+ /**
+ * Validates and atomically installs a new definition set for a namespace (FR-17/FR-29).
+ *
+ * Every definition is validated against its target backend's capabilities first; if any is
+ * invalid the whole reload is rejected and nothing is installed (atomic). On success the
+ * namespace's snapshot is swapped atomically and its version hash recomputed (FR-40).
+ *
+ * @param namespace the namespace to reload
+ * @param definitions the new definition set
+ * @return the newly installed snapshot
+ * @throws IllegalArgumentException if any definition is invalid (the message lists every
+ * violation)
+ * @throws UnsupportedOperationException if the configured provider does not support reload
+ */
+ public FeatureDefinitions reload(
+ final Namespace namespace, final List definitions) {
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(definitions, "definitions");
+ if (!(definitionProvider instanceof MutableFeatureDefinitionProvider mutable)) {
+ throw new UnsupportedOperationException(
+ "reload requires a MutableFeatureDefinitionProvider; configured provider is "
+ + definitionProvider.getClass().getName());
+ }
+ final CapabilityValidationResult validation = validateReload(definitions);
+ if (!validation.valid()) {
+ final StringBuilder message = new StringBuilder("reload rejected for namespace ");
+ message.append(namespace.value()).append(':');
+ for (final Violation violation : validation.violations()) {
+ message
+ .append("\n - ")
+ .append(violation.feature())
+ .append(": ")
+ .append(violation.message());
+ }
+ throw new IllegalArgumentException(message.toString());
+ }
+ return mutable.reload(namespace, definitions);
+ }
+
+ private static List stampAll(
+ final List outcomes, final String versionHash) {
+ final List stamped = new ArrayList<>(outcomes.size());
+ for (final PerFeature outcome : outcomes) {
+ stamped.add(
+ new PerFeature(
+ outcome.feature(), outcome.status(), stamp(outcome.result(), versionHash)));
+ }
+ return stamped;
+ }
+
+ private static List stampResults(
+ final List results, final String versionHash) {
+ final List stamped = new ArrayList<>(results.size());
+ for (final FeatureResult result : results) {
+ stamped.add(stamp(result, versionHash));
+ }
+ return stamped;
+ }
+
+ private static FeatureResult stamp(final FeatureResult result, final String versionHash) {
+ if (result instanceof FeatureResult.Success success) {
+ final FeatureValue value = success.value();
+ return FeatureResult.success(
+ new FeatureValue(
+ value.feature(),
+ value.window(),
+ value.value(),
+ value.exactness(),
+ value.readYourWriteLevel(),
+ versionHash,
+ value.windowBounds(),
+ value.asOf()));
+ }
+ return result;
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/CapabilityValidationResult.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/CapabilityValidationResult.java
new file mode 100644
index 0000000..f646466
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/CapabilityValidationResult.java
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core.model;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * The outcome of validating feature definitions against their target backends' capabilities
+ * (FR-29).
+ *
+ * Validation collects all violations rather than failing on the first, so a reload is
+ * rejected with a complete, precise error list. A result with no violations is {@link #valid()}.
+ *
+ * @param violations every violation found; empty when all definitions are valid
+ */
+public record CapabilityValidationResult(List violations) {
+
+ /** Stores an unmodifiable copy of the violations. */
+ public CapabilityValidationResult {
+ Objects.requireNonNull(violations, "violations");
+ violations = List.copyOf(violations);
+ }
+
+ /**
+ * Whether every validated definition passed.
+ *
+ * @return {@code true} if there are no violations
+ */
+ public boolean valid() {
+ return violations.isEmpty();
+ }
+
+ /**
+ * A single capability violation of one feature definition (FR-29).
+ *
+ * @param feature the offending feature's name
+ * @param message the precise, human-readable reason
+ */
+ public record Violation(String feature, String message) {
+
+ /** Validates both components are present. */
+ public Violation {
+ Objects.requireNonNull(feature, "feature");
+ Objects.requireNonNull(message, "message");
+ }
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/FanOutResult.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/FanOutResult.java
new file mode 100644
index 0000000..7ba022b
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/FanOutResult.java
@@ -0,0 +1,44 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core.model;
+
+import com.codeheadsystems.velocity.spi.model.Intent;
+import com.codeheadsystems.velocity.spi.model.PerFeature;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * The resolved fan-out of one {@code record()} event (FR-18): the backend-neutral write intents
+ * grouped by their target backend, plus the outcomes for features that matched the event but could
+ * not be applied.
+ *
+ * A single event fans out across multiple subjects and backends (FR-18); {@code
+ * intentsByBackend} keys the intents by backend name so the engine can dispatch each group to its
+ * backend and sub-group by aggregation. {@code skipped} carries the pre-computed {@link PerFeature}
+ * outcomes for definitions that applied to this event's subject but were skipped — a SUM whose
+ * value was missing, or a DISTINCT whose dimension was absent — so they surface in the aggregate
+ * result rather than being silently dropped.
+ *
+ * @param intentsByBackend the write intents grouped by target backend name; defensively copied
+ * @param skipped the pre-computed outcomes for matched-but-not-applied features; defensively copied
+ */
+public record FanOutResult(Map> intentsByBackend, List skipped) {
+
+ /** Stores unmodifiable copies of both collections. */
+ public FanOutResult {
+ Objects.requireNonNull(intentsByBackend, "intentsByBackend");
+ Objects.requireNonNull(skipped, "skipped");
+ intentsByBackend = Map.copyOf(intentsByBackend);
+ skipped = List.copyOf(skipped);
+ }
+
+ /**
+ * Whether this fan-out produced no intents and no skipped outcomes (FR-4: no matching
+ * definition).
+ *
+ * @return {@code true} if nothing matched the event
+ */
+ public boolean isEmpty() {
+ return intentsByBackend.isEmpty() && skipped.isEmpty();
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/FeatureDefinition.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/FeatureDefinition.java
new file mode 100644
index 0000000..5fe0ae4
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/FeatureDefinition.java
@@ -0,0 +1,143 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core.model;
+
+import com.codeheadsystems.velocity.spi.model.Aggregation;
+import com.codeheadsystems.velocity.spi.model.AggregationType;
+import com.codeheadsystems.velocity.spi.model.Window;
+import java.util.List;
+import java.util.Objects;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The configuration for a single feature (FR-16): what event pattern feeds it, its subject, its
+ * aggregation, the windows it is tracked over, and the backend that provides it.
+ *
+ * A feature is bound to exactly one backend (FR-16/FR-29). Definitions are data, not code — they
+ * are imported/exported as YAML (FR-28) and hot-reloaded at runtime (FR-17).
+ *
+ * @param name the stable, non-blank feature name (e.g. {@code card.count.1h})
+ * @param subjectType the non-blank subject type this feature keys on (e.g. {@code card}, {@code
+ * ip})
+ * @param subjectSource where the fan-out subject comes from — the event's primary subject or a
+ * dimension-derived one (FR-18)
+ * @param aggregation what the feature aggregates (COUNT/SUM/DISTINCT)
+ * @param windows the non-empty set of windows the feature is tracked over
+ * @param backend the non-blank name of the backend that provides this feature (FR-16)
+ * @param distinctThreshold the per-{@code (subject, bucket)} exact→HLL threshold for DISTINCT
+ * features (ADR 0006); {@code null} means "use the backend default", and it MUST be null for
+ * non-DISTINCT features
+ */
+public record FeatureDefinition(
+ String name,
+ String subjectType,
+ SubjectSource subjectSource,
+ Aggregation aggregation,
+ List windows,
+ String backend,
+ @Nullable Long distinctThreshold) {
+
+ /** Validates the definition's invariants and stores an unmodifiable copy of the windows. */
+ public FeatureDefinition {
+ Objects.requireNonNull(name, "name");
+ Objects.requireNonNull(subjectType, "subjectType");
+ Objects.requireNonNull(subjectSource, "subjectSource");
+ Objects.requireNonNull(aggregation, "aggregation");
+ Objects.requireNonNull(windows, "windows");
+ Objects.requireNonNull(backend, "backend");
+ if (name.isBlank()) {
+ throw new IllegalArgumentException("feature name must not be blank");
+ }
+ if (subjectType.isBlank()) {
+ throw new IllegalArgumentException("subjectType must not be blank");
+ }
+ if (backend.isBlank()) {
+ throw new IllegalArgumentException("backend must not be blank");
+ }
+ if (windows.isEmpty()) {
+ throw new IllegalArgumentException("feature '" + name + "' must declare at least one window");
+ }
+ if (aggregation.type() != AggregationType.DISTINCT && distinctThreshold != null) {
+ throw new IllegalArgumentException(
+ "distinctThreshold is only valid for DISTINCT features, was set on '" + name + "'");
+ }
+ windows = List.copyOf(windows);
+ }
+
+ /**
+ * A COUNT feature over the event's own subject.
+ *
+ * @param name the feature name
+ * @param subjectType the subject type
+ * @param backend the backend name
+ * @param windows the windows
+ * @return a primary-subject COUNT definition
+ */
+ public static FeatureDefinition count(
+ final String name,
+ final String subjectType,
+ final String backend,
+ final List windows) {
+ return new FeatureDefinition(
+ name, subjectType, SubjectSource.primary(), Aggregation.count(), windows, backend, null);
+ }
+
+ /**
+ * A SUM feature over the event's own subject.
+ *
+ * @param name the feature name
+ * @param subjectType the subject type
+ * @param backend the backend name
+ * @param windows the windows
+ * @return a primary-subject SUM definition
+ */
+ public static FeatureDefinition sum(
+ final String name,
+ final String subjectType,
+ final String backend,
+ final List windows) {
+ return new FeatureDefinition(
+ name, subjectType, SubjectSource.primary(), Aggregation.sum(), windows, backend, null);
+ }
+
+ /**
+ * A DISTINCT feature over the given dimension, keyed on the event's own subject.
+ *
+ * @param name the feature name
+ * @param subjectType the subject type
+ * @param dimension the dimension whose distinct values are counted
+ * @param backend the backend name
+ * @param windows the windows
+ * @return a primary-subject DISTINCT definition with the backend-default threshold
+ */
+ public static FeatureDefinition distinct(
+ final String name,
+ final String subjectType,
+ final String dimension,
+ final String backend,
+ final List windows) {
+ return new FeatureDefinition(
+ name,
+ subjectType,
+ SubjectSource.primary(),
+ Aggregation.distinct(dimension),
+ windows,
+ backend,
+ null);
+ }
+
+ /**
+ * The largest window duration this feature is tracked over — the retention floor its backend must
+ * satisfy (FR-22a).
+ *
+ * @return the longest window duration
+ */
+ public java.time.Duration largestWindow() {
+ java.time.Duration largest = windows.get(0).duration();
+ for (final Window window : windows) {
+ if (window.duration().compareTo(largest) > 0) {
+ largest = window.duration();
+ }
+ }
+ return largest;
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/FeatureDefinitions.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/FeatureDefinitions.java
new file mode 100644
index 0000000..d05c2b2
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/FeatureDefinitions.java
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core.model;
+
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * An immutable, namespace-scoped snapshot of a namespace's feature definitions (FR-17), stamped
+ * with a deterministic {@code versionHash} (FR-40).
+ *
+ * Hot-reload swaps a whole {@code FeatureDefinitions} atomically (FR-17): a reader either sees
+ * the old snapshot in full or the new one in full, never a half-applied set. The {@code
+ * versionHash} is the value stamped onto every {@link
+ * com.codeheadsystems.velocity.spi.model.FeatureValue} the snapshot produces, so a caller can
+ * detect that a definition changed under a running rule (FR-40).
+ *
+ * @param namespace the namespace this snapshot is for
+ * @param definitions the definitions in this snapshot; defensively copied, feature names must be
+ * unique
+ * @param versionHash the deterministic version stamp computed from {@code definitions} (FR-40)
+ */
+public record FeatureDefinitions(
+ Namespace namespace, List definitions, String versionHash) {
+
+ /** Validates uniqueness of feature names and stores an unmodifiable copy of the definitions. */
+ public FeatureDefinitions {
+ Objects.requireNonNull(namespace, "namespace");
+ Objects.requireNonNull(definitions, "definitions");
+ Objects.requireNonNull(versionHash, "versionHash");
+ definitions = List.copyOf(definitions);
+ final Map byName = new LinkedHashMap<>();
+ for (final FeatureDefinition definition : definitions) {
+ if (byName.putIfAbsent(definition.name(), definition) != null) {
+ throw new IllegalArgumentException(
+ "duplicate feature name '" + definition.name() + "' in namespace " + namespace.value());
+ }
+ }
+ }
+
+ /**
+ * Looks up a definition by feature name.
+ *
+ * @param featureName the feature name
+ * @return the matching definition, or empty if this snapshot has no such feature
+ */
+ public Optional definition(final String featureName) {
+ for (final FeatureDefinition definition : definitions) {
+ if (definition.name().equals(featureName)) {
+ return Optional.of(definition);
+ }
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Whether this snapshot has no definitions (e.g. an unconfigured namespace).
+ *
+ * @return {@code true} if there are no definitions
+ */
+ public boolean isEmpty() {
+ return definitions.isEmpty();
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/SubjectSource.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/SubjectSource.java
new file mode 100644
index 0000000..fa977a8
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/SubjectSource.java
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core.model;
+
+import java.util.Objects;
+
+/**
+ * Where a feature's fan-out subject comes from for a given event (D5, FR-18).
+ *
+ * A single {@code record()} can update a feature keyed on the event's own subject and
+ * features keyed on a dimension-derived subject (e.g. also counting per-{@code ip}). This sealed
+ * type names those two origins so the {@code FanOutResolver} can decide, per definition, which
+ * {@link com.codeheadsystems.velocity.spi.model.Subject subject} an event contributes to.
+ */
+public sealed interface SubjectSource permits SubjectSource.Primary, SubjectSource.FromDimension {
+
+ /**
+ * The event's own primary subject (the subject passed to {@code record()}). The definition
+ * applies only when that subject's type matches the definition's {@code subjectType}.
+ */
+ record Primary() implements SubjectSource {}
+
+ /**
+ * A subject derived from one of the event's dimensions (FR-18). The definition applies only when
+ * the named dimension is present on the event; the derived subject is {@code (subjectType,
+ * dimensionValue)}.
+ *
+ * @param dimensionName the non-blank dimension whose value becomes the subject value
+ */
+ record FromDimension(String dimensionName) implements SubjectSource {
+
+ /** Validates the dimension name is present and non-blank. */
+ public FromDimension {
+ Objects.requireNonNull(dimensionName, "dimensionName");
+ if (dimensionName.isBlank()) {
+ throw new IllegalArgumentException("dimensionName must not be blank");
+ }
+ }
+ }
+
+ /**
+ * The event's own primary subject.
+ *
+ * @return a {@link Primary} source
+ */
+ static SubjectSource primary() {
+ return new Primary();
+ }
+
+ /**
+ * A subject derived from the named dimension.
+ *
+ * @param dimensionName the dimension whose value becomes the subject value
+ * @return a {@link FromDimension} source
+ */
+ static SubjectSource fromDimension(String dimensionName) {
+ return new FromDimension(dimensionName);
+ }
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/package-info.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/package-info.java
new file mode 100644
index 0000000..ca848b9
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/package-info.java
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: BSD-3-Clause
+
+/**
+ * The value types of the {@code velocity-core} engine: feature definitions, the namespace-scoped
+ * definition snapshot, subject-source model, and validation/fan-out result records.
+ *
+ *
These are plain, immutable records/sealed types that validate their invariants in compact
+ * constructors — the engine's configuration and result vocabulary, kept separate from the engine
+ * logic (which lives in the parent {@code com.codeheadsystems.velocity.core} package and
+ * is tested to the coverage gate). This package is coverage-exempt (the {@code **}/{@code model/**}
+ * carve-out); no engine logic is parked here.
+ */
+@NullMarked
+package com.codeheadsystems.velocity.core.model;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/yaml/FeatureDefinitionDoc.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/yaml/FeatureDefinitionDoc.java
new file mode 100644
index 0000000..1a8cc86
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/yaml/FeatureDefinitionDoc.java
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core.model.yaml;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import java.util.List;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The YAML wire shape of one feature definition (FR-28).
+ *
+ * @param name the feature name
+ * @param subjectType the subject type
+ * @param subjectSource where the fan-out subject comes from (defaults to primary when absent)
+ * @param aggregation the aggregation (type + optional distinct dimension)
+ * @param windows the windows the feature is tracked over
+ * @param backend the backend that provides this feature
+ * @param distinctThreshold the DISTINCT exact→HLL threshold; null uses the backend default
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record FeatureDefinitionDoc(
+ @Nullable String name,
+ @Nullable String subjectType,
+ @Nullable SubjectSourceDoc subjectSource,
+ @Nullable AggregationDoc aggregation,
+ @Nullable List windows,
+ @Nullable String backend,
+ @Nullable Long distinctThreshold) {
+
+ /**
+ * The YAML shape of a subject source: {@code kind: PRIMARY} or {@code kind: FROM_DIMENSION} with
+ * a {@code dimension}.
+ *
+ * @param kind {@code PRIMARY} or {@code FROM_DIMENSION}
+ * @param dimension the dimension name (only for {@code FROM_DIMENSION})
+ */
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public record SubjectSourceDoc(@Nullable String kind, @Nullable String dimension) {}
+
+ /**
+ * The YAML shape of an aggregation: a {@code type} and, for {@code DISTINCT}, a {@code
+ * dimension}.
+ *
+ * @param type {@code COUNT}, {@code SUM}, or {@code DISTINCT}
+ * @param dimension the distinct dimension (only for {@code DISTINCT})
+ */
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public record AggregationDoc(@Nullable String type, @Nullable String dimension) {}
+
+ /**
+ * The YAML shape of a window: an ISO-8601 {@code duration} (e.g. {@code PT1H}) and a {@code
+ * type}.
+ *
+ * @param duration the ISO-8601 duration string
+ * @param type {@code SLIDING} or {@code TUMBLING}
+ */
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public record WindowDoc(@Nullable String duration, @Nullable String type) {}
+}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/yaml/FeatureDefinitionsDoc.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/yaml/FeatureDefinitionsDoc.java
new file mode 100644
index 0000000..4f98561
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/yaml/FeatureDefinitionsDoc.java
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core.model.yaml;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import java.util.List;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The YAML wire shape of a namespace's feature definition set (FR-28).
+ *
+ * This is the canonical external representation of feature configuration — deliberately a plain,
+ * Jackson-bound DTO separate from the domain {@code FeatureDefinition} so the domain types stay
+ * serialization-neutral (mirroring the {@code velocity-spi} model philosophy). {@code
+ * FeatureDefinitionYaml} maps between this shape and the domain. Unknown fields are tolerated on
+ * read for forward compatibility.
+ *
+ * @param namespace the namespace these definitions belong to; may be null in a namespace-agnostic
+ * document (the caller supplies the namespace on import)
+ * @param definitions the feature definitions
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record FeatureDefinitionsDoc(
+ @Nullable String namespace, @Nullable List definitions) {}
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/yaml/package-info.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/yaml/package-info.java
new file mode 100644
index 0000000..869ca64
--- /dev/null
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/model/yaml/package-info.java
@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: BSD-3-Clause
+
+/**
+ * The Jackson-bound YAML wire shapes for feature-definition import/export (FR-28).
+ *
+ * Kept deliberately separate from the domain {@code FeatureDefinition} so the domain stays
+ * serialization-neutral: {@code FeatureDefinitionYaml} maps between these DTOs and the domain.
+ * These are coverage-exempt DTOs under {@code **}/{@code model/**}.
+ */
+@NullMarked
+package com.codeheadsystems.velocity.core.model.yaml;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/package-info.java b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/package-info.java
index 47546d0..6094bff 100644
--- a/velocity-core/src/main/java/com/codeheadsystems/velocity/core/package-info.java
+++ b/velocity-core/src/main/java/com/codeheadsystems/velocity/core/package-info.java
@@ -1,4 +1,29 @@
// SPDX-License-Identifier: BSD-3-Clause
-/** The velocity-core engine: record()/query(), fan-out resolution, and feature-definition I/O. */
+/**
+ * The {@code velocity-core} engine: the embeddable heart that turns a {@code record()} into
+ * backend-neutral intents and back into per-feature results.
+ *
+ *
This package holds the engine logic — {@link
+ * com.codeheadsystems.velocity.core.VelocityEngine} (the public API), the {@link
+ * com.codeheadsystems.velocity.core.FanOutResolver fan-out resolver} (FR-18), the {@link
+ * com.codeheadsystems.velocity.core.CapabilityValidator capability validator} (FR-29), the {@link
+ * com.codeheadsystems.velocity.core.DimensionHasher keyed dimension hasher} (FR-38), the {@link
+ * com.codeheadsystems.velocity.core.FeatureDefinitionProvider definition provider} with atomic
+ * hot-reload (FR-17), the {@link com.codeheadsystems.velocity.core.BackendRegistry backend
+ * registry}, and {@link com.codeheadsystems.velocity.core.FeatureDefinitionYaml YAML I/O} (FR-28).
+ * The value types live in {@code com.codeheadsystems.velocity.core.model}.
+ *
+ *
Deferred follow-ups (this increment). Micrometer metrics wiring (NFR-11) is
+ * intentionally out — Micrometer stays a {@code compileOnly} dependency until the service tier
+ * wires a real {@code MeterRegistry}. A KMS-backed {@link
+ * com.codeheadsystems.velocity.core.NamespaceSaltProvider} is deferred (only an in-memory default
+ * ships here; production MUST source the salt from a separate trust domain per §15 R11).
+ * Non-primary hot-reload sources (loading definitions from a store/watcher, OQ-C) are deferred; the
+ * {@link com.codeheadsystems.velocity.core.MutableFeatureDefinitionProvider} provides the
+ * atomic-swap mechanism a source would drive.
+ */
+@NullMarked
package com.codeheadsystems.velocity.core;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/velocity-core/src/test/java/com/codeheadsystems/velocity/core/BackendRegistryTest.java b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/BackendRegistryTest.java
new file mode 100644
index 0000000..753b2d5
--- /dev/null
+++ b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/BackendRegistryTest.java
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.codeheadsystems.velocity.testkit.InMemoryVelocityBackend;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class BackendRegistryTest {
+
+ private final InMemoryVelocityBackend backend = new InMemoryVelocityBackend();
+
+ @Test
+ void resolvesRegisteredBackend() {
+ final BackendRegistry registry = new BackendRegistry(Map.of("memory", backend));
+ assertThat(registry.backend("memory")).isSameAs(backend);
+ assertThat(registry.names()).containsExactly("memory");
+ }
+
+ @Test
+ void unknownBackendThrowsWithKnownNames() {
+ final BackendRegistry registry = new BackendRegistry(Map.of("memory", backend));
+ assertThatThrownBy(() -> registry.backend("redis"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("redis")
+ .hasMessageContaining("memory");
+ }
+
+ @Test
+ void emptyRegistryIsRejected() {
+ assertThatThrownBy(() -> new BackendRegistry(Map.of()))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void blankNameIsRejected() {
+ assertThatThrownBy(() -> new BackendRegistry(Map.of(" ", backend)))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/velocity-core/src/test/java/com/codeheadsystems/velocity/core/CapabilityValidatorTest.java b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/CapabilityValidatorTest.java
new file mode 100644
index 0000000..6c2d850
--- /dev/null
+++ b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/CapabilityValidatorTest.java
@@ -0,0 +1,117 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.codeheadsystems.velocity.core.model.CapabilityValidationResult;
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.spi.model.AggregationType;
+import com.codeheadsystems.velocity.spi.model.BackendCapabilities;
+import com.codeheadsystems.velocity.spi.model.BackendCapabilities.WindowSpec;
+import com.codeheadsystems.velocity.spi.model.Exactness;
+import com.codeheadsystems.velocity.spi.model.ReadYourWriteLevel;
+import com.codeheadsystems.velocity.spi.model.Window;
+import com.codeheadsystems.velocity.spi.model.WindowType;
+import java.time.Duration;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+class CapabilityValidatorTest {
+
+ private final CapabilityValidator validator = new CapabilityValidator();
+
+ private static BackendCapabilities caps(
+ final Set aggregations,
+ final List windows,
+ final Duration maxRetention) {
+ return new BackendCapabilities(
+ aggregations,
+ windows.stream().map(w -> new WindowSpec(w, Exactness.EXACT, w.duration())).toList(),
+ false,
+ Long.MAX_VALUE,
+ Long.MAX_VALUE,
+ maxRetention,
+ ReadYourWriteLevel.ATOMIC,
+ false,
+ false,
+ Integer.MAX_VALUE);
+ }
+
+ @Test
+ void validDefinitionPasses() {
+ final CapabilityValidationResult result =
+ validator.validate(
+ Fixtures.cardCount(),
+ caps(
+ Set.of(AggregationType.COUNT),
+ List.of(Fixtures.TUMBLING_1M, Fixtures.TUMBLING_1H),
+ Duration.ofDays(30)));
+ assertThat(result.valid()).isTrue();
+ assertThat(result.violations()).isEmpty();
+ }
+
+ @Test
+ void unsupportedWindowIsRejected() {
+ final CapabilityValidationResult result =
+ validator.validate(
+ Fixtures.cardCount(),
+ caps(
+ Set.of(AggregationType.COUNT), List.of(Fixtures.TUMBLING_1M), Duration.ofDays(30)));
+ assertThat(result.valid()).isFalse();
+ assertThat(result.violations())
+ .anySatisfy(v -> assertThat(v.message()).contains("PT1H").contains("not supported"));
+ }
+
+ @Test
+ void unsupportedAggregationIsRejected() {
+ final CapabilityValidationResult result =
+ validator.validate(
+ Fixtures.cardSum(),
+ caps(
+ Set.of(AggregationType.COUNT),
+ List.of(Fixtures.TUMBLING_1M, Fixtures.TUMBLING_1H),
+ Duration.ofDays(30)));
+ assertThat(result.violations())
+ .anySatisfy(v -> assertThat(v.message()).contains("SUM").contains("not supported"));
+ }
+
+ @Test
+ void retentionTooShortIsRejected() {
+ final FeatureDefinition oneHour =
+ FeatureDefinition.count(
+ "card.count.1h", "card", Fixtures.BACKEND, List.of(Fixtures.TUMBLING_1H));
+ final CapabilityValidationResult result =
+ validator.validate(
+ oneHour,
+ caps(
+ Set.of(AggregationType.COUNT),
+ List.of(Fixtures.TUMBLING_1H),
+ Duration.ofMinutes(30)));
+ assertThat(result.violations())
+ .anySatisfy(v -> assertThat(v.message()).contains("maxRetention").contains("FR-22a"));
+ }
+
+ @Test
+ void distinctOnCountOnlyBackendIsRejected() {
+ final CapabilityValidationResult result =
+ validator.validate(
+ Fixtures.cardDistinctIp(),
+ caps(
+ Set.of(AggregationType.COUNT), List.of(Fixtures.TUMBLING_1H), Duration.ofDays(30)));
+ assertThat(result.violations()).anySatisfy(v -> assertThat(v.message()).contains("DISTINCT"));
+ }
+
+ @Test
+ void collectsAllViolationsNotJustTheFirst() {
+ // Wrong aggregation AND an unsupported window in the same definition.
+ final CapabilityValidationResult result =
+ validator.validate(
+ Fixtures.cardSum(),
+ caps(
+ Set.of(AggregationType.COUNT),
+ List.of(new Window(Duration.ofSeconds(5), WindowType.SLIDING)),
+ Duration.ofDays(30)));
+ assertThat(result.violations()).hasSizeGreaterThanOrEqualTo(2);
+ }
+}
diff --git a/velocity-core/src/test/java/com/codeheadsystems/velocity/core/DefinitionVersionHasherTest.java b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/DefinitionVersionHasherTest.java
new file mode 100644
index 0000000..8a250ed
--- /dev/null
+++ b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/DefinitionVersionHasherTest.java
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class DefinitionVersionHasherTest {
+
+ @Test
+ void isDeterministic() {
+ assertThat(DefinitionVersionHasher.hash(Fixtures.allDefinitions()))
+ .isEqualTo(DefinitionVersionHasher.hash(Fixtures.allDefinitions()));
+ }
+
+ @Test
+ void isOrderIndependent() {
+ final List forward = Fixtures.allDefinitions();
+ final List reversed =
+ List.of(
+ Fixtures.ipCount(),
+ Fixtures.cardDistinctIp(),
+ Fixtures.cardSum(),
+ Fixtures.cardCount());
+ assertThat(DefinitionVersionHasher.hash(forward))
+ .isEqualTo(DefinitionVersionHasher.hash(reversed));
+ }
+
+ @Test
+ void changesWhenADefinitionChanges() {
+ assertThat(DefinitionVersionHasher.hash(List.of(Fixtures.cardCount())))
+ .isNotEqualTo(DefinitionVersionHasher.hash(List.of(Fixtures.cardSum())));
+ }
+
+ @Test
+ void emptySetHashesStably() {
+ assertThat(DefinitionVersionHasher.hash(List.of()))
+ .isEqualTo(DefinitionVersionHasher.hash(List.of()))
+ .isNotBlank();
+ }
+}
diff --git a/velocity-core/src/test/java/com/codeheadsystems/velocity/core/DimensionHasherTest.java b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/DimensionHasherTest.java
new file mode 100644
index 0000000..b61a4c4
--- /dev/null
+++ b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/DimensionHasherTest.java
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.codeheadsystems.velocity.spi.model.DistinctMember;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import org.junit.jupiter.api.Test;
+
+class DimensionHasherTest {
+
+ private static final Namespace ACME = new Namespace("acme");
+ private final DimensionHasher hasher =
+ new DimensionHasher(new InMemoryNamespaceSaltProvider("fixed".getBytes(UTF_8)));
+
+ @Test
+ void sameValueHashesDeterministically() {
+ assertThat(hasher.hash(ACME, "203.0.113.7")).isEqualTo(hasher.hash(ACME, "203.0.113.7"));
+ }
+
+ @Test
+ void differentValuesProduceDifferentMembers() {
+ assertThat(hasher.hash(ACME, "1.1.1.1")).isNotEqualTo(hasher.hash(ACME, "2.2.2.2"));
+ }
+
+ @Test
+ void tokenIsAFixedWidthHashNotTheRawValue() {
+ final DistinctMember member = hasher.hash(ACME, "203.0.113.7");
+ assertThat(member.token()).hasSize(32);
+ // FR-38/R11: the raw dimension value is never what gets stored.
+ assertThat(member.token()).isNotEqualTo("203.0.113.7".getBytes(UTF_8));
+ }
+
+ @Test
+ void saltIsPerNamespace() {
+ assertThat(hasher.hash(ACME, "203.0.113.7"))
+ .isNotEqualTo(hasher.hash(new Namespace("beta"), "203.0.113.7"));
+ }
+
+ @Test
+ void differentMasterSecretsProduceDifferentMembers() {
+ final DimensionHasher other =
+ new DimensionHasher(new InMemoryNamespaceSaltProvider("other".getBytes(UTF_8)));
+ assertThat(hasher.hash(ACME, "203.0.113.7")).isNotEqualTo(other.hash(ACME, "203.0.113.7"));
+ }
+}
diff --git a/velocity-core/src/test/java/com/codeheadsystems/velocity/core/FeatureDefinitionYamlTest.java b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/FeatureDefinitionYamlTest.java
new file mode 100644
index 0000000..237ffc7
--- /dev/null
+++ b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/FeatureDefinitionYamlTest.java
@@ -0,0 +1,95 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.core.model.SubjectSource;
+import com.codeheadsystems.velocity.spi.model.Aggregation;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class FeatureDefinitionYamlTest {
+
+ private static final Namespace ACME = new Namespace("acme");
+ private final FeatureDefinitionYaml yaml = new FeatureDefinitionYaml();
+
+ private static FeatureDefinition distinctWithThreshold() {
+ return new FeatureDefinition(
+ "card.distinct.ip.capped",
+ "card",
+ SubjectSource.primary(),
+ Aggregation.distinct("ip"),
+ List.of(Fixtures.TUMBLING_1H),
+ Fixtures.BACKEND,
+ 5000L);
+ }
+
+ @Test
+ void exportThenImportRoundTrips() {
+ final List definitions =
+ List.of(
+ Fixtures.cardCount(),
+ Fixtures.cardSum(),
+ Fixtures.cardDistinctIp(),
+ Fixtures.ipCount(),
+ distinctWithThreshold());
+
+ final String exported = yaml.export(ACME, definitions);
+ final List imported = yaml.importDefinitions(exported);
+
+ assertThat(imported).isEqualTo(definitions);
+ assertThat(yaml.importNamespace(exported)).isEqualTo(ACME);
+ }
+
+ @Test
+ void exportedYamlIsHumanReadable() {
+ final String exported = yaml.export(ACME, List.of(Fixtures.ipCount()));
+ assertThat(exported)
+ .contains("acme")
+ .contains("ip.count")
+ .contains("FROM_DIMENSION")
+ .contains("PT1H");
+ }
+
+ @Test
+ void subjectSourceDefaultsToPrimaryWhenAbsent() {
+ final String withoutSource =
+ """
+ namespace: acme
+ definitions:
+ - name: card.count
+ subjectType: card
+ backend: memory
+ aggregation:
+ type: COUNT
+ windows:
+ - duration: PT1H
+ type: TUMBLING
+ """;
+ final List imported = yaml.importDefinitions(withoutSource);
+ assertThat(imported).hasSize(1);
+ assertThat(imported.get(0).subjectSource()).isInstanceOf(SubjectSource.Primary.class);
+ }
+
+ @Test
+ void unknownFieldsAreToleratedForForwardCompatibility() {
+ final String withExtra =
+ """
+ namespace: acme
+ futureField: ignored
+ definitions:
+ - name: card.count
+ subjectType: card
+ backend: memory
+ someNewKnob: 7
+ aggregation:
+ type: COUNT
+ windows:
+ - duration: PT1M
+ type: TUMBLING
+ """;
+ assertThat(yaml.importDefinitions(withExtra)).hasSize(1);
+ }
+}
diff --git a/velocity-core/src/test/java/com/codeheadsystems/velocity/core/Fixtures.java b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/Fixtures.java
new file mode 100644
index 0000000..f55468b
--- /dev/null
+++ b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/Fixtures.java
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.spi.VelocityBackend;
+import com.codeheadsystems.velocity.spi.model.Window;
+import com.codeheadsystems.velocity.spi.model.WindowType;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+
+/** Shared test fixtures for the engine tests: standard windows, the canonical definition set. */
+final class Fixtures {
+
+ static final String BACKEND = "memory";
+ static final Window TUMBLING_1M = new Window(Duration.ofMinutes(1), WindowType.TUMBLING);
+ static final Window TUMBLING_1H = new Window(Duration.ofHours(1), WindowType.TUMBLING);
+
+ private Fixtures() {}
+
+ static FeatureDefinition cardCount() {
+ return FeatureDefinition.count(
+ "card.count", "card", BACKEND, List.of(TUMBLING_1M, TUMBLING_1H));
+ }
+
+ static FeatureDefinition cardSum() {
+ return FeatureDefinition.sum("card.sum", "card", BACKEND, List.of(TUMBLING_1M, TUMBLING_1H));
+ }
+
+ static FeatureDefinition cardDistinctIp() {
+ return FeatureDefinition.distinct(
+ "card.distinct.ip", "card", "ip", BACKEND, List.of(TUMBLING_1H));
+ }
+
+ /** An {@code ip}-subject COUNT whose subject is derived from the event's {@code ip} dimension. */
+ static FeatureDefinition ipCount() {
+ return new FeatureDefinition(
+ "ip.count",
+ "ip",
+ com.codeheadsystems.velocity.core.model.SubjectSource.fromDimension("ip"),
+ com.codeheadsystems.velocity.spi.model.Aggregation.count(),
+ List.of(TUMBLING_1M, TUMBLING_1H),
+ BACKEND,
+ null);
+ }
+
+ static List allDefinitions() {
+ return List.of(cardCount(), cardSum(), cardDistinctIp(), ipCount());
+ }
+
+ static VelocityEngine engine(
+ final MutableFeatureDefinitionProvider provider, final VelocityBackend backend) {
+ final BackendRegistry registry = new BackendRegistry(Map.of(BACKEND, backend));
+ final DimensionHasher hasher =
+ new DimensionHasher(new InMemoryNamespaceSaltProvider("test-master".getBytes(UTF_8)));
+ return new VelocityEngine(registry, provider, hasher, new CapabilityValidator());
+ }
+}
diff --git a/velocity-core/src/test/java/com/codeheadsystems/velocity/core/MutableFeatureDefinitionProviderTest.java b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/MutableFeatureDefinitionProviderTest.java
new file mode 100644
index 0000000..cf00b8b
--- /dev/null
+++ b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/MutableFeatureDefinitionProviderTest.java
@@ -0,0 +1,86 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.core.model.FeatureDefinitions;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+
+class MutableFeatureDefinitionProviderTest {
+
+ private static final Namespace ACME = new Namespace("acme");
+ private final MutableFeatureDefinitionProvider provider = new MutableFeatureDefinitionProvider();
+
+ @Test
+ void unknownNamespaceReturnsEmptySnapshot() {
+ final FeatureDefinitions snapshot = provider.definitions(ACME);
+ assertThat(snapshot.isEmpty()).isTrue();
+ assertThat(snapshot.versionHash()).isNotBlank();
+ }
+
+ @Test
+ void reloadSwapsSnapshotAndChangesVersion() {
+ final String empty = provider.definitions(ACME).versionHash();
+ provider.reload(ACME, List.of(Fixtures.cardCount()));
+ final FeatureDefinitions after = provider.definitions(ACME);
+ assertThat(after.versionHash()).isNotEqualTo(empty);
+ assertThat(after.definition("card.count")).isPresent();
+ }
+
+ @Test
+ void concurrentReadsAlwaysSeeAWholeSnapshot() throws InterruptedException {
+ final List setA = List.of(Fixtures.cardCount());
+ final List setB =
+ List.of(Fixtures.cardCount(), Fixtures.cardSum(), Fixtures.cardDistinctIp());
+ provider.reload(ACME, setA);
+
+ final Set observedSizes = ConcurrentHashMap.newKeySet();
+ final int readers = 6;
+ final CountDownLatch start = new CountDownLatch(1);
+ final CountDownLatch done = new CountDownLatch(readers + 1);
+
+ final Thread writer =
+ new Thread(
+ () -> {
+ await(start);
+ for (int i = 0; i < 2000; i++) {
+ provider.reload(ACME, (i % 2 == 0) ? setA : setB);
+ }
+ done.countDown();
+ });
+ writer.start();
+
+ for (int r = 0; r < readers; r++) {
+ new Thread(
+ () -> {
+ await(start);
+ for (int i = 0; i < 5000; i++) {
+ observedSizes.add(provider.definitions(ACME).definitions().size());
+ }
+ done.countDown();
+ })
+ .start();
+ }
+
+ start.countDown();
+ assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
+ // Every observation was a whole snapshot — only setA's or setB's size, never a torn count.
+ assertThat(observedSizes).isSubsetOf(setA.size(), setB.size());
+ }
+
+ private static void await(final CountDownLatch latch) {
+ try {
+ latch.await();
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/velocity-core/src/test/java/com/codeheadsystems/velocity/core/UnsupportedMixinTest.java b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/UnsupportedMixinTest.java
new file mode 100644
index 0000000..64df328
--- /dev/null
+++ b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/UnsupportedMixinTest.java
@@ -0,0 +1,98 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.spi.CountStore;
+import com.codeheadsystems.velocity.spi.model.ApplyContext;
+import com.codeheadsystems.velocity.spi.model.ApplyResult;
+import com.codeheadsystems.velocity.spi.model.ApplyStatus;
+import com.codeheadsystems.velocity.spi.model.BackendCapabilities;
+import com.codeheadsystems.velocity.spi.model.BackendCapabilities.WindowSpec;
+import com.codeheadsystems.velocity.spi.model.Exactness;
+import com.codeheadsystems.velocity.spi.model.FeatureResult;
+import com.codeheadsystems.velocity.spi.model.Intent.CountIntent;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import com.codeheadsystems.velocity.spi.model.QueryContext;
+import com.codeheadsystems.velocity.spi.model.QueryTuple;
+import com.codeheadsystems.velocity.spi.model.ReadYourWriteLevel;
+import com.codeheadsystems.velocity.spi.model.Subject;
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.Test;
+
+/**
+ * When a definition is bound to a backend that does not implement the required aggregation mix-in
+ * (an inconsistent configuration the capability validator would normally catch), the engine emits a
+ * distinguishable FAILED outcome rather than throwing.
+ */
+class UnsupportedMixinTest {
+
+ private static final Namespace ACME = new Namespace("acme");
+ private static final Subject CARD = new Subject("card", "1234");
+
+ @Test
+ void sumIntentToCountOnlyBackendFailsGracefully() {
+ final BackendRegistry registry = new BackendRegistry(Map.of("fake", new CountOnlyBackend()));
+ final MutableFeatureDefinitionProvider provider = new MutableFeatureDefinitionProvider();
+ // Install directly (bypassing engine.reload's validation) to reach the defensive path.
+ provider.reload(
+ ACME,
+ List.of(FeatureDefinition.sum("card.sum", "card", "fake", List.of(Fixtures.TUMBLING_1H))));
+ final VelocityEngine engine =
+ new VelocityEngine(
+ registry,
+ provider,
+ new DimensionHasher(new InMemoryNamespaceSaltProvider("m".getBytes(UTF_8))),
+ new CapabilityValidator());
+
+ final ApplyResult result = engine.record(ACME, CARD, Map.of(), new BigDecimal("100"));
+ assertThat(result.perFeature())
+ .allSatisfy(pf -> assertThat(pf.status()).isEqualTo(ApplyStatus.FAILED))
+ .allSatisfy(pf -> assertThat(pf.result()).isInstanceOf(FeatureResult.Failure.class));
+
+ // The query path is symmetric: a Failure per requested window, never a thrown exception.
+ assertThat(engine.query(ACME, CARD, "card.sum"))
+ .allSatisfy(r -> assertThat(r).isInstanceOf(FeatureResult.Failure.class));
+ }
+
+ /** A backend that only implements COUNT, used to exercise the engine's missing-mix-in path. */
+ private static final class CountOnlyBackend implements CountStore {
+
+ @Override
+ public BackendCapabilities capabilities() {
+ return new BackendCapabilities(
+ Set.of(com.codeheadsystems.velocity.spi.model.AggregationType.COUNT),
+ List.of(new WindowSpec(Fixtures.TUMBLING_1H, Exactness.EXACT, Duration.ofHours(1))),
+ false,
+ Long.MAX_VALUE,
+ Long.MAX_VALUE,
+ Duration.ofDays(30),
+ ReadYourWriteLevel.ATOMIC,
+ false,
+ false,
+ Integer.MAX_VALUE);
+ }
+
+ @Override
+ public ApplyResult applyCount(final ApplyContext ctx, final List intents) {
+ return new ApplyResult(List.of());
+ }
+
+ @Override
+ public List queryCount(final QueryContext ctx, final List tuples) {
+ return List.of();
+ }
+
+ @Override
+ public void purge(final Namespace namespace, final @Nullable Subject subject) {
+ // no-op
+ }
+ }
+}
diff --git a/velocity-core/src/test/java/com/codeheadsystems/velocity/core/VelocityEngineTest.java b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/VelocityEngineTest.java
new file mode 100644
index 0000000..97ff006
--- /dev/null
+++ b/velocity-core/src/test/java/com/codeheadsystems/velocity/core/VelocityEngineTest.java
@@ -0,0 +1,228 @@
+// SPDX-License-Identifier: BSD-3-Clause
+package com.codeheadsystems.velocity.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.codeheadsystems.velocity.core.model.FeatureDefinition;
+import com.codeheadsystems.velocity.spi.model.ApplyResult;
+import com.codeheadsystems.velocity.spi.model.ApplyStatus;
+import com.codeheadsystems.velocity.spi.model.FeatureResult;
+import com.codeheadsystems.velocity.spi.model.FeatureValue;
+import com.codeheadsystems.velocity.spi.model.Namespace;
+import com.codeheadsystems.velocity.spi.model.PerFeature;
+import com.codeheadsystems.velocity.spi.model.Subject;
+import com.codeheadsystems.velocity.testkit.InMemoryVelocityBackend;
+import com.codeheadsystems.velocity.testkit.MutableClock;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** End-to-end tests driving the real in-memory backend through the engine. */
+class VelocityEngineTest {
+
+ private static final Namespace ACME = new Namespace("acme");
+ private static final Subject CARD = new Subject("card", "1234");
+ private static final Map DIMENSIONS =
+ Map.of("ip", "203.0.113.7", "merchant", "m-88");
+ private static final BigDecimal VALUE = new BigDecimal("14950");
+
+ private MutableClock clock;
+ private InMemoryVelocityBackend backend;
+ private MutableFeatureDefinitionProvider provider;
+ private VelocityEngine engine;
+
+ @BeforeEach
+ void setUp() {
+ clock = new MutableClock(Instant.parse("2026-03-15T12:30:30Z"));
+ backend = new InMemoryVelocityBackend(clock);
+ provider = new MutableFeatureDefinitionProvider();
+ engine = Fixtures.engine(provider, backend);
+ engine.reload(ACME, Fixtures.allDefinitions());
+ }
+
+ @Test
+ void fanOut_updatesEveryMatchedFeatureAcrossSubjects() {
+ engine.record(ACME, CARD, DIMENSIONS, VALUE);
+
+ assertThat(value(engine.query(ACME, CARD, "card.count"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("1");
+ assertThat(value(engine.query(ACME, CARD, "card.sum"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("14950");
+ assertThat(value(engine.query(ACME, CARD, "card.distinct.ip"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("1");
+ // The ip.count feature fanned out to a *different* subject derived from the ip dimension.
+ final Subject ipSubject = new Subject("ip", "203.0.113.7");
+ assertThat(value(engine.query(ACME, ipSubject, "ip.count"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("1");
+ }
+
+ @Test
+ void record_resultReflectsTheWrite_readYourWrite() {
+ final ApplyResult result = engine.record(ACME, CARD, DIMENSIONS, VALUE);
+
+ final PerFeature cardCount1h =
+ result.perFeature().stream()
+ .filter(
+ pf ->
+ pf.feature().name().equals("card.count")
+ && pf.result() instanceof FeatureResult.Success success
+ && success.value().window().equals(Fixtures.TUMBLING_1H))
+ .findFirst()
+ .orElseThrow();
+ assertThat(cardCount1h.status()).isEqualTo(ApplyStatus.APPLIED);
+ final FeatureValue value = ((FeatureResult.Success) cardCount1h.result()).value();
+ assertThat(value.value()).isEqualByComparingTo("1");
+ // FR-40: every returned value is stamped with the definition version it was computed under.
+ assertThat(value.definitionVersionHash())
+ .isEqualTo(provider.definitions(ACME).versionHash())
+ .isNotNull();
+ }
+
+ @Test
+ void record_noMatchingDefinition_isEmptyNotError() {
+ // A subject whose type matches no definition and no dimension-derived fan-out applies.
+ final ApplyResult result = engine.record(ACME, new Subject("device", "d-1"), Map.of(), null);
+ assertThat(result.perFeature()).isEmpty();
+ }
+
+ @Test
+ void record_sumWithoutValue_isSkippedWithReason() {
+ final ApplyResult result = engine.record(ACME, CARD, DIMENSIONS, null);
+
+ final PerFeature sum =
+ result.perFeature().stream()
+ .filter(pf -> pf.feature().name().equals("card.sum"))
+ .findFirst()
+ .orElseThrow();
+ assertThat(sum.status()).isEqualTo(ApplyStatus.SKIPPED);
+ assertThat(sum.result()).isInstanceOf(FeatureResult.Failure.class);
+ // COUNT still applied even though SUM was skipped.
+ assertThat(value(engine.query(ACME, CARD, "card.count"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("1");
+ }
+
+ @Test
+ void record_distinctWithoutDimension_isSkippedWithReason() {
+ // The card.distinct.ip feature resolves its (primary card) subject, but the "ip" dimension it
+ // counts distinctly is absent, so no member can be hashed and the write is skipped — the
+ // DISTINCT twin of record_sumWithoutValue_isSkippedWithReason (FR-18 fan-out skip path).
+ final ApplyResult result = engine.record(ACME, CARD, Map.of(), null);
+
+ final PerFeature distinct =
+ result.perFeature().stream()
+ .filter(pf -> pf.feature().name().equals("card.distinct.ip"))
+ .findFirst()
+ .orElseThrow();
+ assertThat(distinct.status()).isEqualTo(ApplyStatus.SKIPPED);
+ assertThat(distinct.result()).isInstanceOf(FeatureResult.Failure.class);
+ // COUNT still applied even though DISTINCT was skipped.
+ assertThat(value(engine.query(ACME, CARD, "card.count"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("1");
+ }
+
+ @Test
+ void distinct_countsDistinctHashedValues() {
+ engine.record(ACME, CARD, Map.of("ip", "1.1.1.1"), null);
+ engine.record(ACME, CARD, Map.of("ip", "2.2.2.2"), null);
+ assertThat(value(engine.query(ACME, CARD, "card.distinct.ip"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("2");
+
+ // Recording the same ip again does not raise cardinality.
+ engine.record(ACME, CARD, Map.of("ip", "1.1.1.1"), null);
+ assertThat(value(engine.query(ACME, CARD, "card.distinct.ip"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("2");
+ }
+
+ @Test
+ void namespaces_areIsolated() {
+ final Namespace beta = new Namespace("beta");
+ engine.reload(beta, Fixtures.allDefinitions());
+
+ engine.record(ACME, CARD, DIMENSIONS, VALUE);
+ engine.record(ACME, CARD, DIMENSIONS, VALUE);
+ engine.record(beta, CARD, DIMENSIONS, VALUE);
+
+ assertThat(value(engine.query(ACME, CARD, "card.count"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("2");
+ assertThat(value(engine.query(beta, CARD, "card.count"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("1");
+ }
+
+ @Test
+ void reload_isAtomicAndChangesVersionAndResolvableFeatures() {
+ final String before = provider.definitions(ACME).versionHash();
+
+ // Swap: keep card.count, drop card.sum, keep the distinct + ip features.
+ engine.reload(
+ ACME, List.of(Fixtures.cardCount(), Fixtures.cardDistinctIp(), Fixtures.ipCount()));
+ final String after = provider.definitions(ACME).versionHash();
+ assertThat(after).isNotEqualTo(before);
+
+ // The removed feature no longer resolves.
+ assertThatThrownBy(() -> engine.query(ACME, CARD, "card.sum"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("card.sum");
+
+ // A retained feature still works.
+ engine.record(ACME, CARD, DIMENSIONS, VALUE);
+ assertThat(value(engine.query(ACME, CARD, "card.count"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("1");
+ }
+
+ @Test
+ void reload_rejectsInvalidDefinitionSet_andDoesNotApply() {
+ final String before = provider.definitions(ACME).versionHash();
+ final var badWindow =
+ FeatureDefinition.count(
+ "card.count.1d",
+ "card",
+ Fixtures.BACKEND,
+ List.of(
+ new com.codeheadsystems.velocity.spi.model.Window(
+ java.time.Duration.ofDays(1),
+ com.codeheadsystems.velocity.spi.model.WindowType.TUMBLING)));
+
+ assertThatThrownBy(() -> engine.reload(ACME, List.of(Fixtures.cardCount(), badWindow)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("card.count.1d");
+
+ // Rejected atomically: the previous snapshot is untouched.
+ assertThat(provider.definitions(ACME).versionHash()).isEqualTo(before);
+ }
+
+ @Test
+ void capabilities_isPassthrough() {
+ assertThat(engine.capabilities(Fixtures.BACKEND)).isSameAs(backend.capabilities());
+ }
+
+ @Test
+ void purge_erasesAcrossBackends() {
+ engine.record(ACME, CARD, DIMENSIONS, VALUE);
+ engine.purge(ACME, CARD);
+ assertThat(value(engine.query(ACME, CARD, "card.count"), Fixtures.TUMBLING_1H))
+ .isEqualByComparingTo("0");
+ }
+
+ @Test
+ void query_batchReturnsPerFeatureResults() {
+ engine.record(ACME, CARD, DIMENSIONS, VALUE);
+ final Map> results =
+ engine.query(ACME, CARD, List.of("card.count", "card.sum"));
+ assertThat(results).containsOnlyKeys("card.count", "card.sum");
+ assertThat(value(results.get("card.sum"), Fixtures.TUMBLING_1H)).isEqualByComparingTo("14950");
+ }
+
+ private static BigDecimal value(
+ final List results,
+ final com.codeheadsystems.velocity.spi.model.Window window) {
+ return results.stream()
+ .filter(r -> r instanceof FeatureResult.Success s && s.value().window().equals(window))
+ .map(r -> ((FeatureResult.Success) r).value().value())
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no success result for window " + window));
+ }
+}