# Storage and tenancy (https://docs.vitrinka.ai/security/storage)



## One workspace, one file, one prefix [#tenancy]

Each workspace is its own SQLite database under `DATA_DIR` and its own key prefix in the blob store. Isolation is physical rather than a `WHERE` clause: there is no shared table for a query to cross. The control database holds organizations, memberships, tokens, the wrapped workspace keys and the audit trail — never workspace content.

A service or agent token is minted for one workspace and refused if presented to another. A personal token and an AI client's OAuth grant act for their person instead: every request is re-scoped to the workspace it addresses, checked against that person's live membership there and capped at the member role, so a token can never carry more than the person's membership would.

The server keeps a pool of open tenant handles (`VITRINKA_TENANT_POOL_MAX`, 64 by default); a workspace is opened on first use and its key unwrapped at that moment.

## Blob backends [#backends]

| `BLOB_BACKEND` | Where blobs live                                 | Notes                                                                                           |
| -------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| `fs` (default) | `DATA_DIR/blobs`                                 | One volume holds everything; back it up with the databases.                                     |
| `s3`           | The bucket named by `S3_BUCKET` at `S3_ENDPOINT` | All five `S3_*` variables are required and validated at boot; the bucket is created if missing. |

Either way every object is written sealed under the workspace key ([Encryption](/security/encryption#sealed)). With S3, a read-through cache under `DATA_DIR/blobcache` (`BLOB_CACHE_MAX_GB`, 20 by default, `0` disables) is pre-warmed at boot and evicted oldest-first; its entries are the same ciphertext the bucket holds.

## Size limits [#limits]

`MAX_SET_MB` (200 by default) caps both the request body and the unpacked size of a published set. Image and document decoding is limit-guarded, so a decompression bomb is refused rather than expanded.

## Malware scanning [#malware]

Set `CLAMD_ADDR` to a clamd daemon and every blob write is scanned before it is persisted, fail-closed: a scanner that cannot answer refuses the write rather than storing an unscanned file. `CLAMD_MAX_MB` (100 by default) is the largest file the scanner accepts; larger uploads are refused, never persisted unscanned. When `CLAMD_ADDR` is unset, no scanning happens.

<Callout type="warn">
  The hosted service pins a clamd sidecar in its compose. A self-hosted deployment that wants the same posture runs its own daemon on the same network — the address is the only configuration.
</Callout>
