Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
be16df4
add credit leases, reservations, and preflight checks
bpapillon Sep 17, 2026
d7759d2
close parity gaps with the node lease client
bpapillon Sep 17, 2026
ff9cce4
chore(docs): say reservation, not hold, and trim the lease readme
bpapillon Sep 17, 2026
b33b89a
send the preflight on the REST flag check
bpapillon Sep 17, 2026
fcf3584
chore(docs): tighten the preflight readme line
bpapillon Sep 17, 2026
5352507
address review: one close budget, guard prewarm after close
bpapillon Sep 17, 2026
0b2b2a8
honour the per-check default when the API fallback fails
bpapillon Sep 18, 2026
de504fd
chore(tests): wait for the joiner to park before the wire throws
bpapillon Sep 18, 2026
35f3df2
address review: spawn an extend only when due, size holds from whole …
bpapillon Sep 18, 2026
5dfaf22
address review: match node after stop and on hold sizing
bpapillon Sep 18, 2026
87d7fe9
address review: resolve auto mode per check, thread timeouts, settle …
bpapillon Sep 18, 2026
bdd9b82
address review: join budget, keys-first resolution, round holds up
bpapillon Sep 18, 2026
ed3ff34
address review: gate on a loaded engine, release in parallel
bpapillon Sep 18, 2026
dc52434
resolve the caller's default on the datastream branch
bpapillon Sep 18, 2026
f1e6b3c
chore(docs): correct the preflight rounding comments
bpapillon Sep 18, 2026
4ad9e66
build the lease plumbing only where a check will gate on it
bpapillon Sep 18, 2026
a8dd647
round a fractional server hold up to whole units
bpapillon Sep 28, 2026
5be8649
skip the refund for a hold that names no lease
bpapillon Sep 28, 2026
42a3ffe
deregister lease work before completing it
bpapillon Sep 28, 2026
63df41b
bound a check's lease waits by one deadline
bpapillon Sep 28, 2026
716f0f5
chore(tests): fix tests broken by the rebase onto main
bpapillon Sep 30, 2026
dd63468
register an extend flight atomically
bpapillon Sep 30, 2026
04aaeae
tighten extend joins, deadlines and stop checks
bpapillon Sep 30, 2026
3081e1c
bump cached metrics once per reservation
bpapillon Sep 30, 2026
5b12aeb
chore(docs): fix overrides wording and compile more README snippets
bpapillon Sep 30, 2026
423ec69
chore: trim lease manager comments
bpapillon Sep 30, 2026
09f4200
chore(tests): assert lease test threads finish, drop a sleep
bpapillon Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ src/main/java/com/schematic/api/HttpEventSender.java
src/main/java/com/schematic/api/IdentifyOptions.java
src/main/java/com/schematic/api/Schematic.java
src/main/java/com/schematic/api/TrackOptions.java
src/main/java/com/schematic/api/credits/
conformance/
src/main/java/com/schematic/api/cache/CacheProvider.java
src/main/java/com/schematic/api/cache/CachedItem.java
src/main/java/com/schematic/api/cache/LocalCache.java
Expand All @@ -35,6 +37,7 @@ src/test/java/com/schematic/api/TestOfflineMode.java
src/test/java/com/schematic/api/TestReadme.java
src/test/java/com/schematic/api/TestSchematic.java
src/test/java/com/schematic/api/cache/RedisCacheProviderTest.java
src/test/java/com/schematic/api/credits/
src/test/java/com/schematic/api/datastream/
src/test/java/com/schematic/webhook/
.fern/replay.lock
Expand Down
164 changes: 164 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,153 @@ user.put("user_id", "your-user-id");
boolean flagValue = schematic.checkFlag("some-flag-key", company, user);
```

`checkFlagWithEntitlement` answers the same question and hands back the whole result: the value, the reason the rules engine gave, and the matched entitlement.

## Credit Leases and Reservations

For features metered by credit burndown (inference tokens, for example), `check` reserves credits for the work about to run and `trackWithReservation` settles the reservation with the actual usage. The SDK gates in one of two modes:

- **Client mode** acquires a **lease**, a tranche of credits held against the company's balance, and carves a per-request **reservation** out of it locally, so a check needs no API call. It requires [DataStream](#datastream) (or [Replicator Mode](#replicator-mode)) and, across multiple processes, a shared Redis so every instance gates against the same lease.
- **Server mode** makes one check-and-reserve API call per check. No lease, no Redis, no local state.

`mode` defaults to `AUTO`: client when DataStream is enabled, server otherwise. Client mode suits high-throughput gating; server mode suits low-volume checks and operations that run for seconds.

### Setup

```java
import com.schematic.api.Schematic;
import com.schematic.api.credits.CreditLeaseConfig;
import com.schematic.api.datastream.DatastreamOptions;
import java.time.Duration;
import redis.clients.jedis.JedisPooled;

JedisPooled redisClient = new JedisPooled("localhost", 6379);

Schematic schematic = Schematic.builder()
.apiKey("YOUR_API_KEY")
.datastreamOptions(DatastreamOptions.builder().build())
.creditLeases(CreditLeaseConfig.builder()
.defaultLeaseSize(10000) // credits requested per lease
.defaultLeaseDuration(Duration.ofMinutes(5)) // lease lifetime
.defaultReservationTtl(Duration.ofSeconds(60)) // how long a reservation is held if no track settles it
.redisClient(redisClient) // lease and reservation state
.build())
.build();
```

Leases reuse the Redis client the DataStream cache is configured with, if there is one. The example above configures DataStream without a Redis cache, so it passes `redisClient` explicitly. Set it whenever the DataStream cache is local, or when lease state should live in a different Redis from the cache. With no Redis on either side the SDK falls back to per-process in-memory state, which gates one process only and warns at startup.

Server mode needs only a TTL:

```java
import com.schematic.api.Schematic;
import com.schematic.api.credits.CreditLeaseConfig;
import java.time.Duration;

Schematic schematic = Schematic.builder()
.apiKey("YOUR_API_KEY")
.creditLeases(CreditLeaseConfig.builder()
.defaultReservationTtl(Duration.ofSeconds(60)) // just under an hour at most, which is as far out as the API will reserve credits
.build())
.build();
```

Only `mode` and `defaultReservationTtl` apply in server mode; the client warns at startup if a client-only option is set.

### Checking and tracking

```java
import com.schematic.api.credits.CheckOptions;
import com.schematic.api.credits.CheckResult;
import java.util.HashMap;
import java.util.Map;

Map<String, String> company = new HashMap<>();
company.put("id", "your-company-id");

// Reserve up to maxTokens for this operation.
CheckResult result = schematic.check("inference", company, null, CheckOptions.builder()
.usage(maxTokens) // upper bound for this operation
.eventSubtype("inference_tokens") // the metered event
.build());
if (!result.isAllowed()) {
throw new IllegalStateException("credit balance exceeded");
}

long tokensUsed = runInference();

// Report the actual usage; the unused slice of the reservation is refunded.
if (result.getReservation() != null) {
schematic.trackWithReservation(result.getReservation(), tokensUsed);
} else {
schematic.track("inference_tokens", company, null, null, tokensUsed);
}
```

A check can allow without reserving credits, when the feature is not credit-metered, when `usage` is 0, or when the check failed open, and that usage still has to be tracked.

`usage` may be fractional, but credits are always sized in whole event units: a client-mode reservation records the fractional quantity, while the credits it reserves and the debit its settle makes are both `ceil(usage) x consumption rate`, so the local ledger moves by exactly what the track event bills. The integer fields on the wire round up for the same reason: the preflight quantity and the quantity a track event bills, so a partial unit is never billed as none.

`usage` still gates a check that reserves nothing: it is sent as a preflight, locally or to the API, so the verdict accounts for what the call is about to spend. Preflighted verdicts are not cached.

`CheckOptions.timeout` bounds every call a check waits on: the check-and-reserve call in server mode, the REST flag check a check can fall back to, and the client-mode lease acquire and extend. Lease calls are shared between concurrent checks, and a check that joins one somebody else opened waits no longer than its own timeout before giving up and taking its failure path, leaving that call running for the checks still on it. Background top-ups keep the client's own timeout.

An unsettled reservation expires after `defaultReservationTtl` and its credits return to the lease. A late settle still bills the usage, since the track event carries a deterministic idempotency key that keeps it from double-billing, but it does not re-debit the local lease. Set `defaultReservationTtl` above the longest expected gap between the check and the settle.

### Pre-warming

Warm leases when the user is identified, so a session's first check does not wait on a lease acquire:

```java
import com.schematic.api.IdentifyOptions;
import com.schematic.api.types.EventBodyIdentifyCompany;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

Map<String, String> userKeys = new HashMap<>();
userKeys.put("user_id", "your-user-id");

Map<String, String> companyKeys = new HashMap<>();
companyKeys.put("id", "your-company-id");

schematic.identify(
userKeys,
EventBodyIdentifyCompany.builder().keys(companyKeys).build(),
"Your User",
null,
IdentifyOptions.builder()
.prewarm(Collections.singletonList("credit-type-id"))
.build());
```

Identifying with a prewarm flushes the event buffer first, so the server has the company before the warm-up asks for a lease against it. That makes it a session-start call, not one to put on every event.

Or call `schematic.prewarm(companyKeys, creditTypeIds)` directly. Both are no-ops in server mode.

Pre-warming resolves the company the way the server does: it looks the keys up first, whatever they are named, and only when nothing matches does it read a value carrying Schematic's `comp_` prefix as the company id.

### Failure behavior

In server mode, a check that times out after the server has already reserved leaves those credits reserved until the TTL expires, so keep `defaultReservationTtl` short there.

A check that cannot gate, because the API is unreachable, Redis is down, or the lease is exhausted, fails closed by default. Override it per check:

```java
import com.schematic.api.credits.CheckOptions;
import com.schematic.api.credits.OnAcquireFailure;

CheckOptions options = CheckOptions.builder()
.usage(maxTokens)
.eventSubtype("inference_tokens")
.onAcquireFailure(OnAcquireFailure.FAIL_OPEN)
.build();
```

In client mode `FAIL_OPEN` still evaluates the flag's rules with the credit balance assumed sufficient, so plan targeting and every non-credit condition apply and only the credit gate is bypassed. In server mode it returns the flag's default value, which is false unless the check passes `defaultValue` or the client configures a flag default.

See [Credit Lease Options](#credit-lease-options) for the full set of options.

## Webhook Verification

Schematic can send webhooks to notify your application of events. To ensure the security of these webhooks, Schematic signs each request using HMAC-SHA256. The Java SDK provides utility functions to verify these signatures.
Expand Down Expand Up @@ -277,6 +424,23 @@ Schematic schematic = Schematic.builder()
.build();
```

### Credit Lease Options

Set with `creditLeases(CreditLeaseConfig.builder()...build())`. Per-credit-type overrides take a `CreditLeaseOverride` under `override(creditTypeId, ...)`.

| Option | Type | Default | Description |
|---|---|---|---|
| `mode` | `CreditLeaseMode` | `AUTO` | Where credits are reserved; `AUTO` picks client when DataStream is enabled, server otherwise |
| `defaultReservationTtl` | `Duration` | 60 seconds | How long an unsettled reservation is held |
| `defaultLeaseDuration` | `Duration` | 5 minutes | (client mode) Lease lifetime |
| `defaultLeaseSize` | `double` | 10000 | (client mode) Credits requested per lease acquire or extend |
| `lowWaterMark` | `double` | 0.25 | (client mode) Extend in the background when the lease balance dips below this fraction |
| `sweepInterval` | `Duration` | 1 second | (client mode) How often expired reservations are swept |
| `prewarmResolveTimeout` | `Duration` | 5 seconds | (client mode) How long `prewarm` waits for a freshly identified company to surface; zero resolves from the DataStream cache only |
| `redisClient` | `JedisPooled` | the DataStream cache's client | (client mode) Redis client for lease and reservation state |
| `redisKeyPrefix` | `String` | the DataStream cache's prefix | (client mode) Key prefix for lease and reservation keys |
| `overrides` | `Map<String, CreditLeaseOverride>` | none | (client mode) Per-credit-type overrides of `defaultLeaseDuration`, `defaultReservationTtl`, `defaultLeaseSize` and `lowWaterMark`, keyed by credit type id |

### Offline Mode

In development or testing environments, you may want to avoid making network requests when checking flags or submitting events. You can run Schematic in offline mode:
Expand Down
10 changes: 10 additions & 0 deletions conformance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Credit lease conformance suite

`SPEC.md` and `vectors/*.json` are copied verbatim from `conformance/` in
[schematic-node](https://github.com/SchematicHQ/schematic-node), the reference
implementation. Do not edit them here: change them there, then copy the new
versions across, so every SDK runs the same contract.

The runner is the only language-specific piece. This SDK's lives in
`src/test/java/com/schematic/api/credits/conformance/`, and runs every vector
against both store backends: the in-memory stores and the Redis stores.
Loading
Loading