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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **§18 `AxiamClient.close()` semantics** — idempotent via `compareAndSet` (a concurrent
double-close does the work once), clears the memo, and use-after-close throws `NetworkError`
rather than silently reconnecting. It does **not** log out and never reaches the network:
the server-side session outlives the client object, and a `close()` that logged out would
end every user's session on each deploy.
- **§19 telemetry hooks** — `Builder.telemetryHook(...)`, the **sealed** `TelemetryEvent`
hierarchy (`RequestStart`, `RequestEnd`, `Retry`, `Refresh`) and `examples/telemetry-hook`.
A throwing hook cannot fail the operation that fired it, and no event payload can carry a
token. One request pair per *attempt*.
- **§17 decision memo — opt-in, off by default** — `Builder.decisionMemoTtl(...)`, clamped to
`DecisionMemo.MAX_TTL` (5 s), thread-safe. Allows and denies memoized identically, failures
never memoized, cleared on any credential change.
**Reads-your-own-writes is not guaranteed.**
- `Builder.retryDisabled()` (§16.6). No builder method for the attempt cap, base or delay
cap: §16.1 forbids raising them.
- `Retry.withRetry` gains an attempt-aware overload that passes the 1-based attempt to the
operation and emits the §16.5 retry event.

### Changed

- Re-vendored `CONTRACT.md` at **1.8.2**. `openapi.json` unchanged — docs-only contract revs.
- `login`, `verifyMfa`, `refresh` and `logout` clear the decision memo (§17.1 rule 9) and
reject after close (§18.1 rule 4).

### Notes

- §16's arithmetic is **unchanged**: this SDK's policy was already conformant, and of the
five SDKs that had invented one it was the only one that got both full jitter and
`Retry-After`-as-a-floor right. The contract adopted its parameters.

## [1.0.0-alpha24] - 2026-08-04

### Added
Expand Down
384 changes: 377 additions & 7 deletions CONTRACT.md

Large diffs are not rendered by default.

86 changes: 85 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Source: [ilpanich/axiam-java-sdk](https://github.com/ilpanich/axiam-java-sdk)

## Contract conformance

This SDK conforms to CONTRACT.md §1–§13 and §12.7, §14, §15 — including §6.1 mTLS
This SDK conforms to CONTRACT.md §1–§13 and §12.7, §14, §15, §17, §19 — including §6.1 mTLS
(client-certificate authentication), the §1.1 gRPC-only `getUserInfo` operation,
the §10.1 minimum local-verification set, the §12 OIDC/SSO relying-party helpers,
and the §13 webhook-signature verifier.
Expand Down Expand Up @@ -514,3 +514,87 @@ this SDK.
## Status

Java SDK, extracted from the AXIAM monorepo into its own repository.

## Client quality-of-life (CONTRACT.md §16–§19)

### Retry policy (§16)

Read-only authorization checks — `checkAccess` (both overloads) and `batchCheck` — retry
transient failures under the contract's normative table: **3 attempts** (1 initial + 2
retries), 200 ms base, 5 s cap, **full jitter** (uniform over `[0, backoff]`), and
`Retry-After` honored as a **floor**.

This SDK's `Retry` was already conformant — it is the implementation whose parameters the
contract adopted, and of the five SDKs that had invented a policy it was the only one that
got jitter and `Retry-After` both right. D5 adds the disable switch and the §16.5 retry
event; the arithmetic is unchanged.

```java
// Turn it off if you own your own retry layer — you know your deadline, this SDK doesn't.
AxiamClient client = AxiamClient.builder(baseUrl, "acme").retryDisabled().build();
```

There is deliberately no builder method for the attempt cap, base delay or delay cap: §16.1
forbids raising them, and eleven SDKs agreeing on one table is the point.

### Deterministic shutdown (§18)

`close()` releases the client's local resources. It is idempotent — a concurrent double-close
does the work once — and any call afterwards throws `NetworkError` naming the cause rather
than silently reconnecting.

**`close()` does not log out.** It never reaches the network. The server-side session
deliberately outlives the client object — that is what lets a process restart and resume — so
a `close()` that logged out would silently end every user's session on each deploy. Call
`logout()` first if ending the session is what you want.

### Telemetry hooks (§19)

Wire metrics without this library depending on any metrics API:

```java
AxiamClient client = AxiamClient.builder(baseUrl, "acme")
.telemetryHook(event -> {
if (event instanceof TelemetryEvent.RequestEnd end) {
histogram.record(end.duration().toMillis(), /* labels */);
} else if (event instanceof TelemetryEvent.Retry retry) {
counter.increment(/* labels */);
}
})
.build();
```

- **A hook that throws cannot fail the operation that fired it.** Telemetry is not permitted
to fail an authorization check.
- **No event payload can carry a token.** `TelemetryEvent` is a **sealed** hierarchy of
records with fixed component lists — no code outside the SDK can add a variant, which is
what makes that guarantee checkable rather than aspirational.
- **Path templates, not URLs**, so a metric label cannot become a cardinality bomb.

One `RequestStart`/`RequestEnd` pair is emitted **per attempt**, so you can count real wire
calls. See [`examples/telemetry-hook`](examples/telemetry-hook).

### Decision memo (§17) — opt-in, off by default

An optional TTL-bounded cache for `checkAccess` results. **Disabled by default**, because
§11.2 rule 6's ban on caching authorization decisions is still the default behaviour.

```java
AxiamClient client = AxiamClient.builder(baseUrl, "acme")
.decisionMemoTtl(Duration.ofSeconds(5))
.build();
```

**What you are accepting.** The staleness bound is the TTL, in *both* directions: a grant
revoked on the server can still read as allowed for up to the TTL, and a grant just added can
still read as denied for up to the TTL.

> **Reads-your-own-writes is not guaranteed.** An admin UI that grants a role and immediately
> re-checks is the case that breaks, and it breaks silently. If that is your workload, leave
> this off.

The TTL is clamped to `DecisionMemo.MAX_TTL` (5 s) rather than rejected. Allows and denies are
memoized identically — asymmetric caching would leak which outcome occurred through latency.
Failures are never memoized: caching a transport error as a deny would turn a blip into a
TTL-long outage. The memo is cleared on `login`, `verifyMfa`, `refresh` and `logout`, since
entries are keyed by subject rather than by session. It is thread-safe.
144 changes: 144 additions & 0 deletions examples/telemetry-hook/TelemetryHookExample.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package io.axiam.sdk.examples.telemetryhook;

import io.axiam.sdk.AxiamClient;
import io.axiam.sdk.AxiamClient.AccessResult;
import io.axiam.sdk.telemetry.TelemetryEvent;

import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

/**
* Demonstrates telemetry hooks (CONTRACT.md §19): wiring metrics to an
* AXIAM client <strong>without this library depending on any metrics API</strong>.
*
* <p>The sink below aggregates in-process so the example runs with no extra
* dependencies; the comment block at the bottom shows the exact mapping onto
* Micrometer, which is a drop-in replacement for the body. Imports ONLY public
* SDK entry points.
*
* <p>Run: {@code AXIAM_BASE_URL=... AXIAM_TENANT_ID=... java TelemetryHookExample.java}
*/
public final class TelemetryHookExample {

/** (operation, outcome) to [count, totalMillis]. */
private static final Map<String, long[]> REQUESTS = new ConcurrentHashMap<>();
/** operation to retry count. */
private static final Map<String, AtomicLong> RETRIES = new ConcurrentHashMap<>();

public static void main(String[] args) {
String baseUrl = getenv("AXIAM_BASE_URL", "https://localhost:8443");
String tenantId = getenv("AXIAM_TENANT_ID", "acme");
String orgSlug = getenv("AXIAM_ORG_SLUG", "acme");

// §18: try-with-resources. close() releases local resources and does
// NOT log out — the server-side session outlives this object.
try (AxiamClient client = AxiamClient.builder(baseUrl, tenantId)
.orgSlug(orgSlug)
.telemetryHook(TelemetryHookExample::record)
.build()) {

// This will usually fail against a host that is not running, which
// is the point: a failing call still emits a RequestEnd carrying
// the failure, and the §16 retries are visible as Retry events.
try {
AccessResult decision = client.checkAccess(
"read", "00000000-0000-0000-0000-000000000000");
System.out.printf("allowed=%s (%s)%n",
decision.allowed(),
decision.reasonCode() == null ? "no reason code" : decision.reasonCode());
} catch (RuntimeException e) {
System.out.println("check failed as expected in this example: " + e.getMessage());
}

report();
}
}

/** A §19 sink. Aggregates in memory; see the Micrometer mapping below. */
private static void record(TelemetryEvent event) {
if (event instanceof TelemetryEvent.RequestEnd end) {
// One pair per ATTEMPT, not per logical call (§19.2 rule 5), so
// counting these gives the real number of wire calls — including
// the ones a retry made on your behalf.
String key = end.operation() + "/" + end.outcome();
REQUESTS.compute(key, (k, v) -> {
long[] stat = (v == null) ? new long[] {0, 0} : v;
stat[0]++;
stat[1] += end.duration().toMillis();
return stat;
});
} else if (event instanceof TelemetryEvent.Retry retry) {
// §16.5 — the reason this event exists. A retried-then-succeeded
// operation is otherwise invisible: the caller sees a slow success
// and no signal that the server is failing. Alert on this rate, not
// on the error rate, or a degrading server looks healthy right up
// until the retries stop being enough.
RETRIES.computeIfAbsent(retry.operation(), k -> new AtomicLong()).incrementAndGet();
}
}

private static void report() {
System.out.println("--- requests (per attempt) ---");
REQUESTS.forEach((key, stat) ->
System.out.printf(" %-24s count=%d mean=%dms%n", key, stat[0], stat[1] / stat[0]));
System.out.println("--- retries ---");
if (RETRIES.isEmpty()) {
System.out.println(" (none)");
}
RETRIES.forEach((op, n) -> System.out.printf(" %-24s %d%n", op, n.get()));
}

private static String getenv(String name, String fallback) {
String value = System.getenv(name);
return (value == null || value.isBlank()) ? fallback : value;
}

private TelemetryHookExample() {
}
}

// ---------------------------------------------------------------------------
// The same sink, against Micrometer
// ---------------------------------------------------------------------------
//
// This library deliberately declares no micrometer/OpenTelemetry dependency —
// §19's whole point is that you choose your metrics stack. With Micrometer on
// YOUR classpath, record(...) becomes:
//
// MeterRegistry registry = ...;
//
// static void record(TelemetryEvent event) {
// if (event instanceof TelemetryEvent.RequestEnd end) {
// Timer.builder("axiam.client.request")
// .tag("axiam.operation", end.operation())
// // The path TEMPLATE, never a substituted URL: a metric label
// // carrying a UUID is a cardinality bomb.
// .tag("http.route", end.pathTemplate())
// .tag("http.response.status_code", String.valueOf(end.status()))
// .tag("axiam.outcome", end.outcome().name())
// .register(registry)
// .record(end.duration());
// } else if (event instanceof TelemetryEvent.Retry retry) {
// Counter.builder("axiam.client.retries")
// .tag("axiam.operation", retry.operation())
// .tag("axiam.attempt", String.valueOf(retry.attempt()))
// .register(registry)
// .increment();
// }
// }
//
// Two rules to keep in mind when writing any adapter:
//
// * DO NOT BLOCK. Hooks run on the calling thread (§19.2 rule 4). Every
// mature metrics library already buffers; if yours does not, buffer on your
// side rather than doing I/O here.
// * DO NOT ENRICH EVENTS FROM ELSEWHERE. TelemetryEvent is a sealed hierarchy
// precisely so this surface cannot leak a token into a metrics backend
// (§19.2 rule 3). Adding, say, the current Authorization header would
// defeat that on your side of the boundary.
//
// A hook that throws is swallowed by the SDK (§19.2 rule 2) — an authorization
// check is never failed by telemetry — but that is a backstop, not a licence to
// let a sink throw.
Loading
Loading