# Go SDK

The first-party Lockwell SDK for Go, split into five packages. Each is a separate import, so you pull in only the
surface you need.

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

The non-test source imports only the Go standard library. A consumer gets a light dependency tree and never reaches into
server internals.

```sh
go env -w GOPRIVATE=github.com/RusticStack/*
go get github.com/RusticStack/lockwell-sdk-go/lockwellsdk@latest
```

```go
import (
    "github.com/RusticStack/lockwell-sdk-go/lockwellsdk"    // S3 (SigV4) data plane
    "github.com/RusticStack/lockwell-sdk-go/lockwellwire"   // LNW/1 binary native data plane
    "github.com/RusticStack/lockwell-sdk-go/lockwellnative" // native JSON data plane
    "github.com/RusticStack/lockwell-sdk-go/lockwelladmin"  // JSON admin API
    "github.com/RusticStack/lockwell-sdk-go/lockwellkit"    // the app kit
)
```

> The SDK module is `github.com/RusticStack/lockwell-sdk-go`, hosted in the private
> [`RusticStack/lockwell-sdk-go`](https://github.com/RusticStack/lockwell-sdk-go) repository. Set
> `GOPRIVATE=github.com/RusticStack/*` and authenticate GitHub with an account that can read the repo before `go get`.

Every client is safe for concurrent use by multiple goroutines. Every client redacts its secret on `String()` and `%#v`,
so it can never be logged with credential material.

## `lockwellsdk` (`lockwell-sdk-go`) (the S3 client)

A SigV4 S3 client. Buffered writes sign the exact SHA-256 of the body and are eligible for idempotent retry. Streaming
writes use the documented streaming-chunk or `UNSIGNED-PAYLOAD` forms and are not transparently replayed.

```go
ctx := context.Background()

c, err := lockwellsdk.New(
    "https://objects.example.com",
    lockwellsdk.Credentials{
        AccessKeyID: os.Getenv("LOCKWELL_ACCESS_KEY_ID"),
        SecretKey:   os.Getenv("LOCKWELL_SECRET_KEY"),
    },
)
if err != nil {
    log.Fatal(err)
}

// Private bucket (Lockwell exposes no way to make one public).
if err := c.CreateBucket(ctx, "reports"); err != nil {
    log.Fatal(err)
}

// One PUT with SSE-S3 at rest, a server-verified CRC64NVME checksum, and an
// idempotency key for safe retry.
put, err := c.PutObject(ctx, "reports", "q1.txt", []byte("hello"),
    lockwellsdk.WithContentType("text/plain"),
    lockwellsdk.WithServerSideEncryption(),
    lockwellsdk.WithChecksumAlgorithm(lockwellsdk.ChecksumCRC64NVME),
    lockwellsdk.WithIdempotencyKey("q1-2026"),
)
fmt.Println(put.ETag, put.VersionID)

// GetObject streams; the caller owns Body and must close it.
out, err := c.GetObject(ctx, "reports", "q1.txt")
if err != nil {
    if lockwellsdk.IsNotFound(err) { /* missing key */ }
    log.Fatal(err)
}
defer out.Body.Close()
io.Copy(os.Stdout, out.Body)
```

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 is included in SigV4 canonicalization. Already-mounted `/api/v1` and
`/admin/api/v1` suffixes are normalized exactly once; traversal, encoded separators, malformed escapes, and unsafe
interior path segments are rejected. For an endpoint configured with wildcard bucket DNS, pass
`lockwellsdk.WithVirtualHostedStyle()` to put the bucket in the signed host for both normal requests and presigned URLs;
dotted bucket names are preserved.

### Construction

Construct the client with `New(endpoint string, creds Credentials, opts ...Option) (*Client, error)`. The endpoint
scheme must be `http` or `https`. Path-style is the default; pass `WithVirtualHostedStyle()` for an endpoint with
wildcard bucket DNS. That option moves the bucket, including dotted bucket names, into the signed host for normal and
presigned requests.

| Option                         | Effect                                                                                                                                    |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `WithHTTPClient(*http.Client)` | Control timeouts, transport pooling, or TLS. The default client has no timeout, so set one here or always pass a context with a deadline. |
| `WithRequestTimeout(Duration)` | Bound each individual HTTP attempt; retries receive a fresh budget, while the caller context remains the overall deadline.                |
| `WithUserAgent(string)`        | Override the `User-Agent` header.                                                                                                         |
| `WithRetryPolicy(RetryPolicy)` | Override automatic retry. The default retries safe and idempotent requests; pass `DisabledRetryPolicy()` to turn it off.                  |
| `WithRegion(string)`           | Select the server-configured SigV4 region (default `us-east-1`).                                                                          |
| `WithResponseMetadata(func)`   | Observe successful request-id, Amazon request-id, and `traceparent` headers.                                                              |
| `WithVirtualHostedStyle()`     | Put the bucket in the signed host when wildcard DNS is configured.                                                                        |

### Buckets

| Method                | Signature                                                                |
| --------------------- | ------------------------------------------------------------------------ |
| `CreateBucket`        | `CreateBucket(ctx, bucket string, opts ...BucketOption) error`           |
| `HeadBucket`          | `HeadBucket(ctx, bucket string) error`                                   |
| `DeleteBucket`        | `DeleteBucket(ctx, bucket string) error`                                 |
| `PutBucketVersioning` | `PutBucketVersioning(ctx, bucket string, status VersioningStatus) error` |
| `GetBucketVersioning` | `GetBucketVersioning(ctx, bucket string) (VersioningStatus, error)`      |

`WithObjectLockEnabled(mode ObjectLockMode, days int)` is the only `BucketOption`. It enables Object Lock (and the
versioning it requires) at create time with a default retention rule.

Lockwell requires a default retention when enabling lock at create, so pass a mode (`ObjectLockGovernance` or
`ObjectLockCompliance`) and a positive day count. `VersioningStatus` is `VersioningEnabled` or `VersioningSuspended`;
`GetBucketVersioning` returns `""` when versioning was never enabled.

```go
err := c.CreateBucket(ctx, "vault",
    lockwellsdk.WithObjectLockEnabled(lockwellsdk.ObjectLockCompliance, 30))
```

### Objects

| Method            | Signature                                                                                                                                    |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `PutObject`       | `PutObject(ctx, bucket, key string, body []byte, opts ...PutOption) (*PutObjectResult, error)`                                               |
| `PutObjectStream` | `PutObjectStream(ctx, bucket, key string, r io.Reader, size int64, checksum ChecksumAlgorithm, opts ...PutOption) (*PutObjectResult, error)` |
| `GetObject`       | `GetObject(ctx, bucket, key string, opts ...GetOption) (*GetObjectOutput, error)`                                                            |
| `HeadObject`      | `HeadObject(ctx, bucket, key string, opts ...GetOption) (*HeadObjectOutput, error)`                                                          |
| `DeleteObject`    | `DeleteObject(ctx, bucket, key string, opts ...GetOption) error`                                                                             |
| `DeleteObjects`   | `DeleteObjects(ctx, bucket string, objects []ObjectIdentifier, opts ...DeleteObjectsOption) (*DeleteObjectsOutput, error)`                   |
| `CopyObject`      | `CopyObject(ctx, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, opts ...CopyOption) (*CopyObjectOutput, error)`                  |

`GetObjectOutput.Body` is an `io.ReadCloser` you must close. A successful `GetObject` transfers body ownership to you;
the error path drains and closes it for you.

#### `PutObject` options

| Option                                                                | Effect                                                                                                                                                                                                                       |
| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `WithContentType(ct string)`                                          | Sets `Content-Type`.                                                                                                                                                                                                         |
| `WithMetadata(m map[string]string)`                                   | User metadata, stored and returned as `x-amz-meta-*`.                                                                                                                                                                        |
| `WithIdempotencyKey(key string)`                                      | Makes the write idempotent. A retry with the same key and identical request returns the original result instead of writing twice. The SDK signs the `X-Lockwell-Idempotency-Key` header so it cannot be stripped in transit. |
| `WithChecksumAlgorithm(a ChecksumAlgorithm)`                          | Server computes, verifies, and persists an end-to-end checksum. The digest is returned on the result and can be demanded on later reads.                                                                                     |
| `WithServerSideEncryption()`                                          | Requests SSE-S3 (server-managed, per-tenant key) at rest.                                                                                                                                                                    |
| `WithObjectLockRetention(mode ObjectLockMode, retainUntil time.Time)` | Applies a retention mode and retain-until date on PUT. The bucket must have Object Lock enabled.                                                                                                                             |
| `WithObjectLockLegalHold(on bool)`                                    | Places or clears a legal hold on PUT.                                                                                                                                                                                        |
| `WithPutIfNoneMatch("*")`                                             | Atomic create-only write; returns a typed 412 error when the key already exists.                                                                                                                                             |
| `WithPutSSECustomerKey(key []byte)`                                   | Supplies a raw 32-byte SSE-C key; copied into request-local state and never persisted by the SDK.                                                                                                                            |
| `WithProgress(fn)` / `WithPutProgress(fn)`                            | Synchronous, backpressure-safe progress callback; returning an error cancels the request.                                                                                                                                    |

`PutObjectResult` carries `ETag`, `VersionID`, `Checksums`, and `ServerSideEncryption`.

<!-- VitePress slugifies the heading's backticks; MD051 assumes GitHub slugs -->
<!-- markdownlint-disable MD051 -->

> The S3 `PutObject` supports create-only `If-None-Match: *` through `WithPutIfNoneMatch("*")`. Overwrite-only
> `If-Match` is available on the [native client](#lockwellnative-lockwell-sdk-go-legacy-httpjson-compatibility-client); S3 copy-source conditionals are
> listed below.

<!-- markdownlint-enable MD051 -->

#### `GetObject` / `HeadObject` options

| Option                                      | Effect                                                                                              |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `WithRange(start, end int64)`               | Bytes `[start, end]` inclusive; pass `end < 0` for "to end".                                        |
| `WithPartNumber(n int)`                     | Returns the byte range of one multipart part (1-based) with a 206 and the total part count.         |
| `WithVersionID(id string)`                  | Targets a specific object version. Also accepted by `DeleteObject`, tagging, and Object Lock reads. |
| `WithResponseContentType(ct string)`        | The `response-content-type` override on this read.                                                  |
| `WithResponseContentDisposition(cd string)` | The `response-content-disposition` override.                                                        |
| `WithIfMatch(etag string)`                  | Return the object only when its current ETag matches.                                               |
| `WithIfNoneMatch(etag string)`              | Return the object only when its current ETag differs.                                               |
| `WithIfModifiedSince(httpDate string)`      | Return the object only when it changed after the HTTP date.                                         |
| `WithIfUnmodifiedSince(httpDate string)`    | Return the object only when it did not change after the HTTP date.                                  |
| `WithReadSSECustomerKey(key []byte)`        | Supply the raw 32-byte key for an SSE-C object.                                                     |
| `WithDownloadProgress(fn)`                  | Report streamed download progress; callback failure cancels/closes the body.                        |

#### Streaming uploads

`PutObjectStream` streams a body without buffering the whole object. The checksum is computed incrementally and sent in
an `aws-chunked` trailer, so `checksum` is required.

`WithIdempotencyKey` is not supported here, because the trailing checksum is not known at reservation time; use
`PutObject` for idempotent writes. Streaming bodies are never auto-retried, since the reader is already consumed. The
unsigned streaming-trailer variant requires the server to permit unsigned payloads, which is the default.

Streaming PUT/GET and multipart part APIs also accept synchronous `ProgressFunc` callbacks. Updates report cumulative
bytes and the known total (or `lockwellsdk.UnknownTotal`); callback execution is part of the read path, so a slow
callback applies backpressure and no whole-object progress buffer is created. Returning an error cancels the request and
closes the stream. Use `WithResponseMetadata` to observe successful `X-Request-Id`, optional `X-Amz-Request-Id`, and
optional `traceparent` headers without changing the existing result types.

The concrete helpers are `GetObjectWithProgress`, `WithProgress` / `WithPutProgress`, `WithGetProgress` /
`WithGetObjectProgress` / `WithDownloadProgress`, and `WithPartProgress` / `WithMultipartProgress` /
`WithUploadPartProgress`. `Progress.TotalKnown()` and `Progress.Complete()` interpret `UnknownTotal` without guessing.

When progress is attached to buffered `PutObject` or `UploadPart`, Go uses a one-pass body so callback cancellation is
immediate; automatic replay is disabled even with an idempotency key. If progress and retries are both needed, own the
replay loop and reuse the same body-binding checksum and key.

```go
f, _ := os.Open("big.bin")
defer f.Close()
info, _ := f.Stat()
res, err := c.PutObjectStream(ctx, "reports", "big.bin", f, info.Size(),
    lockwellsdk.ChecksumCRC64NVME, lockwellsdk.WithContentType("application/octet-stream"))
```

#### Batch delete

`DeleteObjects` deletes up to 1000 objects in one `POST /{bucket}?delete`. Pass an `ObjectIdentifier{Key, VersionID}`
per key; set `VersionID` to delete a specific version.

The batch may partially succeed, so inspect `Output.Deleted` and `Output.Errors`. `WithQuietDelete()` suppresses the
per-key `Deleted` entries (errors are always returned). The SDK rejects an oversized batch locally before the request.

```go
res, err := c.DeleteObjects(ctx, "reports", []lockwellsdk.ObjectIdentifier{
    {Key: "old/a.txt"},
    {Key: "old/b.txt", VersionID: "v2"},
})
for _, e := range res.Errors {
    log.Printf("could not delete %s: %s", e.Key, e.Message)
}
```

#### Copy

`CopyObject` copies server-side. Pass `srcVersionID` to copy a specific version (`""` copies the current version). Copy
options:

| Option                                          | Effect                                                             |
| ----------------------------------------------- | ------------------------------------------------------------------ |
| `WithCopyServerSideEncryption()`                | SSE-S3 for the destination.                                        |
| `WithCopyMetadata(m map[string]string)`         | Replace destination metadata (default copies the source metadata). |
| `WithCopyIfMatch(etag string)`                  | Copy only if the source ETag matches.                              |
| `WithCopyIfNoneMatch(etag string)`              | Copy only if the source ETag does not match.                       |
| `WithCopyIfModifiedSince(httpDate string)`      | Copy only if the source changed since this HTTP date.              |
| `WithCopyIfUnmodifiedSince(httpDate string)`    | Copy only if the source has not changed since this HTTP date.      |
| `WithCopySourceSSECustomerKey(key []byte)`      | Decrypt an SSE-C source.                                           |
| `WithCopyDestinationSSECustomerKey(key []byte)` | Encrypt the destination with SSE-C.                                |

### Listing

| Method                 | Signature                                                                                                  |
| ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| `ListObjectsV2`        | `ListObjectsV2(ctx, bucket string, opts ...ListOption) (*ListObjectsV2Output, error)`                      |
| `ListObjects`          | `ListObjects(ctx, bucket string, opts ...ListOption) (*ListObjectsOutput, error)`                          |
| `ListObjectVersions`   | `ListObjectVersions(ctx, bucket string, opts ...ListVersionsOption) (*ListObjectVersionsOutput, error)`    |
| `ListMultipartUploads` | `ListMultipartUploads(ctx, bucket string, opts ...ListUploadsOption) (*ListMultipartUploadsOutput, error)` |
| `ListParts`            | `ListParts(ctx, bucket, key, uploadID string, opts ...ListPartsOption) (*ListPartsOutput, error)`          |

`ListObjectsV2` options: `WithPrefix`, `WithDelimiter`, `WithStartAfter`, `WithContinuationToken`, `WithMaxKeys`.
`ListObjects` (v1) uses `WithMarker` instead of `WithStartAfter`/`WithContinuationToken`. Prefer V2 for new code; v1
exists for parity with clients that page by marker.

`ListObjectVersions` returns both `Versions` and `DeleteMarkers`, paged with `WithKeyMarker(NextKeyMarker)` plus
`WithVersionIDMarker(NextVersionIDMarker)`. Its options also include `WithVersionsPrefix`, `WithVersionsDelimiter`, and
`WithVersionsMaxKeys`.

### Paginators

Every page-based list has an auto-pager that threads continuation and marker tokens for you. Construct it with the
client, bucket, and the same options the one-shot method takes, then loop on `HasMorePages()` / `NextPage(ctx)`.

```go
p := c.NewListObjectsV2Paginator("reports", lockwellsdk.WithPrefix("logs/"))
for p.HasMorePages() {
    page, err := p.NextPage(ctx)
    if err != nil {
        log.Fatal(err)
    }
    for _, obj := range page.Objects {
        fmt.Println(obj.Key, obj.Size)
    }
}
```

The four constructors are `NewListObjectsV2Paginator`, `NewListObjectVersionsPaginator`,
`NewListMultipartUploadsPaginator`, and `NewListPartsPaginator`. Do not also pass the marker or continuation options by
hand; the paginator owns them.

### Multipart

| Method                    | Signature                                                                                                                                                                                 |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CreateMultipartUpload`   | `CreateMultipartUpload(ctx, bucket, key string, opts ...PutOption) (*CreateMultipartUploadOutput, error)`                                                                                 |
| `UploadPart`              | `UploadPart(ctx, bucket, key, uploadID string, partNumber int, body []byte, opts ...UploadPartOption) (*UploadPartOutput, error)`                                                         |
| `UploadPartStream`        | `UploadPartStream(ctx, bucket, key, uploadID string, partNumber int, r io.Reader, size int64, opts ...UploadPartOption) (*UploadPartOutput, error)`                                       |
| `UploadPartCopy`          | `UploadPartCopy(ctx, srcBucket, srcKey, srcVersionID, dstBucket, dstKey, uploadID string, partNumber int, byteRange string, opts ...UploadPartCopyOption) (*UploadPartCopyOutput, error)` |
| `CompleteMultipartUpload` | `CompleteMultipartUpload(ctx, bucket, key, uploadID string, parts []CompletedPart, opts ...PutOption) (*CompleteMultipartUploadOutput, error)`                                            |
| `AbortMultipartUpload`    | `AbortMultipartUpload(ctx, bucket, key, uploadID string) error`                                                                                                                           |

When you create the upload with `WithChecksumAlgorithm`, pass the returned `ChecksumAlgorithm` to each `UploadPart` via
`WithPartChecksum(alg)`. Every part then carries a verified per-part digest, and the composite checksum comes back on
`CompleteMultipartUpload`.

For SSE-C, pass the same key through `WithPutSSECustomerKey`, `WithPartSSECustomerKey`, and
`WithCompleteSSECustomerKey`. Copy parts use `WithPartCopySourceSSECustomerKey` and
`WithPartCopyDestinationSSECustomerKey`. `WithPartProgress`, `WithMultipartProgress`, and `WithUploadPartProgress` are
equivalent progress aliases.

```go
mpu, _ := c.CreateMultipartUpload(ctx, "reports", "big.bin",
    lockwellsdk.WithChecksumAlgorithm(lockwellsdk.ChecksumCRC32C))

p1, _ := c.UploadPart(ctx, "reports", "big.bin", mpu.UploadID, 1, part1,
    lockwellsdk.WithPartChecksum(mpu.ChecksumAlgorithm))
p2, _ := c.UploadPart(ctx, "reports", "big.bin", mpu.UploadID, 2, part2,
    lockwellsdk.WithPartChecksum(mpu.ChecksumAlgorithm))

done, _ := c.CompleteMultipartUpload(ctx, "reports", "big.bin", mpu.UploadID,
    []lockwellsdk.CompletedPart{
        {PartNumber: 1, ETag: p1.ETag},
        {PartNumber: 2, ETag: p2.ETag},
    })
fmt.Println(done.ETag, done.Checksums.CRC32C)
```

### Tagging and Object Lock

| Method                | Signature                                                                                         |
| --------------------- | ------------------------------------------------------------------------------------------------- |
| `PutObjectTagging`    | `PutObjectTagging(ctx, bucket, key string, tags map[string]string, opts ...GetOption) error`      |
| `GetObjectTagging`    | `GetObjectTagging(ctx, bucket, key string, opts ...GetOption) (map[string]string, error)`         |
| `DeleteObjectTagging` | `DeleteObjectTagging(ctx, bucket, key string, opts ...GetOption) error`                           |
| `SetObjectRetention`  | `SetObjectRetention(ctx, bucket, key string, retention ObjectRetention, opts ...GetOption) error` |
| `PutObjectRetention`  | `PutObjectRetention(ctx, bucket, key string, retention ObjectRetention, opts ...GetOption) error` |
| `GetObjectRetention`  | `GetObjectRetention(ctx, bucket, key string, opts ...GetOption) (*ObjectRetention, error)`        |
| `SetObjectLegalHold`  | `SetObjectLegalHold(ctx, bucket, key string, on bool, opts ...GetOption) error`                   |
| `PutObjectLegalHold`  | `PutObjectLegalHold(ctx, bucket, key string, on bool, opts ...GetOption) error`                   |
| `GetObjectLegalHold`  | `GetObjectLegalHold(ctx, bucket, key string, opts ...GetOption) (bool, error)`                    |

Retention and legal hold can be set at write time through `WithObjectLockRetention` and `WithObjectLockLegalHold` (see
`PutObject` options), or changed after a write with `SetObjectRetention` and `SetObjectLegalHold`. The `Put...` methods
are equivalent S3-operation-named aliases. Post-write retention takes an `ObjectRetention` with an
`ObjectLockGovernance` or `ObjectLockCompliance` mode and a future `time.Time`; legal hold takes a boolean. Pass
`WithVersionID` to target a specific version in every Object Lock operation.

```go
until := time.Now().Add(30 * 24 * time.Hour)
err := c.SetObjectRetention(ctx, "vault", "ledger.json", lockwellsdk.ObjectRetention{
    Mode:            lockwellsdk.ObjectLockCompliance,
    RetainUntilDate: until,
}, lockwellsdk.WithVersionID(versionID))
if err != nil { /* handle typed S3 API error */ }

err = c.SetObjectLegalHold(ctx, "vault", "ledger.json", true,
    lockwellsdk.WithVersionID(versionID))
```

### Presigned object URLs

`PresignGetObject`, `PresignPutObject`, `PresignHeadObject`, and `PresignDeleteObject` return time-limited query-SigV4
object URLs matching the server's supported methods. `PresignGetObject` accepts `GetOption` values for version and
`response-*` overrides.

`WithVersionID` and the `response-*` overrides are folded into the signature; range and part-number options are ignored.
The server enforces its own maximum TTL and rejects anything longer.

::: info The four S3 helpers have implementation and offline method-binding coverage. The tracked Phase 2 quick matrix
also contains separate passing live GET, PUT, HEAD, and DELETE rows for Go, Node, and Java, including object-state
checks and wrong-method denials. The quick profile deliberately excludes the 10/15 GiB scenarios. :::

```go
url, err := c.PresignGetObject("reports", "q1.txt", 15*time.Minute)
```

For a native signed write URL, use the native client's [`SignURL`](#signed-urls-get-and-put).

### Checksums

`ChecksumAlgorithm` is one of `ChecksumCRC32`, `ChecksumCRC32C`, `ChecksumCRC64NVME`, `ChecksumSHA1`, `ChecksumSHA256`.
The SDK computes the digest client-side from the standard library and sends a precomputed `x-amz-checksum-<alg>` header
the server validates against the body it received. `Checksums` on a result carries the base64 digest for whichever
algorithm the server echoed.

### Retry

By default a `Client` uses `DefaultRetryPolicy()`: up to 3 attempts, 100ms base backoff doubling to a 2s cap, with full
jitter.

It retries GET/HEAD/DELETE and any PUT/POST that carries an idempotency key, on transport errors and on 5xx/429
responses. A 4xx other than 429 is never retried. Streaming bodies are never retried.

```go
// Tune it:
c, _ := lockwellsdk.New(endpoint, creds, lockwellsdk.WithRetryPolicy(lockwellsdk.RetryPolicy{
    MaxAttempts: 5,
    BaseBackoff: 200 * time.Millisecond,
    MaxBackoff:  4 * time.Second,
    Jitter:      1.0,
}))

// Or turn it off:
c, _ = lockwellsdk.New(endpoint, creds, lockwellsdk.WithRetryPolicy(lockwellsdk.DisabledRetryPolicy()))
```

### Errors

Server errors are `*lockwellsdk.APIError` with `Code`, `Message`, `StatusCode`, and `RequestID` (for support
correlation). Use `lockwellsdk.IsNotFound(err)` for a missing bucket, key, or upload.

```go
out, err := c.GetObject(ctx, "reports", "missing.txt")
if lockwellsdk.IsNotFound(err) {
    // 404 / NoSuchKey / NoSuchBucket / NoSuchUpload
}
var apiErr *lockwellsdk.APIError
if errors.As(err, &apiErr) {
    log.Printf("code=%s status=%d requestId=%s", apiErr.Code, apiErr.StatusCode, apiErr.RequestID)
}
```

## `lockwellwire` (`lockwell-sdk-go`) (the LNW/1 native-wire client)

Production Go native data-plane integrations use `lockwellwire` (`lockwell-sdk-go`), which
speaks the binary LNW/1 protocol directly over the separately configured native
wire listener. It requires TLS 1.3 and normal hostname/certificate verification,
supports mTLS and additive certificate pins, refreshes credentials for every
new handshake, and provides typed streaming, ranges, multipart, versions,
Object Lock, CORS, notifications, and signed-capability operations.

```go
wire, err := lockwellwire.New(lockwellwire.Config{
    Address: "objects.example.com:9443",
    Credentials: lockwellwire.Credentials{
        AccessKeyID: os.Getenv("LOCKWELL_ACCESS_KEY_ID"),
        SecretKey: os.Getenv("LOCKWELL_SECRET_KEY"),
    },
})
if err != nil { log.Fatal(err) }
defer wire.Close()

object, err := wire.GetObject(ctx, lockwellwire.GetObjectInput{
    Bucket: "reports", Key: "q1.txt",
})
if err != nil { log.Fatal(err) }
defer object.Body.Close()
_, _ = io.Copy(os.Stdout, object.Body)
```

The wire client never emits HTTP, JSON, XML, or S3 signatures. Context
cancellation closes streaming bodies and closeable upload producers. Callers
own retries; `*lockwellwire.Error` exposes stable codes and bounded retry-after
metadata, and writes should use an idempotency key plus a replayable source when
the caller chooses to retry. `Client.Ping` is available for an authenticated
PING/PONG liveness check that does not create an application stream.

## `lockwellnative` (`lockwell-sdk-go`) (legacy HTTP/JSON compatibility client)

Talks to the native JSON data plane at `/api/v1/`. No SigV4, no XML.

This package remains only for migration compatibility. `New` is deprecated;
use `NewHTTPCompatibility` to make the legacy transport explicit. It does not
provide LNW/1 guarantees. See the
[`Go LNW/1 migration guide`](https://github.com/RusticStack/lockwell/blob/main/docs/sdk-go-native-wire-migration.md).

It mints a short-lived bearer token from your access key on first use, caches it until shortly before expiry, refreshes
transparently, and re-mints once on a 401. Token management is thread-safe with single-flight refresh, so a burst of
concurrent requests mints at most one token.

```go
nc, err := lockwellnative.NewHTTPCompatibility(
    "https://objects.example.com", // public listener; /api/v1 is mounted automatically
    os.Getenv("LOCKWELL_ACCESS_KEY_ID"),
    os.Getenv("LOCKWELL_SECRET_KEY"),
)
if err != nil {
    log.Fatal(err)
}

// Streaming PUT. The body is streamed, never whole-object buffered.
res, err := nc.PutObject(ctx, lockwellnative.PutObjectInput{
    Bucket:         "reports",
    Key:            "q1.txt",
    Body:           strings.NewReader("hello"),
    ContentType:    "text/plain",
    IdempotencyKey: "q1-2026",
    Checksums:      map[string]string{"sha256": sha256Base64},
})
fmt.Println(res.ETag, res.VersionID)

// Streaming GET. Read the body and Close it.
obj, err := nc.GetObject(ctx, lockwellnative.GetObjectInput{Bucket: "reports", Key: "q1.txt"})
if err != nil {
    if lockwellnative.IsNotFound(err) { /* missing key */ }
    log.Fatal(err)
}
defer obj.Close()
io.Copy(os.Stdout, obj)
```

`New` takes `WithHTTPClient` and `WithUserAgent` options, the same as the S3 client.

The streaming PUT is safe across a token refresh. Because the body cannot be replayed, `PutObject` proactively ensures a
fresh, unexpired token before streaming rather than relying on a 401-retry.

### Buckets

| Method                | Signature                                                                   |
| --------------------- | --------------------------------------------------------------------------- |
| `ListBuckets`         | `ListBuckets(ctx) ([]Bucket, error)`                                        |
| `CreateBucket`        | `CreateBucket(ctx, in CreateBucketInput) (*Bucket, error)`                  |
| `GetBucket`           | `GetBucket(ctx, bucket string) (*Bucket, error)`                            |
| `DeleteBucket`        | `DeleteBucket(ctx, bucket string) error`                                    |
| `GetBucketVersioning` | `GetBucketVersioning(ctx, bucket string) (*VersioningState, error)`         |
| `SetBucketVersioning` | `SetBucketVersioning(ctx, bucket, status string) (*VersioningState, error)` |

`CreateBucketInput{Name, Versioning, ObjectLockEnabled}` is private by design (there is no public option). Versioning
may be set here, and Object Lock can only be enabled at create time. `SetBucketVersioning` takes `"enabled"` or
`"suspended"`. A create-on-existing returns a `NativeError` with 409 (`IsAlreadyExists`).

### Objects

| Method               | Signature                                                                                   |
| -------------------- | ------------------------------------------------------------------------------------------- |
| `PutObject`          | `PutObject(ctx, in PutObjectInput) (*PutObjectResult, error)`                               |
| `GetObject`          | `GetObject(ctx, in GetObjectInput) (*ObjectReader, error)`                                  |
| `HeadObject`         | `HeadObject(ctx, in GetObjectInput) (*ObjectInfo, error)`                                   |
| `DeleteObject`       | `DeleteObject(ctx, bucket, key, versionID string) (*DeleteObjectResult, error)`             |
| `ListObjects`        | `ListObjects(ctx, in ListObjectsInput) (*ListObjectsResult, error)`                         |
| `ListObjectsAll`     | `ListObjectsAll(ctx, in ListObjectsInput) *ObjectIterator`                                  |
| `BatchDeleteObjects` | `BatchDeleteObjects(ctx, bucket string, keys []BatchDeleteKey) (*BatchDeleteResult, error)` |
| `CopyObject`         | `CopyObject(ctx, in CopyObjectInput) (*CopyObjectResult, error)`                            |

`PutObjectInput` fields:

| Field                         | Effect                                                                                                                      |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `Body io.Reader`              | Streamed with no whole-object buffering.                                                                                    |
| `ContentType`                 | Stored media type (default `application/octet-stream`).                                                                     |
| `ContentLength int64`         | When > 0, sets `Content-Length` so the server enforces the size cap and quota up front; otherwise the body is sent chunked. |
| `IdempotencyKey`              | The same key replays the stored result instead of writing twice.                                                            |
| `IfNoneMatch: "*"`            | Create only when the key is absent (412 otherwise).                                                                         |
| `IfMatch: "<etag>"`           | Overwrite only when the current ETag matches (412 otherwise).                                                               |
| `Checksums map[string]string` | Algorithm (`"sha256"`, `"crc32c"`, ...) to expected base64 digest. A bad digest is rejected before any bytes are committed. |
| `Metadata map[string]string`  | User metadata, stored as `X-Lockwell-Meta-*`.                                                                               |

`GetObjectInput{Bucket, Key, VersionID, Range}` drives both `GetObject` and `HeadObject`. `ObjectReader` embeds
`io.ReadCloser` and surfaces native fields: `ContentType`, `ContentLength`, `ETag`, `VersionID`, `ContentRange`,
`Checksums`, `Encrypted`, `StorageClass`, `LegalHold`, `RetainUntil`.

`ListObjectsAll` returns an `*ObjectIterator` that follows continuation tokens for you:

```go
it := nc.ListObjectsAll(ctx, lockwellnative.ListObjectsInput{Bucket: "reports", Prefix: "logs/"})
for it.Next() {
    obj := it.Object()
    fmt.Println(obj.Key, obj.Size)
}
if err := it.Err(); err != nil {
    log.Fatal(err)
}
```

`CopyObjectInput` carries the destination `Bucket`/`Key` plus `SourceBucket`, `SourceKey`, `SourceVersionID`,
`MetadataDirective` (`"COPY"` default or `"REPLACE"`), `ContentType`, `Metadata`, the source conditionals (`IfMatch`,
`IfNoneMatch`, `IfModifiedSince`, `IfUnmodifiedSince`), and the destination preconditions (`RequireAbsent`,
`RequireMatchETag`). Cross-tenant copy is impossible, since the source resolves under the token's tenant.

### Tags, retention, legal hold, versions

| Method               | Signature                                                                                |
| -------------------- | ---------------------------------------------------------------------------------------- |
| `GetObjectTags`      | `GetObjectTags(ctx, bucket, key string) ([]Tag, error)`                                  |
| `SetObjectTags`      | `SetObjectTags(ctx, bucket, key string, tags []Tag) ([]Tag, error)`                      |
| `DeleteObjectTags`   | `DeleteObjectTags(ctx, bucket, key string) error`                                        |
| `GetObjectRetention` | `GetObjectRetention(ctx, bucket, key string) (*Retention, error)`                        |
| `SetObjectRetention` | `SetObjectRetention(ctx, bucket, key, mode, retainUntil string) (*Retention, error)`     |
| `GetObjectLegalHold` | `GetObjectLegalHold(ctx, bucket, key string) (*LegalHold, error)`                        |
| `SetObjectLegalHold` | `SetObjectLegalHold(ctx, bucket, key, status string) (*LegalHold, error)`                |
| `ListObjectVersions` | `ListObjectVersions(ctx, in ListObjectVersionsInput) (*ListObjectVersionsResult, error)` |

Both clients can set retention and legal hold after a write; the native client uses JSON-native result types. `mode` is
`"GOVERNANCE"` or `"COMPLIANCE"` and `retainUntil` is RFC3339; `status` is `"ON"` or `"OFF"`.

::: warning The server enforces the same WORM gate as the S3 path. There is no governance bypass on the native path. :::

### Multipart

| Method                    | Signature                                                                              |
| ------------------------- | -------------------------------------------------------------------------------------- |
| `CreateMultipartUpload`   | `CreateMultipartUpload(ctx, bucket, key string) (*MultipartUpload, error)`             |
| `UploadPart`              | `UploadPart(ctx, in UploadPartInput) (*UploadedPart, error)`                           |
| `ListParts`               | `ListParts(ctx, bucket, key, uploadID string) (*PartListing, error)`                   |
| `CompleteMultipartUpload` | `CompleteMultipartUpload(ctx, in CompleteMultipartInput) (*CompletedMultipart, error)` |
| `AbortMultipartUpload`    | `AbortMultipartUpload(ctx, bucket, key, uploadID string) error`                        |
| `ListMultipartUploads`    | `ListMultipartUploads(ctx, bucket string) (*MultipartUploadListing, error)`            |

`UploadPartInput{Bucket, Key, UploadID, PartNumber, Body, ContentLength}` streams the part body.
`CompleteMultipartInput{Bucket, Key, UploadID, Parts, IfNoneMatch, IfMatch}` gates the completed object atomically at
the commit. `Parts` is a required ordered `[]CompleteMultipartPart` manifest; the server validates each referenced part
and assembles exactly that selection.

### Bucket CORS

The native client exposes browser CORS as JSON structs over the same server-side validator as S3 `?cors`:

```go
cfg := lockwellnative.CORSConfiguration{
    Rules: []lockwellnative.CORSRule{{
        AllowedOrigins: []string{"https://app.example.com"},
        AllowedMethods: []string{"GET", "HEAD", "PUT"},
        AllowedHeaders: []string{"content-type"},
        ExposeHeaders:  []string{"ETag"},
        MaxAgeSeconds:  600,
    }},
}
stored, err := nc.SetBucketCORS(ctx, "reports", cfg)
got, err := nc.GetBucketCORS(ctx, "reports")
err = nc.DeleteBucketCORS(ctx, "reports")
```

Changing CORS is an admin-scoped bucket operation. For app onboarding, prefer `lockwellkit.ConfigureBucketCORS` or
`ProvisionTenantInput.DefaultBucketCORS`, which use a transient admin key and revoke it after the update.

### Signed URLs (GET and PUT)

Unlike the S3 presigner, the native API supports signed write URLs.

```go
// A browser-usable upload URL that needs NO bearer token.
upload, err := nc.SignURL(ctx, lockwellnative.SignURLInput{
    Method: "PUT", Bucket: "reports", Key: "incoming.bin", TTLSeconds: 300,
})
download, err := nc.SignURL(ctx, lockwellnative.SignURLInput{
    Method: "GET", Bucket: "reports", Key: "q1.txt",
})
```

`SignURL(ctx, in SignURLInput) (string, error)` returns an absolute URL whose authorization rides in a `token` query
parameter. The URL can never exceed the minting key's scope: a read-only key minting a PUT URL is denied with 403
(`IsForbidden`).

`TTLSeconds` is clamped server-side to `security.max_presign_ttl`; 0 uses the server default. See
[signed URLs](/guide/signed-urls).

### Bucket notifications

Native notifications configure signed webhook delivery. SNS/SQS/Lambda targets are a 501 non-goal.

The per-config signing secret is returned exactly once for a new config ID; GET and same-ID updates carry only
`HasSecret`.

```go
views, err := nc.SetBucketNotification(ctx, "reports", lockwellnative.SetBucketNotificationInput{
    Configs: []lockwellnative.NotificationConfig{{
        ID:         "reports-events",
        WebhookURL: "https://my-app.example.com/hooks/lockwell",
        Events:     []string{"s3:ObjectCreated:*", "s3:ObjectRemoved:*"},
        Filters:    []lockwellnative.NotificationFilter{{Name: "prefix", Value: "incoming/"}},
    }},
})
signingSecret := views[0].SigningSecret // shown once; store securely
fmt.Println(views[0].HasSecret) // true; SigningSecret is empty on later GETs
```

| Method                     | Signature                                                                                              |
| -------------------------- | ------------------------------------------------------------------------------------------------------ |
| `SetBucketNotification`    | `SetBucketNotification(ctx, bucket string, in SetBucketNotificationInput) ([]NotificationView, error)` |
| `GetBucketNotification`    | `GetBucketNotification(ctx, bucket string) ([]NotificationView, error)`                                |
| `DeleteBucketNotification` | `DeleteBucketNotification(ctx, bucket string) error`                                                   |

An empty `Configs` list on `SetBucketNotification` clears the configuration. See [webhooks](/guide/webhooks) for
verifying deliveries.

### Errors

Server errors are `*lockwellnative.NativeError` (decoded from `problem+json`) with `Code`, `Message`, `StatusCode`, and
`RequestID`. The helpers map HTTP status and exact JSON codes to intent:

| Helper                         | Status/code                                        |
| ------------------------------ | -------------------------------------------------- |
| `IsUnauthorized(err)`          | 401, including key revoked/expired/tenant disabled |
| `IsForbidden(err)`             | 403 scope or bucket-policy denial                  |
| `IsNotFound(err)`              | 404 missing bucket or key                          |
| `IsAlreadyExists(err)`         | `already_exists`                                   |
| `IsConflict(err)`              | any 409                                            |
| `IsIdempotencyConflict(err)`   | `idempotency_conflict`                             |
| `IsIdempotencyInProgress(err)` | `idempotency_in_progress`                          |
| `IsPreconditionFailed(err)`    | 412 conditional-write or copy-source precondition  |
| `IsRetentionBlocked(err)`      | `retention_blocked`                                |
| `IsLegalHoldBlocked(err)`      | `legal_hold_blocked`                               |
| `IsQuotaExceeded(err)`         | 507 tenant storage quota exceeded                  |

`AsNativeError(err) (*NativeError, bool)` extracts the concrete error without importing the type at the call site.

## `lockwelladmin` (`lockwell-sdk-go`) (the admin client)

Talks to the JSON Admin API at `/admin/api/v1/` on the admin listener (never the public S3 port). It authenticates with
an admin API bearer token minted with `lockwell admin-token create`.

```go
admin, err := lockwelladmin.New(
    "https://admin.example.com", // admin listener, not the S3 port
    os.Getenv("LOCKWELL_ADMIN_TOKEN"),
)
if err != nil {
    log.Fatal(err)
}

tenant, _, err := admin.CreateTenant(ctx, lockwelladmin.CreateTenantInput{ID: "acme", Name: "Acme Inc"})

// The secret is returned EXACTLY ONCE on create/rotate. Persist it now.
nk, _, err := admin.CreateKey(ctx, "acme", lockwelladmin.CreateKeyInput{Scopes: "read,write,delete"})
fmt.Println(nk.AccessKeyID, nk.SecretKey)
```

`New` takes `WithHTTPClient` and `WithUserAgent`.

### Operations

| Method          | Signature                                                                                     |
| --------------- | --------------------------------------------------------------------------------------------- |
| `ListTenants`   | `ListTenants(ctx) ([]Tenant, error)`                                                          |
| `GetTenant`     | `GetTenant(ctx, id string) (*Tenant, error)`                                                  |
| `CreateTenant`  | `CreateTenant(ctx, in CreateTenantInput) (*Tenant, *DryRunResult, error)`                     |
| `DisableTenant` | `DisableTenant(ctx, id string, in DisableTenantInput) (*Plan, error)`                         |
| `DeleteTenant`  | `DeleteTenant(ctx, id string, in DeleteTenantInput) (*Plan, error)`                           |
| `GetQuota`      | `GetQuota(ctx, tenantID string) (*Quota, error)`                                              |
| `SetQuota`      | `SetQuota(ctx, tenantID string, in SetQuotaInput) (*Quota, *DryRunResult, error)`             |
| `ClearQuota`    | `ClearQuota(ctx, tenantID string, dryRun bool) (*Quota, *DryRunResult, error)`                |
| `GetUsage`      | `GetUsage(ctx, tenantID string) (*Usage, error)`                                              |
| `ListAccounts`  | `ListAccounts(ctx, tenantID string) ([]Account, error)`                                       |
| `CreateAccount` | `CreateAccount(ctx, tenantID string, in CreateAccountInput) (*Account, *DryRunResult, error)` |
| `ListKeys`      | `ListKeys(ctx, tenantID string) ([]Key, error)` (never returns secrets)                       |
| `CreateKey`     | `CreateKey(ctx, tenantID string, in CreateKeyInput) (*NewKey, *DryRunResult, error)`          |
| `RotateKey`     | `RotateKey(ctx, tenantID, keyID string, in RotateKeyInput) (*NewKey, *DryRunResult, error)`   |
| `RevokeKey`     | `RevokeKey(ctx, tenantID, keyID string, in RevokeKeyInput) (*Key, *DryRunResult, error)`      |
| `QueryAudit`    | `QueryAudit(ctx, in QueryAuditInput) ([]AuditEvent, error)`                                   |

The secret on a created or rotated key is shown once on `NewKey.SecretKey` and is never recoverable. `ListKeys` returns
metadata only.

::: warning A created or rotated key returns its secret exactly once. Persist it at that moment; there is no way to read
it back. :::

### Scope grammar

`CreateKeyInput.Scopes` is a scope string. The simple form is a comma-separated verb list (`read`, `write`, `delete`,
`admin`), for example `read,write,delete`. The resource form scopes the verbs to a bucket and optional prefix:

```text
op=read:bucket=reports,op=write:bucket=reports,op=delete:bucket=reports
op=read:bucket=reports:prefix=incoming/
```

`ExpiresAt` is an optional RFC3339 or `YYYY-MM-DD` string (empty means never).

### Dry runs

Every mutation accepts `DryRun: true`, which sends `?dryRun=true` so the server returns the plan and applies nothing. On
a dry run the typed result is nil and the `*DryRunResult` is populated instead.

```go
plan, err := admin.DeleteTenant(ctx, "acme", lockwelladmin.DeleteTenantInput{
    Reason: "offboarding", Confirm: "acme", DryRun: true,
})
fmt.Println(plan.Buckets, plan.Objects, plan.RetainedVersions)
```

Destructive lifecycle calls require a `Reason`, and `DeleteTenant` additionally requires `Confirm` to equal the tenant
id. The server fails closed with a 412 when retention or a legal hold gates the delete.

### Errors

Server errors are `*lockwelladmin.AdminError` with `Code`, `Message`, `StatusCode`, `RequestID`. Helpers: `IsNotFound`
(404), `IsUnauthorized` (401), `IsForbidden` (403, RBAC or cross-tenant), `IsPreconditionFailed` (412,
retention/legal-hold gated delete). `AsAdminError(err)` extracts the concrete error. See the
[Admin API reference](/reference/admin-api).

## `lockwellkit` (`lockwell-sdk-go`) (the app kit)

A thin composition over the admin and native clients. It introduces no new wire surface; every call goes through the
admin and native JSON APIs. It composes them into the jobs a multi-tenant app would otherwise hand-roll.

```go
admin, _ := lockwelladmin.New("https://admin.example.com", os.Getenv("LOCKWELL_ADMIN_TOKEN"))
kit, _ := lockwellkit.New(admin, "https://objects.example.com")

// 1) Provision: ensure the tenant exists (idempotent), mint a fresh scoped key,
//    optionally create a default bucket. Creds are returned ONCE. Store them.
res, err := kit.ProvisionTenant(ctx, "acme", lockwellkit.ProvisionTenantInput{
    DefaultBucket: "inbox",
    DefaultBucketCORS: &lockwellnative.CORSConfiguration{
        Rules: []lockwellnative.CORSRule{{
            AllowedOrigins: []string{"https://app.example.com"},
            AllowedMethods: []string{"GET", "HEAD", "PUT"},
            AllowedHeaders: []string{"content-type"},
        }},
    },
})
// persist res.Creds.AccessKeyID + res.Creds.SecretKey in your tenant store

// 2) A per-tenant native client (cached per tenant+creds; auto-manages the token).
client, _ := kit.ClientForTenant("acme", res.Creds)
client.PutObject(ctx, lockwellnative.PutObjectInput{Bucket: "inbox", Key: "hi.txt", Body: strings.NewReader("hi")})

// 3) Browser direct upload/download. Hand the URL straight to the browser.
up, _ := kit.SignedUploadURL(ctx, res.Creds, "inbox", "photo.jpg",
    lockwellkit.SignedUploadURLInput{TTLSeconds: 300, ContentType: "image/jpeg"})
dl, _ := kit.SignedDownloadURL(ctx, res.Creds, "inbox", "photo.jpg", 300)

// Or update CORS later with another transient admin-scoped key.
_, _ = kit.ConfigureBucketCORS(ctx, "acme", "inbox", lockwellnative.CORSConfiguration{
    Rules: []lockwellnative.CORSRule{{
        AllowedOrigins: []string{"https://app.example.com"},
        AllowedMethods: []string{"GET", "HEAD", "PUT"},
    }},
})

// 4) Verify an incoming webhook (constant-time HMAC-SHA256).
ok := lockwellkit.VerifyWebhook(rawBody, req.Header.Get(lockwellkit.WebhookSignatureHeader), secret)
```

| Method                | Signature                                                                                                                            |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `New`                 | `New(admin *lockwelladmin.Client, nativeEndpoint string, opts ...Option) (*Kit, error)`                                              |
| `ProvisionTenant`     | `ProvisionTenant(ctx, tenantID string, in ProvisionTenantInput) (*ProvisionResult, error)`                                           |
| `ClientForTenant`     | `ClientForTenant(tenantID string, creds TenantCreds) (*lockwellnative.Client, error)`                                                |
| `ConfigureBucketCORS` | `ConfigureBucketCORS(ctx, tenantID, bucket string, cfg lockwellnative.CORSConfiguration) (*lockwellnative.CORSConfiguration, error)` |
| `SignedUploadURL`     | `SignedUploadURL(ctx, creds TenantCreds, bucket, key string, in SignedUploadURLInput) (*SignedUpload, error)`                        |
| `SignedDownloadURL`   | `SignedDownloadURL(ctx, creds TenantCreds, bucket, key string, ttlSeconds int64) (string, error)`                                    |
| `VerifyWebhook`       | `VerifyWebhook(rawBody []byte, signatureHeader string, secret []byte) bool`                                                          |
| `Admin`               | `Admin() *lockwelladmin.Client`                                                                                                      |

`ProvisionTenant` mints a data key only (default `read,write,delete`, or
`op=read:bucket=<Bucket>,op=write:bucket=<Bucket>,op=delete:bucket=<Bucket>` when `Bucket` is set, or a custom `Scopes`
string). It never mints a management-capable key.

When `DefaultBucket` is set, the kit mints a transient admin-on-that-bucket key, creates the bucket, and revokes the
transient key immediately, so the bucket-create capability never outlives the call.

`ClientForTenant` caches one native client per (tenant, creds), so repeated calls share one token manager. Reach the
underlying admin client via `kit.Admin()` for operations the kit does not wrap.

> Store `SigningSecret` from the new-config response immediately. GET and same-ID updates expose only `HasSecret`; use
> the stored value with `VerifyWebhook`. See [webhooks](/guide/webhooks).

## Coverage at a glance

The full S3 operation matrix shared by all three S3 clients lives on the
[S3 operations reference](/reference/s3-operations). The native and admin wire contracts are documented on the
[native API](/reference/native-api) and [Admin API](/reference/admin-api) reference pages.
