Java SDK
The first-party Lockwell SDK for the JVM is a native, encrypted alternative to the AWS S3 SDK for Java 21+ and Spring Boot services. It has zero runtime dependencies (JDK only: java.net.http, javax.crypto, java.util.zip, java.security).
It shares the language-neutral SigV4 signing fixtures with the Go and Node SDKs, so all three sign byte-for-byte identically.
Install
<dependency>
<groupId>com.lockwell</groupId>
<artifactId>lockwell-sdk</artifactId>
<version>0.2.2</version>
</dependency>Every client uses a fluent builder, is thread-safe, and never renders its secret (Credentials.toString() redacts it). 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.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 core, native, admin, and kit clients compile as Java 21 bytecode and are tested on Java 25, so Spring Boot 4 services can use those plain clients directly. The optional com.lockwell.sdk.spring auto-configuration is a separate S3-style starter; native/admin/kit Spring Boot 4 auto-configuration is tracked as integration ergonomics, not as a runtime compatibility prerequisite.
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)
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 (Lockwell never issues presigned writes on the S3 client).
String url = client.presignGetObject("reports", "q1.txt", 900);Builder
LockwellClient.builder() accepts .endpoint(...), .credentials(...), .httpClient(HttpClient), .userAgent(String), .clock(Supplier<Instant>), .requestTimeout(Duration), and .retryPolicy(RetryPolicy). 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().
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 reads, presign
putObjectTagging(bucket, key, Map<String,String> tags), getObjectTagging(bucket, key), deleteObjectTagging(bucket, key), getObjectRetention(bucket, key) (a RetentionResult), getObjectLegalHold(bucket, key) (a boolean), and presignGetObject(bucket, key, long expiresSeconds).
Retention and legal hold are set on the write through PutOptions.objectLock(...) and .legalHold(...). Presign is GET-only; for a signed write URL use the native client's signUrl.
Errors and the async client
Server errors throw ApiException with code(), statusCode(), requestId(), and isNotFound(). A LockwellAsyncClient wraps the same operations with CompletableFuture results, built the same way via its own builder().
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.
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) / .ifNoneMatch(v) for conditional writes, .checksum(alg, value), and metadata. The streaming overload takes a Supplier<? extends InputStream> so the body is opened lazily.
For production retry and timeout settings, see the Java native client guide.
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:
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:
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 for the full set and the security shape.
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.
signUrlResult(...) accepts the same options and returns the full SignedUrl record.
See signed URLs.
Bucket notifications
Webhook-only delivery; the per-config signing secret is generated server-side and is never returned (the view reports only hasSecret()).
import java.util.List;
NotificationConfig cfg = new NotificationConfig("https://app.example.com/hook",
List.of("s3:ObjectCreated:*", "s3:ObjectRemoved:*"))
.filter("prefix", "incoming/");
client.setBucketNotification("reports", cfg);
NotificationConfiguration current = client.getBucketNotification("reports");
boolean signed = current.configs().get(0).hasSecret(); // true; secret itself never returned
client.deleteBucketNotification("reports"); // clearsetBucketNotification 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.
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).
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.
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.
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.
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:
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.
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 explicitCOMPLIANCEretention 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.
Reach the underlying admin client via kit.admin() for operations the kit does not wrap. See the app kit guide.
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.
# 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@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.
Not supported (by design)
No public-bucket or anonymous access without a token or signed URL, no SSE-KMS/SSE-C, 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 S3 LockwellClient additionally exposes no presigned PUT/HEAD/DELETE; presigning there is GET-only.