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 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 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 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 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 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 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 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 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