# Java SDK

The first-party Lockwell SDK for the JVM is a JDK 25 client for Java 25 / Spring Boot 4 services and Lockwell's native
binary LNW/1 data plane. LNW/1 uses
TLS 1.3 and bounded binary frames directly over a socket; it does not wrap HTTP, JSON, XML, or S3. The same artifact
also contains explicit S3 and HTTP/JSON compatibility clients plus the separate JSON Admin control plane.

::: info Early access
Lockwell is closed source during early access. The package versions on this page are historical builds; early-access
customers receive current packages, the deployment repository and a licence with their access. Request access at
[hello@rusticstack.com](mailto:hello@rusticstack.com). An MIT-licensed source release is planned.
:::

It shares the language-neutral SigV4 signing fixtures with the Go and Node SDKs, so all three sign byte-for-byte
identically.

## Install

```xml
<dependency>
  <groupId>com.lockwell</groupId>
  <artifactId>lockwell-sdk</artifactId>
  <version>0.2.2</version>
</dependency>
```

The synchronous S3, native, and admin clients use fluent builders, are thread-safe, and never render secrets
(`Credentials.toString()` redacts them). `LockwellAsyncClient` wraps an existing `LockwellClient` and optionally an
application-owned `Executor`; it does not have a separate builder.
The surfaces live in their own packages:

| Package                      | Client                                  | Surface                  |
| ---------------------------- | --------------------------------------- | ------------------------ |
| `com.lockwell.sdk`           | `LockwellClient`, `LockwellAsyncClient` | S3 (SigV4 + XML)         |
| `com.lockwell.sdk.nativeapi` | `LockwellNativeClient`                  | native JSON (`/api/v1/`) |
| `com.lockwell.sdk.springwire` | `LockwellNativeWireClient`, `LockwellNativeWireAsyncClient` | binary LNW/1 |
| `com.lockwell.sdk.admin`     | `LockwellAdminClient`                   | JSON admin API           |
| `com.lockwell.sdk.kit`       | `LockwellKit`                           | the app kit              |
| `com.lockwell.sdk.spring`    | auto-configuration                      | Spring Boot starter      |

## Distribution and compatibility

The Java SDK is published privately to GitHub Packages at `com.lockwell:lockwell-sdk`. Use a `read:packages` token in
Maven/Gradle settings, pin an immutable version, and mirror the jar, POM, sources jar, generated checksums, release tag,
and Lockwell supply-chain evidence into any offline/on-prem install media. Maven Central is deferred for the current
TangibleShift adoption gate; use an internal/customer artifact mirror seeded from the vetted GitHub Packages artifact
for customer builds.

The SDK is compiled with `--release 25` and supports JDK 25. The separate
`com.lockwell:lockwell-spring-boot-starter` contains only Spring Boot 4.1.1 autoconfiguration, health, and lifecycle glue
for the same wire core; it does not contain a second implementation. Java 21 bytecode/runtime compatibility is not
claimed.

### LNW/1 native wire

```java
import com.lockwell.sdk.springwire.*;

var properties = new LockwellNativeWireProperties();
properties.setHost("lockwell.internal.example");
properties.setAccessKeyId(System.getenv("LOCKWELL_ACCESS_KEY_ID"));
properties.setSecretKey(System.getenv("LOCKWELL_SECRET_KEY").toCharArray());
try (var client = new LockwellNativeWireClient(properties)) {
    var page = client.listBuckets(NativeWireTypes.RequestOptions.defaults());
}
```

The wire client exposes typed synchronous and virtual-thread asynchronous APIs for every registry operation: buckets,
streaming objects and ranges, checksums/metadata, pagination, copy, multipart resume/abort, versions/delete markers,
Object Lock retention/legal hold, tags, CORS, notifications, signed capabilities, readiness, and capability negotiation.
Unknown critical fields and frames fail closed; application retries are never hidden, while narrowly scoped authentication
retries expose bounded metadata and use fresh handshakes.

The public wire model is grouped under `NativeWireTypes`: `BucketResult`, `BucketPage`, `PutObjectRequest`,
`GetObjectRequest`, `GetObjectResult`, `HeadObjectResult`, `ByteRange`, `ChecksumSet`, `ListObjectsRequest`, `ObjectPage`,
`ObjectSummary`, `CopyObjectRequest`, `ListVersionsRequest`, `VersionPage`, `DeleteObjectIdentifier`,
`BatchDeleteRequest`, `BatchDeleteSuccess`, `BatchDeleteFailure`, `MultipartParts`, `CorsRule`, `CorsConfiguration`,
`NotificationConfig`, `SignedCapabilityRequest`, `SignedCapability`, `TagsResult`, and `LegalHoldResult`. These records
carry the same bounded operation fields as the language-neutral registry rather than untyped maps or JSON documents.
Streaming responses use the typed `ObjectStreamHandler` callback so callers can process payload bytes without whole-object
buffering.

Pin the SDK and Lockwell server/image to the same release line unless release notes explicitly allow otherwise. The SDK
targets the versioned JSON Admin API (`/admin/api/v1`) and native API (`/api/v1`); installers can compare
`/admin/api/v1/openapi.json` and `/api/v1/openapi.json` against the pinned SDK before provisioning tenants.

## `LockwellClient` (the S3 client)

```java
import com.lockwell.sdk.*;
import java.time.Duration;
import java.util.Map;

LockwellClient client = LockwellClient.builder()
    .endpoint("https://objects.example.com")
    .credentials(new Credentials(System.getenv("LOCKWELL_ACCESS_KEY_ID"),
                                 System.getenv("LOCKWELL_SECRET_KEY")))
    .requestTimeout(Duration.ofSeconds(30))
    .retryPolicy(RetryPolicy.defaults()) // opt in; honors Retry-After on 429/5xx
    .build();

// SSE-S3, a server-verified CRC64NVME checksum, and idempotent retry, in one call.
var put = client.putObject("reports", "q1.txt", "hello".getBytes(),
    new LockwellClient.PutOptions()
        .contentType("text/plain")
        .serverSideEncryption()
        .checksum("CRC64NVME")
        .idempotencyKey("q1-2026"));

var got = client.getObject("reports", "q1.txt", Map.of());
System.out.println(new String(got.body()));

// Stream a large file (checksum sent in an aws-chunked trailer; no buffering).
try (var in = java.nio.file.Files.newInputStream(java.nio.file.Path.of("big.bin"))) {
    client.putObjectStream("reports", "big.bin", in, "CRC64NVME", null);
}

// Presigned GET; PUT, HEAD, and DELETE helpers are also available.
String url = client.presignGetObject("reports", "q1.txt", 900);
```

Path-style addressing is the default. A clean reverse-proxy prefix such as `https://objects.example.com/lockwell` is
preserved in normal and presigned request paths and participates in SigV4 signing. Already-mounted `/api/v1` and
`/admin/api/v1` suffixes are normalized once, and traversal, encoded separators, malformed escapes, and unsafe interior
path segments are rejected. On an endpoint with wildcard bucket DNS, add `.virtualHostedStyle(true)` to the builder;
normal requests and presigned URLs then put the bucket, including dotted bucket names, in the signed host.

### Builder

`LockwellClient.builder()` accepts `.endpoint(...)`, `.credentials(...)`, `.httpClient(HttpClient)`,
`.userAgent(String)`, `.clock(Supplier<Instant>)`, `.requestTimeout(Duration)`, `.retryPolicy(RetryPolicy)`,
`.region(String)`, `.virtualHostedStyle(boolean)`, and `.responseMetadataListener(Consumer<ResponseMetadata>)`. S3
retries are off by default for backward compatibility; pass `RetryPolicy.defaults()` to retry safe and idempotent
requests with backoff, jitter, and `Retry-After` handling.

`requestTimeout` is per HTTP attempt. If retries are enabled, total wall time can include more than one request timeout
plus retry backoff. Streaming uploads are bounded for the full upload attempt, so size this timeout for large object
writes.

### Buckets

| Method                | Signature                                                                                         |
| --------------------- | ------------------------------------------------------------------------------------------------- |
| `createBucket`        | `createBucket(String bucket)` / `createBucket(bucket, String objectLockMode, int objectLockDays)` |
| `headBucket`          | `headBucket(String bucket)`                                                                       |
| `deleteBucket`        | `deleteBucket(String bucket)`                                                                     |
| `putBucketVersioning` | `putBucketVersioning(bucket, String status)` (`"Enabled"`/`"Suspended"`)                          |
| `getBucketVersioning` | `String getBucketVersioning(bucket)`                                                              |

### Objects

| Method            | Signature                                                                                               |
| ----------------- | ------------------------------------------------------------------------------------------------------- |
| `putObject`       | `PutResult putObject(bucket, key, byte[] body, PutOptions opts)`                                        |
| `putObjectStream` | `PutResult putObjectStream(bucket, key, InputStream source, String checksumAlgorithm, PutOptions opts)` |
| `getObject`       | `GetResult getObject(bucket, key, Map<String,String> queryAndRange)`                                    |
| `getObjectStream` | `StreamingGetResult getObjectStream(bucket, key, Map<String,String> queryAndRange)`                     |
| `headObject`      | `GetResult headObject(bucket, key)`                                                                     |
| `deleteObject`    | `deleteObject(bucket, key)`                                                                             |
| `deleteObjects`   | `DeleteObjectsResult deleteObjects(bucket, List<ObjectIdentifier> objects, boolean quiet)`              |
| `copyObject`      | `CopyResult copyObject(srcBucket, srcKey, srcVersionId, dstBucket, dstKey, ...)`                        |

`PutOptions` is a fluent builder: `.contentType(v)`, `.metadata(k, v)`, `.idempotencyKey(v)`, `.serverSideEncryption()`,
`.checksum(alg)`, `.objectLock(mode, retainUntilRfc3339)`, `.legalHold(boolean)`. The `queryAndRange` map on
`getObject`/`getObjectStream` carries `range`, `partNumber`, `versionId`, and the `response-*` overrides; pass
`Map.of()` for a plain read.

`StreamingGetResult` is `AutoCloseable` and exposes the `InputStream body()` plus a `readAllBytes()` convenience; close
it (try-with-resources) to release the connection.

### Listing and paginators

| Method                 | Signature                                                                                    |
| ---------------------- | -------------------------------------------------------------------------------------------- |
| `listObjectsV2`        | `ListResult listObjectsV2(bucket, String prefix, Integer maxKeys, String continuationToken)` |
| `listObjects`          | `ListV1Result listObjects(bucket, prefix, marker, Integer maxKeys, delimiter)`               |
| `listObjectVersions`   | `ListVersionsResult listObjectVersions(bucket, ListVersionsOptions opts)`                    |
| `listMultipartUploads` | `ListMultipartUploadsResult listMultipartUploads(bucket, ListUploadsOptions opts)`           |
| `listParts`            | `ListPartsResult listParts(bucket, key, uploadId, ListPartsOptions opts)`                    |

The marker-paged lists each have a `Paginator<P>` that threads markers for you. A paginator is an `Iterable<P>` of
pages: drive it with `hasMorePages()` / `nextPage()`, a for-each, or `toList()`.

```java
var p = client.listObjectVersionsPaginator("reports",
    new LockwellClient.ListVersionsOptions().prefix("logs/"));
while (p.hasMorePages()) {
    var page = p.nextPage();
    page.versions().forEach(v -> System.out.println(v.key() + " " + v.versionId()));
}
```

The constructors are `listObjectVersionsPaginator`, `listMultipartUploadsPaginator`, and `listPartsPaginator`. The
`ListVersionsOptions` / `ListUploadsOptions` / `ListPartsOptions` builders carry `prefix`, `delimiter`, the relevant
markers, and the page cap.

### Multipart

`createMultipartUpload(bucket, key, contentType)` returns a `CreateMpuResult`; the checksum-aware overload
`createMultipartUpload(bucket, key, contentType, checksumAlgorithm)` returns a `CreateMpuChecksumResult`.

Then `uploadPart(...)`, `uploadPartCopy(...)`, `completeMultipartUpload(bucket, key, uploadId, parts)`, and
`abortMultipartUpload(bucket, key, uploadId)`. The checksum-aware `uploadPart` overload sends a verified per-part digest
and folds it into the composite checksum on complete.

### Tagging, Object Lock, presign

`putObjectTagging(bucket, key, Map<String,String> tags)`, `getObjectTagging(bucket, key)`,
`deleteObjectTagging(bucket, key)`, typed `getTypedObjectRetention` / `putObjectRetention`, typed
`getObjectLegalHoldStatus` / `putObjectLegalHold`, and the legacy `getObjectRetention` / `getObjectLegalHold` readers.
Each Object Lock mutation accepts a version id overload. The four presign methods are `presignGetObject`,
`presignPutObject`, `presignHeadObject`, and `presignDeleteObject`.

Retention and legal hold can be set on the write through `PutOptions.objectLock(...)` and `.legalHold(...)` or changed
afterward with the typed methods, subject to Object Lock enforcement. The native client separately exposes `signUrl`
for constrained native GET/PUT URLs.

### Errors and the async client

Server errors throw `ApiException` with `code()`, `statusCode()`, `requestId()`, and `isNotFound()`. A
`LockwellAsyncClient` wraps an existing synchronous client with `CompletableFuture` results for bucket, object,
listing/paginator, copy, multipart/checksum, tagging, typed Object Lock, and all four presign operations. Closing the
wrapper only closes an executor it created; a supplied executor remains application-owned. Cancelling a future marks
that future cancelled, while interruption of an in-flight HTTP exchange depends on the supplied executor and call state.

`NativeException` and `AdminException` preserve RFC 9457 `type()`, `title()`, `detail()`, `instance()`, and
`extensions()` alongside the stable code/status/request id. Branch on the exact machine code or documented predicate,
not the human detail string.

## `LockwellNativeClient` (the native client)

The native JSON data plane at `/api/v1/`. No SigV4, no XML.

Configured with an access-key id plus secret, it auto-manages the bearer token: mints a `lwtk_…` token on first use,
caches it until shortly before expiry, refreshes transparently, and re-mints once on a 401. Token management is
thread-safe (single-flight refresh under a lock), so concurrent callers share one in-flight mint.

The Java native client retries safe/idempotent requests on transient transport errors, `429`, and `5xx` by default,
honoring `Retry-After`. `GET`, `HEAD`, and `DELETE` are replayed automatically; `PUT` and `POST` require an
`Idempotency-Key`, such as `PutOptions.idempotencyKey(...)`. Pass `retryPolicy(RetryPolicy.disabled())` to attempt each
request once.

```java
import com.lockwell.sdk.RetryPolicy;
import com.lockwell.sdk.nativeapi.*;
import com.lockwell.sdk.nativeapi.NativeTypes.*;
import java.time.Duration;

LockwellNativeClient client = LockwellNativeClient.builder()
    .endpoint("https://objects.example.com")        // public S3 port; native API at /api/v1
    .accessKeyId(System.getenv("LOCKWELL_ACCESS_KEY_ID"))
    .secretKey(System.getenv("LOCKWELL_SECRET_KEY"))
    .requestTimeout(Duration.ofSeconds(30))
    .retryPolicy(RetryPolicy.defaults())
    .build();

client.createBucket("reports");

// An idempotent PUT needs a body-integrity signal: pass an expected checksum.
PutResult put = client.putObject("reports", "q1.txt", "hello".getBytes(),
    new PutOptions().contentType("text/plain").idempotencyKey("q1-2026").checksum("sha256", sha));

// Streaming GET (InputStream body, no whole-object buffering). Caller closes it.
try (GetResult got = client.getObject("reports", "q1.txt")) {
    got.body().transferTo(System.out);
}

// Streaming PUT from an InputStream supplier.
client.putObject("reports", "big.bin", () -> Files.newInputStream(path),
    new PutOptions().contentType("application/octet-stream"));
```

### Buckets and objects

| Method                                                 | Signature                                                                                                                           |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `listBuckets`                                          | `List<Bucket> listBuckets()`                                                                                                        |
| `createBucket`                                         | `createBucket(bucket)` / `createBucket(bucket, CreateBucketOptions opts)`                                                           |
| `getBucket` / `deleteBucket`                           | `Bucket getBucket(bucket)` / `void deleteBucket(bucket)`                                                                            |
| `getBucketVersioning` / `setBucketVersioning`          | `VersioningState` (`setBucketVersioning(bucket, status)`)                                                                           |
| `setBucketCORS` / `getBucketCORS` / `deleteBucketCORS` | browser CORS rules                                                                                                                  |
| `putObject`                                            | `putObject(bucket, key, byte[] body[, PutOptions])` or `putObject(bucket, key, Supplier<InputStream> body, PutOptions)` (streaming) |
| `getObject`                                            | `getObject(bucket, key)` / `getObject(bucket, key, String range, String versionId)`                                                 |
| `headObject`                                           | `headObject(bucket, key)` / `headObject(bucket, key, String versionId)`                                                             |
| `deleteObject`                                         | `deleteObject(bucket, key)` / `deleteObject(bucket, key, String versionId)`                                                         |
| `listObjects`                                          | `ListObjectsResult listObjects(bucket, ListObjectsOptions opts)`                                                                    |
| `batchDeleteObjects`                                   | `BatchDeleteResult batchDeleteObjects(bucket, List<ObjectIdentifier> objects)`                                                      |
| `copyObject`                                           | `CopyResult copyObject(destBucket, destKey, sourceBucket, sourceKey, CopyOptions opts)`                                             |

`PutOptions` here is a native fluent builder: `.contentType(v)`, `.idempotencyKey(v)`, `.ifMatch(v)` / `.ifAbsent()`
for conditional writes, `.checksum(alg, value)`, and metadata. The streaming overload takes a
`Supplier<? extends InputStream>` so the body is opened lazily.

S3 streaming uploads/downloads and multipart parts have additive
`ProgressListener` overloads. `TransferProgress` reports cumulative bytes,
known/unknown total, direction, and part number; listener execution applies
backpressure and listener failure cancels/closes the transfer. Configure
`responseMetadataListener(Consumer<ResponseMetadata>)` on the S3, native, or
admin builder to observe successful request-id/trace headers without changing
result records. `listObjectsV2` is the explicit page API; `paginateObjectsV2`
and `iterateObjectsV2` provide lazy continuation-token iteration.

For production retry and timeout settings, see the [Java native client guide](/sdks/java-native).

### ERP security review

For fiscal and GDPR artifacts, the Java native client and `StorageProfiles` write through the same server-side object
pipeline as every Lockwell data-plane write. Production configs set `encryption.enabled = true`, so native writes are
stored as encrypted chunks under per-tenant data keys. Verify finalized objects with `HeadResult.encrypted()` or
`GetResult.encrypted()`, both derived from the `X-Lockwell-Encrypted` response header, and do not run tenant-handling
ERP data on an encryption-disabled deployment.

Key management is deliberately explicit: `encryption.key_provider = "local"` is the only accepted runtime provider
today. External KMS and customer-managed key support is deferred, so unsupported provider values fail config validation
instead of creating fake compliance evidence or hidden storage network calls. `lockwell keys rotate` affects future
per-tenant object writes; `lockwell keys rewrap ...` is the tracked workflow for historical chunks. Access-key
master-key rewrap is a separate credential-maintenance workflow.

Residency is decided by deployment placement, not by the SDK. SaaS deployments keep TangibleShift artifacts in
EU/Portugal-approved infrastructure by placing the Lockwell node, `storage.data_dir`, metadata store, backups, and key
escrow there and exposing only that endpoint to the ERP. On-prem/server and desktop-local deployments keep objects local
by running `lockwelld` against a customer-controlled `storage.data_dir` and local backup/key custody.

Keep bucket and key names non-sensitive. Object bytes are encrypted, but bucket names, object keys, object tags, and
audit resource strings remain operational metadata. A `metadata-backup` stream is decrypted by design except for
application-sealed fields such as access-key secrets, so treat it as a secret. Complete fiscal backups must include the
master key, `metadata-backup` stream, data-encryption key directory, and blob store; backup/restore evidence must prove
retention policies, legal holds, checksums, and audit trail survive restore.

### Tags, retention, legal hold, versions, multipart

`getObjectTags` / `setObjectTags`, `getObjectRetention` / `setObjectRetention(bucket, key, mode, retainUntil)`,
`getObjectLegalHold` / `setObjectLegalHold(bucket, key, status)`, `listObjectVersions(bucket, ListVersionsOptions)`, and
the multipart set (`createMultipartUpload`, `uploadPart`, `listParts`, `completeMultipartUpload`,
`abortMultipartUpload`, `listMultipartUploads`).

### Bucket CORS

Browser CORS is available on the native client as `CORSConfiguration` / `CORSRule`:

```java
import com.lockwell.sdk.nativeapi.NativeTypes.CORSConfiguration;
import com.lockwell.sdk.nativeapi.NativeTypes.CORSRule;
import java.util.List;

CORSConfiguration cfg = new CORSConfiguration(List.of(new CORSRule(
    "browser-direct",
    List.of("https://app.example.com"),
    List.of("GET", "HEAD", "PUT"),
    List.of("content-type"),
    List.of("ETag"),
    600)));

CORSConfiguration stored = client.setBucketCORS("reports", cfg);
CORSConfiguration got = client.getBucketCORS("reports");
client.deleteBucketCORS("reports");
```

Changing CORS is an admin-scoped bucket operation. For app onboarding, prefer `kit.configureBucketCORS(...)` or
`new ProvisionOptions().bucketCORS(...)`, which use a transient admin key and revoke it after the update.

### Signed URLs (GET and PUT)

The native API supports signed write URLs (unlike the S3 presigner). `signUrl` returns a `String` usable without a
bearer token:

```java
String download = client.signUrl("GET", "reports", "q1.txt", 900);
String upload   = client.signUrl("PUT", "reports", "incoming.bin", 600);
```

### Signed-URL constraints (browser-direct upload/download)

Pass `SignedUrlOptions` to pin properties the browser cannot be trusted to send correctly. The constraints are
HMAC-covered in the signed token and enforced by the server at dispatch time. See [signed URLs](/guide/signed-urls) for
the full set and the security shape.

```java
import com.lockwell.sdk.nativeapi.NativeTypes.SignedUrlOptions;

// PUT: pin content-type, cap size, verify the body, and make a retry idempotent.
String upload = client.signUrl("PUT", "fiscal", "2026/0001.pdf", 300,
    new SignedUrlOptions()
        .contentType("application/pdf")
        .contentLengthMax(10L * 1024 * 1024)
        .checksum("SHA256", sha256B64)
        .idempotencyKey("invoice-2026-0001"));

// PUT prefix-scoped: browser chooses the suffix under "imports/".
String prefixUpload = client.signUrl("PUT", "imports", null, 300,
    new SignedUrlOptions().keyPrefix("imports/"));

// GET: predictable download headers.
String download = client.signUrl("GET", "fiscal", "2026/0001.pdf", 300,
    new SignedUrlOptions()
        .responseContentType("application/pdf")
        .responseContentDisposition("attachment; filename=\"invoice-2026-0001.pdf\""));
```

For `keyPrefix`, insert the full object key under the prefix into the returned URL's path before the `?token=` query.
See [signed URLs](/guide/signed-urls#prefix-scoped-uploads).

`signUrlResult(...)` accepts the same options and returns the full `SignedUrl` record.

See [signed URLs](/guide/signed-urls).

### Bucket notifications

Webhook-only delivery; a new config ID returns its signing secret exactly once. GET and same-ID updates report only
`hasSecret()`.

```java
import java.util.List;

NotificationConfig cfg = new NotificationConfig("https://app.example.com/hook",
        List.of("s3:ObjectCreated:*", "s3:ObjectRemoved:*"))
            .id("reports-events")
    .filter("prefix", "incoming/");
NotificationConfiguration created = client.setBucketNotification("reports", cfg);
String signingSecret = created.configs().get(0).signingSecret(); // store securely

NotificationConfiguration current = client.getBucketNotification("reports");
boolean signed = current.configs().get(0).hasSecret(); // signingSecret() is null on GET

client.deleteBucketNotification("reports"); // clear
```

`setBucketNotification` also takes a `List<NotificationConfig>` overload for multiple targets.

### Errors

Native errors throw `NativeException` with `code()`, `statusCode()`, `requestId()` and the predicates `isUnauthorized()`
(401), `isForbidden()` (403), `isNotFound()` (404), `isConflict()` (409), `isPreconditionFailed()` (412),
`isQuotaExceeded()` (507).

### ERP error taxonomy and retry classification

Use `ErpErrors.classify(Throwable)` when mapping Lockwell failures to TangibleShift RFC 9457 problem details. It accepts
`AdminException` and `NativeException` and returns a stable `Classification` with `category`, `statusCode`, `code`,
`requestId`, `problemType`, `auditReason`, `retryDecision`, and write-retry proof flags. It intentionally does not copy
the raw exception message, so secrets, signed URLs, object keys, tenant names, and customer names do not leak into ERP
problem bodies.

The classifier keys on exact Lockwell JSON problem codes first. ERP-specific codes include `tenant_disabled`,
`key_revoked`, `key_expired`, `quota_exceeded`, `retention_blocked`, `legal_hold_blocked`, `idempotency_conflict`, and
`idempotency_in_progress`; status-only fallback is used only when a non-Lockwell response has no machine code.

```java
import com.lockwell.sdk.kit.ErpErrors;
import com.lockwell.sdk.kit.ErpErrors.Category;

try {
    nativeClient.putObject("imports", key, body, opts);
} catch (RuntimeException e) {
    var c = ErpErrors.classify(e);
    if (c.category() == Category.RATE_LIMITED || c.category() == Category.TRANSIENT_UPSTREAM) {
        // Retry writes only when the request has an idempotency key and checksum.
        if (c.writeRetryRequiresIdempotencyKey()) scheduleReplayWithProof(c.requestId());
    }
    throw toProblemDetail(c.problemType(), c.statusCode(), c.code(), c.requestId());
}
```

- `NOT_FOUND`, `ALREADY_EXISTS`, `VALIDATION_ERROR`, `FORBIDDEN`: do not retry blindly; fix ERP state, scope, or request
  shape.
- `UNAUTHORIZED`, `KEY_EXPIRED`: refresh the admin token or native bearer/credential path, then retry once.
- `KEY_REVOKED`, `TENANT_DISABLED`: operator action required; rotate/select a new purpose key or stop tenant work.
- `QUOTA_EXCEEDED`: operator action required; raise quota or stop the import/export job.
- `RATE_LIMITED`, `TRANSIENT_UPSTREAM`, `IDEMPOTENCY_IN_PROGRESS`: back off; retry writes only with an idempotency key
  and body-binding checksum.
- `RETENTION_BLOCKED`, `LEGAL_HOLD_BLOCKED`: do not retry; surface fiscal/legal evidence and keep the object.
- `PRECONDITION_FAILED`, `IDEMPOTENCY_CONFLICT`: do not retry with changed bytes; reconcile the ERP row and object
  version.

For multi-GB SAF-T/import/export artifacts, prefer streaming APIs, set `contentLength` when known so quota is checked up
front, and choose multipart for resumable large writes. Any automated replay of a write must carry the same idempotency
key plus a checksum over the same bytes.

## `LockwellAdminClient` (the admin client)

Targets `/admin/api/v1/` on the admin listener (never the public S3 port). It authenticates by an admin API bearer token
(`lockwell admin-token create`).

```java
import com.lockwell.sdk.admin.*;
import com.lockwell.sdk.admin.AdminTypes.*;

LockwellAdminClient admin = LockwellAdminClient.builder()
    .endpoint("https://admin.example.com")          // admin listener, NOT the S3 port
    .token(System.getenv("LOCKWELL_ADMIN_TOKEN"))   // Authorization: Bearer <token>
    .build();

for (Tenant t : admin.listTenants()) System.out.println(t.id());
Tenant acme = admin.createTenant("acme", "Acme Inc");

// Every mutation has a *DryRun twin that sends ?dryRun=true and returns the plan.
DryRunPlan plan = admin.deleteTenantDryRun("acme", "offboarding", "acme");

// The secret is returned exactly once on create/rotate. Store it immediately.
NewKey key = admin.createKey("acme",
    new CreateKeyOptions("sa-1", "read,write,delete", null,
        "ERP bootstrap", "ts-install-123:reports"));
System.out.println(key.secretKey());
```

| Method                                                    | Dry-run twin                              |
| --------------------------------------------------------- | ----------------------------------------- |
| `listTenants()` / `getTenant(id)`                         | (read-only)                               |
| `createTenant(id, name)`                                  | `createTenantDryRun(id, name)`            |
| `disableTenant(id, reason)`                               | `disableTenantDryRun(id, reason)`         |
| `deleteTenant(id, reason, confirm)`                       | `deleteTenantDryRun(id, reason, confirm)` |
| `getQuota(id)` / `setQuota(id, bytes)` / `clearQuota(id)` | `setQuotaDryRun`, `clearQuotaDryRun`      |
| `getUsage(id)`                                            | (read-only)                               |
| `listAccounts(id)` / `createAccount(id, name)`            | `createAccountDryRun(id, name)`           |
| `listKeys(id)` (never returns secrets)                    | (read-only)                               |
| `createKey(id, CreateKeyOptions)`                         | `createKeyDryRun(id, opts)`               |
| `rotateKey(id, keyId, RotateKeyOptions)`                  | `rotateKeyDryRun(id, keyId, opts)`        |
| `revokeKey(id, keyId, reason)`                            | `revokeKeyDryRun(id, keyId, reason)`      |
| `queryAudit(AuditQuery q)`                                | (read-only)                               |

`CreateKeyOptions(accountId, scopes, expiresAt[, reason[, externalRef]])` follows the scope grammar (verb list
`read,write,delete,admin`, or resource form `op=read:bucket=reports:prefix=in/,op=write:bucket=reports:prefix=in/`).
`accountId` is a real service-account id; use `externalRef` for caller-owned ERP installation/purpose metadata. The
secret on a created or rotated key is on `NewKey.secretKey()` and is shown exactly once.

Errors throw `AdminException` with `isUnauthorized()`, `isForbidden()`, `isNotFound()`, `isRetentionBlocked()` (the 412
retention/legal-hold gate). See the [Admin API reference](/reference/admin-api).

::: warning `NewKey.secretKey()` is readable exactly once, on create or rotate. Persist it immediately; it is never
recoverable afterward. :::

## `LockwellKit` (the app kit)

Composes the admin and native clients with near-zero glue. The per-tenant native-client cache is a `ConcurrentHashMap`,
so it is thread-safe.

```java
import com.lockwell.sdk.kit.*;
import com.lockwell.sdk.kit.KitTypes.*;
import com.lockwell.sdk.nativeapi.LockwellNativeClient;
import java.time.Duration;

LockwellKit kit = LockwellKit.builder()
    .adminEndpoint("https://admin.example.com")        // admin listener
    .adminToken(System.getenv("LOCKWELL_ADMIN_TOKEN"))
    .nativeEndpoint("https://objects.example.com")     // public S3 port; native API at /api/v1
    .build();

// Provision: ensure the tenant exists, mint a fresh read/write/delete key
// (optionally bucket-scoped), optionally create a default bucket. Secret returned ONCE.
ProvisionResult p = kit.provisionTenant("acme",
    new LockwellKit.ProvisionOptions().defaultBucket("uploads")
        .bucketCORS(new CORSConfiguration(List.of(new CORSRule(
            List.of("https://app.example.com"), List.of("GET", "HEAD", "PUT"))))));
store(p.credentials());                                 // {accessKeyId, secretKey}

// ERP-safe bootstrap retry: keyExternalRef is the stable external reference.
EnsureProvisionResult ensured = kit.ensureTenantProvisioning("acme",
    new LockwellKit.ProvisionOptions()
        .keyExternalRef("ts-install-123:uploads")
        .bucketScope("uploads")
        .reason("ERP bootstrap"));
if (ensured.key().created()) {
    store(ensured.key().credentials());                  // secret shown ONCE
} else {
    rememberAccessKeyId(ensured.key().key().accessKeyId());
}

// A per-tenant native client (cached per (tenant, creds); auto-manages the bearer token).
LockwellNativeClient acme = kit.clientForTenant("acme", p.credentials());
acme.putObject("uploads", "hello.txt", "hi".getBytes());

// Browser direct upload/download. Signed URLs the browser uses with NO bearer token.
BrowserSignedUrl up = kit.signedUploadUrl(p.credentials(), "uploads", "in.bin",
    Duration.ofMinutes(10), "application/octet-stream");
BrowserSignedUrl dl = kit.signedDownloadUrl(p.credentials(), "uploads", "hello.txt",
    Duration.ofMinutes(15));

// Or update CORS later with another transient admin-scoped key.
kit.configureBucketCORS("acme", "uploads", new CORSConfiguration(List.of(
    new CORSRule(List.of("https://app.example.com"), List.of("GET", "HEAD", "PUT")))));

// Verify an incoming webhook (constant-time HMAC-SHA256).
boolean ok = LockwellKit.verifyWebhook(requestBodyBytes,
    request.getHeader("X-Lockwell-Signature"), mySecret);
```

`ProvisionOptions` is a fluent builder: `.tenantName(name)`, `.bucketScope(bucket)`, `.defaultBucket(bucket)`,
`.bucketCORS(cfg)`, `.keyExternalRef(externalRef)`, `.keyExpiresAt(rfc3339)`, `.reason(reason)`. The default bucket and
optional CORS rules are applied with a transient admin-on-that-bucket key that is revoked immediately, so the long-lived
tenant key stays admin-free.

Use `provisionTenant` when the app intentionally wants a fresh one-time secret every call. Use
`ensureTenantProvisioning` for idempotent ERP/on-prem bootstrap: it requires `.keyExternalRef(...)`, lists active key
metadata, and reuses a matching `externalRef + scopes + expiry` key without creating another live key. Existing key
metadata never carries the secret. If the ERP lost the one-time secret before storing it, rotate the returned key
through `kit.admin().rotateKey(...)` with a `RotateKeyOptions` audit reason and persist the new secret from that
rotation response; omitted rotate scopes/expiry preserve the existing key's values. `.reason(...)` on
`ProvisionOptions`, `CreateKeyOptions`, and `RotateKeyOptions` is written into Lockwell's audit row; the Admin API also
emits `X-Request-Id` and stores it as audit correlation. Concurrent first bootstrap attempts can still both observe "no
key yet" before either create reaches the server; serialize that first call with the ERP's DB lock/outbox until Lockwell
exposes a server-side idempotency-key primitive for key create.

### ERP transaction, outbox, and reconciliation

Lockwell calls are not part of the ERP database transaction. During the ERP tenant/company transaction, only derive the
opaque refs (`tenantPublicRef`, `installationRef`, `lockwellTenantId`, company ref, purpose) and persist an ERP outbox
record. Do not call `ensureTenantProvisioning`, create buckets, or mint keys until the ERP row has committed.

The outbox worker is the only place that talks to Lockwell for onboarding. It should run these steps with one ERP
database lock per tenant/company: call `ensureTenantProvisioning` with a stable `keyExternalRef`, ensure the default
bucket/profile, create purpose keys with `ErpScopes`, store each access-key id, and store one-time secrets only on the
nested key result's `created=true`/credentials-present path. Retries are safe when every key uses stable
`externalRef + scopes + expiry`: `ensureTenant` accepts an existing tenant, `ensureBucket` accepts an existing bucket,
and `ensureKey` reuses matching active keys instead of duplicating live credentials. If the ERP lost a one-time secret,
rotate the returned key with an audited reason and store the replacement secret.

Reconciliation is ERP-owned. Keep mapping rows in states such as `pending_lockwell`, `active`, `failed_lockwell`,
`disabled`, and `delete_ready`; compare them with `listTenants`, `getTenant`, and `listKeys` output. A committed ERP row
with missing Lockwell resources goes back through the outbox. A Lockwell tenant with no active ERP mapping is an orphan:
first disable it with an audited reason, revoke purpose keys, and only then decide whether it can be deleted. Use
`deleteTenantDryRun` to preview affected buckets, keys, versions, retention, legal holds, and bytes before destructive
cleanup.

Offboarding is a sequence, not a helper. Disabling the ERP tenant, disabling the Lockwell tenant, revoking purpose keys,
retaining fiscal artifacts, deleting non-retained data, and final tenant deletion are separate audited steps. Never
delete a Lockwell tenant just because the ERP row was removed; fiscal retention and legal holds can outlive the ERP
account.

Topology is explicit. In SaaS, TangibleShift operates the regional Lockwell deployment and the ERP backend owns the
admin token; browsers and customer code never see it. In customer on-prem server installs, Lockwell runs beside the ERP
server as an external daemon owned by the customer/operator, with a scoped provisioning token stored in the ERP secret
store. In desktop-local installs, the installer may bootstrap a local `lockwelld`, but retention-grade evidence requires
documented backups, key custody, and clock monitoring before fiscal data is stored there.

Retention deadlines are enforced by the Lockwell server clock at write/delete time. The ERP may compute the policy date,
but on-prem and desktop deployments must monitor NTP/time drift and record the clock source used for fiscal evidence. If
clock drift is detected, pause retention-sensitive onboarding, offboarding, and delete workflows until the operator has
reconciled the time source.

### ERP tenant/company/purpose layout

`ErpScopes` keeps the ERP-side mapping table explicit. TangibleShift stores opaque `tenantPublicRef`, `installationRef`,
and `lockwellTenantId` values, then the helper derives purpose paths and key metadata. Bucket names and prefixes must
not contain customer legal names, tax ids, or free-form site labels.

```java
import com.lockwell.sdk.kit.ErpScopes;
import com.lockwell.sdk.kit.ErpScopes.Purpose;

var mapping = ErpScopes.tenantMapping("ts_tenant_7f3a", "install_9b12", "lwtenant_9");
var imports = ErpScopes.purposePath(mapping, "co_a812", Purpose.IMPORTS);

System.out.println(imports.bucket());      // imports
System.out.println(imports.prefix());      // companies/co_a812/imports/
System.out.println(imports.externalRef()); // ts:install_9b12:ts_tenant_7f3a:co_a812:imports

kit.ensureKey(mapping.lockwellTenantId(),
    ErpScopes.temporaryBrowserUploadKey(imports, "2026-01-02T03:04:05Z", "ERP import upload"));
```

All company object keys should start with `companies/<opaque-company-ref>/<purpose>/`. The purpose-scoped access-key
templates cover `fiscalArchiveAppendKey`, `importReadWriteKey`, `temporaryBrowserUploadKey`, `exportReadKey`,
`dataRightsReadWriteKey`, and `supportDiagnosticReadKey`. Store the access key id per company/purpose so ERP support can
rotate/revoke by purpose, restating the same generated scope and audit reason. After rotation, persist the new secret
and drop the ERP credential cache; `clientForTenant` sees new credentials as a fresh cache key, so cache invalidation
creates a new native token manager. Tests should assert `path.prefix()` on every write/read path to catch cross-company
mistakes.

`configureBucketCORS` updates an existing bucket the same way: mint a transient admin-scoped key, call the native CORS
route, revoke the key. `signedUploadUrl` and `signedDownloadUrl` accept either `TenantCredentials` or a
`LockwellNativeClient`. For browser-direct flows with constraints (pinned content-type, size cap, checksum, idempotency
key, prefix scope, or GET response overrides), pass a `SignedUrlOptions`:

```java
import com.lockwell.sdk.nativeapi.NativeTypes.SignedUrlOptions;

BrowserSignedUrl up = kit.signedUploadUrl(creds, "inbox", "photo.jpg",
    Duration.ofMinutes(5),
    new SignedUrlOptions().contentType("image/jpeg").contentLengthMax(5L * 1024 * 1024));
```

### ERP storage profiles

The Java kit includes `StorageProfiles` for ERP-owned artifact classes. These helpers encode safe Lockwell option
combinations; they do **not** decide TangibleShift's fiscal retention durations, GDPR outcomes, or support retention
policy.

```java
import com.lockwell.sdk.kit.StorageProfiles;

client.createBucket("fiscal-archive", StorageProfiles.fiscalArchiveBucket());

var fiscal = StorageProfiles.fiscalArchiveWrite(
    "application/pdf",
    "2033-01-01T00:00:00Z", // ERP-owned retain-until date
    "SHA256",
    pdfSha256Base64,
    "invoice-2026-0001");
client.putObject("fiscal-archive", "2026/0001.pdf", pdfBytes, fiscal.putOptions());
client.setObjectRetention("fiscal-archive", "2026/0001.pdf",
    fiscal.retention().mode(), fiscal.retention().retainUntil()); // COMPLIANCE

var importUpload = StorageProfiles.importUploadUrl(Duration.ofMinutes(5),
    "application/json", 2L * 1024 * 1024, "SHA256", importSha256Base64,
    "import-job-123", "imports/");
BrowserSignedUrl up = kit.signedUploadUrl(creds, "imports", null,
    importUpload.ttl(), importUpload.options());

var exportDownload = StorageProfiles.exportDownloadUrl(Duration.ofMinutes(5),
    "application/pdf", "attachment; filename=\"invoice-2026-0001.pdf\"",
    "ERP export invoice-2026-0001");
BrowserSignedUrl dl = kit.signedDownloadUrl(creds, "exports", "2026/0001.pdf",
    exportDownload.ttl(), exportDownload.options());
```

Apply retention (and any legal hold) before acknowledging a fiscal archive as finalized. The native API applies
retention after the object exists, so ERP startup/reconciliation should use the ERP archive ledger to find finalized
object keys whose retention was not applied after a crash and repair them before deletion is possible.

Named profiles:

- `fiscal-archive`: versioning + Object Lock bucket, no-overwrite write options, checksum verification, idempotency, and
  explicit `COMPLIANCE` retention helpers for finalized PDFs and SAF-T exports.
- `imports`: server-side writes and short-lived browser PUT URLs with prefix scope, size cap, checksum, and idempotency.
- `exports`: signed GET URLs with response content headers and an HMAC-covered audit reason.
- `data-rights`: export artifact writes/downloads where the ERP owns expiry and retention-vs-erasure conflict handling.
- `support-bundles`: `redactedSupportBundleWrite`; redact before upload, because the SDK does not inspect payload bytes.

Governance mode is not the fiscal recipe. Lockwell's native and S3 object-lock tests prove active retention cannot be
shortened or deleted before the retain-until date, and governance bypass headers are rejected.

Signed-URL audit reasons are signed but not encrypted in the URL token; use stable job/document references, not secrets,
raw personal data, or sensitive free text.

For ERP deployments where the backend uses an internal service URL but the browser reaches a different public origin,
set `signedUrlPublicOrigin` on the kit builder; set `signedUrlMaxTtl` for a client-side TTL cap. Both are optional. See
[signed URLs](/guide/signed-urls#separate-internal-and-public-origins).

Reach the underlying admin client via `kit.admin()` for operations the kit does not wrap. See
[the app kit guide](/guide/app-kit).

### ERP live conformance

The production test harness includes a Java ERP live conformance runner that composes `LockwellKit`,
`LockwellAdminClient`, and `LockwellNativeClient` against the packaged Lockwell image. It covers the minimum
TangibleShift cutover flow: idempotent provisioning, object-lock fiscal writes, signed browser GET/PUT URLs, large
streaming artifacts, typed quota and credential errors, tenant-disable denial, and audit request-id correlation.

Backup and restore remain operational CLI drills (`backup-plan`, `backup-verify`, restore dry-run/verify) documented in
`docs/backup-restore.md`, not Java SDK helper APIs.

## Spring Boot starter

The `com.lockwell.sdk.spring` package auto-configures a `LockwellClient` and a `LockwellAsyncClient` bean when the
`lockwell.*` properties are present. Spring is an optional dependency, so non-Spring consumers never pull it in and the
core SDK keeps its zero-runtime-dependency profile.

```yaml
# application.yml
lockwell:
  endpoint: https://objects.example.com
  access-key-id: ${LOCKWELL_ACCESS_KEY_ID}
  secret-key: ${LOCKWELL_SECRET_KEY}
  # user-agent: my-service/1.0   # optional override
```

```java
@Service
public class ReportService {
  private final LockwellClient lockwell;
  public ReportService(LockwellClient lockwell) { this.lockwell = lockwell; }
  // ...
}
```

The supported properties are `endpoint`, `access-key-id`, `secret-key`, and the optional `user-agent`. Both beans are
`@ConditionalOnMissingBean`, so an application-defined client always wins.

The S3-style starter above is distinct from the opt-in LNW/1 starter. See
[Spring Boot Native Wire](/sdks/java-spring-wire) for `lockwell.native-wire.*` properties, JDK 25/Spring Boot 4.1.1 compatibility, and the
server-only binary streaming contract. LNW does not change this core client's Java 21 bytecode or HTTP JSON behavior.

## Health and readiness

`LockwellNativeClient` and `LockwellAdminClient` expose `healthz()`, `healthzAsync()`, `readyz()`, and `readyzAsync()`.
They return typed `HealthResult` components and do not send credentials to probe endpoints. These are operational
probes, not substitutes for an authenticated request. See [operations and observability](/guide/operations-and-observability).

## Not supported (by design)

No public-bucket or anonymous access without a token or signed URL, no SSE-KMS, no IAM/STS/bucket-policy, no
website/tiering/Select/Object-Lambda, and webhook is the only notification target (SNS/SQS/Lambda are 501 server-side).

The Lockwell server and this first-party client support SSE-C and copy-source SSE-C with raw 32-byte customer-key
options on object, copy, and multipart operations. The S3 `LockwellClient` exposes presigned GET/PUT/HEAD/DELETE object
operations.
