Current section
Files
Jump to
Current section
Files
docs/v2/05-validation-and-limits.md
# Validation and Limits
**Status**: Proposal
**Depends on**: all other docs
**Required by**: enforcement at API boundaries
## 1. Purpose
Concord runs commands through Raft. A misbehaving client can:
- Block the cluster with oversized commands
- Crash the state machine with malformed input
- Break determinism with non-replicable values (functions, PIDs, refs)
- Exhaust memory by creating unbounded objects
All inputs must be validated **at the API boundary**, before submission to Raft. Validation failure returns `{:error, {:invalid_*, reason}}` to the client without affecting cluster state.
This doc consolidates the limits referenced in the other docs into a single source of truth.
## 2. Configuration
```elixir
config :concord,
# Transaction limits
txn: [
max_compare_ops: 64,
max_success_ops: 128,
max_failure_ops: 128,
max_txn_bytes: 1_000_000, # 1 MiB serialized spec
max_range_limit: 1_000
],
# KV limits
kv: [
max_key_bytes: 1024,
max_value_bytes: 1_000_000, # 1 MiB
max_metadata_bytes: 8192,
max_content_type_bytes: 256,
default_list_limit: 1_000,
max_list_limit: 10_000
],
# Sync / watch limits
sync: [
max_watches_per_node: 10_000,
max_watches_per_pid: 100,
default_changes_limit: 100,
max_changes_limit: 1_000,
watcher_mailbox_capacity: 1_000,
change_log_retention_count: 1_000_000,
change_log_retention_time: 86_400, # 24h
min_prefix_bytes: 1
],
# Lease limits
lease: [
max_attached_keys: 10_000,
max_active_leases: 100_000,
expiration_tick_interval: 1_000, # ms
expiration_batch_size: 50,
min_ttl_seconds: 1,
idempotency_cache_size: 100_000,
idempotency_retention_revisions: 10_000
],
# Snapshot limits
snapshot: [
snapshot_interval_commands: 50_000,
snapshot_interval_seconds: 3_600
]
```
Defaults are conservative for small-to-medium deployments. Tune for production load.
## 3. Validation rules
### Universal — applied to every command
Walk the entire input recursively. Reject if any of:
- An anonymous function appears anywhere (`fn -> end`, `&Mod.fun/n` references)
- A PID, port, or reference appears
- A struct from a module that isn't in the allowlist of serializable structs
- Any binary exceeds its respective size limit
- A key is `""` or contains null bytes outside permitted positions
- A revision-bearing field is negative or non-integer
- A TTL value is `<= 0` or `> 2^31`
Walking is depth-bounded (default depth 100). Specs nested beyond this are rejected as `{:invalid_spec, :depth_exceeded}`.
### Transactions
| Check | Limit | Rejection |
|---|---|---|
| Compare op count | `txn.max_compare_ops` (64) | `:too_many_compares` |
| Success op count | `txn.max_success_ops` (128) | `:too_many_success_ops` |
| Failure op count | `txn.max_failure_ops` (128) | `:too_many_failure_ops` |
| Serialized size | `txn.max_txn_bytes` (1 MiB) | `:spec_too_large` |
| Range op without `limit` | mandatory limit on `:prefix` / `:range` selectors | `:missing_range_limit` |
| `limit` value | `<= txn.max_range_limit` (1000) | `:range_limit_too_high` |
| Compare field unknown | not in `[:exists, :value, :field, :version, :create_revision, :mod_revision, :lease, :ttl]` | `:unsupported_compare_field` |
| Compare operator unknown | not in `[:==, :!=, :>, :>=, :<, :<=]` | `:unsupported_compare_op` |
| Operator-field mismatch | e.g., `:>` against `:value` | `:invalid_compare_operator` |
| Operation type unknown | not in `[:get, :put, :delete, :touch]` | `:unsupported_op` |
| Put with both `ttl` and `lease` | conflict | `:ttl_and_lease_conflict` |
| Put references unknown lease | `lease_id` not in lease table | `:unknown_lease` (deferred to apply; rejected there) |
| Duplicate compare on same `(field, key)` | each `(field, key)` allowed once | `:duplicate_compare` |
| Idempotency key with PID/ref | non-serializable | `:invalid_idempotency_key` |
### KV operations
| Check | Limit | Rejection |
|---|---|---|
| Key size | `kv.max_key_bytes` (1024) | `:key_too_large` |
| Empty key | not allowed | `:empty_key` |
| Value serialized size | `kv.max_value_bytes` (1 MiB) | `:value_too_large` |
| Metadata size | `kv.max_metadata_bytes` (8 KiB) | `:metadata_too_large` |
| Content-type size | `kv.max_content_type_bytes` (256) | `:content_type_too_large` |
| List limit | `<= kv.max_list_limit` (10,000) | `:list_limit_too_high` |
### Sync / watch
| Check | Limit | Rejection |
|---|---|---|
| Watches per node | `sync.max_watches_per_node` (10,000) | `:too_many_watches` |
| Watches per subscribing PID | `sync.max_watches_per_pid` (100) | `:too_many_watches_per_pid` |
| `from_revision` below `compact_revision` | normal compaction event | `{:compacted, compact_revision}` (not an `:invalid_*` error) |
| Prefix length | `>= sync.min_prefix_bytes` (1) | `:prefix_too_short` |
| Changes limit | `<= sync.max_changes_limit` (1000) | `:changes_limit_too_high` |
### Leases
| Check | Limit | Rejection |
|---|---|---|
| TTL value | `>= lease.min_ttl_seconds` (1) | `:invalid_ttl` |
| Active leases | `<= lease.max_active_leases` (100,000) | `:lease_limit_exceeded` |
| Attached keys per lease | `<= lease.max_attached_keys` (10,000) | `:lease_attachment_limit` |
## 4. Validation locations
| Layer | What it validates |
|---|---|
| Public API (`Concord.*` modules) | All client-facing limits. Returns `{:error, {:invalid_*, reason}}` synchronously. |
| Spec serializer | Recursive walk for functions/PIDs/refs/depth. Last line of defense before Raft submission. |
| State machine `apply/3` | Validates against current state (e.g., `:unknown_lease`, `:not_found`). Returns `{:error, reason}` in the apply result. |
**The state machine itself must also re-check size limits** as a backstop. A direct Raft command bypass (operator tooling, replay attack) could submit oversized data without going through the public API. The state machine treats any oversized input as a protocol violation and refuses to apply.
## 5. Error model
Three categories, semantically distinct:
### Validation errors — `{:error, {:invalid_*, reason}}`
```elixir
{:error, {:invalid_txn, :too_many_compares}}
{:error, {:invalid_kv, :key_too_large}}
{:error, {:invalid_lease, :ttl_too_short}}
```
Synchronous, never reach Raft. Caller fixes input and retries.
### Apply errors — `{:error, reason}`
Returned from a Raft-applied command when the state forbids the operation:
```elixir
{:error, :not_found}
{:error, :unknown_lease}
{:error, :idempotency_conflict}
{:error, :lease_limit_exceeded}
```
These are real Raft commits (consumed a log entry) but did not mutate state. The cluster paid the consensus cost; the command was rejected at apply time.
### Cluster errors — `{:error, reason}`
Infrastructure failures:
```elixir
{:error, :no_leader}
{:error, :timeout}
{:error, {:not_leader, leader}}
{:error, :cluster_not_ready}
```
Caller retries with backoff. Operation may or may not have committed — clients should use idempotency keys for write retries.
### Compaction errors — `{:error, {:compacted, compact_revision}}`
Special case for sync resumption. Not really an error from the application's perspective — it's a normal lifecycle event saying "the requested revision is no longer available; re-snapshot from current."
### Successful failure — `{:ok, %Result{succeeded: false}}`
A transaction whose compares didn't hold returns `{:ok, ...}` with `succeeded: false`. This is **not an error**. Conflating compare-failure with `{:error, ...}` would force callers to use try/catch for normal control flow.
## 6. Telemetry
Validation events:
- `[:concord, :validation, :rejected]` — measurements: `%{}`, metadata: `%{api: atom, reason: atom}`
- `[:concord, :validation, :spec_depth_exceeded]` — metadata: `%{depth: n, api: atom}`
- `[:concord, :validation, :forbidden_value]` — metadata: `%{type: :function | :pid | :reference, api: atom}`
These should be alarmed on in production — a sustained spike indicates a buggy client or an attack.
## 7. Open questions
1. **Should validation walk be cached by spec hash?** Many clients submit similar specs repeatedly. Caching saves re-walks. Recommend: defer; measure first.
2. **Should size limits be configurable per-namespace?** E.g., bigger limits for `/notes/` than for `/config/`. Adds complexity; defer.
3. **Should the state machine soft-fail on resource limits (e.g., return `:lease_limit_exceeded` from apply)?** Or hard-crash and rely on snapshot recovery? Recommend soft-fail — graceful degradation matters in production.
4. **Should we expose validation as a separate API (`Concord.validate/1`)?** Clients could pre-check before submission. Cheap to add; useful for tooling.