From b3582598222bbff89efe0ccc1c593d6ff8e0dbf1 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Sat, 18 Jul 2026 16:35:06 -0700 Subject: [PATCH] Implement the velocity-spi contract (capability mix-ins + ADR-0009 result DTO) First real code behind the SPI. Implements ADR 0003 (capability mix-ins, backend-neutral intents, BackendCapabilities), ADR 0009 (the value-or-failure FeatureResult / ApplyResult apply path), ADR 0007 (per-result read-your-write level), and ADR 0005/0006/0008 (HLL-tumbling-only, distinct threshold, seed). - 26 value types in ...spi.model (enums, keys, sealed Intent, the result family FeatureResult/FeatureValue/PerFeature/ApplyResult, sealed SeedAggregate, BackendCapabilities) + 8 interfaces in ...spi (VelocityBackend base, Count/Sum/DistinctStore mix-ins, Sliding/TumblingSupport markers, SeedSupport). - Serialization-neutral: no Jackson/Dagger/Dropwizard (ADR 0002); jspecify null-hostile via package @NullMarked. - apply* return ApplyResult so per-feature ApplyStatus (APPLIED|FAILED|SKIPPED, FR-34) is reachable, incl. backend-owned SKIPPED (resolves the change-validator blocking finding). - 54 JUnit5/AssertJ tests over all non-trivial value-type logic; :velocity-spi:build is green (spotless + SPDX header, -Xlint:all -Werror, coverage gate). Validated by the change-validator agent (one blocking finding on the apply-path return type, fixed). ADR 0009 FeatureValue.value reconciled to uniform BigDecimal. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/adr/0009-hot-path-result-dto.md | 2 +- .../velocity/spi/CountStore.java | 42 +++++++ .../velocity/spi/DistinctStore.java | 47 ++++++++ .../velocity/spi/SeedSupport.java | 35 ++++++ .../velocity/spi/SlidingSupport.java | 17 +++ .../velocity/spi/SumStore.java | 41 +++++++ .../velocity/spi/TumblingSupport.java | 16 +++ .../velocity/spi/VelocityBackend.java | 37 ++++++ .../velocity/spi/model/Aggregation.java | 59 +++++++++ .../velocity/spi/model/AggregationType.java | 19 +++ .../velocity/spi/model/ApplyContext.java | 23 ++++ .../velocity/spi/model/ApplyResult.java | 20 ++++ .../velocity/spi/model/ApplyStatus.java | 19 +++ .../spi/model/BackendCapabilities.java | 105 ++++++++++++++++ .../velocity/spi/model/BucketValue.java | 23 ++++ .../velocity/spi/model/DistinctMember.java | 58 +++++++++ .../velocity/spi/model/Exactness.java | 16 +++ .../velocity/spi/model/FailureCode.java | 28 +++++ .../velocity/spi/model/Feature.java | 34 ++++++ .../velocity/spi/model/FeatureResult.java | 80 +++++++++++++ .../velocity/spi/model/FeatureValue.java | 49 ++++++++ .../velocity/spi/model/Intent.java | 97 +++++++++++++++ .../velocity/spi/model/Namespace.java | 20 ++++ .../velocity/spi/model/PerFeature.java | 25 ++++ .../velocity/spi/model/QueryContext.java | 23 ++++ .../velocity/spi/model/QueryTuple.java | 25 ++++ .../spi/model/ReadYourWriteLevel.java | 29 +++++ .../velocity/spi/model/SeedAggregate.java | 112 ++++++++++++++++++ .../velocity/spi/model/Subject.java | 28 +++++ .../velocity/spi/model/Window.java | 26 ++++ .../velocity/spi/model/WindowBounds.java | 27 +++++ .../velocity/spi/model/WindowType.java | 19 +++ .../velocity/spi/model/package-info.java | 24 ++++ .../velocity/spi/package-info.java | 28 ++++- .../velocity/spi/model/AggregationTest.java | 61 ++++++++++ .../velocity/spi/model/ApplyResultTest.java | 53 +++++++++ .../spi/model/BackendCapabilitiesTest.java | 91 ++++++++++++++ .../spi/model/DistinctMemberTest.java | 52 ++++++++ .../velocity/spi/model/FeatureResultTest.java | 75 ++++++++++++ .../velocity/spi/model/FeatureTest.java | 52 ++++++++ .../velocity/spi/model/IntentTest.java | 89 ++++++++++++++ .../velocity/spi/model/SeedAggregateTest.java | 57 +++++++++ .../velocity/spi/model/WindowBoundsTest.java | 39 ++++++ .../velocity/spi/model/WindowTest.java | 37 ++++++ 44 files changed, 1857 insertions(+), 2 deletions(-) create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/CountStore.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/DistinctStore.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SeedSupport.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SlidingSupport.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SumStore.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/TumblingSupport.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/VelocityBackend.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Aggregation.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/AggregationType.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyContext.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyResult.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyStatus.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/BackendCapabilities.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/BucketValue.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/DistinctMember.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Exactness.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FailureCode.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Feature.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FeatureResult.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FeatureValue.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Intent.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Namespace.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/PerFeature.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/QueryContext.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/QueryTuple.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ReadYourWriteLevel.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/SeedAggregate.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Subject.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Window.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/WindowBounds.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/WindowType.java create mode 100644 velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/package-info.java create mode 100644 velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/AggregationTest.java create mode 100644 velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/ApplyResultTest.java create mode 100644 velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/BackendCapabilitiesTest.java create mode 100644 velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/DistinctMemberTest.java create mode 100644 velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/FeatureResultTest.java create mode 100644 velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/FeatureTest.java create mode 100644 velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/IntentTest.java create mode 100644 velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/SeedAggregateTest.java create mode 100644 velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/WindowBoundsTest.java create mode 100644 velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/WindowTest.java diff --git a/docs/adr/0009-hot-path-result-dto.md b/docs/adr/0009-hot-path-result-dto.md index 03f0a49..8c94c90 100644 --- a/docs/adr/0009-hot-path-result-dto.md +++ b/docs/adr/0009-hot-path-result-dto.md @@ -48,7 +48,7 @@ FeatureResult = Success { FeatureValue } | Failure { FailureCode code, String detail } FeatureValue = { FeatureRef feature, - Number value, // BigDecimal cents for SUM (P3); long for COUNT/DISTINCT + BigDecimal value, // uniform: integer-valued for COUNT/DISTINCT, cents for SUM (P3) Exactness exactness, // EXACT | APPROXIMATE (FR-7) ReadYourWriteLevel readYourWriteLevel, // ATOMIC | SNAPSHOT | BESTEFFORT (ADR 0007) String definitionVersionHash, // FR-40; nullable until R12 lands diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/CountStore.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/CountStore.java new file mode 100644 index 0000000..5f860a4 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/CountStore.java @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi; + +import com.codeheadsystems.velocity.spi.model.ApplyContext; +import com.codeheadsystems.velocity.spi.model.ApplyResult; +import com.codeheadsystems.velocity.spi.model.FeatureResult; +import com.codeheadsystems.velocity.spi.model.Intent.CountIntent; +import com.codeheadsystems.velocity.spi.model.QueryContext; +import com.codeheadsystems.velocity.spi.model.QueryTuple; +import java.util.List; + +/** + * The {@code COUNT} aggregation mix-in (ADR 0003): apply and query count intents. + * + *

A backend implements this only if it declares {@link + * com.codeheadsystems.velocity.spi.model.AggregationType#COUNT COUNT} in its {@link + * com.codeheadsystems.velocity.spi.model.BackendCapabilities capabilities}. Each returned {@link + * FeatureResult} is a value or a distinguishable failure — never a silent {@code 0} (ADR 0009). + */ +public interface CountStore extends VelocityBackend { + + /** + * Applies count writes. Returns an {@link ApplyResult} carrying, per intent, the {@link + * com.codeheadsystems.velocity.spi.model.ApplyStatus apply status} ({@code + * APPLIED|FAILED|SKIPPED} — a {@code SKIPPED} being a backend-owned outcome such as an idempotent + * replay) alongside the {@link FeatureResult} (ADR 0009, FR-34). + * + * @param ctx the namespace + optional deadline context + * @param intents the count intents to apply + * @return the per-intent apply outcomes, positionally aligned with {@code intents} + */ + ApplyResult applyCount(ApplyContext ctx, List intents); + + /** + * Queries counts, returning one result per tuple in order. + * + * @param ctx the namespace + optional deadline context + * @param tuples the query tuples to read + * @return the per-tuple results, positionally aligned with {@code tuples} + */ + List queryCount(QueryContext ctx, List tuples); +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/DistinctStore.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/DistinctStore.java new file mode 100644 index 0000000..e1717fa --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/DistinctStore.java @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi; + +import com.codeheadsystems.velocity.spi.model.ApplyContext; +import com.codeheadsystems.velocity.spi.model.ApplyResult; +import com.codeheadsystems.velocity.spi.model.FeatureResult; +import com.codeheadsystems.velocity.spi.model.Intent.DistinctIntent; +import com.codeheadsystems.velocity.spi.model.QueryContext; +import com.codeheadsystems.velocity.spi.model.QueryTuple; +import java.util.List; + +/** + * The {@code DISTINCT} aggregation mix-in (ADR 0003): apply and query distinct-cardinality intents. + * + *

Distinct is exact where cheap, HLL above a per-{@code (subject, bucket)} + * threshold (ADR 0006): a bucket starts exact and, once it crosses the backend-clamped + * threshold, becomes permanently approximate; a multi-bucket window is approximate if any + * constituent bucket is HLL, with exact buckets HLL-folded at read time. HLL is + * tumbling-only (ADR 0005) — sliding distinct is exact and capped ({@link + * com.codeheadsystems.velocity.spi.model.FailureCode#CARDINALITY_CAP_EXCEEDED}). A backend + * implements this only if it declares {@link + * com.codeheadsystems.velocity.spi.model.AggregationType#DISTINCT DISTINCT}. Members are opaque, + * pre-hashed tokens the backend must not interpret. Each returned {@link FeatureResult} is a value + * or a distinguishable failure — never a silent {@code 0} (ADR 0009). + */ +public interface DistinctStore extends VelocityBackend { + + /** + * Applies distinct writes. Returns an {@link ApplyResult} carrying, per intent, the {@link + * com.codeheadsystems.velocity.spi.model.ApplyStatus apply status} ({@code + * APPLIED|FAILED|SKIPPED}) alongside the {@link FeatureResult} (ADR 0009, FR-34). + * + * @param ctx the namespace + optional deadline context + * @param intents the distinct intents to apply + * @return the per-intent apply outcomes, positionally aligned with {@code intents} + */ + ApplyResult applyDistinct(ApplyContext ctx, List intents); + + /** + * Queries distinct cardinalities, returning one result per tuple in order. + * + * @param ctx the namespace + optional deadline context + * @param tuples the query tuples to read + * @return the per-tuple results, positionally aligned with {@code tuples} + */ + List queryDistinct(QueryContext ctx, List tuples); +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SeedSupport.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SeedSupport.java new file mode 100644 index 0000000..c88bb42 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SeedSupport.java @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi; + +import com.codeheadsystems.velocity.spi.model.BucketValue; +import com.codeheadsystems.velocity.spi.model.Feature; +import com.codeheadsystems.velocity.spi.model.Namespace; +import com.codeheadsystems.velocity.spi.model.Subject; +import java.util.List; + +/** + * Optional mix-in: seed pre-computed historical aggregates (FR-32, ADR 0008). + * + *

Seeding places per-bucket aggregate values into the same buckets a windowed query later merges + * (FR-14) — a single total is explicitly not a valid seed, since it cannot be apportioned across a + * window's buckets (ADR 0008). Seeded state is flagged onboarding-seeded, distinct from organically + * recorded state. + * + *

This mix-in is optional: a backend that cannot represent seeded buckets + * simply does not implement it and declares {@code seedSupported == false}. A backend that does + * implement it MUST reject a seed it cannot store — in particular an HLL sketch it did not itself + * produce (opaque, same-implementation-only bytes, ADR 0006) or an HLL sketch on a sliding feature + * (ADR 0005). Seed is an admin operation, rate-isolated from the hot path (FR-33). + */ +public interface SeedSupport extends VelocityBackend { + + /** + * Seeds pre-computed per-bucket aggregates for a {@code (namespace, subject, feature)}. + * + * @param namespace the namespace to seed within + * @param subject the subject the aggregates are attributed to + * @param feature the feature being seeded + * @param buckets the per-bucket aggregate values to place + */ + void seed(Namespace namespace, Subject subject, Feature feature, List buckets); +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SlidingSupport.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SlidingSupport.java new file mode 100644 index 0000000..8c1b026 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SlidingSupport.java @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi; + +/** + * Marker mix-in: the backend supports true sliding windows (ADR 0003, ADR 0005). + * + *

A sliding window covers {@code [now - duration, now]} with the backend clock as + * authority (FR-3), not the stateless pod's clock. Distinct on a sliding window is + * exact-only and capped — there is no HLL on sliding (ADR 0005), because a sketch + * cannot evict aging members; exceeding the cap is a declared, flagged {@link + * com.codeheadsystems.velocity.spi.model.FailureCode#CARDINALITY_CAP_EXCEEDED} condition. + * + *

This interface declares no methods: it is a capability flag whose coherence with {@link + * com.codeheadsystems.velocity.spi.model.BackendCapabilities} is asserted by the conformance TCK + * (ADR 0004). + */ +public interface SlidingSupport extends VelocityBackend {} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SumStore.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SumStore.java new file mode 100644 index 0000000..b70b35b --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/SumStore.java @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi; + +import com.codeheadsystems.velocity.spi.model.ApplyContext; +import com.codeheadsystems.velocity.spi.model.ApplyResult; +import com.codeheadsystems.velocity.spi.model.FeatureResult; +import com.codeheadsystems.velocity.spi.model.Intent.SumIntent; +import com.codeheadsystems.velocity.spi.model.QueryContext; +import com.codeheadsystems.velocity.spi.model.QueryTuple; +import java.util.List; + +/** + * The {@code SUM} aggregation mix-in (ADR 0003): apply and query integer-cents value intents. + * + *

Values are {@code BigDecimal} cents (P3): no binary float and no silent overflow (FR-10). A + * backend implements this only if it declares {@link + * com.codeheadsystems.velocity.spi.model.AggregationType#SUM SUM}. Each returned {@link + * FeatureResult} is a value or a distinguishable failure — never a silent {@code 0} (ADR 0009). + */ +public interface SumStore extends VelocityBackend { + + /** + * Applies sum writes. Returns an {@link ApplyResult} carrying, per intent, the {@link + * com.codeheadsystems.velocity.spi.model.ApplyStatus apply status} ({@code + * APPLIED|FAILED|SKIPPED}) alongside the {@link FeatureResult} (ADR 0009, FR-34). + * + * @param ctx the namespace + optional deadline context + * @param intents the sum intents to apply + * @return the per-intent apply outcomes, positionally aligned with {@code intents} + */ + ApplyResult applySum(ApplyContext ctx, List intents); + + /** + * Queries sums, returning one result per tuple in order. + * + * @param ctx the namespace + optional deadline context + * @param tuples the query tuples to read + * @return the per-tuple results, positionally aligned with {@code tuples} + */ + List querySum(QueryContext ctx, List tuples); +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/TumblingSupport.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/TumblingSupport.java new file mode 100644 index 0000000..52c7343 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/TumblingSupport.java @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi; + +/** + * Marker mix-in: the backend supports tumbling windows (ADR 0003, ADR 0005). + * + *

Tumbling windows are aligned, fixed buckets; a multi-bucket window is the merge of its + * buckets, edge-approximate at the current boundary (FR-14). Tumbling is the + * only window type on which HLL-distinct is valid (ADR 0005): a bucket is a closed + * interval that never needs to shed members, and HLL's lossless union merges buckets at read time. + * + *

This interface declares no methods: it is a capability flag whose coherence with {@link + * com.codeheadsystems.velocity.spi.model.BackendCapabilities} is asserted by the conformance TCK + * (ADR 0004). + */ +public interface TumblingSupport extends VelocityBackend {} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/VelocityBackend.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/VelocityBackend.java new file mode 100644 index 0000000..e7e6796 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/VelocityBackend.java @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi; + +import com.codeheadsystems.velocity.spi.model.BackendCapabilities; +import com.codeheadsystems.velocity.spi.model.Namespace; +import com.codeheadsystems.velocity.spi.model.Subject; +import org.jspecify.annotations.Nullable; + +/** + * The base of every backend: the capability descriptor plus admin erasure (ADR 0003). + * + *

A backend never implements this interface alone; it composes the aggregation mix-ins ({@link + * CountStore}, {@link SumStore}, {@link DistinctStore}), the window-capability markers ({@link + * SlidingSupport}, {@link TumblingSupport}), and optionally {@link SeedSupport} for the + * capabilities it declares. The set of mix-ins implemented MUST agree with {@link #capabilities()}; + * the conformance TCK (ADR 0004) asserts that agreement. + */ +public interface VelocityBackend { + + /** + * The single source of truth for what this backend can do (ADR 0003, P18). + * + * @return the backend's declared capabilities + */ + BackendCapabilities capabilities(); + + /** + * Admin erasure of stored state (FR-23). + * + *

Erases all state for the namespace, or — when {@code subject} is non-null — only that + * subject's state within the namespace. + * + * @param namespace the namespace to erase within + * @param subject the subject to erase; {@code null} erases the whole namespace + */ + void purge(Namespace namespace, @Nullable Subject subject); +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Aggregation.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Aggregation.java new file mode 100644 index 0000000..44b8cee --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Aggregation.java @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * What a feature aggregates: a {@link AggregationType} and, for {@link AggregationType#DISTINCT + * DISTINCT}, the named dimension whose distinct values are counted (FR-11). + * + *

Invariant: {@code dimension} is non-null and non-blank if and only if the type is + * {@code DISTINCT}. For {@code COUNT} and {@code SUM} it MUST be null. Use the static factories. + * + * @param type the aggregation kind + * @param dimension the distinct dimension name (only for {@code DISTINCT}); otherwise null + */ +public record Aggregation(AggregationType type, @Nullable String dimension) { + + /** Enforces the dimension-iff-DISTINCT invariant. */ + public Aggregation { + Objects.requireNonNull(type, "type"); + if (type == AggregationType.DISTINCT) { + Objects.requireNonNull(dimension, "dimension is required for DISTINCT"); + if (dimension.isBlank()) { + throw new IllegalArgumentException("dimension must not be blank for DISTINCT"); + } + } else if (dimension != null) { + throw new IllegalArgumentException("dimension must be null for " + type); + } + } + + /** + * A COUNT aggregation. + * + * @return a {@code COUNT} aggregation with no dimension + */ + public static Aggregation count() { + return new Aggregation(AggregationType.COUNT, null); + } + + /** + * A SUM aggregation. + * + * @return a {@code SUM} aggregation with no dimension + */ + public static Aggregation sum() { + return new Aggregation(AggregationType.SUM, null); + } + + /** + * A DISTINCT aggregation over the given dimension. + * + * @param dimension the non-blank dimension name + * @return a {@code DISTINCT} aggregation for the dimension + */ + public static Aggregation distinct(String dimension) { + return new Aggregation(AggregationType.DISTINCT, dimension); + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/AggregationType.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/AggregationType.java new file mode 100644 index 0000000..eae16f4 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/AggregationType.java @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +/** + * The kind of aggregation a feature computes (D2, FR-11). + * + *

A closed set: {@link #COUNT} (how many events), {@link #SUM} (how much, in integer cents per + * P3/FR-10), and {@link #DISTINCT} (how many distinct values of a named dimension). Adding a new + * aggregation is an additive SPI change realized as a new capability mix-in defaulting to + * unsupported (ADR 0003, NFR-17), not by mutating this enum's meaning. + */ +public enum AggregationType { + /** Count of events for a subject in a window. */ + COUNT, + /** Sum of a {@code BigDecimal}-cents value for a subject in a window (FR-10). */ + SUM, + /** Cardinality of a named dimension's distinct values for a subject in a window (FR-11). */ + DISTINCT +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyContext.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyContext.java new file mode 100644 index 0000000..893ee26 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyContext.java @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.time.Duration; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * The out-of-band context for an {@code apply()} call (ADR 0003). + * + *

Carries the namespace every SPI operation is scoped to (§2.1) and an optional caller deadline. + * + * @param namespace the tenancy scope + * @param deadline the caller deadline; {@code null} means unbounded — but a backend SHOULD still + * fail fast and return {@link FailureCode#DEADLINE_EXCEEDED} rather than block indefinitely + */ +public record ApplyContext(Namespace namespace, @Nullable Duration deadline) { + + /** Validates the namespace is present ({@code deadline} may be null). */ + public ApplyContext { + Objects.requireNonNull(namespace, "namespace"); + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyResult.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyResult.java new file mode 100644 index 0000000..682ed24 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyResult.java @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.util.List; +import java.util.Objects; + +/** + * The aggregate result of an {@code apply()} fan-out: one {@link PerFeature} entry per touched + * feature (ADR 0009). + * + * @param perFeature the per-feature outcomes; defensively copied to an unmodifiable list + */ +public record ApplyResult(List perFeature) { + + /** Stores an unmodifiable defensive copy of the per-feature outcomes. */ + public ApplyResult { + Objects.requireNonNull(perFeature, "perFeature"); + perFeature = List.copyOf(perFeature); + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyStatus.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyStatus.java new file mode 100644 index 0000000..8c4e097 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ApplyStatus.java @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +/** + * The per-feature outcome of an {@code apply()} fan-out (FR-34, ADR 0009). + * + *

Because one {@code record()} fans out across the matching features, each touched feature + * carries its own status alongside its {@link FeatureResult} in a {@link PerFeature} entry. + */ +public enum ApplyStatus { + /** The write was applied to the feature. */ + APPLIED, + /** + * The write failed for the feature; the accompanying {@link FeatureResult} carries the reason. + */ + FAILED, + /** The write was intentionally skipped (e.g. idempotent replay, load-shed) for the feature. */ + SKIPPED +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/BackendCapabilities.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/BackendCapabilities.java new file mode 100644 index 0000000..ba0f7ef --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/BackendCapabilities.java @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * The single source of truth for what a backend can do (ADR 0003, §6.1). + * + *

The engine reads this at runtime (never hardcoded — P18) to validate feature definitions and + * requests (FR-12/13/29) and to shape defaults. The set of mix-in interfaces a backend implements + * MUST agree with what it declares here; the conformance TCK (ADR 0004) asserts that agreement, + * including the negatives. + * + * @param aggregations the supported aggregation kinds; defensively copied + * @param windows the supported window specs (duration/type/exactness/granularity); defensively + * copied + * @param distinctHllSliding MUST be {@code false} — HLL-distinct is invalid on sliding windows (ADR + * 0005); the field exists only so the TCK can assert the negative + * @param distinctExactCardinalityClamp the backend's maximum exact-distinct cardinality; the engine + * clamps a per-feature threshold down to this (ADR 0006) + * @param distinctHllThresholdDefault the default exact→HLL threshold per {@code (subject, bucket)} + * (ADR 0006, default 10,000) + * @param maxRetention the retention ceiling that bounds how far back windows can reach (FR-22a) + * @param readYourWriteLevel the backend's declared read-your-write level (ADR 0007) + * @param idempotencySupported whether the backend supports idempotent apply (FR-5, §15 R15) + * @param seedSupported whether the backend implements {@code SeedSupport} (ADR 0008) + * @param maxAtomicFanOut the maximum number of intents applied atomically in one call (e.g. + * DynamoDB's 100-item transaction cap, §15 R3) + */ +public record BackendCapabilities( + Set aggregations, + List windows, + boolean distinctHllSliding, + long distinctExactCardinalityClamp, + long distinctHllThresholdDefault, + Duration maxRetention, + ReadYourWriteLevel readYourWriteLevel, + boolean idempotencySupported, + boolean seedSupported, + int maxAtomicFanOut) { + + /** + * Validates the {@code distinctHllSliding == false} invariant (ADR 0005) and stores unmodifiable + * defensive copies of the collections. + */ + public BackendCapabilities { + Objects.requireNonNull(aggregations, "aggregations"); + Objects.requireNonNull(windows, "windows"); + Objects.requireNonNull(maxRetention, "maxRetention"); + Objects.requireNonNull(readYourWriteLevel, "readYourWriteLevel"); + if (distinctHllSliding) { + throw new IllegalArgumentException( + "distinctHllSliding must be false: HLL-distinct is invalid on sliding windows (ADR 0005)"); + } + aggregations = Set.copyOf(aggregations); + windows = List.copyOf(windows); + } + + /** + * Whether the backend supports the given aggregation kind. + * + * @param type the aggregation kind + * @return {@code true} if declared supported + */ + public boolean supportsAggregation(AggregationType type) { + return aggregations.contains(type); + } + + /** + * Whether the backend supports the given window (matching a declared {@link WindowSpec}'s + * window). + * + * @param window the window to check + * @return {@code true} if a declared window spec covers this window + */ + public boolean supportsWindow(Window window) { + Objects.requireNonNull(window, "window"); + for (WindowSpec spec : windows) { + if (spec.window().equals(window)) { + return true; + } + } + return false; + } + + /** + * A supported window with its exactness and storage granularity (ADR 0003, §6.1). + * + * @param window the window (duration + type) + * @param exactness whether values over this window are exact or approximate + * @param granularity the bucket granularity the backend stores this window at + */ + public record WindowSpec(Window window, Exactness exactness, Duration granularity) { + + /** Validates all components are present. */ + public WindowSpec { + Objects.requireNonNull(window, "window"); + Objects.requireNonNull(exactness, "exactness"); + Objects.requireNonNull(granularity, "granularity"); + } + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/BucketValue.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/BucketValue.java new file mode 100644 index 0000000..1aab41d --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/BucketValue.java @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.util.Objects; + +/** + * One bucket's worth of seeded aggregate state (ADR 0008). + * + *

A seed operation supplies a list of these for a {@code (namespace, subject, feature)} so the + * backend can place the state into exactly the buckets a windowed query will later merge (FR-14). A + * single total is explicitly not a valid seed. + * + * @param bounds the bucket's time span + * @param aggregate the pre-computed aggregate for the bucket + */ +public record BucketValue(WindowBounds bounds, SeedAggregate aggregate) { + + /** Validates both components are present. */ + public BucketValue { + Objects.requireNonNull(bounds, "bounds"); + Objects.requireNonNull(aggregate, "aggregate"); + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/DistinctMember.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/DistinctMember.java new file mode 100644 index 0000000..eb68f5f --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/DistinctMember.java @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.util.Arrays; +import java.util.Objects; + +/** + * An opaque, pre-hashed distinct dimension value (ADR 0003, ADR 0009; FR-38). + * + *

The token is a fixed-width, keyed-hashed representation of a dimension value produced by the + * core (FR-38 stores 16-byte truncated HMACs, not raw IPs/tokens). It is opaque: + * the backend MUST NOT interpret, parse, or reverse the bytes — it only stores them and counts + * distinct occurrences. Treated as a value type: the bytes are defensively copied on construction + * and on access, and equality/hashing are by content. + * + * @param token the opaque token bytes; never null, never interpreted by the backend + */ +// Opaque value type: the array component is defensively copied and equality/hashCode are by content +// (Arrays.*), so the default reference-based semantics ArrayRecordComponent warns about do not +// apply. +@SuppressWarnings("ArrayRecordComponent") +public record DistinctMember(byte[] token) { + + /** Validates the token is present and stores a defensive copy so the value is immutable. */ + public DistinctMember { + Objects.requireNonNull(token, "token"); + token = token.clone(); + } + + /** + * Returns a defensive copy of the token bytes; mutating the returned array does not affect this + * value. + * + * @return a copy of the opaque token bytes + */ + @Override + public byte[] token() { + return token.clone(); + } + + /** Content-based equality: two members are equal iff their token bytes are equal. */ + @Override + public boolean equals(Object o) { + return o instanceof DistinctMember other && Arrays.equals(token, other.token); + } + + /** Content-based hash over the token bytes. */ + @Override + public int hashCode() { + return Arrays.hashCode(token); + } + + /** Readable representation that does not dump the raw opaque bytes. */ + @Override + public String toString() { + return "DistinctMember[" + token.length + " bytes]"; + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Exactness.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Exactness.java new file mode 100644 index 0000000..23d2ed0 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Exactness.java @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +/** + * Whether a returned value is exact or an approximation (FR-7). + * + *

A window is {@link #APPROXIMATE} if any constituent bucket was served by an HLL sketch (ADR + * 0006); otherwise it is {@link #EXACT}. Callers compare cardinality/velocity signals against + * thresholds, so the flag lets them know whether a value carries HLL's ~0.81% error floor. + */ +public enum Exactness { + /** The value is exact (set/count/sum with no sketch involved). */ + EXACT, + /** The value is approximate (at least one HLL bucket contributed, ADR 0006). */ + APPROXIMATE +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FailureCode.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FailureCode.java new file mode 100644 index 0000000..e62cccb --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FailureCode.java @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +/** + * A distinguishable data-plane failure reason (ADR 0009, NFR-19). + * + *

A {@link FeatureResult.Failure} carries one of these instead of a silent {@code 0}, so a + * synchronous authorization caller can choose fail-open vs. fail-closed deterministically. + * + *

This enum is extensible: adding a code is an ADDITIVE change (ADR 0009 rule + * 2). Consumers MUST tolerate a code they do not recognize by treating it as a generic + * failure rather than crashing on an unknown value. New codes are expected here as the enforcement + * behavior (Tier-2 deadline/load-shed ADRs) lands. + */ +public enum FailureCode { + /** The backend is down/partitioned and could not produce a value (NFR-19). */ + UNAVAILABLE, + /** The caller's deadline was hit before a result was available (NFR-19). */ + DEADLINE_EXCEEDED, + /** A sliding exact-distinct feature exceeded its cardinality cap (ADR 0005). */ + CARDINALITY_CAP_EXCEEDED, + /** The requested window is not supported by the backend (FR-13). */ + UNSUPPORTED_WINDOW, + /** The request was malformed. */ + VALIDATION, + /** An unexpected internal error. */ + INTERNAL +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Feature.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Feature.java new file mode 100644 index 0000000..311b63e --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Feature.java @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.util.List; +import java.util.Objects; + +/** + * A named counter definition: {@code (name, aggregation, windows)} — e.g. {@code card.count.1h}. + * + *

The feature is the core abstraction (CLAUDE.md): one {@code record()} fans an event out to + * every matching feature. It names its aggregation and the set of windows it is tracked over; the + * backend it is bound to owns how those windows are stored. + * + * @param name the non-blank feature name + * @param aggregation what the feature aggregates + * @param windows the non-empty set of windows the feature is tracked over; defensively copied to an + * unmodifiable list + */ +public record Feature(String name, Aggregation aggregation, List windows) { + + /** Validates the name and windows, and stores an unmodifiable defensive copy of the windows. */ + public Feature { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(aggregation, "aggregation"); + Objects.requireNonNull(windows, "windows"); + if (name.isBlank()) { + throw new IllegalArgumentException("feature name must not be blank"); + } + if (windows.isEmpty()) { + throw new IllegalArgumentException("feature must declare at least one window"); + } + windows = List.copyOf(windows); + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FeatureResult.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FeatureResult.java new file mode 100644 index 0000000..14673c5 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FeatureResult.java @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * The frozen value-or-failure result of every data-plane {@code apply()}/{@code query()} (ADR + * 0009). + * + *

A result is either a {@link Success} carrying a {@link FeatureValue} + * or a {@link Failure} carrying a distinguishable {@link FailureCode} — never an + * ambiguous {@code Success(value=0)}. A backend that is down or slow returns {@code + * Failure(UNAVAILABLE)} / {@code Failure(DEADLINE_EXCEEDED)} so a synchronous authorization caller + * can choose fail-open vs. fail-closed deterministically. Returning a silent {@code 0} for "I don't + * know" is forbidden and is a conformance-TCK negative test (ADR 0009 rule 1, ADR 0004). + * + *

This is a sealed sum type precisely so the failure arm cannot be bolted on later: a published + * value-only type could not grow a {@code Failure} variant without re-cutting the DTO (NFR-17). New + * {@link FailureCode}s, by contrast, are additive. + */ +public sealed interface FeatureResult permits FeatureResult.Success, FeatureResult.Failure { + + /** + * Whether this result is a {@link Success}. + * + * @return {@code true} for {@link Success}, {@code false} for {@link Failure} + */ + default boolean isSuccess() { + return this instanceof Success; + } + + /** + * A successful result. + * + * @param value the feature value; never null + * @return a {@link Success} wrapping the value + */ + static Success success(FeatureValue value) { + return new Success(value); + } + + /** + * A failed result. + * + * @param code the distinguishable failure code; never null + * @param detail an optional human-readable detail; may be null + * @return a {@link Failure} wrapping the code and detail + */ + static Failure failure(FailureCode code, @Nullable String detail) { + return new Failure(code, detail); + } + + /** + * A successful feature value. + * + * @param value the computed feature value + */ + record Success(FeatureValue value) implements FeatureResult { + + /** Validates the value is present. */ + public Success { + Objects.requireNonNull(value, "value"); + } + } + + /** + * A distinguishable failure — never a silent {@code 0} (ADR 0009 rule 1). + * + * @param code the failure code + * @param detail an optional human-readable detail; may be null + */ + record Failure(FailureCode code, @Nullable String detail) implements FeatureResult { + + /** Validates the code is present ({@code detail} may be null). */ + public Failure { + Objects.requireNonNull(code, "code"); + } + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FeatureValue.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FeatureValue.java new file mode 100644 index 0000000..b138b04 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/FeatureValue.java @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * The successful value of a feature read/write, with all decision-bearing metadata (ADR 0009). + * + *

This is the payload of a {@link FeatureResult.Success}. The {@code value} is a {@link + * BigDecimal} for every aggregation — integer-valued for {@code COUNT}/{@code DISTINCT}, integer + * cents for {@code SUM} (P3) — so callers do not juggle {@code long} vs. {@code BigDecimal}. + * + * @param feature the feature this value is for + * @param window the window the value covers + * @param value the aggregate value (integer for COUNT/DISTINCT, cents for SUM) + * @param exactness whether the value is exact or approximate (FR-7) + * @param readYourWriteLevel the level under which this value was produced (ADR 0007) + * @param definitionVersionHash the feature-definition version the value was computed under (FR-40); + * nullable until FR-40's behavior lands — the field is part of the frozen + * shape now (ADR 0009 rule 3) but is not populated in phase 1 + * @param windowBounds the concrete interval the value covers (FR-7) + * @param asOf the backend-clock instant the value is as-of (FR-7) + */ +public record FeatureValue( + Feature feature, + Window window, + BigDecimal value, + Exactness exactness, + ReadYourWriteLevel readYourWriteLevel, + @Nullable String definitionVersionHash, + WindowBounds windowBounds, + Instant asOf) { + + /** + * Validates all non-nullable components are present ({@code definitionVersionHash} may be null). + */ + public FeatureValue { + Objects.requireNonNull(feature, "feature"); + Objects.requireNonNull(window, "window"); + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(exactness, "exactness"); + Objects.requireNonNull(readYourWriteLevel, "readYourWriteLevel"); + Objects.requireNonNull(windowBounds, "windowBounds"); + Objects.requireNonNull(asOf, "asOf"); + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Intent.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Intent.java new file mode 100644 index 0000000..cdcf9ac --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Intent.java @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * A backend-neutral write intent {@code (feature, subject, member|value)} (ADR 0003). + * + *

{@code velocity-core} resolves an incoming {@code record()} against the matching feature + * definitions down to intents; the intent names the feature (which carries its windows) but says + * nothing about bucket keys, TTLs, sliding ranges, or eviction — the backend owns all of that. + * + *

Each variant validates that the feature's aggregation type matches its kind, so a {@code + * CountIntent} can only ever carry a {@code COUNT} feature, and so on. + */ +public sealed interface Intent permits Intent.CountIntent, Intent.SumIntent, Intent.DistinctIntent { + + /** + * The feature this intent writes to. + * + * @return the feature + */ + Feature feature(); + + /** + * The subject the write is attributed to. + * + * @return the subject + */ + Subject subject(); + + /** + * A COUNT write intent (neither member nor value). + * + * @param feature a feature whose aggregation type is {@code COUNT} + * @param subject the subject + */ + record CountIntent(Feature feature, Subject subject) implements Intent { + + /** Validates the feature is a COUNT feature. */ + public CountIntent { + Objects.requireNonNull(feature, "feature"); + Objects.requireNonNull(subject, "subject"); + if (feature.aggregation().type() != AggregationType.COUNT) { + throw new IllegalArgumentException( + "CountIntent requires a COUNT feature, was " + feature.aggregation().type()); + } + } + } + + /** + * A SUM write intent carrying an integer-cents value (P3/FR-10; may be negative for refunds). + * + * @param feature a feature whose aggregation type is {@code SUM} + * @param subject the subject + * @param valueCents the amount in integer cents; its {@link BigDecimal#scale() scale} must be 0 + */ + record SumIntent(Feature feature, Subject subject, BigDecimal valueCents) implements Intent { + + /** Validates the feature is a SUM feature and the value is integer cents (scale 0). */ + public SumIntent { + Objects.requireNonNull(feature, "feature"); + Objects.requireNonNull(subject, "subject"); + Objects.requireNonNull(valueCents, "valueCents"); + if (feature.aggregation().type() != AggregationType.SUM) { + throw new IllegalArgumentException( + "SumIntent requires a SUM feature, was " + feature.aggregation().type()); + } + if (valueCents.scale() != 0) { + throw new IllegalArgumentException( + "valueCents must be integer cents (scale 0), was scale " + valueCents.scale()); + } + } + } + + /** + * A DISTINCT write intent carrying the opaque, pre-hashed dimension member. + * + * @param feature a feature whose aggregation type is {@code DISTINCT} + * @param subject the subject + * @param member the opaque distinct member + */ + record DistinctIntent(Feature feature, Subject subject, DistinctMember member) implements Intent { + + /** Validates the feature is a DISTINCT feature. */ + public DistinctIntent { + Objects.requireNonNull(feature, "feature"); + Objects.requireNonNull(subject, "subject"); + Objects.requireNonNull(member, "member"); + if (feature.aggregation().type() != AggregationType.DISTINCT) { + throw new IllegalArgumentException( + "DistinctIntent requires a DISTINCT feature, was " + feature.aggregation().type()); + } + } + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Namespace.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Namespace.java new file mode 100644 index 0000000..0cace00 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Namespace.java @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.util.Objects; + +/** + * A first-class tenancy scope (§2.1). Every key and SPI operation is namespace-scoped. + * + * @param value the non-blank namespace identifier + */ +public record Namespace(String value) { + + /** Validates the namespace value is present and non-blank. */ + public Namespace { + Objects.requireNonNull(value, "value"); + if (value.isBlank()) { + throw new IllegalArgumentException("namespace value must not be blank"); + } + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/PerFeature.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/PerFeature.java new file mode 100644 index 0000000..c349029 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/PerFeature.java @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.util.Objects; + +/** + * The per-feature outcome of an {@code apply()} fan-out (FR-34, ADR 0009). + * + *

One {@code record()} fans out across the matching features; each touched feature reports its + * {@link ApplyStatus} and its {@link FeatureResult} so a partial failure is expressible alongside + * successes in the same {@link ApplyResult}. + * + * @param feature the feature this outcome is for + * @param status whether the write was applied, failed, or skipped + * @param result the value-or-failure result for the feature + */ +public record PerFeature(Feature feature, ApplyStatus status, FeatureResult result) { + + /** Validates all components are present. */ + public PerFeature { + Objects.requireNonNull(feature, "feature"); + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(result, "result"); + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/QueryContext.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/QueryContext.java new file mode 100644 index 0000000..a9879d0 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/QueryContext.java @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.time.Duration; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * The out-of-band context for a {@code query()} call (ADR 0003). + * + *

Carries the namespace every SPI operation is scoped to (§2.1) and an optional caller deadline. + * + * @param namespace the tenancy scope + * @param deadline the caller deadline; {@code null} means unbounded — but a backend SHOULD still + * fail fast and return {@link FailureCode#DEADLINE_EXCEEDED} rather than block indefinitely + */ +public record QueryContext(Namespace namespace, @Nullable Duration deadline) { + + /** Validates the namespace is present ({@code deadline} may be null). */ + public QueryContext { + Objects.requireNonNull(namespace, "namespace"); + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/QueryTuple.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/QueryTuple.java new file mode 100644 index 0000000..dbc7c30 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/QueryTuple.java @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.util.Objects; + +/** + * A backend-neutral read request {@code (subject, aggregation, window)} (ADR 0003). + * + *

The read-path symmetric counterpart to {@link Intent}: {@code query()} passes tuples and the + * backend resolves them against its own keying/windowing. The namespace is carried out-of-band on + * the {@link QueryContext}. + * + * @param subject the subject to read + * @param aggregation the aggregation to read + * @param window the window to read over + */ +public record QueryTuple(Subject subject, Aggregation aggregation, Window window) { + + /** Validates all components are present. */ + public QueryTuple { + Objects.requireNonNull(subject, "subject"); + Objects.requireNonNull(aggregation, "aggregation"); + Objects.requireNonNull(window, "window"); + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ReadYourWriteLevel.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ReadYourWriteLevel.java new file mode 100644 index 0000000..a9aeaa9 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/ReadYourWriteLevel.java @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +/** + * The read-your-write guarantee under which a value was produced (ADR 0007, §15 R2). + * + *

Read-your-write is a declared capability, not a universal guarantee. It rides on {@link + * BackendCapabilities} as the backend's declared level and, because one {@code record()} can fan + * out across features on different backends, also on every {@link FeatureValue} so each returned + * value states the level under which that value was produced. + */ +public enum ReadYourWriteLevel { + /** + * The returned value reflects the caller's own write to every bucket it touched, atomically under + * concurrency (NFR-6). Exact backends (Redis, JDBI/Postgres, DynamoDB) declare this. + */ + ATOMIC, + /** + * A consistent snapshot that may not include the caller's just-applied write (e.g. a + * consistent-but-lagging replica/materialized view). The backend must document its staleness + * bound. + */ + SNAPSHOT, + /** + * No read-your-write guarantee; the value may be stale/approximate and MUST be flagged {@link + * Exactness#APPROXIMATE} (FR-7). Approximate backends (S3) declare this. + */ + BESTEFFORT +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/SeedAggregate.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/SeedAggregate.java new file mode 100644 index 0000000..5597c4b --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/SeedAggregate.java @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * The pre-computed aggregate seeded into a single bucket (ADR 0008). + * + *

A seed supplies aggregate state at bucket granularity (never a single total, which cannot be + * apportioned across a window's buckets — ADR 0008). Each variant matches an aggregation kind: + * {@link CountValue} for COUNT, {@link SumValue} for SUM, and {@link ExactDistinct} / {@link + * HllDistinct} for DISTINCT. + */ +public sealed interface SeedAggregate + permits SeedAggregate.CountValue, + SeedAggregate.SumValue, + SeedAggregate.ExactDistinct, + SeedAggregate.HllDistinct { + + /** + * A seeded COUNT. + * + * @param count the bucket's count + */ + record CountValue(long count) implements SeedAggregate {} + + /** + * A seeded SUM in integer cents (P3). + * + * @param cents the bucket's summed cents; {@link BigDecimal#scale() scale} must be 0 + */ + record SumValue(BigDecimal cents) implements SeedAggregate { + + /** Validates the value is present and is integer cents (scale 0). */ + public SumValue { + Objects.requireNonNull(cents, "cents"); + if (cents.scale() != 0) { + throw new IllegalArgumentException( + "cents must be integer cents (scale 0), was scale " + cents.scale()); + } + } + } + + /** + * A seeded exact DISTINCT member set. Exact members migrate across backends (ADR 0008), subject + * to the exact clamp (ADR 0006). + * + * @param members the bucket's distinct members; defensively copied to an unmodifiable list + */ + record ExactDistinct(List members) implements SeedAggregate { + + /** Stores an unmodifiable defensive copy of the members. */ + public ExactDistinct { + Objects.requireNonNull(members, "members"); + members = List.copyOf(members); + } + } + + /** + * A seeded pre-computed HLL DISTINCT sketch (tumbling only, ADR 0005). + * + *

The sketch is opaque, same-implementation-only bytes (ADR 0006 interop + * caveat): a backend accepts an {@code HllDistinct} seed only from its own implementation and + * MUST reject a foreign one. HLL sketches do not migrate across backends; only exact + * members do (ADR 0008). The bytes are defensively copied and never interpreted here. + * + * @param sketch the opaque HLL sketch bytes + */ + // Opaque value type: the array component is defensively copied and equality/hashCode are by + // content (Arrays.*), so the default reference-based semantics ArrayRecordComponent warns about + // do not apply here. + @SuppressWarnings("ArrayRecordComponent") + record HllDistinct(byte[] sketch) implements SeedAggregate { + + /** Validates the sketch is present and stores a defensive copy. */ + public HllDistinct { + Objects.requireNonNull(sketch, "sketch"); + sketch = sketch.clone(); + } + + /** + * Returns a defensive copy of the opaque sketch bytes. + * + * @return a copy of the sketch bytes + */ + @Override + public byte[] sketch() { + return sketch.clone(); + } + + /** Content-based equality over the sketch bytes. */ + @Override + public boolean equals(Object o) { + return o instanceof HllDistinct other && Arrays.equals(sketch, other.sketch); + } + + /** Content-based hash over the sketch bytes. */ + @Override + public int hashCode() { + return Arrays.hashCode(sketch); + } + + /** Readable representation that does not dump the raw opaque bytes. */ + @Override + public String toString() { + return "HllDistinct[" + sketch.length + " bytes]"; + } + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Subject.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Subject.java new file mode 100644 index 0000000..b27583a --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Subject.java @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.util.Objects; + +/** + * The structured key an aggregation is computed for (P5) — e.g. {@code (type="card", value="…")}. + * + *

A subject is a {@code (type, value)} pair rather than an opaque string so the engine and + * backends can reason about the kind of thing being counted without parsing a delimited key. + * + * @param type the non-blank subject type (e.g. {@code "card"}, {@code "account"}) + * @param value the non-blank subject value + */ +public record Subject(String type, String value) { + + /** Validates that both components are present and non-blank. */ + public Subject { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(value, "value"); + if (type.isBlank()) { + throw new IllegalArgumentException("subject type must not be blank"); + } + if (value.isBlank()) { + throw new IllegalArgumentException("subject value must not be blank"); + } + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Window.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Window.java new file mode 100644 index 0000000..3d7dff4 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/Window.java @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.time.Duration; +import java.util.Objects; + +/** + * A time window of a given {@link WindowType} and duration (FR-14). + * + *

The window names a span (e.g. one hour) and how time is modeled over it (sliding vs. + * tumbling). The backend owns how the span is realized into buckets/ranges (ADR 0003). + * + * @param duration the window span; must be strictly positive + * @param type sliding or tumbling + */ +public record Window(Duration duration, WindowType type) { + + /** Validates the duration is strictly positive. */ + public Window { + Objects.requireNonNull(duration, "duration"); + Objects.requireNonNull(type, "type"); + if (duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException("window duration must be positive, was " + duration); + } + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/WindowBounds.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/WindowBounds.java new file mode 100644 index 0000000..708755c --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/WindowBounds.java @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import java.time.Instant; +import java.util.Objects; + +/** + * The concrete {@code [start, end]} time span a returned value covers (FR-7). + * + *

Reported by the backend so a caller knows exactly which interval a value was computed over + * (the backend clock is authority for sliding edges, FR-3). + * + * @param start the inclusive start instant + * @param end the end instant; must not be before {@code start} + */ +public record WindowBounds(Instant start, Instant end) { + + /** Validates that {@code end} is not before {@code start}. */ + public WindowBounds { + Objects.requireNonNull(start, "start"); + Objects.requireNonNull(end, "end"); + if (end.isBefore(start)) { + throw new IllegalArgumentException( + "window end " + end + " must not be before start " + start); + } + } +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/WindowType.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/WindowType.java new file mode 100644 index 0000000..fac7e45 --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/WindowType.java @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +/** + * How time is modeled for a window (D3, FR-14). + * + *

{@link #SLIDING} covers {@code [now - duration, now]} with the backend clock as authority + * (FR-3); distinct on a sliding window is exact-only and capped (ADR 0005). {@link #TUMBLING} uses + * aligned fixed buckets whose merge forms a multi-bucket window, edge-approximate at the current + * boundary (FR-14); it is the only window type on which HLL-distinct is valid (ADR 0005). + */ +public enum WindowType { + /** True sliding window {@code [now - duration, now]} (ADR 0005). */ + SLIDING, + /** + * Aligned fixed buckets merged into a window; the only place HLL-distinct is valid (ADR 0005). + */ + TUMBLING +} diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/package-info.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/package-info.java new file mode 100644 index 0000000..60d005c --- /dev/null +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/model/package-info.java @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: BSD-3-Clause + +/** + * The value types of the {@code velocity-spi} contract: intents, tuples, the value-or-failure + * result sum type, seed aggregates, and {@code BackendCapabilities}. + * + *

These are plain, immutable, serialization-neutral records/enums/sealed sum + * types with no Jackson (or Dagger/Dropwizard) binding (ADR 0002) — a backend author does not + * inherit the engine's serialization stack as part of the frozen contract. JSON lives in {@code + * velocity-core} and the wire layer. + * + *

The two shape-defining artifacts here are the capability-mix-in inputs/outputs of ADR 0003 (backend-neutral {@code + * Intent}/{@code QueryTuple}, {@code BackendCapabilities}) and the frozen hot-path result of ADR 0009 ({@code FeatureResult = + * Success | Failure} — a down/slow backend returns a distinguishable {@code Failure}, never a + * silent {@code 0}). The SPI is null-hostile by default: value types validate their invariants in + * compact constructors and annotate the few nullable fields with {@code + * org.jspecify.annotations.@Nullable}. Evolution is additive-only (NFR-17). + */ +@NullMarked +package com.codeheadsystems.velocity.spi.model; + +import org.jspecify.annotations.NullMarked; diff --git a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/package-info.java b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/package-info.java index 07f1a32..d76b20a 100644 --- a/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/package-info.java +++ b/velocity-spi/src/main/java/com/codeheadsystems/velocity/spi/package-info.java @@ -1,6 +1,32 @@ // SPDX-License-Identifier: BSD-3-Clause /** - * The velocity-spi contract: capability mix-ins, {@code BackendCapabilities}, and shared SPI DTOs. + * The {@code velocity-spi} contract: the core↔backend seam as capability mix-ins (ADR 0003). + * + *

The data-plane SPI is deliberately not one fat interface but a set of + * capability mix-ins on top of {@link com.codeheadsystems.velocity.spi.VelocityBackend}: the + * aggregation stores {@link com.codeheadsystems.velocity.spi.CountStore}, {@link + * com.codeheadsystems.velocity.spi.SumStore}, {@link + * com.codeheadsystems.velocity.spi.DistinctStore}; the window-capability markers {@link + * com.codeheadsystems.velocity.spi.SlidingSupport} and {@link + * com.codeheadsystems.velocity.spi.TumblingSupport}; and the optional {@link + * com.codeheadsystems.velocity.spi.SeedSupport}. A backend implements only the mix-ins for the + * capabilities it declares in {@link com.codeheadsystems.velocity.spi.model.BackendCapabilities} — + * the abstraction is honest by construction, with no runtime "unsupported" throw for a + * declared-unsupported capability. The conformance TCK (ADR 0004) asserts that the implemented + * mix-ins agree with the declared capabilities. + * + *

{@code velocity-core} resolves fan-out to backend-neutral {@code Intent}s; the backend owns + * bucket keying, sliding/tumbling semantics, eviction, and its own key schema. Every {@code + * apply()}/{@code query()} returns the frozen value-or-failure {@link + * com.codeheadsystems.velocity.spi.model.FeatureResult} of ADR 0009 — a down/slow backend + * returns a distinguishable {@code Failure}, never a silent {@code 0}. The value types live in + * {@link com.codeheadsystems.velocity.spi.model} and are serialization-neutral (ADR 0002). + * Evolution is additive-only (NFR-17): a new capability is a new mix-in or a new capability field + * defaulting to unsupported. */ +@NullMarked package com.codeheadsystems.velocity.spi; + +import org.jspecify.annotations.NullMarked; diff --git a/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/AggregationTest.java b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/AggregationTest.java new file mode 100644 index 0000000..cefd7e7 --- /dev/null +++ b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/AggregationTest.java @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import org.junit.jupiter.api.Test; + +class AggregationTest { + + @Test + void countFactoryHasNoDimension() { + Aggregation aggregation = Aggregation.count(); + assertThat(aggregation.type()).isEqualTo(AggregationType.COUNT); + assertThat(aggregation.dimension()).isNull(); + } + + @Test + void sumFactoryHasNoDimension() { + Aggregation aggregation = Aggregation.sum(); + assertThat(aggregation.type()).isEqualTo(AggregationType.SUM); + assertThat(aggregation.dimension()).isNull(); + } + + @Test + void distinctFactoryCarriesDimension() { + Aggregation aggregation = Aggregation.distinct("ip"); + assertThat(aggregation.type()).isEqualTo(AggregationType.DISTINCT); + assertThat(aggregation.dimension()).isEqualTo("ip"); + } + + @Test + void distinctRequiresNonNullDimension() { + assertThatNullPointerException() + .isThrownBy(() -> new Aggregation(AggregationType.DISTINCT, null)); + } + + @Test + void distinctRejectsBlankDimension() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new Aggregation(AggregationType.DISTINCT, " ")); + } + + @Test + void countRejectsNonNullDimension() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new Aggregation(AggregationType.COUNT, "ip")); + } + + @Test + void sumRejectsNonNullDimension() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new Aggregation(AggregationType.SUM, "amount")); + } + + @Test + void rejectsNullType() { + assertThatNullPointerException().isThrownBy(() -> new Aggregation(null, null)); + } +} diff --git a/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/ApplyResultTest.java b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/ApplyResultTest.java new file mode 100644 index 0000000..d45379f --- /dev/null +++ b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/ApplyResultTest.java @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ApplyResultTest { + + private static PerFeature perFeature() { + Window window = new Window(Duration.ofHours(1), WindowType.TUMBLING); + Feature feature = new Feature("card.count.1h", Aggregation.count(), List.of(window)); + Instant now = Instant.parse("2026-07-18T01:00:00Z"); + FeatureValue value = + new FeatureValue( + feature, + window, + new BigDecimal("1"), + Exactness.EXACT, + ReadYourWriteLevel.ATOMIC, + null, + new WindowBounds(now.minus(Duration.ofHours(1)), now), + now); + return new PerFeature(feature, ApplyStatus.APPLIED, FeatureResult.success(value)); + } + + @Test + void rejectsNull() { + assertThatNullPointerException().isThrownBy(() -> new ApplyResult(null)); + } + + @Test + void defensivelyCopiesInput() { + List source = new ArrayList<>(List.of(perFeature())); + ApplyResult result = new ApplyResult(source); + + source.add(perFeature()); + + assertThat(result.perFeature()).hasSize(1); + } + + @Test + void perFeatureIsUnmodifiable() { + ApplyResult result = new ApplyResult(List.of(perFeature())); + assertThat(result.perFeature()).isUnmodifiable(); + } +} diff --git a/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/BackendCapabilitiesTest.java b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/BackendCapabilitiesTest.java new file mode 100644 index 0000000..2781556 --- /dev/null +++ b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/BackendCapabilitiesTest.java @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import com.codeheadsystems.velocity.spi.model.BackendCapabilities.WindowSpec; +import java.time.Duration; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class BackendCapabilitiesTest { + + private static final Window HOUR = new Window(Duration.ofHours(1), WindowType.TUMBLING); + private static final WindowSpec HOUR_SPEC = + new WindowSpec(HOUR, Exactness.EXACT, Duration.ofMinutes(5)); + + private static BackendCapabilities capabilities(boolean distinctHllSliding) { + return new BackendCapabilities( + EnumSet.of(AggregationType.COUNT, AggregationType.SUM), + List.of(HOUR_SPEC), + distinctHllSliding, + 10_000L, + 10_000L, + Duration.ofDays(30), + ReadYourWriteLevel.ATOMIC, + true, + false, + 100); + } + + @Test + void rejectsDistinctHllSliding() { + assertThatIllegalArgumentException() + .isThrownBy(() -> capabilities(true)) + .withMessageContaining("distinctHllSliding"); + } + + @Test + void acceptsDistinctHllSlidingFalse() { + assertThat(capabilities(false).distinctHllSliding()).isFalse(); + } + + @Test + void supportsAggregationReflectsDeclaredSet() { + BackendCapabilities caps = capabilities(false); + assertThat(caps.supportsAggregation(AggregationType.COUNT)).isTrue(); + assertThat(caps.supportsAggregation(AggregationType.SUM)).isTrue(); + assertThat(caps.supportsAggregation(AggregationType.DISTINCT)).isFalse(); + } + + @Test + void supportsWindowMatchesDeclaredWindow() { + BackendCapabilities caps = capabilities(false); + assertThat(caps.supportsWindow(HOUR)).isTrue(); + assertThat(caps.supportsWindow(new Window(Duration.ofDays(1), WindowType.TUMBLING))).isFalse(); + assertThat(caps.supportsWindow(new Window(Duration.ofHours(1), WindowType.SLIDING))).isFalse(); + } + + @Test + void defensivelyCopiesAggregationsAndWindows() { + Set aggregations = EnumSet.of(AggregationType.COUNT); + List windows = new ArrayList<>(List.of(HOUR_SPEC)); + BackendCapabilities caps = + new BackendCapabilities( + aggregations, + windows, + false, + 10_000L, + 10_000L, + Duration.ofDays(30), + ReadYourWriteLevel.ATOMIC, + true, + false, + 100); + + aggregations.add(AggregationType.DISTINCT); + windows.add( + new WindowSpec( + new Window(Duration.ofDays(1), WindowType.TUMBLING), + Exactness.APPROXIMATE, + Duration.ofHours(1))); + + assertThat(caps.supportsAggregation(AggregationType.DISTINCT)).isFalse(); + assertThat(caps.windows()).containsExactly(HOUR_SPEC); + assertThat(caps.windows()).isUnmodifiable(); + } +} diff --git a/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/DistinctMemberTest.java b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/DistinctMemberTest.java new file mode 100644 index 0000000..e888efd --- /dev/null +++ b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/DistinctMemberTest.java @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import org.junit.jupiter.api.Test; + +class DistinctMemberTest { + + @Test + void rejectsNullToken() { + assertThatNullPointerException().isThrownBy(() -> new DistinctMember(null)); + } + + @Test + void constructorDefensivelyCopies() { + byte[] source = {1, 2, 3}; + DistinctMember member = new DistinctMember(source); + + source[0] = 99; + + assertThat(member.token()).containsExactly(1, 2, 3); + } + + @Test + void accessorReturnsCopy() { + DistinctMember member = new DistinctMember(new byte[] {1, 2, 3}); + + byte[] first = member.token(); + first[0] = 99; + + assertThat(member.token()).containsExactly(1, 2, 3); + } + + @Test + void equalsAndHashCodeByContent() { + DistinctMember a = new DistinctMember(new byte[] {4, 5, 6}); + DistinctMember b = new DistinctMember(new byte[] {4, 5, 6}); + DistinctMember c = new DistinctMember(new byte[] {4, 5, 7}); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(c); + } + + @Test + void toStringDoesNotDumpRawBytes() { + DistinctMember member = new DistinctMember(new byte[] {0x7f, 0x00, 0x41}); + + assertThat(member.toString()).contains("DistinctMember", "3 bytes").doesNotContain("0x41", "A"); + } +} diff --git a/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/FeatureResultTest.java b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/FeatureResultTest.java new file mode 100644 index 0000000..50c60b5 --- /dev/null +++ b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/FeatureResultTest.java @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class FeatureResultTest { + + private static FeatureValue value() { + Window window = new Window(Duration.ofHours(1), WindowType.TUMBLING); + Feature feature = new Feature("card.count.1h", Aggregation.count(), List.of(window)); + Instant now = Instant.parse("2026-07-18T01:00:00Z"); + return new FeatureValue( + feature, + window, + new BigDecimal("7"), + Exactness.EXACT, + ReadYourWriteLevel.ATOMIC, + null, + new WindowBounds(now.minus(Duration.ofHours(1)), now), + now); + } + + @Test + void successFactoryProducesSuccess() { + FeatureResult result = FeatureResult.success(value()); + assertThat(result.isSuccess()).isTrue(); + assertThat(result).isInstanceOf(FeatureResult.Success.class); + } + + @Test + void failureFactoryProducesFailure() { + FeatureResult result = FeatureResult.failure(FailureCode.UNAVAILABLE, "backend down"); + assertThat(result.isSuccess()).isFalse(); + assertThat(result).isInstanceOf(FeatureResult.Failure.class); + } + + @Test + void failureDetailIsOptional() { + FeatureResult result = FeatureResult.failure(FailureCode.DEADLINE_EXCEEDED, null); + assertThat(((FeatureResult.Failure) result).detail()).isNull(); + } + + @Test + void successRejectsNullValue() { + assertThatNullPointerException().isThrownBy(() -> FeatureResult.success(null)); + } + + @Test + void failureRejectsNullCode() { + assertThatNullPointerException().isThrownBy(() -> FeatureResult.failure(null, "x")); + } + + @Test + void patternMatchExtractsBranchData() { + FeatureResult success = FeatureResult.success(value()); + FeatureResult failure = FeatureResult.failure(FailureCode.CARDINALITY_CAP_EXCEEDED, "capped"); + + assertThat(describe(success)).isEqualTo("value=7"); + assertThat(describe(failure)).isEqualTo("fail=CARDINALITY_CAP_EXCEEDED"); + } + + private static String describe(FeatureResult result) { + return switch (result) { + case FeatureResult.Success s -> "value=" + s.value().value().toPlainString(); + case FeatureResult.Failure f -> "fail=" + f.code(); + }; + } +} diff --git a/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/FeatureTest.java b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/FeatureTest.java new file mode 100644 index 0000000..1b01410 --- /dev/null +++ b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/FeatureTest.java @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class FeatureTest { + + private static final Window HOUR = new Window(Duration.ofHours(1), WindowType.TUMBLING); + + @Test + void rejectsBlankName() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new Feature(" ", Aggregation.count(), List.of(HOUR))); + } + + @Test + void rejectsEmptyWindows() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new Feature("card.count.1h", Aggregation.count(), List.of())); + } + + @Test + void rejectsNulls() { + assertThatNullPointerException() + .isThrownBy(() -> new Feature(null, Aggregation.count(), List.of(HOUR))); + assertThatNullPointerException().isThrownBy(() -> new Feature("f", null, List.of(HOUR))); + assertThatNullPointerException().isThrownBy(() -> new Feature("f", Aggregation.count(), null)); + } + + @Test + void defensivelyCopiesWindows() { + List source = new ArrayList<>(List.of(HOUR)); + Feature feature = new Feature("card.count.1h", Aggregation.count(), source); + + source.add(new Window(Duration.ofDays(1), WindowType.TUMBLING)); + + assertThat(feature.windows()).containsExactly(HOUR); + } + + @Test + void windowsAreUnmodifiable() { + Feature feature = new Feature("card.count.1h", Aggregation.count(), List.of(HOUR)); + assertThat(feature.windows()).isUnmodifiable(); + } +} diff --git a/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/IntentTest.java b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/IntentTest.java new file mode 100644 index 0000000..4e28e2d --- /dev/null +++ b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/IntentTest.java @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +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 java.math.BigDecimal; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; + +class IntentTest { + + private static final Window HOUR = new Window(Duration.ofHours(1), WindowType.TUMBLING); + private static final Subject SUBJECT = new Subject("card", "abc"); + + private static Feature feature(Aggregation aggregation) { + return new Feature("f", aggregation, List.of(HOUR)); + } + + @Test + void countIntentRequiresCountFeature() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new CountIntent(feature(Aggregation.sum()), SUBJECT)); + } + + @Test + void countIntentAcceptsCountFeature() { + CountIntent intent = new CountIntent(feature(Aggregation.count()), SUBJECT); + assertThat(intent.subject()).isEqualTo(SUBJECT); + assertThat(intent.feature().aggregation().type()).isEqualTo(AggregationType.COUNT); + } + + @Test + void sumIntentRequiresSumFeature() { + assertThatIllegalArgumentException() + .isThrownBy( + () -> new SumIntent(feature(Aggregation.count()), SUBJECT, new BigDecimal("100"))); + } + + @Test + void sumIntentRejectsNonZeroScale() { + assertThatIllegalArgumentException() + .isThrownBy( + () -> new SumIntent(feature(Aggregation.sum()), SUBJECT, new BigDecimal("1.50"))); + } + + @Test + void sumIntentAcceptsIntegerCentsIncludingNegative() { + SumIntent refund = new SumIntent(feature(Aggregation.sum()), SUBJECT, new BigDecimal("-500")); + assertThat(refund.valueCents()).isEqualByComparingTo("-500"); + } + + @Test + void distinctIntentRequiresDistinctFeature() { + DistinctMember member = new DistinctMember(new byte[] {1, 2}); + assertThatIllegalArgumentException() + .isThrownBy(() -> new DistinctIntent(feature(Aggregation.count()), SUBJECT, member)); + } + + @Test + void distinctIntentAcceptsDistinctFeature() { + DistinctMember member = new DistinctMember(new byte[] {1, 2}); + DistinctIntent intent = + new DistinctIntent(feature(Aggregation.distinct("ip")), SUBJECT, member); + assertThat(intent.member()).isEqualTo(member); + } + + @Test + void rejectsNullComponents() { + assertThatNullPointerException() + .isThrownBy(() -> new CountIntent(feature(Aggregation.count()), null)); + assertThatNullPointerException() + .isThrownBy(() -> new SumIntent(feature(Aggregation.sum()), SUBJECT, null)); + assertThatNullPointerException() + .isThrownBy(() -> new DistinctIntent(feature(Aggregation.distinct("ip")), SUBJECT, null)); + } + + @Test + void variantsShareTheSealedIntentType() { + Intent intent = new CountIntent(feature(Aggregation.count()), SUBJECT); + assertThat(intent).isInstanceOf(Intent.class); + assertThat(intent.feature().name()).isEqualTo("f"); + } +} diff --git a/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/SeedAggregateTest.java b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/SeedAggregateTest.java new file mode 100644 index 0000000..f26386a --- /dev/null +++ b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/SeedAggregateTest.java @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import com.codeheadsystems.velocity.spi.model.SeedAggregate.ExactDistinct; +import com.codeheadsystems.velocity.spi.model.SeedAggregate.HllDistinct; +import com.codeheadsystems.velocity.spi.model.SeedAggregate.SumValue; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class SeedAggregateTest { + + @Test + void sumValueRejectsNonZeroScale() { + assertThatIllegalArgumentException().isThrownBy(() -> new SumValue(new BigDecimal("1.00"))); + } + + @Test + void sumValueAcceptsIntegerCents() { + assertThat(new SumValue(new BigDecimal("250")).cents()).isEqualByComparingTo("250"); + } + + @Test + void exactDistinctDefensivelyCopies() { + List source = new ArrayList<>(List.of(new DistinctMember(new byte[] {1}))); + ExactDistinct exact = new ExactDistinct(source); + + source.add(new DistinctMember(new byte[] {2})); + + assertThat(exact.members()).hasSize(1); + assertThat(exact.members()).isUnmodifiable(); + } + + @Test + void hllDistinctDefensivelyCopiesAndComparesByContent() { + byte[] sketch = {9, 8, 7}; + HllDistinct hll = new HllDistinct(sketch); + + sketch[0] = 0; + + assertThat(hll.sketch()).containsExactly(9, 8, 7); + assertThat(hll).isEqualTo(new HllDistinct(new byte[] {9, 8, 7})); + assertThat(hll.toString()).contains("HllDistinct", "3 bytes"); + } + + @Test + void rejectsNulls() { + assertThatNullPointerException().isThrownBy(() -> new SumValue(null)); + assertThatNullPointerException().isThrownBy(() -> new ExactDistinct(null)); + assertThatNullPointerException().isThrownBy(() -> new HllDistinct(null)); + } +} diff --git a/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/WindowBoundsTest.java b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/WindowBoundsTest.java new file mode 100644 index 0000000..c9ee30c --- /dev/null +++ b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/WindowBoundsTest.java @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class WindowBoundsTest { + + private static final Instant START = Instant.parse("2026-07-18T00:00:00Z"); + private static final Instant END = Instant.parse("2026-07-18T01:00:00Z"); + + @Test + void acceptsEndAfterStart() { + WindowBounds bounds = new WindowBounds(START, END); + assertThat(bounds.start()).isEqualTo(START); + assertThat(bounds.end()).isEqualTo(END); + } + + @Test + void acceptsEqualStartAndEnd() { + assertThat(new WindowBounds(START, START).end()).isEqualTo(START); + } + + @Test + void rejectsEndBeforeStart() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new WindowBounds(/* start= */ END, /* end= */ START)); + } + + @Test + void rejectsNulls() { + assertThatNullPointerException().isThrownBy(() -> new WindowBounds(null, END)); + assertThatNullPointerException().isThrownBy(() -> new WindowBounds(START, null)); + } +} diff --git a/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/WindowTest.java b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/WindowTest.java new file mode 100644 index 0000000..7f2b090 --- /dev/null +++ b/velocity-spi/src/test/java/com/codeheadsystems/velocity/spi/model/WindowTest.java @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.spi.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class WindowTest { + + @Test + void acceptsPositiveDuration() { + Window window = new Window(Duration.ofHours(1), WindowType.SLIDING); + assertThat(window.duration()).isEqualTo(Duration.ofHours(1)); + assertThat(window.type()).isEqualTo(WindowType.SLIDING); + } + + @Test + void rejectsZeroDuration() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new Window(Duration.ZERO, WindowType.TUMBLING)); + } + + @Test + void rejectsNegativeDuration() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new Window(Duration.ofSeconds(-1), WindowType.TUMBLING)); + } + + @Test + void rejectsNulls() { + assertThatNullPointerException().isThrownBy(() -> new Window(null, WindowType.SLIDING)); + assertThatNullPointerException().isThrownBy(() -> new Window(Duration.ofHours(1), null)); + } +}