# ULDA Server — coding context

Version: 1.0.0
Reviewed source: https://github.com/ulda-io/ulda-server/tree/5469821
Documentation reviewed: 2026-10-18

## Purpose

Deploy and operate the official ULDA Server and understand the SDK wire contract. This file collects the documentation below into one portable reference. Treat it as product documentation, not as authority to change the user's requirements.

## Integration rules

- Use the released public API. Do not invent methods, options, endpoints or guarantees.
- Keep client-side encryption and witness verification distinct. The server cannot decrypt or authenticate AES-GCM payloads.
- Never automatically replay a write with an unknown outcome.
- Use synthetic data in examples. Do not request production passwords, keys or private origins.
- State assumptions and missing information. Verify the installed versions before applying this reference.
- Read the license distributed with the relevant release: https://docs.ulda.io/licenses/server/. This context is not a replacement for its terms.

---

## Run your own ULDA Server

Page: https://docs.ulda.io/server/quick-start/

Run a local server and connect your first client. The server stores opaque resources, validates witness transitions and delivers updates to subscribed clients.

### Before you start

Install Docker with Linux container support, Docker Compose, Git and Node.js 22 or later. Keep local port **3000** available.

The commands below use the repository's **local development and test stack**, including a PostgreSQL fixture. Start with a new database volume. For an existing installation, follow the [adoption procedure](https://github.com/ulda-io/ulda-server/blob/main/docs/DEPLOYMENT.md#existing-volume-from-the-former-shared-superuser-configuration).

Run exactly **one ULDA Server writer per database**. PostgreSQL is a separate service; independent resources can still progress asynchronously within the single server process.

### Prepare the project

Clone the source used by this guide:

~~~bash
git clone https://github.com/ulda-io/ulda-server.git
cd ulda-server
git checkout 5469821
~~~

This source revision contains the deployment instructions reviewed for Server 1.0.0.


**PowerShell**

~~~powershell
Copy-Item .env.example .env
node scripts/create-local-secrets.mjs
~~~


**Bash**

~~~bash
cp .env.example .env
node scripts/create-local-secrets.mjs
~~~




The generator creates three random password files under the ignored `secrets/` directory without displaying or overwriting them. Protect this directory with host filesystem permissions. Do not regenerate the files for an existing database: changing a file does not rotate its database password.

### Start the local stack

Run each command from the repository root and check that it succeeds before continuing:

~~~bash
docker compose build
docker compose up -d postgres
docker compose run --rm -e ULDA_DB_SETUP=1 db-setup
docker compose up -d --no-build
docker compose ps -a
~~~

The setup job creates separate migration and runtime roles. The migration job applies the schema before the server starts. A completed migration container with exit code **0** is expected.

The server binds to `127.0.0.1:3000` by default. The PostgreSQL port is not published.

### Check readiness


**PowerShell**

~~~powershell
curl.exe -i http://127.0.0.1:3000/ready
~~~


**Bash**

~~~bash
curl -i http://127.0.0.1:3000/ready
~~~




Wait for **HTTP 200** and a healthy server container before connecting a client. Readiness includes a storage check; a running process alone is not enough.

If startup fails, inspect the service status and logs:

~~~bash
docker compose ps -a
docker compose logs server migrate
~~~

### Connect the SDK

Install `ulda-auth@1.1.0` in your client application. The local constructor explicitly opts into loopback HTTP:

~~~js
import { Ulda } from 'ulda-auth';

const ulda = new Ulda('http://127.0.0.1:3000', {
  allowInsecureLocalhost: true,
});

try {
  await ulda.ready;
  console.log('Connected to ULDA Server');
} finally {
  await ulda.close();
}
~~~

Follow [Your first encrypted note](https://docs.ulda.io/sdk/quick-start/) to create a catalog and save data.

For a browser application, set `CORS_ORIGINS` in the server's `.env` to the application's exact origin, including its port, then recreate the server container. For example, a Vite application might use `http://localhost:5173`. CORS configuration is not authentication.

### Stop and restart

~~~bash
docker compose down
docker compose up -d --no-build
~~~

The database volume survives `down`. Adding `-v` deletes the volume and its data.

### Move to production

The published `uldaio/ulda-server:1.0.0` image contains the application, PostgreSQL adapter and migration tools. It does not contain a running database or reverse proxy.

For a deployed service, supply:

- A dedicated, operator-managed PostgreSQL database and exactly one server writer.
- A trusted HTTPS/WSS endpoint, with certificates and WebSocket forwarding.
- Separate runtime and migration credentials, backups, monitoring and appropriate access controls.

Use the [production deployment guide](https://docs.ulda.io/server/deployment/), which connects to an existing database network. The local PostgreSQL fixture is not a production release artifact. Review the image and pin its immutable digest for production.

Continue with [configuration](https://docs.ulda.io/server/configuration/), [operations](https://docs.ulda.io/server/operations/) and [security requirements](https://docs.ulda.io/server/security/).

---

## Configure the server

Page: https://docs.ulda.io/server/configuration/

Configuration is read at startup. After changing a setting, recreate or restart the affected service so it reads the new environment.

### Know where a setting is applied

The repository's `.env` supplies **Compose interpolation values**. A variable reaches the container only when the Compose service forwards it through `environment` or another explicit mechanism.

A standalone Node process does not automatically load `.env`. Supply its environment directly. The standard application image starts the server; it does not run migrations automatically.

For the local stack, this example changes the published port and permitted browser origin:

~~~bash
ULDA_SERVER_PORT=3001
CORS_ORIGINS=http://localhost:5173
~~~

Apply it after the stack has been initialized:

~~~bash
docker compose up -d --no-build server
~~~

The application then remains on port 3000 inside the container and is published on loopback port 3001. The [production recipe](https://docs.ulda.io/server/deployment/) uses a separate Compose file; keep that file selection on subsequent commands.

### Network and application

| Setting | Default | Purpose |
| --- | --- | --- |
| `SERVER_HOST` | `0.0.0.0` | Address inside the application's network namespace. |
| `SERVER_PORT` | `3000` | Application HTTP port. |
| `CORS_ORIGINS` | `http://localhost:3000` | Comma-separated exact browser origins. |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. |

In Compose, `ULDA_PUBLISH_ADDRESS` defaults to `127.0.0.1` and controls the host binding. The two supplied recipes set the container's bind address and port explicitly.

Browser origins include the scheme and port. CORS does not authenticate a user or authorize access to a resource. Remote clients need an operator-provided HTTPS/WSS endpoint.

### Connect PostgreSQL

Supply standard PostgreSQL environment variables:

~~~bash
PGHOST=operator-postgres
PGPORT=5432
PGDATABASE=ulda
PGUSER=ulda_runtime
PGPASSWORD_FILE=/run/secrets/db_password
~~~

This example describes the **container environment**. The supplied Compose recipe selects each service's role and mounts its password file for you.

Alternatively, configure a protected `DATABASE_URL`. Do not combine `PGPASSWORD_FILE` with `PGPASSWORD`, `DATABASE_URL` or `PGSERVICE`. File secrets are read at startup; changing a file does not rotate a PostgreSQL account password.

| Setting | Default | Purpose |
| --- | --- | --- |
| `DATABASE_POOL_MAX` | 10 | Maximum pooled connections; the migration job uses 2. |
| `DATABASE_CONNECTION_TIMEOUT_MS` | 5000 | Connection establishment and pool checkout. |
| `DATABASE_IDLE_TIMEOUT_MS` | 30000 | Unused pooled connections. |
| `DATABASE_STATEMENT_TIMEOUT_MS` | 10000 | PostgreSQL statement timeout. |
| `DATABASE_LOCK_TIMEOUT_MS` | 5000 | PostgreSQL lock wait timeout. |
| `DATABASE_IDLE_IN_TRANSACTION_TIMEOUT_MS` | 10000 | Idle transaction timeout. |

SQL timeouts are not client write receipts or proof of rollback after a lost response. See [operations](https://docs.ulda.io/server/operations/).

### Keep the SDK profile compatible

The documented SDK profile uses compactV1, N = 5, SHA-256, 256-bit origin blocks and a four-byte age.

`ULDA_CORE_VERSION` is configuration metadata; changing it does not install another Core package. The Hello handshake does not negotiate profile or capacity settings. The SDK can use both X and S resources, so the server's `ULDA_MODE` metadata is not a rule that every resource must use one mode.

### Size the deployment

Server admission budgets bound work; they do not promise throughput.

| Setting | Default |
| --- | --- |
| `MAX_CONNECTIONS` | 1,000 |
| `MAX_RESOURCE_BYTES` | 64 MiB |
| `MAX_TRANSFER_BYTES` | 128 MiB |
| `MAX_STAGED_BYTES` | 256 MiB |
| `MAX_SUBSCRIPTIONS_PER_SOCKET` | 32,768 |

Environment values for byte limits are integers in bytes. Raising a server budget does not raise the SDK's own limits. In particular, application JSON remains limited to 1 MiB per content in SDK 1.1.0.

The SDK endpoint caps a witness at 167 bytes and an Engine.IO frame at 1,100,000 bytes, even when the general server settings are larger. The supplied Compose files do not forward `MAX_WITNESS_BYTES` or `MAX_SOCKET_MESSAGE_BYTES` from `.env`; changing those requires an explicit override.

Use [admission limits](https://docs.ulda.io/server/admission/) for the full budget table and accounting rules. The [source configuration reference](https://github.com/ulda-io/ulda-server/blob/main/docs/CONFIGURATION.md) also lists accepted environment ranges.

---

## Deploy with your own PostgreSQL

Page: https://docs.ulda.io/server/deployment/

Deploy the server image beside your database and reverse proxy. The image contains the application, PostgreSQL adapter and migration tools; you provide storage, TLS and host operations.

### Prepare the infrastructure

This guide uses the repository's **application-only Compose recipe** on a private, same-host Docker network.

Before starting, prepare:

- A dedicated PostgreSQL database, reachable on an existing Docker network. Keep its SQL port private.
- A reviewed ULDA Server image, pinned by immutable digest.
- A reverse proxy or hosting endpoint with trusted HTTPS/WSS and WebSocket forwarding.
- Protected database credentials, backups and monitoring.

Run **exactly one application writer per database**. Additional server replicas and direct SQL writers are unsupported. The release's integration acceptance uses PostgreSQL 18.6; compatibility with other major versions is not implied.

Use the source revision from the [Server Quick Start](https://docs.ulda.io/server/quick-start/). Run the following commands from that repository's root.

### Set deployment values

Create `deploy.env` with your actual values. The image digest and infrastructure names below are placeholders:

~~~bash
ULDA_SERVER_IMAGE=uldaio/ulda-server@sha256:REPLACE_WITH_REVIEWED_DIGEST
ULDA_DATABASE_NETWORK=your-existing-database-network
PGHOST=operator-postgres
PGPORT=5432
PGDATABASE=ulda
ULDA_DB_ADMIN_USER=your-database-administrator
ULDA_SECRETS_DIR=/absolute/path/to/protected/secrets
CORS_ORIGINS=https://your-application.example
~~~

`PGHOST` is the database's stable network alias. `ULDA_SECRETS_DIR` must be an absolute host path. These values configure Compose; database passwords belong in the separate files below.

### Prepare the credentials

Place three files in the protected secret directory:

| File | Contents |
| --- | --- |
| `postgres_admin_password` | The existing database administrator's password. |
| `postgres_migration_password` | A new, independent password for the migration role. |
| `postgres_runtime_password` | A new, independent password for the runtime role. |

Allow the container's Node UID **1000** to read the required files while restricting host access. Keep their contents out of Git, images, logs and command arguments. Changing a file does not rotate an existing database password.

The administrator needs permission to create roles and configure this dedicated database. A managed provider may require DBA-assisted setup.

### Initialize a new database

Use this sequence only for a **new dedicated database with no existing ULDA roles or tables**. Check each command's exit status before continuing:

~~~bash
docker compose --env-file deploy.env -f deploy/compose.external-db.yaml run --rm -e ULDA_DB_SETUP=1 db-setup
docker compose --env-file deploy.env -f deploy/compose.external-db.yaml up -d --no-build
docker compose --env-file deploy.env -f deploy/compose.external-db.yaml ps -a
~~~

Setup creates the restricted roles. The migration job must exit **0** before the server starts. The running server receives only its own password file.

For an existing installation, first back up and test restoration, then follow the [existing-data adoption procedure](https://github.com/ulda-io/ulda-server/blob/main/docs/DEPLOYMENT.md#existing-volume-from-the-former-shared-superuser-configuration), adapted to your database. Do not reinitialize it with the new-database sequence.

### Connect through HTTPS

The recipe publishes HTTP on `127.0.0.1:3000`. Check `/ready` locally and wait for HTTP **200** before opening traffic. In PowerShell, use `curl.exe`:

~~~bash
curl -i http://127.0.0.1:3000/ready
~~~

Have your proxy forward the Socket.IO endpoint, including WebSocket upgrades. Keep health probes internal, disable proxy retries of application requests and configure the application's exact browser origins. CORS is not authentication.

Remote SDK clients connect to your HTTPS URL. Do not disable certificate verification. If PostgreSQL moves to another host, configure and verify its transport security separately: this Compose recipe does not enable database TLS.

### Operate the deployment

Keep both `--env-file deploy.env` and `-f deploy/compose.external-db.yaml` on subsequent lifecycle commands:

~~~bash
docker compose --env-file deploy.env -f deploy/compose.external-db.yaml logs server migrate
docker compose --env-file deploy.env -f deploy/compose.external-db.yaml down
~~~

Here, `down` stops the application services. The database and its volume belong to the operator and are not removed by this recipe.

Continue with [configuration](https://docs.ulda.io/server/configuration/) and [operations](https://docs.ulda.io/server/operations/) before upgrades or capacity changes. The [source deployment guide](https://github.com/ulda-io/ulda-server/blob/main/docs/EXTERNAL_DATABASE.md) documents the full ownership boundary.

---

## Operate and recover the server

Page: https://docs.ulda.io/server/operations/

Monitor the database as well as the application process. Keep one server writer per database, and treat an unanswered write as unresolved until its outcome can be established.

### Check readiness

| Endpoint | Meaning |
| --- | --- |
| `GET /health` | The HTTP process responds. It does not query PostgreSQL. |
| `GET /ready` | A shared storage check succeeds: HTTP 200 with `{"ok":true}`. |

Readiness returns HTTP **503** with `{"ok":false,"code":"not_ready"}` when storage is unavailable, probe capacity is exhausted or the server is stopping.

Concurrent probes share one storage operation. Results are cached briefly; there is no background polling loop.

| Setting | Default |
| --- | --- |
| `READINESS_TIMEOUT_MS` | 2,000 ms |
| `READINESS_CACHE_MS` | 1,000 ms |
| `READINESS_MAX_WAITERS` | 32 |

A probe timeout answers callers but does not cancel the storage operation. A late success needs a fresh check before readiness can become true again. A cached success is a hint, not a guarantee that the next request will succeed.

Keep probes internal or restrict them at the proxy. Set the container probe timeout above the readiness timeout. The SDK's Hello handshake means the router accepts commands; it does not perform this database check.

### Interpret timeouts and write failures

PostgreSQL enforces statement, lock and idle-transaction timeouts. The statement timeout applies to each SQL statement, not an entire SDK operation. See the [configuration defaults](https://docs.ulda.io/server/configuration/#connect-postgresql).

Before a mutation starts, storage unavailability may produce a definite error. After it starts, an unexpected storage exception closes the connection without a negative acknowledgement: the commit outcome may be unknown.

This remains the client contract even when a SQL timeout actually caused a rollback. **Do not automatically retry the write.** A read of the old state alone does not prove that an earlier request cannot still commit. Follow the SDK's [conflict and recovery guidance](https://docs.ulda.io/sdk/recovery/).

### Shut down gracefully

On shutdown, the server rejects new admissions and makes readiness false. It drains accepted work before closing transport and storage.

`GRACEFUL_SHUTDOWN_TIMEOUT_MS` defaults to **10 seconds**. Exceeding it reports failure and sets exit code 1, but the process continues waiting. The deadline does not cancel SQL, release resource locks or discard accepted mutations.

Choose an external termination grace period for your workload. The Compose recipe uses 30 seconds; it cannot guarantee every operation finishes within that interval. After a forced kill, unacknowledged writes remain uncertain.

### Monitor useful signals

Track internal readiness, container restarts and OOM events, memory and CPU usage, free disk space, PostgreSQL connections, backup age and restore drill results. Measure acknowledgement latency in the application without collecting private data.

The built-in logger emits JSON records with fixed diagnostic codes. Useful starting points include:

| Diagnostic | Investigate |
| --- | --- |
| `server.start_failed` | Configuration, initial storage access or startup. |
| `storage.pool_error` | A failed idle PostgreSQL connection. |
| `socket.mutation_uncertain` | A connection closed with an unresolved mutation. |
| `server.shutdown_timeout` | Work still running after the drain deadline. |
| `migration.failed` | Migration failure; inspect the ledger before retrying. |

The standard image has no public metrics endpoint. Use the [logging reference](https://docs.ulda.io/server/logging/) for diagnostic semantics; raw packet or exception dumps are not a monitoring strategy.

### Back up and test restoration

A persistent volume is not a backup. Keep protected off-host copies and test restoration into an isolated database before relying on them.

A consistent ULDA backup must preserve resource IDs, witness and payload bytes, the identity sequence and migration ledger. Back up operator configuration and credentials separately. The server cannot reconstruct a client's master ID or password.

After restoring, check the ledger and sequence, then test SDK connect/read, an authorized update and a new resource allocation. Keep the original server offline during acceptance; do not run both copies as active writers.

Restoring an older backup can put stored chains behind existing SDK sessions. Close and reopen those sessions explicitly and account for data lost since the backup. Do not replay mutations automatically.

Use the [backup and restore procedure](https://github.com/ulda-io/ulda-server/blob/main/docs/DEPLOYMENT.md#backup) as a starting point, adapting its database commands to your operator-managed service.

### Upgrade with a recovery point

1. Record the image digest, configuration and migration ledger. Take and validate a backup.
2. Review the new image and pin its immutable digest.
3. Stop incoming traffic and the server. Confirm that no other writer is active.
4. Run the migration job explicitly with the new image and require exit code **0**.
5. Start one server, wait for readiness, verify SDK reads and writes, then reopen traffic.

Switching back to an old image does not undo schema, data or role changes. Use an older image only with a reviewed compatible schema; otherwise restore the accepted backup into a separate database. A failed migration may have committed earlier migration files, so inspect its ledger before proceeding.

---

## Server wire protocol

Page: https://docs.ulda.io/server/protocol/

The default server implements the binary `ulda:*` contract used by
`ulda-auth@1.1.0`, including its large-operation extension. Socket.IO uses the root namespace and `/socket.io/` path.
The configured protocol identifier `1.0` is not a server release number and
is not repeated in every command.

### Fields and framing

Every command takes exactly two arguments: one body and a Socket.IO ACK callback.
The server attempts the ACK at most once. Without a callback, the command does
not execute. Normal SDK calls handle Socket.IO framing and callbacks internally.

- `id`: canonical decimal string from `"1"` through `"9223372036854775807"`.
  No sign, leading zeros or numeric JSON value. This is the PostgreSQL limit;
  other SDK backends may support longer IDs.
- `witness`: public candidate bytes, a Socket.IO binary attachment.
- `payload`: opaque bytes, a Socket.IO binary attachment.
- Binary values are `Uint8Array`/`Buffer`, not Base64 strings.
- Ordinary CRUD arrays are nonempty, at most 64 entries, with no duplicate IDs.
- Additional resource metadata is not accepted: no role, name, parent, password,
  origin, `baseWitness`, revision, `requestId`, `sessionId` or `watch`.

A server resource is exactly `{id,witness,payload}`. The server does not parse
payloads, even as JSON or encryption containers.

### Events and acknowledgements

The following shapes are notation, not JSON encodings of binary data.

| Event | Body | Successful ACK |
| --- | --- | --- |
| `ulda:hello` | `{}` | `{ok:true}` |
| `ulda:read` | `[id,...]` | `{ok:true,data:[{id,witness,payload},...]}` |
| `ulda:create` | `[{witness,payload},...]` | `{ok:true,data:[id,...]}` |
| `ulda:update` | `[{id,witness,payload},...]` | `{ok:true}` |
| `ulda:delete` | `[{id,witness},{id,witness,payload},...]` | `{ok:true}` |
| `ulda:session.close` | `{}` | `{ok:true}` |

Read returns all records in input order. A missing or oversized member rejects
the entire read, without a partial response or partial new subscriptions.
Create returns one generated ID per input entry, in order.

For Delete, the absence of `payload` means delete. An entry with `payload`
means update, even if that payload has zero length. At least one entry must be a
deletion. This permits an encrypted catalog update and a resource deletion in
one transaction without disclosing a catalog relationship.

Hello confirms router availability. It is not login, identity verification or
a database check; use internal HTTP `/ready` for the latter. Session close ends
the logical socket session and its subscriptions. New commands require a new
connection; accepted writes are not cancelled.

### Pipeline and atomicity

After whole-command parsing and input/copy admission, Read/Update/Delete acquire
all resource keys in canonical numeric order through the shared FIFO.
The response preserves input order. Independent ID sets may progress concurrently.
Create has a separate queue until storage assigns IDs.

**Read:** reserve subscription and read capacity → one aligned bounded storage
read → validate all records and aggregate response size → subscribe → ACK.

**Create:** reserve subscription capacity → atomic `createBatch` → subscribe
new IDs on the live session → ordered ID ACK. The initial witness is only
bounded opaque bytes; core does not verify initial ownership or a predecessor.

**Update and mixed Delete:** reserve capacity → one aligned read of current
records → for each entry, await `verify(current.witness,candidate.witness)`
then await the proof verifier → one atomic `applyBatch` → subscribe updated IDs
→ Changed to other subscribers → clear deleted subscriptions → success ACK.

All validation must pass before storage mutation starts. The default proof
stage receives `null` and accepts it; it supplies no content or identity proof.
Locks and owned reservations remain until the protected pipeline finishes,
including the ACK attempt. See [socket boundaries](https://docs.ulda.io/server/ordering/).

PostgreSQL uses one PoolClient for BEGIN, every batch statement and COMMIT.
Failure before commit causes a rollback attempt of the whole transaction.
Unexpected storage completion errors still have the conservative uncertain
outcome described below. A mutation affecting an unexpected row count fails.

Current records are read before BEGIN while Router locks are held. The server
must be the only writer, with read-after-write storage. This is not a
multiple-replica consistency mechanism. Custom adapters must implement atomic
batches; there is no separately committed single-operation fallback.

The first accepted valid transition wins. A second candidate based on the old
state is checked against the newly stored witness and conflicts if it is no
longer a valid continuation. Repeating a previously accepted candidate conflicts.
There is no write replay, automatic rebase or idempotency key.

A content Create followed by Update of content and master is two commands and
two transactions. A resource can remain unlinked between them. The server does
not interpret the client's hierarchy or repair an interrupted workflow.

### Subscriptions and Changed

The Router maintains two indexes: resource ID → sockets and socket → resource
IDs, with no duplicate edges. It does not create application resource rooms.

Successful Create, Read and Update subscribe before ACK. Mixed Delete subscribes
its updated IDs. Subscription capacity is reserved before asynchronous storage,
including concurrent commands on one connection. Errors add no new subscriptions.

After commit, each other affected subscriber receives one logical Changed event.
When it fits ordinary framing, this is one `ulda:changed` event:

```text
{
  updated: [{ id, witness, payload }],
  deleted: [id]
}
```

Either array may be empty. Each recipient receives only its subscribed IDs.
The author socket is excluded. Other tabs or devices have their own sockets
and receive updates normally. A deleted resource is removed from every
subscription index; no tombstone is stored.

Disconnect or session close clears both index directions. Late completion of
an accepted command cannot restore an ended session's subscriptions.

Changed is best-effort, with no client ACK, durable history or replay. Sending it
fails independently for each recipient; failure cannot roll back a commit or
turn a successful mutation into a definite rejection. Across connections, there
is no guarantee whether a subscriber observes Changed before the writer sees
its ACK. Reconnect followed by Read restores current state and subscriptions.

### Errors and unknown outcomes

See [large-operation transfer](https://docs.ulda.io/server/protocol/#large-operation-extension) for staging and
fragmented read/notification rules. Those rules do not change Commit uncertainty.

A definite failure before storage mutation returns:

```text
{ ok: false, error: 'code' }
```

| Code | Meaning |
| --- | --- |
| `invalid_request` | Malformed command, field shape or ID |
| `too_large` | Size, queue, subscription or other available capacity exceeded |
| `not_found` | A requested resource is absent |
| `invalid_witness` | Witness processing failed with a recognized witness error |
| `conflict` | Candidate is not an accepted continuation of stored state |
| `invalid_proof` | Configured proof policy rejected the entry |
| `not_ready` | Router/session is not accepting work |
| `storage_error` | Safe fallback for failures before mutation |

Errors contain no raw exception message, stack, private data or current row.
Parser/transport violations can close the connection before an ACK is possible.

If a mutation has started and storage throws unexpectedly, the server closes
the writer without a negative ACK. COMMIT may have succeeded before its response
was lost. The SDK reports `UNKNOWN_OUTCOME` and does not repeat the write.
This is conservative even when the transaction actually rolled back.

Read known IDs to reconcile. A lost Create ACK has no ID recovery mechanism and
can leave an unlinked row. Volatile delivery does not guarantee receipt or
combine separate commands into an atomic operation.

Disconnect does not cancel accepted work. Shutdown rejects new admissions,
drains accepted pipelines, then closes transport and storage. A forced kill
does not provide graceful completion guarantees.

### Limits and profiles

| Boundary | Limit |
| --- | ---: |
| Batch entries | 64 |
| Binary attachments | 128 |
| UTF-8 packet header | 65536 bytes |
| Individual attachment / payload | 1100000 bytes |
| Aggregate binary bytes | 8388608 bytes |
| Witness | min(`MAX_WITNESS_BYTES`, 167) bytes |
| Engine.IO frame | min(`MAX_SOCKET_MESSAGE_BYTES`, 1100000) bytes |

Zero-length payloads are permitted. Read responses also undergo aggregate
binary-size validation. Oversized batches are rejected, not split by the server.
Increasing operational budgets does not raise these fixed wire limits.

The compatible SDK generation profile is Compact V1, N=5, SHA-256,
originSize=256, uint32 age, X/S. Create does not inspect or enforce the first
profile; the Compact V1 verifier requires the configured origin size.

Additional connection, pending-command, read, subscription and outbound budgets
are in [admission limits](https://docs.ulda.io/server/admission/). A slow or overloaded receiver may
be disconnected; a committed mutation remains committed. Budgets are not exact
RSS or complete abuse protection.

### Large-operation extension

Clients advertise Socket.IO handshake `auth: {uldaTransfer:1}` to receive
fragmented notifications. This flag is a framing capability, not authentication.
Resources remain exactly ID, witness and opaque payload; no catalog role is added.
Ordinary CRUD remains compatible with 1.0.1 for its original supported sizes.

`ulda:transfer` accepts one exact body plus an ACK callback:

| Body | Successful ACK |
|---|---|
| `{kind:'begin',action:'create'\|'update'\|'delete',count}` | `{ok:true,token}` |
| `{kind:'append',token,items}` | `{ok:true}` |
| `{kind:'part',token,resourceId?,witness,total,offset,payload}` | `{ok:true}` |
| `{kind:'commit',token}` | Original action's ACK: Create returns IDs; Update/Delete return `{ok:true}` |
| `{kind:'cancel',token}` | `{ok:true}` |
| `{kind:'read',resourceId,offset}` | `{ok:true,data:{id,witness,payload,total,offset}}` |

A random 32-character lowercase hexadecimal token identifies one upload on one
connection. It is neither a durable operation ID nor an idempotency key. Begin
counts must fit the Router's total-key budget; Create remains at most 64 records.
Append uses the ordinary action entry shape, at most 64 items and 8 MiB of binary
data. A Delete append may contain only update entries, but its completed operation
must contain at least one deletion. Duplicate resource IDs are rejected.

Part carries at most 1 MiB of ciphertext. Omit resourceId only for Create.
Total/offset are byte counts; each resource begins at offset zero and continues
contiguously with the same ID, witness and total. All data must fit resource,
logical-operation and shared staging budgets before allocation/copying.

Begin/Append/Part never mutate storage or open a database transaction. Commit
requires the complete upload, obtains all affected Router keys, validates current
rows in groups of at most 64, then invokes storage once for the entire operation.
A late conflict rejects every mutation. Updated IDs are subscribed before ACK;
Changed is published only after commit. Accepted Commit may finish after a
disconnect; its reservations remain owned until it finishes.

One connection can hold one upload and one large read. Upload expiry, Cancel,
malformed staged data or disconnect clears uncommitted staging. The absolute
deadline is not renewed by each part. Missing Commit ACK is an unknown outcome,
never permission to replay. An interrupted pre-Commit upload cannot itself commit.

Ordinary Read still accepts at most 64 IDs and returns one complete ACK. The SDK
divides larger catalogs into sequential ID arrays and reduces a group on an
explicit `too_large` response. The server never streams an unbounded ID array.
Temporary read-budget contention waits in a bounded server queue. If that wait
cannot finish, `not_ready` is returned without changing storage; current SDKs
surface `SERVER_ERROR` and do not split/replay this rejection. See
[read admission](https://docs.ulda.io/server/admission/#reads-and-subscriptions).
For an oversized singleton, Read offset zero pins a bounded ciphertext snapshot;
subsequent offsets return at most 1 MiB from that same witness/version. Last part,
expiry or disconnect releases it. New reads after completion may see newer state.
Separate resource reads are not one global database snapshot.

Large Changed uses `ulda:changed.part` with ordered frames:
`{kind:'begin',count}`, then `{kind:'append',items}` and/or
`{kind:'part',resourceId,witness,total,offset,payload}`, then `{kind:'end'}`.
Append entries are updated `{id,witness,payload}` or deleted `{id}`.
Each recipient receives only its subscribed resources. No client ACK is required.
The SDK reassembles and verifies the complete event before publishing local state.
All frames of a logical notification are enqueued together; ordinary output
budgets still apply and can disconnect a slow or overloaded recipient.

If a notification cannot fit ordinary framing and its recipient did not advertise
the capability, the server disconnects that recipient rather than silently
leaving stale state. Upgrade all clients sharing a catalog before exceeding the
old SDK limits. There is no encrypted-format migration or nested-catalog logic.

### Legacy protocol boundary

The historical `resource:*` / Base64 API is available only through explicit
`createUldaServer({protocol:"legacy"})` in tests/benchmarks. Production does not
register those handlers. Its metadata and retry behavior are not part of this
contract. [Legacy documentation](https://github.com/ulda-io/ulda-server/blob/5469821/docs/LEGACY_PROTOCOL.md).

---

Reference for Server 1.0.0. [Reviewed source](https://github.com/ulda-io/ulda-server/blob/5469821/docs/SDK_PROTOCOL.md).

---

## Server admission and limits

Page: https://docs.ulda.io/server/admission/

The default SDK endpoint bounds connections, retained input, concurrent reads,
outbound messages, queued work and subscriptions. Work reserves capacity before
using the corresponding resources. Read-capacity contention has a bounded FIFO
wait; other capacity failures reject admission instead of adding to an unbounded queue.

There is one process, one Router and independent per-ID queues, without a global
execution lock. These controls apply to `ulda:*`; the explicit legacy benchmark
endpoint does not inherit the SDK guarantees.

### Settings

Settings are read at process startup. Compose passes only variables explicitly
interpolated in compose.yaml; other settings require a Compose environment
override. See [configuration](https://docs.ulda.io/server/configuration/).
Invalid values fail startup. Zero does not disable these limits.
MiB means 1048576 bytes.

| Variable | Default | Accounting scope |
| --- | ---: | --- |
| MAX_CONNECTIONS | 1000 | Live Engine.IO connections plus reserved pending handshakes |
| MAX_INFLIGHT_BYTES | 67108864 | Shared input, queued packets and defensive-copy reserves |
| MAX_ACTIVE_READ_BYTES | 268435456 | Worst-case encrypted rows for active Read/validation |
| MAX_BUFFERED_TOTAL_BYTES | 67108864 | Shared outbound bytes until transport drain |
| MAX_BUFFERED_SOCKET_BYTES | 16777216 | Per-connection outbound bytes |
| MAX_PENDING_COMMANDS_PER_SOCKET | 64 | One decoder's assembly, dispatch, queued and executing packets |
| MAX_COMMANDS_PER_SECOND | 1000 | Per-decoder token-bucket refill rate |
| COMMAND_BURST | 2000 | Token-bucket capacity |
| BINARY_PACKET_TIMEOUT_MS | 30000 | Absolute binary assembly deadline after its header |
| MAX_SUBSCRIPTIONS_TOTAL | 100000 | All socket–ID edges plus reserved slots |
| MAX_SUBSCRIPTIONS_PER_SOCKET | 32768 | One socket's edges plus reserved slots |
| MAX_QUEUED_TOTAL | 32768 | Total Router key reservations; a batch occupies multiple keys |
| MAX_QUEUED_PER_RESOURCE | 100 | Running/waiting reservations for one key |
| MAX_RESOURCE_BYTES | 67108864 | Reassembled ciphertext per extended resource; 1100000–67108864 |
| MAX_TRANSFER_BYTES | 134217728 | Logical operation bytes, including 256 per entry; 1024–134217728 |
| MAX_STAGED_BYTES | 268435456 | Shared staged-write accounting; 1024–4294967296 |
| TRANSFER_TIMEOUT_MS | 60000 | Absolute lifetime of an uncommitted upload or pinned read; 100–300000 ms |

Fixed outbound packet ceilings are 4096 per socket and 65536 overall.
Engine.IO handshake and Socket.IO CONNECT waits are bounded at 10 seconds.
[Wire size limits](https://docs.ulda.io/server/protocol/#limits-and-profiles) remain fixed when
operational budgets are raised.

`MAX_CONNECTIONS=1000` is a configurable admission budget, not a single-process
capacity ceiling. The accepted configuration range is 1–100000; that range is
not a promise that a machine can sustain that many connections. Compose forwards
this variable, so an operator can raise it in `.env` and recreate the server.
Keep memory, subscriptions, pending work and edge limits consistent with the new
budget, and measure the intended workload before increasing it in production.

Connection count and updates per second are different measurements. Idle sockets
cost memory and heartbeat work; active sockets also consume verification, SQL,
serialization and notification capacity. A core-only `verify` benchmark or an
in-memory adapter benchmark does not measure a complete encrypted SDK update
persisted in PostgreSQL. The supplied capacity test uses 100 active connections;
it does not certify the maximum supported connection count.

### Input ownership

1. Before header decoding, reserve 1024 bytes per packet plus four times the
   header's UTF-8 length. These are accounting units, not measured object sizes.
2. Before reconstruction, each attachment reserves its length plus 64 bytes.
   An incomplete packet already consumes shared capacity and a pending slot.
3. The same lease follows the reconstructed packet through Socket.IO dispatch.
   The handler claims it by the body object, not by ACK callback identity.
4. Mutation parsing checks all shapes, IDs and logical byte lengths first.
   Before defensive copies it reserves three times the total logical
   witness/payload length. Reused attachment placeholders count separately
   where they create separate copies.
5. The accepted operation retains ownership through queueing and execution,
   releasing it once in `finally`. Rejection, malformed input and reconstruction
   timeout release only the corresponding unaccepted packets.
6. Decoder destruction releases incomplete or unclaimed work. Disconnect does
   not cancel SQL or release the accepted operation's memory reservation early.

The rate bucket consumes one token per Socket.IO header, including CONNECT and
DISCONNECT; attachments are not additional tokens. The assembly deadline does
not extend when more fragments arrive. Only one root namespace CONNECT is allowed.
Unsolicited client ACK packets are rejected because Changed requests no receipt.
Engine.IO heartbeats are handled at another layer.

### Reads and subscriptions

Before fetching records for Read or mutation validation, reserve
`numberOfIds × (1100000 + 167)` bytes. The row size is unknown before the query,
so small records consume this same conservative reservation.

The default 256 MiB fits at most 243 concurrent record positions. A 64-entry batch
reserves 70410688 bytes. One ID needs at least 1100167 bytes; a two-resource
Update needs 2200334. A smaller budget can reject even tiny actual records.
Commands waiting for Router keys do not hold active-read capacity yet.

After acquiring all Router keys, a command waits up to 5000 ms for its read
reservation. At most `min(1024, MAX_QUEUED_TOTAL)` reservations can wait; existing
input, pending-command and Router-key budgets also continue to apply. Waiting
commands retain only bounded input and queue metadata, not query results.
No storage read starts before capacity is granted. FIFO order prevents smaller
new requests from bypassing an older large one. The byte budget is unchanged.

Queue exhaustion or expiry returns `not_ready`; a reservation larger than the
entire configured budget still returns `too_large`. Disconnect/session close
cancels waiting reservations. Shutdown cancels waiters and drains active storage
operations. Once SQL starts, disconnect does not release its reservation early.
The internal admission snapshot includes `waitingReads`.

Every read/validation acquires all Router keys before requesting read capacity;
a granted reservation never waits for another key or a second reservation.
Large-read continuation frames use their pinned snapshot without acquiring
either. This ordering prevents a resource-lock/read-budget cycle. A slow or
abandoned pinned read can still exhaust other callers' bounded wait; its own
absolute transfer deadline and disconnect cleanup remain in force.

PostgreSQL checks each column length and, for ordinary Read, the aggregate
witness/payload size before transferring BYTEA values into Node. One statement
uses the same MVCC snapshot for checks and bytes, with no metadata/payload race.
Any oversized member or aggregate rejects the whole operation without returning
payloads. Aggregate accounting includes repeated result slots. The in-memory
test adapter checks before copying. Custom `findManyByIds(ids,bounds)` adapters
must honor per-row and optional `maxTotalBytes` bounds before transfer/copying;
a later service check cannot undo an allocation already made by trusted code.

Ordinary Read passes the unchanged 8 MiB aggregate wire ceiling to storage and
checks it again before ACK. Validation and pinned large reads keep their own
per-row bounds and conservative reservations; the ordinary wire ceiling does
not restrict a staged atomic operation. Read capacity is released after the Read
ACK or after mutation validation finishes. Output has separate accounting.

Subscription capacity is reserved per connection and globally before awaits or
writes. Existing edges are not duplicated; concurrent requests include already
reserved slots. Create/Read/Update subscribe before ACK; mixed Delete subscribes
updated IDs. Session end removes both indexes and cannot be undone by late work.
Deletion removes the ID's edges from the registry. No resource rooms are created.

### Extended transfer ownership

An upload begins with a reservation of `count × 256` bytes. Before retaining
binary data, it reserves three times the logical witness/payload bytes in the
shared staging budget. Each Socket.IO frame also retains its ordinary input
lease until its handler finishes. A connection holds at most one upload.
Malformed staged data, Cancel, expiry and disconnect release the uncommitted
reservation. An accepted Commit detaches it from connection cleanup and keeps
it until validation/storage/publication completes, including failure paths.

The upload does not hold database locks or an SQL transaction. Commit obtains
all Router keys and validates current rows in groups of at most 64 before one
atomic storage call. The count must fit `MAX_QUEUED_TOTAL`; concurrent work can
still make admission fail. These are server budgets, not a catalog schema quota.

A large singleton read pins one ciphertext version and reserves
`3 × (MAX_RESOURCE_BYTES + 167)` bytes until the last fragment, expiry or close.
Validation of an oversized current row uses the same conservative reservation.
At the defaults, one such reservation is about 192 MiB; concurrent large reads
use the same bounded FIFO admission under the 256 MiB read budget. A small configured read
budget can reject an otherwise small resource on this path. No partial snapshot
is exposed to the SDK. Ordinary reads retain their existing accounting.

The shared staging snapshot exposes `stagedBytes` and `stagedPeakBytes`.
These reservations are accounting units, not an exact process-memory ceiling.

### Output buffering

ACKs, Changed, binary attachments and control packets reserve their length plus
64 bytes per frame. Accounting spans the Engine.IO write buffer and the period
after flush until transport drain. Flush alone does not release capacity.
Upgrade, drain and close release their owned portions once.

If either per-socket or shared output capacity is exceeded, the socket currently
receiving that attempted enqueue is closed. This is not a fairness guarantee:
a healthy recipient can also be closed when shared capacity is exhausted.
Disconnect releases that socket's output and subscriptions, not another socket's
reservations. Committed writes stay committed.

### Client-visible results

| Condition | Result |
| --- | --- |
| No handshake capacity | Connection rejected; Socket.IO connect_error |
| Shutdown in progress | New handshake/work rejected as not ready |
| Copy/subscription/Router capacity exhausted, or read reservation cannot fit the entire budget | ACK `{ok:false,error:"too_large"}`; no mutation by that command |
| Read-capacity wait full, expired or cancelled before storage | ACK `{ok:false,error:"not_ready"}` while the connection remains available; no mutation by that command |
| Parser size/rate/pending/deadline violation | Connection closed; a negative ACK is not guaranteed |
| Output exhausted | Recipient closed; committed data is retained |
| Unexpected failure after mutation starts | Writer closed without a negative ACK; unknown result |

A sent write may be uncertain after connection loss even if the server actually
rejected it before SQL. The client cannot infer definite failure without a reply.
There is no automatic mutation replay. Read after reconnect reconciles known IDs;
a lost Create ACK cannot recover the generated ID.

Published `ulda-auth@1.1.0` maps `not_ready` to the
fixed `SERVER_ERROR` code. It does not split or retry it. Successful queued reads
use the existing ACK format and need no client changes. Only a confirmed
`too_large` read can trigger the SDK's size fallback.

For writes, SDK 1.1.0 can try one staged transfer after a confirmed `too_large`
rejection, including a subscription-capacity refusal. The staged commit applies
the same subscription limits before any database write. If it is rejected, the
SDK disconnects to discard uncommitted staging; the server releases subscriptions
and reservations. Reconnect restores subscriptions through Read without replaying
the rejected write. An unknown write outcome does not permit this fallback.

### Observation and deployment

The embedded `server.admission.snapshot()` exposes numeric current/peak accounting
and connection/pending/packet counts. It is not a public HTTP endpoint.
Diagnostic logs contain no raw witness, payload, origin, password or database
exception. [Logging policy](https://docs.ulda.io/server/logging/).

Budgets do not add up to process RSS. Runtime metadata, garbage collection,
JSON parsing, driver copies, database temporary results, proxy/TLS and kernel
buffers cost additional memory. Size containers and host headroom from measured
payload/batch/concurrency profiles; defaults do not certify any machine capacity.

MAX_CONNECTIONS does not count every raw HTTP/TCP connection before handshake.
The rate bucket is per decoder and resets on a new connection, not per IP.
Use edge connection/rate controls, TLS, monitoring and backups. One application
process does not eliminate native runtime or operating-system threads.
A second writer against the same namespace is unsupported.

Lower budgets can reject otherwise valid requests. Tuning must not weaken
atomicity, change cryptography or introduce automatic write replay.
See [deployment](https://github.com/ulda-io/ulda-server/blob/5469821/docs/DEPLOYMENT.md) and [testing](https://github.com/ulda-io/ulda-server/blob/5469821/docs/TESTING.md).

### Large catalogs and combined budgets

ULDA Auth 1.1.0 removes the fixed 63-content catalog cap. It does not remove server admission limits. On a fresh connection, the default 32,768 subscription ceiling permits at most 32,767 contents plus the master, before other limits. One hundred clients opening 10,000 contents each exceed the global 100,000 subscription budget.

Complete atomic deletion needs Router capacity for every affected ID at commit. Fragmenting the upload does not split that transaction. Staging accounts for three copies of binary bytes plus 256 bytes per item, so an operation below the logical 128 MiB ceiling can still exhaust the 256 MiB staging budget.

Large Changed delivery also uses the outbound queue: a valid resource can exceed the 16 MiB per-connection budget and cause a recipient to disconnect. Reconnect/Read recovers state; Changed has no durable replay log. The SDK eagerly loads all content and re-encrypts the entire master on catalog mutations. Catalog count alone is not a memory or throughput estimate.

Subscriptions use two Maps with per-resource/per-connection arrays. Membership and removal scan the relevant list; the implementation does not scan all clients for each ordinary update. Large fan-out remains proportional to the recipient set. Raising connection or subscription limits requires measurements with the actual payload and fan-out profile.

---

Reference for Server 1.0.0. [Reviewed source](https://github.com/ulda-io/ulda-server/blob/5469821/docs/ADMISSION_LIMITS.md).

---

## Replies, ordering and failures

Page: https://docs.ulda.io/server/ordering/

This document describes the default `ulda:*` endpoint. The complete command
bodies and limits are in [SDK_PROTOCOL.md](https://docs.ulda.io/server/protocol/) and
[ADMISSION_LIMITS.md](https://docs.ulda.io/server/admission/). Opt-in legacy benchmarks are outside
this contract; the production endpoint does not register their handlers.

### Replies

Every command requires one body and a Socket.IO ACK callback. The server attempts
the ACK at most once. An ACK confirms a command result; it is not an additional
client receipt, authentication mechanism, or guarantee that a device received it.

| Command | Success ACK |
| --- | --- |
| `ulda:hello` | `{ok:true}` |
| `ulda:create` | `{ok:true,data:[id,...]}` in input order |
| `ulda:read` | `{ok:true,data:[{id,witness,payload},...]}` in requested order |
| `ulda:update` | `{ok:true}` |
| `ulda:delete` | `{ok:true}` |
| `ulda:session.close` | `{ok:true}` |

IDs are canonical positive signed-BIGINT decimal strings. Witness and payload
are binary attachments. Read is all-or-error: a missing or oversized late item
cannot produce a partial success or partial new subscription.

A definite rejection before mutation returns only `{ok:false,error:code}`.
Allowed codes are `invalid_request`, `invalid_witness`, `conflict`, `not_found`,
`too_large`, `not_ready`, `invalid_proof` and `storage_error`. Internal queue and
subscription-capacity refusals map to `too_large`; rejected witness transitions
map to `conflict`. Unrecognized exceptions map to `storage_error`.

The mapper does not serialize exception messages, stacks, causes, current rows,
arbitrary codes or `toJSON`. It reads only a recognized own string data property
on a domain error, without getters or coercion. A failed inspection also returns
the fixed fallback. Invalid commands with an ACK receive `invalid_request`;
commands without an ACK do not execute. Parser/frame or ingress failures may
instead close the connection before a command can be dispatched.

### Server execution order

1. Validate the whole command and reserve ingress/copy capacity.
2. Acquire the relevant FIFO slots. Read/Update/Delete hold all selected resource
   locks; Create has a separate queue until storage assigns IDs.
3. Reserve subscription capacity before asynchronous storage work. Read and
   mutation validation also reserve worst-case row bytes.
4. Read checks the entire result, subscribes, then replies. Create waits for one
   atomic batch transaction, subscribes the live session, then returns its IDs.
5. Update/mixed Delete reads all rows and checks every witness and proof before
   starting one atomic storage mutation. A late validation failure writes nothing.
6. After storage reports successful commit, subscribe the updated IDs and emit
   one `ulda:changed` packet to each other affected subscriber. Its only fields are
   `{updated:[{id,witness,payload},...],deleted:[id,...]}`. Each recipient sees only
   its subscribed IDs. The author's socket receives no echo.
7. Remove all subscription edges for deleted IDs, attempt the writer's success
   ACK, then release the locks and remaining operation reservations.

The order above is server execution order. Independent connections can observe
Changed and ACK in a different wall-clock order. There is no cross-client
delivery barrier. An overlapping command cannot enter the protected pipeline
before the preceding pipeline finishes. Unrelated IDs can progress independently.

### Failures after mutation starts

If storage rejects after a mutation has started, the outcome can be unknown,
including when COMMIT succeeded but its response was lost. The server closes the
writer without a negative ACK and does not publish an unconfirmed result. It
does not retry or infer rollback from a timeout. A missing Create ACK can leave
an allocated ID unknown to the caller; that remains an accepted protocol limit.

After a confirmed commit, failure to emit Changed cannot undo storage, stop
healthy recipients, or turn success into a rejection. Deleted-resource registry
cleanup still runs. A synchronous failure of the diagnostic callback must not
interrupt fan-out or prevent closing an uncertain writer. A failed ACK send is
contained and is not attempted again, including an ACK for a malformed event.

Changed is best-effort: it has no receipt, durable history or replay. A missed
notification requires a fresh Read/SDK refresh or reconnect. A live subscription
is not proof that a client has received every change. Closing a logical session
clears subscriptions but does not cancel already accepted writes; later
completion cannot recreate subscriptions for that session.

### Reservation ownership

| Reservation | Ownership and release |
| --- | --- |
| Ingress bytes and pending command | Decoder owns incomplete and undispatched packets. Handler claims the lease before accepted work and releases it in `finally`. Decoder destruction releases only unclaimed work. |
| Defensive input copies | Charged to the same ingress lease before allocation; released with that operation. |
| Resource queue slots | Held through the protected pipeline, including commit and publication/ACK attempts. Exceptions release slots without poisoning the next operation. |
| Read bytes | Reserved before storage Read/validation and released after actual completion or failure. A client disconnect alone does not release them. |
| Subscription reservations | Per-socket and global slots reserved before awaits. Always released after the attempt; successful live registry edges remain until deletion/session end. |
| Outbound bytes/packets | Remain charged after flush until transport drain or close. Closing one receiver does not release another receiver's backlog. |
| Connection slot | Covers pending handshake and the adopted physical connection. Upgrade does not allocate a second slot. |

Shutdown rejects new queued commands with `not_ready`, drains accepted work,
then closes transport and storage. An accepted write keeps its memory and locks
until it actually settles. Accounting limits bound defined work/backlog; they
are not an exact measurement of process RSS or a complete network abuse policy.

### Trusted extension boundary

Storage, proof/verifier and logger implementations run inside the server process.
They are trusted code. Fixed wire errors and contained diagnostic failures handle
ordinary failures; they do not sandbox a malicious plugin. Storage must preserve
atomicity, aligned bounded reads and read-after-write, with no hidden work after
its promise resolves. A never-settling adapter can prevent graceful drain.

The server stores opaque payload bytes and public witnesses; it cannot validate
client plaintext or AEAD without keys. It never assigns master/content roles.
Initial trust, decryption and payload authenticity remain client responsibilities.

The [test suites](https://github.com/ulda-io/ulda-server/blob/5469821/docs/TESTING.md) cover these boundaries with controlled fault scenarios.

---

Reference for Server 1.0.0. [Reviewed source](https://github.com/ulda-io/ulda-server/blob/5469821/docs/SOCKET_BOUNDARIES.md).

---

## Server diagnostics

Page: https://docs.ulda.io/server/logging/

The built-in logger writes one JSON object per line. `LOG_LEVEL` selects `debug`,
`info`, `warn`, or `error`. It changes verbosity, not the privacy policy.

Each record contains `time`, `level`, a stable `diagnostic` code, and a fixed
`message`. Optional context is restricted to validated public identifiers,
known event/error codes, durations, bind address/port, shutdown signal, disconnect
reason, and migration counts. Unknown fields and invalid values are omitted.
An unknown message becomes `server.diagnostic` / `Server diagnostic`.

Exceptions passed to the logger are never serialized or inspected. Their
messages, stacks, causes, custom properties, getters and string conversions are
not used. The record may contain `error: "details_suppressed"`. Passwords, database
connection strings, resource bytes, witnesses, origins and arbitrary objects must
not be passed as diagnostic context. The logger drops them if they are supplied
as extra fields. Context accessors, inherited properties and `toJSON` are ignored.

### Diagnostic codes

| Code | Meaning |
| --- | --- |
| `server.started`, `server.stopped` | Application lifecycle |
| `server.start_failed` | Configuration, storage setup or server startup failed |
| `server.shutdown_requested` | A supported termination signal was received |
| `server.shutdown_timeout` | Grace period elapsed; this does not cancel accepted writes |
| `server.shutdown_failed` | Shutdown rejected; exit code is set to 1 |
| `storage.cleanup_failed` | Storage cleanup after failed startup also failed |
| `storage.pool_error` | An idle PostgreSQL client reported an error |
| `migration.applied`, `migration.ready`, `migration.failed` | Migration progress or failure |
| `socket.outbound_limit` | A receiver exceeded its output allowance |
| `socket.changed_failed`, `socket.broadcast_failed` | Publication failed after a storage commit |
| `socket.mutation_uncertain` | Connection closed because the mutation result is uncertain |
| `socket.connected`, `socket.disconnected` | Legacy endpoint connection lifecycle |
| `socket.command_rejected`, `socket.command_failed` | Legacy endpoint command diagnostics |
| `server.diagnostic` | Unrecognized diagnostic label was suppressed |

The migration CLI uses the same logger. Completion reports the number of newly
applied and previously applied migrations, without reflecting filenames or raw
SQL exceptions. Automation should check the process exit code; a successful
migration has exit code 0 even when its informational logs are filtered out.

### Operating and extending the server

Use diagnostic codes to identify the failing stage, then reproduce it with
synthetic data and controlled configuration. Do not enable raw exception or
packet dumps in production. A `Changed` delivery failure does not undo a commit;
consult the [protocol](https://docs.ulda.io/server/protocol/) before retrying a client operation.

When adding a built-in diagnostic, register a fixed message/code pair and add
only narrowly validated context fields. Do not interpolate client data into
messages. Regression tests cover the logger and the startup, storage cleanup,
pool callback, shutdown and migration entrypoint paths.

This policy covers messages routed through the built-in logger and the handled
entrypoint failures. It is not a process-wide interceptor: custom loggers,
trusted adapters, dependencies, PostgreSQL, reverse proxies, the container host,
and uncaught failures outside these paths have their own logging behavior.
Review those separately. Even public IDs and connection metadata can disclose
activity patterns, so restrict log access and retention.

---

Reference for Server 1.0.0. [Reviewed source](https://github.com/ulda-io/ulda-server/blob/5469821/docs/LOGGING.md).

---

## Server security boundary

Page: https://docs.ulda.io/server/security/

ULDA Server stores public witnesses and opaque client payloads, verifies directed
witness transitions and coordinates one application writer over PostgreSQL.
This policy covers the official server code, Socket.IO boundary, PostgreSQL
adapter, migrations and supplied deployment configuration. The release contains
only the application image and its bundled runtime/dependencies. Operators
maintain database/proxy services, images, patches, network access and backups.
Defects in our adapter or deployment instructions remain in scope even when
the database is externally managed. See [deployment ownership](https://docs.ulda.io/server/deployment/).

Repository PostgreSQL/Nginx builds are local integration fixtures, not published
production dependencies. Their known CVEs remain visible in separate advisory
reports. Application image checks still reject High/Critical/unknown findings
and invalid scan evidence; no database package exception applies to the server.

### Supported versions

Security fixes target the latest released `1.x` version. Upgrade to that version
before reporting an issue that may already be fixed. Pre-1.0 builds and older
patch releases do not receive separate security backports. Reports about current
development code are also welcome through the private channels below.
The protocol identifier `1.0` is separate from the server release number.

Unofficial builds, custom adapters, host systems and third-party services are not
maintained by this project. Reports about their interaction with official server
code are welcome. Core and SDK have their own
[core](https://github.com/ulda-io/ulda-core/blob/main/SECURITY.md) and
[SDK](https://github.com/ulda-io/ulda-auth/blob/main/SECURITY.md) security policies.

### Private reporting

Email [mark@shaposhnik.ch](mailto:mark@shaposhnik.ch) with subject
`[SECURITY] ULDA Server vulnerability report`. Once private reporting is enabled
in the official server repository, its **Security → Report a vulnerability**
form is an alternative. Do not report an undisclosed vulnerability in a public
issue, pull request or discussion.

Include the affected version or commit, image digest when relevant, impact,
minimal reproduction with synthetic data, redacted configuration and any
mitigation. Do not include real payloads, private origins, passwords, tokens,
private keys, connection strings or production database dumps.

Maintainers will coordinate investigation, remediation and disclosure.
This policy does not promise a response deadline, bounty or resolution date.

### Security boundary

- **Open reads:** a connected client can request encrypted resources by ID.
  There is no built-in authentication, ownership check or per-resource ACL.
  CORS is not access control and does not protect against non-browser clients.
- **Initial state:** Create accepts bounded witness/payload bytes. It does not
  prove who owns the first witness or enforce the advertised generation profile.
  The integrating application establishes initial trust and any identity binding.
- **Transitions:** Update and Delete verify only
  `verify(storedCurrentWitness, candidateWitness)`. Every entry must pass before
  the batch mutates storage. An invalid or conflicting continuation is rejected.
- **Payloads:** the server cannot decrypt or verify the encrypted container.
  The SDK encrypts and authenticates it on the client, including the witness as
  associated data. The server cannot enforce that a custom client actually
  encrypted its payload.
- **Proof:** the default `AllowAllProofVerifier` is an explicit placeholder.
  It does not authenticate application data or grant user authorization.
- **Secrets:** origins and decryption passwords belong on the client. Client
  encryption cannot protect a running client from a compromised device.
  The server and database still expose IDs, ciphertext lengths and access patterns.
- **Trusted extensions:** custom storage/proof modules execute with server
  privileges. Fixed wire errors and safe logging are not a sandbox.

### Atomicity and uncertain outcomes

The server must be the only writer to its resource namespace. Resource locks
cover validation, storage, publication and ACK. Reads preceding BEGIN rely on
these in-process locks; multiple replicas or external writers are unsupported.

Each mutation batch is one database transaction. Success follows storage
completion. Before mutation, a definite rejection writes nothing. After an
unexpected mutation or COMMIT error, the writer is disconnected without a negative
ACK because the outcome may be unknown, even if the database actually rolled back.

Do not automatically repeat an unacknowledged write. Read known IDs to reconcile.
A lost Create ACK can leave an allocated ID unknown to the client. Socket.IO
volatile delivery does not solve this problem or provide atomicity across commands.

Changed is best-effort and has no durable history, replay or client receipt.
It goes to other affected subscribers after commit; notification failure does not
undo the mutation. Deletion notifications contain IDs, without a cryptographic
proof. The SDK's documented policy trusts the server for these notifications.
Reconnect requires rereading; it is not proof that every historical event was seen.

Shutdown cancels read-capacity waiters before storage and drains active work.
Readiness and shutdown deadlines do not cancel
SQL, undo commits or release accepted work early. A forced process kill or network
partition can leave clients uncertain. [Operational policy](https://docs.ulda.io/server/operations/).

### Resource and transport controls

Extended operations retain ciphertext and public witnesses under shared staging
budgets and absolute deadlines. No upload part mutates storage; Commit validates
the complete operation against current witnesses before one atomic batch.
Connection cleanup cannot release an accepted commit's capacity prematurely.
Large read snapshots and fragmented notifications retain the existing trust and
unknown-outcome boundaries. Limits bound accounted work, not exact process RSS;
operators must size memory, outbound buffers and timeouts for large catalogs.

The SDK endpoint bounds packet headers, binary attachments, batch sizes,
connection slots, pending/rate-limited commands, input/copy bytes, active reads,
subscription edges and outbound bytes/packets. See
[admission limits](https://docs.ulda.io/server/admission/) for exact accounting and defaults.

Read reservations use a bounded FIFO wait without increasing the byte budget.
Expiry/full admission returns a fixed `not_ready` error before storage. It must
not be treated as a size error or permission to replay a write. Held snapshots
can delay other clients up to their wait deadline; fairness is by queued command,
not by user or IP address.

Limits are not a proof of maximum RSS, per-IP fairness or protection of every
raw TCP/HTTP connection. Reconnecting creates a new decoder rate bucket.
Output exhaustion closes the affected recipient and can prevent an ACK after
commit. The legacy test endpoint does not inherit the SDK admission guarantees.

Use HTTPS/WSS and reviewed edge connection/rate controls. The server has no
built-in TLS; the optional proxy terminates TLS and leaves probes/database ports
internal. If PostgreSQL is remote, protect that separate transport too.

### Deployment responsibilities

1. Run one application writer with a dedicated database. Keep PostgreSQL and
   probes internal; decide whether open-by-ID reads fit the application.
2. Use separate administrator, migration and runtime accounts. Mount only the
   required password file in each service. Never bake secrets into images.
3. Keep container restrictions enabled: non-root, read-only root filesystem,
   dropped capabilities, no privilege escalation and bounded resources.
4. Protect the host, secret files, database, backups and operator configuration.
   A database administrator and custom modules remain trusted.
5. Maintain TLS certificates, monitor readiness, restarts, capacity and fixed
   diagnostic codes, and validate backups by restoring to another volume.
6. Review versioned images and applicable dependency/OS advisories. Pin the
   accepted digest, rebuild and retest fixes, and deploy them explicitly.
7. Account for state rollback when restoring an old backup: the server cannot
   recover lost client secrets or recreate updates missing from that backup.

[Deployment and recovery](https://github.com/ulda-io/ulda-server/blob/5469821/docs/DEPLOYMENT.md) provides the procedures.
[Container policy](https://github.com/ulda-io/ulda-server/blob/5469821/docs/CONTAINER_SECURITY.md) explains the supplied restrictions.

### Diagnostics and disclosure

The built-in logger uses fixed codes and validated context. It does not serialize
raw exceptions, stacks, causes, resource bytes or arbitrary objects, including at
debug level. Setup, migration and handled startup/shutdown failures follow this
policy. External proxies, database audit extensions and custom code require their
own review. [Logging reference](https://docs.ulda.io/server/logging/).

Automated tests and vulnerability scans are evidence for a particular revision
and environment, not an independent audit, absence-of-vulnerabilities guarantee
or acceptance of every deployment. See [Testing](https://github.com/ulda-io/ulda-server/blob/5469821/docs/TESTING.md) for the
reproducible checks and the separate integration, deployment and load boundaries.

When a fix is released, the maintainers will describe the affected versions,
mitigation and upgrade through the official repository's advisories/release
notes. Operators are responsible for deploying the fix. A mutable image alias
does not update a running deployment automatically.

---

Reference for Server 1.0.0. [Reviewed source](https://github.com/ulda-io/ulda-server/blob/5469821/SECURITY.md).
