Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>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<String, VelocityBackend> 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<String, VelocityBackend> 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<String> names() {
return backends.keySet();
}
}
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>The engine rejects an import/reload unless every definition is valid (atomic with FR-17); this
* validator returns <em>all</em> 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.
*
* <p>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<Violation> violations = new ArrayList<>();
collect(definition, capabilities, violations);
return new CapabilityValidationResult(violations);
}

private void collect(
final FeatureDefinition definition,
final BackendCapabilities capabilities,
final List<Violation> 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)"));
}
}
}
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>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<FeatureDefinition> definitions) {
Objects.requireNonNull(definitions, "definitions");
final List<String> 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<String> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>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 <em>when the
* salt lives in a separate trust domain</em> (§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);
}
}
}
Loading
Loading