sqi-server Configuration Reference¶
sqi-server is configured through four layers applied in order, with later
layers overriding earlier ones:
- Built-in defaults — sensible values for local development.
- Config file — YAML or JSON. With no
--config, six paths are tried in order and the first that exists is the only one read:./config/sqi-server.yaml,./config/sqi-server.json,~/.sqi/sqi-server.yaml,~/.sqi/sqi-server.json,/etc/sqi/sqi-server.yaml,/etc/sqi/sqi-server.json. Pass an explicit path with--config /path/to/file(a path that does not exist is an error, unlike the search). - Environment variables — prefixed
SQI_, e.g.SQI_HTTP_ADDR. - CLI flags — highest priority.
--config,--log-leveland--log-formatare available on every subcommand; the remaining flags (--http-addr,--http-cors-origins,--openjd-enforce-limits,--auth-enabled,--auth-validate-job-owner) are onserve.
Print the effective merged configuration at any time with:
sqi-server config print
A commented example file is at
config/sqi-server.example.yaml
(it does not yet include the auth block or http.cors_origins — see those
sections below).
Duration values use Go syntax: 30s, 1m30s, 500ms, 2h, etc.
http — REST API and WebSocket listener¶
http.addr¶
| Type | string |
| Default | "0.0.0.0:8080" |
| Env var | SQI_HTTP_ADDR |
| CLI flag | --http-addr |
TCP address the HTTP server listens on. Use 127.0.0.1:8080 to restrict to
loopback only.
Must be host:port. A host given as a name rather than an IP literal is
resolved at startup, so an unresolvable hostname fails config validation and
the server does not start; 0.0.0.0, :: and an empty host are accepted
without resolution. The same rule applies to nats.addr.
http:
addr: "0.0.0.0:8080"
http.enable_pprof¶
| Type | bool |
| Default | false |
| Env var | SQI_HTTP_ENABLE_PPROF |
| CLI flag | (none — set via config file or env var) |
Expose Go runtime profiling endpoints at /debug/pprof/. Profiling data
reveals memory layout, goroutine stacks, and CPU hotspots — never enable
this on a server accessible to untrusted networks. Enable temporarily on a
loopback-only instance for performance diagnosis.
http:
enable_pprof: false
http.cors_origins¶
| Type | []string |
| Default | [] (empty — treated as ["*"]) |
| Env var | SQI_HTTP_CORS_ORIGINS (comma-separated) |
| CLI flag | --http-cors-origins |
Browser origins the CORS middleware allows. Only relevant to a
separately-hosted web UI calling this server cross-origin; the normal
same-origin deployment (where sqi-server serves the embedded UI itself)
needs none of this.
Each entry must be scheme://host[:port], or the bare wildcard "*". A
trailing slash, a path, a query, a fragment, or embedded whitespace is
rejected at startup with an http.cors_origins validation error — go-chi/cors
could never match such a value, so a typo fails loudly at boot rather than
silently at request time. A wildcard pattern is rejected too
(https://*.example.com, https://app.example.com*): go-chi/cors would honor
any embedded * as a prefix/suffix match — with credentials, once auth is
enabled — so only the bare "*" is supported. Name explicit origins.
With auth.enabled=true a wildcard is dropped at startup (and a warning is
logged — or, when the wildcard came from the empty-list default rather than
explicit config, an informational line): browsers reject
Access-Control-Allow-Credentials combined with
*. An empty list defaults to ["*"] and so is dropped too — meaning a
separately-hosted UI must name its origin explicitly here for credentialed
cross-origin requests to work at all. See
auth.md § CSRF & CORS.
http:
cors_origins:
- "https://ui.example.com"
- "http://localhost:5173"
SQI_HTTP_CORS_ORIGINS="https://ui.example.com,http://localhost:5173"
sqi-server serve --http-cors-origins=https://ui.example.com
nats — Embedded NATS JetStream broker¶
nats.addr¶
| Type | string |
| Default | "0.0.0.0:4222" |
| Env var | SQI_NATS_ADDR |
TCP address the embedded NATS server binds to. Defaults to all interfaces so
that workers which discover the server over mDNS can connect to NATS at the
advertised LAN host. Set this to "127.0.0.1:4222" to restrict NATS to loopback
(single-machine only). Broker authentication does not exist: any host
that can reach this port can register as a worker and receive task
assignments, regardless of auth.enabled — see
Known gaps. Deferred to Phase 4 hardening.
nats:
addr: "0.0.0.0:4222"
nats.data_dir¶
| Type | string |
| Default | "data/nats" |
| Env var | SQI_NATS_DATA_DIR |
Directory used by JetStream for file-backed stream persistence. Created at
startup if it does not exist. Relative paths are resolved from the working
directory at the time sqi-server starts. For production, use an absolute path
on fast local storage.
nats:
data_dir: "/var/lib/sqi/nats"
nats.max_store_mb¶
| Type | int (megabytes) |
| Default | 1024 |
| Env var | SQI_NATS_MAX_STORE_MB |
Maximum disk space JetStream may use. When the limit is reached, older messages are evicted per stream retention policy. Increase this on farms with many active jobs or high log volume.
nats:
max_store_mb: 4096
store — SQLite state store¶
store.sqlite_path¶
| Type | string |
| Default | "sqi.db" |
| Env var | SQI_STORE_SQLITE_PATH |
Path to the SQLite database file. Created at startup if it does not exist. Schema migrations run automatically at startup. For production, use an absolute path on a local SSD.
store:
sqlite_path: "/var/lib/sqi/sqi.db"
The
migrateandbackupsubcommands do not read this key. Their--dbflag defaults to$SQI_SQLITE_PATH(note: notSQI_STORE_SQLITE_PATH), falling back tosqi.dbin the working directory. Pass--dbexplicitly, or export both variables, so schema migrations and backups operate on the database the server actually uses.
store.checkpoint_interval¶
| Type | duration |
| Default | "5m" |
| Env var | SQI_STORE_CHECKPOINT_INTERVAL |
How often the background goroutine runs PRAGMA wal_checkpoint(PASSIVE) to
fold committed WAL frames back into the main database file. Without periodic
checkpointing the WAL grows unboundedly under write load. A final checkpoint
always runs on clean shutdown regardless of this setting, and that one uses
TRUNCATE so the WAL is left at zero bytes.
The periodic checkpoint is deliberately PASSIVE rather than TRUNCATE.
Reads are served from a separate connection pool, so a reader holding a WAL
snapshot blocks a truncating checkpoint — which waits out the whole 5s
busy_timeout on the single write connection and then still fails to
truncate, stalling job submission and task-status writes for five seconds on
every tick. PASSIVE never waits: it moves what it can and the next tick
takes the rest. Shutdown can afford to wait once; a timer cannot.
Set to a large value (e.g. "24h") to disable periodic checkpointing while
keeping the shutdown checkpoint. Must be > 0.
store:
checkpoint_interval: "10m"
log — Structured logging¶
log.level¶
| Type | string |
| Default | "info" |
| Accepted values | debug, info, warn, error |
| Env var | SQI_LOG_LEVEL |
| CLI flag | --log-level |
Minimum log level to emit. debug includes verbose request tracing and
scheduler internals — useful during development but noisy in production.
log:
level: "info"
log.format¶
| Type | string |
| Default | "json" |
| Accepted values | json, text |
| Env var | SQI_LOG_FORMAT |
| CLI flag | --log-format |
Log output format. json is structured and machine-parseable — use it in
production so log aggregators (Loki, Datadog, Splunk, etc.) can index fields.
text is human-readable with aligned columns — use it during local development.
log:
format: "json"
scheduler — Task assignment loop¶
scheduler.heartbeat_timeout¶
| Type | duration |
| Default | "30s" |
| Env var | SQI_SCHEDULER_HEARTBEAT_TIMEOUT |
Duration after which a worker that has not sent a heartbeat is declared offline.
Its in-flight tasks are reclaimed and re-queued for assignment. For high-latency
networks or heavily loaded worker hosts, increase to 60s or more. Must be
> 0.
scheduler:
heartbeat_timeout: "45s"
scheduler.tick_interval¶
| Type | duration |
| Default | "500ms" |
| Env var | SQI_SCHEDULER_TICK_INTERVAL |
Reserved — not yet wired. This key is parsed and validated but the scheduler does not currently consume it; the assignment loop runs on a fixed 1s interval (
AssignInterval). Setting it has no effect today. Must be> 0.
Intended meaning: how often the assignment loop wakes to match ready tasks to idle workers.
scheduler:
tick_interval: "500ms"
scheduler.max_tasks_per_worker¶
| Type | int |
| Default | 1 |
| Env var | SQI_SCHEDULER_MAX_TASKS_PER_WORKER |
Reserved — not yet wired. This key is parsed and validated but the scheduler does not currently consume it; task concurrency is governed by CPU-core commitment (
CPUCount − committed), not a per-worker task cap. Setting it has no effect today. Must be≥ 1.
Intended meaning: the maximum number of tasks simultaneously assigned to a single worker.
scheduler:
max_tasks_per_worker: 1
scheduler.offline_worker_retention¶
| Type | duration |
| Default | 24h |
| Env var | SQI_SCHEDULER_OFFLINE_WORKER_RETENTION |
How long a worker may remain offline before the retention sweep hard-deletes its
record, bounding the growth of the worker list on farms with ephemeral nodes
(e.g. cloud workers that spin up for a burst of work and are then destroyed). The
sweep runs on the heartbeat-sweep tick and only removes workers in the offline
state — online and administratively disabled workers are never auto-removed.
A worker that reconnects after removal simply re-registers. Set to 0 to disable
automatic removal entirely (workers can still be removed manually from the web
UI).
scheduler:
offline_worker_retention: "24h"
scheduler.job_retention¶
| Type | duration |
| Default | 168h (7 days) |
| Env var | SQI_SCHEDULER_JOB_RETENTION |
How long a terminal job is retained before the retention sweep hard-deletes it
and all of its data (steps, tasks, attempts, logs). The sweep runs on the
heartbeat-sweep tick and removes completed and canceled jobs whose completion
time is older than this window; failed jobs are governed by
job_retention_include_failed. Active jobs are never auto-deleted. Set to 0
to disable automatic deletion (jobs can still be deleted manually).
scheduler:
job_retention: "168h"
scheduler.job_retention_include_failed¶
| Type | bool |
| Default | false |
| Env var | SQI_SCHEDULER_JOB_RETENTION_INCLUDE_FAILED |
When true, the retention sweep also removes failed jobs older than
job_retention. Default keeps failed jobs for post-mortem debugging. No effect
when job_retention is 0.
scheduler:
job_retention_include_failed: false
scheduler.unschedulable_grace¶
| Type | duration |
| Default | 30s |
| Env var | SQI_SCHEDULER_UNSCHEDULABLE_GRACE |
How long a ready task may wait with no eligible online worker before it is
flagged "unschedulable" (surfaced in the API/UI so operators can spot stuck
work rather than assume the scheduler is silently making progress). Set to 0
to disable the sweep entirely.
scheduler:
unschedulable_grace: "30s"
See
Why isn't my job running? — Unschedulable tasks
for what the flag means, where it surfaces (task unschedulable_reason, job
task_counts.unschedulable, the job-detail badge), and how it clears.
Retry & failure limits¶
Worker-reported task failures auto-retry with backoff up to a per-task attempt ceiling, and a job auto-parks (pauses) once its cumulative genuine failures reach a failure limit. Three server keys set the farm-wide defaults for this policy:
scheduler.default_max_attempts¶
| Type | int |
| Default | 3 |
| Env var | SQI_SCHEDULER_DEFAULT_MAX_ATTEMPTS |
Farm-wide default number of genuine attempts a task may make before it goes
terminal-failed. Must be ≥ 1; 1 disables auto-retry (a single failure is
immediately terminal, matching pre-retry behavior).
scheduler:
default_max_attempts: 3
scheduler.retry_delay¶
| Type | duration |
| Default | "30s" |
| Env var | SQI_SCHEDULER_RETRY_DELAY |
Backoff applied before a failed task re-enters the ready queue for
re-lease. 0 means immediate re-queue.
scheduler:
retry_delay: "30s"
scheduler.default_failure_limit¶
| Type | int |
| Default | 0 |
| Env var | SQI_SCHEDULER_DEFAULT_FAILURE_LIMIT |
Farm-wide default ceiling on a job's cumulative genuine task failures. Once
reached, the job is auto-parked (status=paused, with a park_reason such as
"failure limit reached (N)"). 0 disables the limit (off — a job never
auto-parks on failure count alone).
scheduler:
default_failure_limit: 0
Precedence: Server → Farm → Queue → Job¶
The effective value for each of the three knobs above is resolved
independently as the first non-null of, in order: Job → Queue → Farm →
server default. A farm sets a studio-wide policy; a queue can narrow it for
a class of work; a job can override it for one submission. Any tier left
unset (null) falls through to the next.
Farm and queue overrides are set via the farm/queue REST resources'
max_attempts / retry_delay_seconds / failure_limit fields (and the
matching Python SDK keyword arguments on create_farm/update_farm and
create_queue/update_queue). A job's overrides use the same three names,
but on POST /api/v1/jobs they travel as query parameters — alongside
priority — not as a body field, since the body is the raw OpenJD template:
curl -X POST "http://localhost:8080/api/v1/jobs?max_attempts=5&retry_delay_seconds=10&failure_limit=20" \
-H 'Content-Type: application/yaml' \
--data-binary @job.yaml
Overrides are validated at the API boundary: max_attempts must be >= 1,
retry_delay_seconds and failure_limit must be >= 0 (an explicit
failure_limit of 0 disables an inherited limit). Out-of-range or
non-integer values are rejected with 400 rather than stored or silently
ignored. The job detail response (GET /api/v1/jobs/{id}) reports both the
configured per-job overrides and effective_retry — the fully resolved
policy the job actually runs under.
A manual POST /api/v1/tasks/{id}/retry or POST /api/v1/jobs/{id}/retry
resets a task/job's failure counters, independent of this policy. See
the task state machine for how
retry, exhaustion, and auto-park interact, and
Retry and auto-park metrics
for the Prometheus counters.
Behavior change on upgrade. The default
scheduler.default_max_attemptsis3, so farms that upgrade into this feature will see transient task failures auto-retry (up to 2 extra attempts) where they previously went straight to terminal-failed. To restore the prior no-auto-retry behavior, setscheduler.default_max_attempts: 1(orSQI_SCHEDULER_DEFAULT_MAX_ATTEMPTS=1).
discovery — mDNS service advertisement¶
discovery.enabled¶
| Type | bool |
| Default | true |
| Env var | SQI_DISCOVERY_ENABLED |
When true, sqi-server broadcasts a _sqi._tcp mDNS record so that workers
and the sqi CLI can discover it automatically on the local network without any
manual address configuration. Disable in environments that prohibit multicast —
most cloud VPCs, VLANs, and container networks fall into this category.
discovery:
enabled: true
discovery.instance_name¶
| Type | string |
| Default | "sqi-server" |
| Env var | SQI_DISCOVERY_INSTANCE_NAME |
The mDNS service instance name advertised on the network. Each sqi-server on
the same subnet should use a distinct name to avoid collisions when running
multiple farms on the same local network.
Must not be empty, and this is checked regardless of discovery.enabled —
explicitly setting it to "" fails config validation and the server will not
start, even with discovery switched off. Leave the default in place rather than
blanking it to disable advertisement; use discovery.enabled: false for that.
discovery:
instance_name: "studio-farm-primary"
openjd — OpenJD submission and validation¶
openjd.enforce_limits¶
| Type | bool |
| Default | true |
| Env var | SQI_OPENJD_ENFORCE_LIMITS |
| CLI flag | --openjd-enforce-limits |
When true, submitted job templates are checked against the OpenJD quantitative
limits — maximum name lengths, parameter-definition counts, per-parameter value
counts, and host-requirement counts — plus one sqi-defined limit the OpenJD
specification does not itself set: a maximum of 100 steps per job template.
A template exceeding 100 steps is rejected with an error at /steps. Set to
false only in operator environments that predate strict limit enforcement
and cannot yet update all templates — including templates with more than 100
steps; resource-exhaustion guards (the per-range value cap and the per-step
task-count cap) always apply regardless of this setting.
The step-count relief above does not carry over to EXPR templates. A
template declaring extensions: [EXPR] is also subject to the
template-wide expression budget (openjd.expr_template_positions,
default 10,000), which is always on — a resource-exhaustion guard, not a
quantitative limit, so enforce_limits: false does not lift it. That budget
counts expression positions, not steps, so the relief is partial rather
than absent: a many-step EXPR template is accepted only while its total
positions stay under the configured position limit. At the
~70-positions-per-step shape the default was sized against, the crossover is
around 143 steps — so a 300-step EXPR template of any realistic content is
rejected whichever way this flag is set, while a 300-step template whose steps
carry almost no expressions still passes. Only a base-spec template gets the
unqualified relief this flag promises. See
docs/openjd-extensions/expr.md for what the
budget bounds.
openjd:
enforce_limits: true
EXPR expression limits — read this before changing any of them¶
Four keys bound what one submitted job template may spend inside the
OpenJD expression-language checker (the EXPR extension —
docs/openjd-extensions/expr.md). They exist
because POST /api/v1/jobs accepts a template of up to 4 MiB and, with auth
off (the default), accepts it anonymously — so the cost of checking a
template is attacker-chosen work on a synchronous request path.
Four routes are bounded by these keys, not one. Three of them accept an
arbitrary client-supplied template body: POST /api/v1/jobs,
POST /api/v1/products and PUT /api/v1/products/{name} (the last two when a
custom product is installed or edited). The fourth,
POST /api/v1/products/{name}/jobs, submits a template the operator already
installed — but the parameters are the client's and every expression is
re-evaluated with them bound, so it is bounded on the same terms. All four are
bounded by these four keys and by
openjd.expr_submission_deadline, and all
four can answer 503 for a deadline.
The preset routes — GET /api/v1/presets/{name} and
POST /api/v1/presets/{name}/install — validate a definition too. Its body is
sha256-pinned against the index at preset_library.url, so it is
operator-vouched rather than client-chosen; since EXPR sub-project H1's
whole-branch review they nevertheless run under these four keys and under the
deadline, because the limits are operator configuration (an operator who
tightened a knob asked for it to be enforced wherever validation happens) and
because the install route sits behind the same permission as
POST /api/v1/products, which grants everyone access while auth is off.
Unlike enforce_limits, these are always on: they are
resource-exhaustion guards, not OpenJD quantitative limits, and no setting
turns them off. 0 is not "unlimited" — it is out of range and fails
startup. An out-of-range value is a startup failure, not a clamp.
These keys are live. Earlier revisions of this section carried a "
EXPRis not accepted yet" callout here: the extension's registry status was in-progress and a template declaringextensions: [EXPR]was rejected at submission before any of these limits could be spent. That is no longer true —EXPRis a supported extension, every EXPR template is accepted on its merits, and each of these keys now decides real submissions. Seedocs/openjd-extensions/expr.md.
Every one of these has a counterpart on the worker
(Worker configuration → expr)
that meters the same thing one phase later. Four caveats apply to all four
keys, and none of them is obvious from the numbers themselves.
A fifth key sits alongside them and is deliberately not one of them:
openjd.expr_submission_deadline is a
wall-clock backstop, so it decides only whether this server keeps working on a
request (503), never whether a template is valid (422). It has no worker
counterpart and none of the four caveats below applies to it — read its own
section.
1. The cumulative operation ceiling is a product, not a measurement¶
Nothing in sqi counts operations across a whole template. The template-wide
budget counts positions — one per format string or let: binding
walked — and each position is separately capped at
openjd.expr_operation_limit operations. The cumulative ceiling an operator
is really setting is therefore the product:
openjd.expr_template_positions x openjd.expr_operation_limit
10,000 x 10,000 = 10^8 (default)
Raising either multiplies that ceiling; raising both multiplies it twice. At the two maxima (100,000 x 100,000) it is 10¹⁰ — 100x the default. Nothing measures the sum, so nothing will report that you crossed it; the product is a derived upper bound on what the per-position limit, applied that many times, could in principle cost.
openjd.expr_memory_limit is not a third multiplier here. It decides how
large a value one evaluation may hold, not what an operation costs: the worst
construction measured under a 100,000-operation budget costs the same 1.43 s
at the default 1,000,000-byte memory limit as at the 10,000,000 maximum,
because it never holds more than one intermediate value live. Raise it because
a template needs to build a large value, not to bound time.
2. None of these limits bounds wall-clock time¶
An operation's real cost is not uniform. Specification section 1.3.10 rule 3
(third_party/openjd-specifications/wiki/2026-02-Expression-Language.md:1090)
prices a string operation at the value's length divided by 256 — so
byte-heavy work is charged almost nothing. Measured in this repository, all
four of these expressions stay inside a single position's default
10,000-operation budget:
| Expression | Operations charged | CPU | per operation |
|---|---|---|---|
("x" * 900000).upper() |
7,034 | ~6 ms | 0.9 µs |
("x" * 900000).title() |
7,034 | ~58 ms | 8.2 µs |
re_findall("x", "x" * 900000) |
3,519 | ~51 ms | 14.5 µs |
max([len(re_findall("x", "x" * 900000)) for i in range(2)]) |
7,048 | ~103 ms | 14.6 µs |
The first two are charged identically and differ by 9x in time; the third is charged half as much as either yet costs nearly as much time. The per-operation cost varies by ~17x, which is why no operation budget bounds time.
The fourth row is the one to size against. Ten thousand positions of it — a template body of roughly 650 KB, well inside the 4 MiB limit — cost about 1,030 seconds (~17 minutes) of server CPU on one synchronous request, with every budget respected the entire way; a position that spent its whole 10,000-operation allowance at that rate would cost ~146 ms, putting the same template near 24 minutes. At the two maxima the same construction reaches 98,646 operations in 1.43 s, and 100,000 such positions are roughly 40 hours.
Caveat 1's 100x is the honest scaling factor between those two settings: the worst per-operation rate measured is the same (~14.6 µs) at both, so ten times the positions times ten times the operations is a hundred times the time. Dividing the two measurements above gives ~139x only because the default-side construction leaves ~30% of its 10,000-operation budget unspent while the maxima-side one uses 98.6% of its 100,000.
This figure has now been measured too low three times, each time by a
construction nobody had run — the previous revision of this table gave 571
seconds, from .title(), which does not maximize the cost per operation. The
expression package's own note calls it "a floor on the worst case, not a
proof of it". Treat the order of magnitude as the claim, never the digit.
So: raising these limits lengthens the worst request your farm can be
asked to serve, roughly in proportion. There is no value of these settings
that makes a slow request impossible. If POST /api/v1/jobs is reachable
without authentication, size your request timeouts and front-end concurrency
against that, not against the position cap.
What does bound the time is a separate key, added because none of these
four can:
openjd.expr_submission_deadline stops one
submission's expression evaluation after a fixed wall-clock allowance (5s by
default) and answers 503. It bounds elapsed time directly, so it holds
whatever the operation pricing turns out to permit and does not depend on the
figures above being right. It does not make the numbers above safe to
raise freely: a request refused at the deadline still spent that time, and
concurrent requests each get their own allowance.
Raising these four keys without also raising the deadline turns legitimate
acceptances into 503s. This coupling is the one realistic way a real
pipeline sees a 503 it does not deserve, and it is not visible from either
side on its own. Measured on this branch with a generated multi-layer VFX
shot-render job — 12 let: bindings per step, comprehensions over real frame
ranges, RANGE_EXPR/LIST[STRING]/PATH parameters — driven through the
real submission path:
| Limits | Shape | Wall clock | Verdict |
|---|---|---|---|
| defaults | 100 steps x 1,000 frames | 0.83 s | accepted |
| defaults | 100 steps x 2,000 frames | 1.11 s | 422, retained bytes |
| legal maxima | 100 steps x 10,000 frames | 7.5 s | 503, deadline |
At the defaults the deterministic budgets always bind first and the clock
never does; the binding dimension is retained bytes, not positions. At the
maxima that inverts, and a template the counters would have accepted is
stopped by a 5s clock instead. If you raise these four, raise
openjd.expr_submission_deadline with
them.
3. The byte dimension is cumulative allocation, not peak live retention¶
openjd.expr_template_retained_bytes sums what every let: block in the
template retains, in evaluation order, across the whole walk. It does not
model when Go's garbage collector reclaims an earlier block's values. A
template that never holds more than 2.7 MB live at any one instant is still
charged 10.8 MB if it retains that much across four sequential let: blocks,
and is rejected exactly as a template that held all of it at once would be.
Two consequences, in both directions:
- Sizing a host's RAM against this number over-provisions — the template never needs that much at once.
- Sizing this number against an observed RSS under-bounds it — you will reject templates that were never going to use that memory.
4. Every worker must be at least as generous as this server¶
All five worker keys meter the same values one phase later, on the host, after the job has already been accepted. A worker that is tighter than this server can be handed work this server accepted and fail it there:
| This server accepts templates under… | …so every worker needs at least |
|---|---|
openjd.expr_operation_limit |
expr.operation_limit |
openjd.expr_memory_limit |
expr.memory_limit |
openjd.expr_template_positions |
expr.assignment_positions |
openjd.expr_template_retained_bytes |
expr.assignment_retained_bytes |
openjd.expr_template_retained_bytes |
expr.let_retained_bytes |
sqi does not leave that to chance. Every worker advertises all five values when it registers, and the scheduler refuses to dispatch an EXPR job to a worker whose values are short — the job is withheld, never accepted and then failed per task on the host. What an operator sees:
- a
WARNdiagnostic when the short worker registers, naming the worker and every short dimension with both numbers and both config keys. It is the server that logs this, so it appears under Admin → Server log — not on that worker's detail page, which shows only what the worker itself published; unschedulable_reasonon anyreadytask no capable worker exists for, carrying the same text, plus a WebSocket event — but only while the unschedulable sweep is on. Withscheduler.unschedulable_graceset to0or negative, the sweep is disabled and such a task simply waitsreadywith nothing written on it; the registrationWARN— which fires once, and may well predate the job — becomes the only signal.
A short worker keeps running everything else; only EXPR work is withheld from it. So raise the workers first, then the server.
The last row pairs one key with two: expr.let_retained_bytes bounds a single
symbol table, a scope this server does not meter separately, so it is compared
against the template-wide budget that upper-bounds it. That comparison is
deliberately conservative, and its floor is a tenth of this key's default —
a worker whose expr.let_retained_bytes is below your
openjd.expr_template_retained_bytes gets no EXPR work, even though the
value it holds may be ample for the templates you actually submit. Lower this
key or raise that one; both are visible, and neither fails a task after
acceptance.
None of the five is a guarantee. They are necessary conditions: phase 3 binds concrete values this server only had placeholders for, so an accepted job can still exhaust a worker that satisfies every row above.
The gate spots an EXPR job by reading the extension list recorded on the job row at submission, so for every job submitted since the upgrade the test is exact: a job flagged with this reason really does declare
EXPR.Job rows written before that column existed record no list, and those alone fall back to the previous heuristic — a scan of the raw template for the bytes
EXPRand the bytesextensions. A base-spec template that declares some other extension and mentions the string incidentally (a comment, or an environment variable such asHOUDINI_EXPR_CACHE) matches that scan, and such a job is withheld from every short worker and flagged with a reason naming EXPR limits it does not use. If you see that message on an older job that uses no expressions, this is why; the fix is the same either way — raise the workers, or lower the server. The fallback only ever errs toward withholding work, never toward dispatching it wrongly.
openjd.expr_operation_limit¶
| Type | int |
| Default | 10000 |
| Range | 1000 – 100000 |
| Env var | SQI_OPENJD_EXPR_OPERATION_LIMIT |
Operations (specification section 1.3.10 —
wiki/2026-02-Expression-Language.md:1071) that one expression
evaluation may spend. An expression exceeding it is rejected with an
operation-limit error naming the configured number, and the template is
refused at POST /api/v1/jobs. Deliberately far below the specification's
own recommended default of 10 million: sqi checks attacker-supplied
templates synchronously, which the recommendation does not assume.
Multiplies with expr_template_positions — see caveat 1 above.
Lowering this toward its floor has a concrete, shipped consequence: the
ffmpeg-segment-transcode-expr reference preset's join-list expression costs
~24.15 operations per slice and documents a 400-slice ceiling measured
against the default of 10,000 — at the floor of 1,000 that ceiling drops
to roughly 41 slices, with no warning surfaced anywhere else.
openjd:
expr_operation_limit: 10000
openjd.expr_memory_limit¶
| Type | int |
| Default | 1000000 |
| Range | 4096 – 10000000 |
| Env var | SQI_OPENJD_EXPR_MEMORY_LIMIT |
Live bytes (specification section 1.3.9 —
wiki/2026-02-Expression-Language.md:1060) that one expression
evaluation may hold. Exceeding it fails that evaluation with a
memory-limit error naming the configured number. Again far below the
specification's recommended 100 million, for the same reason.
This is the value that decides how large a string or list a single expression can build, so it is also what sets the "~900 KB" in caveat 2's measurements.
What raising it really permits is 50x this number. The template-wide
retained-bytes counter is charged once per let: block, after that block
finishes, so a single block can transiently hold up to
50 x openjd.expr_memory_limit before anything sees it — 50 MB at this
default, and 500 MB at the 10000000 maximum, per concurrent request, on a
path that is unauthenticated by default. Size the server's RAM against that
product, not against this number. (50 is the specification's per-block binding
cap: Template Schemas §3.6.) The same ceiling is stated from the other end
under openjd.expr_template_retained_bytes.
It is not a multiplier on wall-clock time — see caveat 1.
openjd:
expr_memory_limit: 1000000
openjd.expr_template_positions¶
| Type | int |
| Default | 10000 |
| Range | 256 – 100000 |
| Env var | SQI_OPENJD_EXPR_TEMPLATE_POSITIONS |
Expression positions one template may contain, summed across a whole
validation pass — one charge per format string checked and one per let:
binding evaluated. Exceeding it rejects the template with a template-wide
budget error.
The default was sized from a worked count of ~70 positions for a generous
real step, times the 100-step cap: ~7,040, with headroom. A template of
~21 positions per step (a command, 15 args, 3 environment variables, 2
embedded files, no let:) uses about 2,100.
Each phase gets its own fresh allowance: template upload, submit-time re-checking, and the parameter-space resolver each walk under a separate budget rather than sharing one pool.
This is the key most likely to strand work if you raise it alone — see
caveat 4. Raise every worker's expr.assignment_positions to at least this
value first.
openjd:
expr_template_positions: 10000
openjd.expr_template_retained_bytes¶
| Type | int |
| Default | 10000000 |
| Range | 65536 – 100000000 |
| Env var | SQI_OPENJD_EXPR_TEMPLATE_RETAINED_BYTES |
Bytes a template's let: bindings may cumulatively retain across one
walk. let: is the only construct in the walk that keeps a value — every
other position renders a result and discards it — so it is the only thing
that charges this counter.
Cumulative allocation, not peak live retention — see caveat 3.
This key cannot be raised without touching every worker. It is compared against two worker keys (caveat 4):
expr.assignment_retained_bytes, whose default of20000000leaves 2× headroom, andexpr.let_retained_bytes, whose default is10000000— exactly this default. So any increase at all puts every default-configured worker below this server and withholds EXPR work from all of them. Raiseexpr.let_retained_byteson every worker first, to at least the value you intend to set here. Lowering this key is always safe.
Even with both worker keys satisfied, this bound does not cover everything the
worker meters: it charges only what a let: block adds, while the worker
measures the whole symbol table the bindings land in — a job's own large
STRING/PATH parameters included. A worker sized exactly at this value can
still fail an accepted job whose parameters are large. See
expr.let_retained_bytes.
Note what this does not bound: the charge lands once per let: block,
after that block finishes evaluating, so a single block can transiently
retain up to 50 x openjd.expr_memory_limit — 50 MB at both defaults —
before this counter ever sees it. (50 is the per-block binding cap the
specification sets: Template Schemas §3.6, "Maximum number of items: 50".)
That per-block ceiling moves with expr_memory_limit, not with this key.
openjd:
expr_template_retained_bytes: 10000000
openjd.expr_submission_deadline¶
| Type | duration |
| Default | 5s |
| Range | 1s – 60s |
| Env var | SQI_OPENJD_EXPR_SUBMISSION_DEADLINE |
How long this server keeps evaluating one submission's expressions before giving up on it.
This key is live. Earlier revisions carried a "
EXPRis not accepted yet" callout here — the extension's registry status was in-progress, so no submission ever reached this deadline and none of the 503s described below could occur. That is no longer true:EXPRis a supported extension, and every route listed below can now answer503for a real submission. Seedocs/openjd-extensions/expr.md.
This is not a fifth limit, and it does not decide whether a template is
valid. The four above are deterministic: a template that breaches one
breaches it on every machine, so the breach is a property of the template and
the submitter is told so — 422 Unprocessable Entity, and retrying is
pointless. This one is wall clock, so the same body would be accepted on an
idle host and refused on a loaded one. A breach therefore reports that this
server gave up — 503 Service Unavailable, and a retry may well succeed.
Persistent 503s mean either a genuinely expensive template or a deadline set
too low.
Six routes can answer 503 for this reason: POST /api/v1/jobs and
POST /api/v1/products/{name}/jobs (which otherwise answer 422 for an
invalid template), POST /api/v1/products and PUT /api/v1/products/{name}
(which otherwise answer 400), and the two preset routes
GET /api/v1/presets/{name} and POST /api/v1/presets/{name}/install (which
otherwise answer 422, and which also answer 503 when no preset library is
configured — an unrelated meaning of the same code). The split is the same
everywhere — the 4xx is a verdict on the template, the 503 is not.
It exists because none of the other four bounds time (caveat 2). Section 1.3.10 prices 256 bytes at one operation, so byte-heavy work is nearly free in operations and expensive in seconds; the worst single request measured on this branch is ~17 minutes of server CPU with every budget respected. This is the only bound here that does not depend on that measurement being right.
The floor is 1s because below it a legitimate large template — a body near
the 4 MiB request cap, on a busy host — could plausibly be refused, and a
backstop that rejects valid work is worse than the exposure it guards. The
ceiling is 60s because the key exists to bound that ~17-minute figure; a
ceiling near it would bound nothing.
The headroom against real work is ~6x, not three orders of magnitude.
5swas originally chosen against an adversarial worst case, and an earlier revision of this callout said so and conceded that no large-but-legitimate template had ever been measured against it. One has now been: a generated multi-layer VFX shot-render job (12let:bindings per step, comprehensions over real frame ranges,RANGE_EXPR/LIST[STRING]/PATHparameters) driven through the real submission path. The worst shape this server still accepts costs 0.83 s — 100 steps over 1,000 frames, 5,013 positions, 1.66M operations — against a 5s default. A production server 2–3x slower than the measuring host puts that same job at 1.7–2.5 s. Still safe, but thinner than "three orders of magnitude" advertised, and worth knowing before you tighten this key.Two things that measurement settled. At the defaults the counters always bind before the clock, and the dimension that binds is retained bytes, not positions — 100 steps over 2,000 frames is refused at 1.11 s with a
422, never reaching this deadline. And the clock and the counters are not co-sized: at the legal maximum limits the same job shape at 10,000 frames runs 7.5 s and is correctly stopped here with a503. Raising the four limits without raising this key converts legitimate acceptances into 503s — see caveat 2 above.None of that makes the ~17-minute figure unreachable, and it must not be read that way. The two numbers describe different template shapes: an adversarial template does op-cheap, byte-heavy work across many positions and discards each result, so it never approaches the retained-bytes cap and this clock is the only thing bounding it, while a legitimate template leans on
let:and therefore hits retained bytes first, at ~1.1 s. The honest statement is that ~1.1 s is the ceiling for realisticlet:-using templates, and the adversarial ceiling is far higher — which is why this wall-clock backstop exists at all.
Unlike the four limits, it has no worker counterpart: the worker's own phase-3 evaluation is work this server already accepted, not an anonymous request, so caveat 4 does not apply to this key.
openjd:
expr_submission_deadline: 5s
A malformed duration in the config file is ignored, not rejected.
5 sec,5000or5 sleaves the default5sin place and the server starts. This is how every duration key in the file layer behaves — the value is parsed with Go'stime.ParseDurationand an unparseable one is skipped — but it is worth stating here because the other fouropenjd.expr_*keys are integers, so a typo in one of those fails YAML unmarshalling loudly. The environment variable does report a parse error, soSQI_OPENJD_EXPR_SUBMISSION_DEADLINE=5 secfails at startup. Check the effective value rather than assuming the file was read: an out-of-range value is a startup failure, but an unparseable one is silence.
diagnostics — In-UI diagnostic log buffer¶
diagnostics.buffer_size¶
| Type | int |
| Default | 1000 |
| Env var | SQI_DIAGNOSTICS_BUFFER_SIZE |
Maximum diagnostic records retained per component (server plus each
connected worker) in sqi-server's in-memory ring buffer. This single value is
also the on/off switch:
0— diagnostics are disabled: no buffer is allocated, the server does not subscribe toworker.diag.>, and the REST diagnostics endpoint returns 503.- positive — the per-component capacity. When a component's buffer is full the oldest records are evicted. The buffer is in-memory only and is cleared on server restart.
Negative values are rejected. The buffer feeds the web UI (Admin → Server log and each worker's detail page).
diagnostics:
buffer_size: 2000 # or 0 to disable
Workers have their own separate
diagnostics.enabledtoggle (they publish rather than buffer) — see Worker configuration below.
See docs/observability.md for the full diagnostics guide.
preset_library — Remote preset catalog¶
preset_library.url¶
| Type | string |
| Default | "https://uberware.github.io/sqi-presets/index.json" |
| Env var | SQI_PRESET_LIBRARY_URL |
URL of the preset library's JSON index. The default points to the official community preset library hosted on GitHub Pages.
Set to an empty string "" to disable the feature. When disabled, the Preset
Library browse page shows a "not configured" empty state and all /api/v1/presets
endpoints return 503.
preset_library:
url: "https://uberware.github.io/sqi-presets/index.json"
To use a private or self-hosted library, provide any accessible HTTP or HTTPS URL
that serves the index JSON. See docs/preset-library.md for the
index format and full integration guide.
auth — Authentication (Phase 3, opt-in)¶
auth.enabled¶
| Type | bool |
| Default | false |
| Env var | SQI_AUTH_ENABLED |
| CLI flag | --auth-enabled |
The single switch for sqi's authentication gate. Default false — the server
is open on a trusted local network and every request is served as an anonymous
superuser.
As of component A1, this is a live gate: setting it to true requires every
REST request and the WebSocket upgrade to carry a valid session, backed by
local accounts (see below and docs/auth.md). Role-based
authorization is enforced as of component B1: every mutating route and several
read routes are gated by a role→permission policy — see
docs/auth.md.
auth:
enabled: false
auth.validate_job_owner¶
| Type | bool |
| Default | true |
| Env var | SQI_AUTH_VALIDATE_JOB_OWNER |
| CLI flag | --auth-validate-job-owner |
Rejects a job submission whose owner (set via a jobs.submit_as override)
names no known user, with 400. Default true — it keeps Job.Owner a
trustworthy key for owner-scoped visibility: the user role's job listings are
filtered by owner, so a typo'd owner yields a job invisible to the person who
actually owns it and missed by an admin filtering on them. Disable it when
owners come from a directory that has not yet provisioned local records.
auth:
validate_job_owner: true
auth.session.ttl¶
| Type | duration |
| Default | 168h (7 days) |
| Env var | SQI_AUTH_SESSION_TTL |
Absolute lifetime of a session created by POST /api/v1/auth/login, from
creation. There is no sliding/idle renewal — using a session does not extend
it. Must be > 0 when auth.enabled is true; ignored (no validation) when
auth is disabled.
auth:
session:
ttl: "168h"
auth.session.cookie_name¶
| Type | string |
| Default | "sqi_session" |
| Env var | SQI_AUTH_SESSION_COOKIE_NAME |
Name of the session cookie set by login and cleared by logout. Change this
only if sqi_session collides with another cookie on the same domain (e.g.
sqi served under a shared reverse-proxy host alongside another app).
Must not be empty when auth.enabled is true — the server fails to start
otherwise. The name is deliberately required rather than defaulted at the point
of use: the CSRF middleware reads the cookie by name, and an empty name would
make every mutating request take the "no session cookie" exempt path, silently
disabling CSRF protection.
auth:
session:
cookie_name: "sqi_session"
auth.session.cookie_secure¶
| Type | string |
| Default | "auto" |
| Accepted values | auto, true, false |
| Env var | SQI_AUTH_SESSION_COOKIE_SECURE |
Controls the session cookie's Secure attribute. "auto" sets Secure when
the request arrived over TLS or carries X-Forwarded-Proto: https — correct
behind a TLS-terminating proxy. sqi's default deployment posture is a
trusted, plain-HTTP LAN, so "false" lets an operator force Secure off
explicitly on such a deployment rather than rely on "auto" guessing right;
"true" forces it on. Any other value fails config validation when
auth.enabled is true.
auth:
session:
cookie_secure: "auto"
auth.bootstrap.username / auth.bootstrap.password¶
| Type | string / string |
| Default | "" / "" |
| Env var | SQI_AUTH_BOOTSTRAP_USERNAME / SQI_AUTH_BOOTSTRAP_PASSWORD |
Seed credentials for the first admin account, applied once at startup only
when auth.enabled is true and the users table is empty; the account
is created with role: "admin". Idempotent — once any user exists, these
values have no further effect and never overwrite an existing password.
Leaving both empty is valid: an auth-enabled server with no users just logs a
WARN and boots without an admin, until an operator sets both and restarts.
Setting only one of the two is a startup validation error (guards against a
typo'd env var name).
auth.bootstrap.password is redacted (<redacted>) in
sqi-server config print and any other re-marshaled dump of the config — it
never appears in that output, even though the loaded value is used normally.
auth:
bootstrap:
username: "admin"
password: "change-me-after-first-login"
See docs/auth.md for the full authentication model, the local
account model, and the role/permission matrix enforced on every route.
auth.ldap.*¶
Directory (LDAP / Active Directory) authentication, component C1. Every key
below sits under auth.ldap. No ldap.* key has a CLI flag — these are
file- or environment-configured only. role_map is additionally file-only:
it has no environment form, because a list of group→role pairs has no
sensible flat encoding.
The whole block is inert while auth.ldap.enabled is false: the server never
builds a verifier and never contacts a directory, whatever else is set here.
Validation of the keys below only runs when auth.ldap.enabled is true.
Setting auth.ldap.enabled: true while auth.enabled is false is not a
silent no-op — it is a configuration error, and the server refuses to start:
auth.ldap.enabled: requires auth.enabled=true; LDAP without the auth gate authenticates nobody
Enable both together, or neither. You cannot stage an LDAP block behind a closed auth gate.
| Key | Type | Default | Env var |
|---|---|---|---|
auth.ldap.enabled |
bool | false |
SQI_AUTH_LDAP_ENABLED |
auth.ldap.url |
string | "" |
SQI_AUTH_LDAP_URL |
auth.ldap.start_tls |
bool | false |
SQI_AUTH_LDAP_START_TLS |
auth.ldap.tls_skip_verify |
bool | false |
SQI_AUTH_LDAP_TLS_SKIP_VERIFY |
auth.ldap.ca_file |
string | "" |
SQI_AUTH_LDAP_CA_FILE |
auth.ldap.timeout |
duration | 10s |
SQI_AUTH_LDAP_TIMEOUT |
auth.ldap.bind_dn |
string | "" |
SQI_AUTH_LDAP_BIND_DN |
auth.ldap.bind_password |
string | "" |
SQI_AUTH_LDAP_BIND_PASSWORD |
auth.ldap.base_dn |
string | "" |
SQI_AUTH_LDAP_BASE_DN |
auth.ldap.user_filter |
string | (sAMAccountName=%s) |
SQI_AUTH_LDAP_USER_FILTER |
auth.ldap.nested_groups |
bool | false |
SQI_AUTH_LDAP_NESTED_GROUPS |
auth.ldap.user_dn_template |
string | "" |
SQI_AUTH_LDAP_USER_DN_TEMPLATE |
auth.ldap.username_attr |
string | sAMAccountName |
SQI_AUTH_LDAP_USERNAME_ATTR |
auth.ldap.display_name_attr |
string | displayName |
SQI_AUTH_LDAP_DISPLAY_NAME_ATTR |
auth.ldap.unique_id_attr |
string | (none — required) | SQI_AUTH_LDAP_UNIQUE_ID_ATTR |
auth.ldap.role_source |
string | directory |
SQI_AUTH_LDAP_ROLE_SOURCE |
auth.ldap.role_map |
list | [] |
(file only — no env form) |
auth.ldap.default_role |
string | read-only |
SQI_AUTH_LDAP_DEFAULT_ROLE |
Transport. url must be ldap://… or ldaps://… and is required when
enabled. start_tls upgrades a plain ldap:// connection and is rejected
against ldaps://, which is already TLS. ca_file is a PEM bundle used to
verify the directory's certificate; an unreadable file aborts boot rather
than yielding a server that looks healthy but can authenticate nobody.
tls_skip_verify disables certificate verification entirely — a MITM can
then harvest every password that crosses the connection — so it is logged as
a WARN at boot and should never be set outside a lab. timeout must be
> 0; it bounds the TCP connect and each request leg, and is the only
bound on a hung directory (see
A hung directory).
Bind mode — mutually exclusive, one is required:
- Search-then-bind: set
base_dn(required) and usuallybind_dn+bind_password.user_filtermust contain%s, the placeholder for the escaped username. Leavingbind_dnempty selects anonymous search, which is valid; settingbind_passwordwithoutbind_dnis a validation error, because the password would be silently discarded. - Template bind: set
user_dn_template, which must contain%s(e.g.uid=%s,ou=people,dc=example,dc=com). Combining it withbind_dn/base_dnis a validation error.
nested_groups requires search mode — template bind reads the flat
memberOf attribute, so the combination is rejected at boot rather than
silently ignored.
Roles. role_source must be directory or local (see
role_source for what each
means and why one value drives both the login re-sync and the API's 409).
role_map is ordered and first match wins; each entry needs a non-empty
group and a role that is one of admin, operator, user, read-only
— an unknown role fails validation rather than falling through to
default_role. default_role accepts those same four values or empty,
where empty means reject any login that matched no group.
bind_password is redacted (<redacted>) in sqi-server config print,
the same as auth.bootstrap.password. Prefer SQI_AUTH_LDAP_BIND_PASSWORD
over writing it into a config file regardless — a secret that never lands on
disk cannot leak from one.
auth:
enabled: true
ldap:
enabled: true
url: "ldaps://dc01.example.com:636"
timeout: "10s"
bind_dn: "CN=sqi-svc,OU=Service Accounts,DC=example,DC=com"
# bind_password via SQI_AUTH_LDAP_BIND_PASSWORD
base_dn: "DC=example,DC=com"
user_filter: "(sAMAccountName=%s)"
username_attr: "sAMAccountName"
display_name_attr: "displayName"
unique_id_attr: "objectGUID" # "entryUUID" on OpenLDAP; no default, always required
nested_groups: true
role_source: "directory"
role_map:
- group: "CN=Farm Admins,OU=Groups,DC=example,DC=com"
role: admin
- group: "CN=Farm Operators,OU=Groups,DC=example,DC=com"
role: operator
default_role: "read-only"
unique_id_attr is a breaking change for an existing enabled-LDAP
deployment. It has no default anywhere — not in the defaults struct, not in
the loader, not in the environment — because no single value is correct on both
Active Directory (objectGUID) and RFC 4530 servers (entryUUID), and
guessing on a server exposing both would silently pick the wrong one. A config
with auth.ldap.enabled: true and no unique_id_attr fails validation and
the server does not boot. Accounts provisioned before this key existed carry
an empty identifier and must be recreated — see
Upgrading from an earlier sqi.
See docs/auth.md for the model behind
these keys: why LDAP attaches at login rather than per request, how
just-in-time provisioning works, why accounts match on a stable identifier
rather than a username, why a local admin account is required in directory
mode, and the revocation-lag and timing caveats.
auth.oidc.*¶
OAuth2/OIDC single sign-on, component C2. Every key below sits under
auth.oidc. No oidc.* key has a CLI flag — these are file- or
environment-configured only. role_map is additionally file-only: it has
no environment form, for the same reason as auth.ldap.role_map — a list of
group→role pairs has no sensible flat encoding.
The whole block is inert while auth.oidc.enabled is false: the server never
builds the SSO route, whatever else is set here. Validation of the keys below
only runs when auth.oidc.enabled is true.
Setting auth.oidc.enabled: true while auth.enabled is false is not a
silent no-op — it is a configuration error, and the server refuses to start:
auth.oidc.enabled: requires auth.enabled=true; SSO without the auth gate authenticates nobody
Enable both together, or neither. Unlike LDAP, issuer discovery happens lazily on
first use, not at boot — a briefly unreachable provider must not stop the
scheduler from starting.
Deliberately a single provider block, not a list: almost every organization has one identity provider.
| Key | Type | Default | Env var |
|---|---|---|---|
auth.oidc.enabled |
bool | false |
SQI_AUTH_OIDC_ENABLED |
auth.oidc.issuer |
string | "" |
SQI_AUTH_OIDC_ISSUER |
auth.oidc.client_id |
string | "" |
SQI_AUTH_OIDC_CLIENT_ID |
auth.oidc.client_secret |
string | "" |
SQI_AUTH_OIDC_CLIENT_SECRET |
auth.oidc.redirect_url |
string | "" |
SQI_AUTH_OIDC_REDIRECT_URL |
auth.oidc.scopes |
[]string | [openid, profile, email] |
SQI_AUTH_OIDC_SCOPES (comma-separated) |
auth.oidc.username_claim |
string | preferred_username |
SQI_AUTH_OIDC_USERNAME_CLAIM |
auth.oidc.display_name_claim |
string | name |
SQI_AUTH_OIDC_DISPLAY_NAME_CLAIM |
auth.oidc.groups_claim |
string | groups |
SQI_AUTH_OIDC_GROUPS_CLAIM |
auth.oidc.role_source |
string | directory |
SQI_AUTH_OIDC_ROLE_SOURCE |
auth.oidc.role_map |
list | [] |
(file only — no env form) |
auth.oidc.default_role |
string | read-only |
SQI_AUTH_OIDC_DEFAULT_ROLE |
auth.oidc.reauth_mode |
string | after_logout |
SQI_AUTH_OIDC_REAUTH_MODE |
auth.oidc.logout_mode |
string | local |
SQI_AUTH_OIDC_LOGOUT_MODE |
auth.oidc.post_logout_redirect_url |
string | "" |
SQI_AUTH_OIDC_POST_LOGOUT_REDIRECT_URL |
auth.oidc.button_label |
string | Sign in with SSO |
SQI_AUTH_OIDC_BUTTON_LABEL |
Endpoint. issuer, client_id, client_secret, and redirect_url are
all required when enabled — none has a safe empty meaning. issuer and
redirect_url must each be an absolute URL. redirect_url must resolve to
this server's /api/v1/auth/oidc/callback; post_logout_redirect_url is
only read (and only required) when logout_mode is "provider".
Claims. scopes must be non-empty and include "openid"; note that
"groups" is not standard OIDC — whether group membership needs a scope, a
provider-side mapper, or both varies by provider. username_claim and
display_name_claim carry defaults, so an empty value means the operator
explicitly cleared it (same reasoning as auth.ldap.username_attr /
display_name_attr); groups_claim feeds role_map the same way.
Roles. role_source must be directory or local, mirroring
auth.ldap.role_source but tracked separately — an operator may trust one
provider's groups and not the other's. role_map is ordered and first
match wins; each entry needs a non-empty group and a role that is one
of admin, operator, user, read-only — an unknown role fails
validation rather than falling through to default_role. default_role
accepts those same four values or empty, where empty means reject any
login that matched no group.
Reauth and logout. reauth_mode is one of after_logout (re-prompt only
on the login following an explicit logout), always (re-prompt every
login), or never (silent re-login always permitted). logout_mode is
local (end only the sqi session) or provider (also end the session at the
identity provider, signing the user out of every tool that trusts it — off
by default because of that blast radius).
client_secret is redacted (<redacted>) in sqi-server config print,
the same as auth.ldap.bind_password. Prefer SQI_AUTH_OIDC_CLIENT_SECRET
over writing it into a config file regardless — a secret that never lands on
disk cannot leak from one.
auth:
enabled: true
oidc:
enabled: true
issuer: "https://login.microsoftonline.com/<tenant>/v2.0"
client_id: "sqi"
# client_secret via SQI_AUTH_OIDC_CLIENT_SECRET
redirect_url: "https://sqi.example.com/api/v1/auth/oidc/callback"
scopes: ["openid", "profile", "email"]
username_claim: "preferred_username"
display_name_claim: "name"
groups_claim: "groups"
role_source: "directory"
role_map:
- group: "sqi-farm-admins"
role: admin
- group: "sqi-farm-operators"
role: operator
default_role: "read-only"
reauth_mode: "after_logout"
logout_mode: "local"
The mode constants (ReauthAfterLogout/ReauthAlways/ReauthNever,
LogoutLocal/LogoutProvider, RoleSourceDirectory/RoleSourceLocal) live
in internal/auth/oidc, not internal/config — internal/api reads them
directly and must not import the config loader, the same boundary that
toLDAPConfig exists to keep for LDAP.
See docs/auth.md for the model behind these keys: the
login flow and its defenses, why accounts match on the sub claim, how
reauth_mode and logout_mode differ, and the limits — revocation lag, roles
applying at next login only, what logout_mode: provider actually does on
Keycloak, and which providers CI does and does not cover.
Queue identity: run_as_user / run_as_group (task isolation)¶
Unlike everything else in this reference, run_as_user and run_as_group
are not sqi-server config-file keys — there is no server-wide or
farm-wide default for either. They are set per queue, via the queue REST
resource only (POST /api/v1/queues, PUT /api/v1/queues/{id}) — there is
no web UI field or Python SDK field for either yet — and are documented here
because, like the retry overrides above, they are queue-level settings that
change worker behavior.
| Field | Type | Default | Effect |
|---|---|---|---|
run_as_user |
string \| null |
null |
OS username tasks (and OpenJD environment onEnter/onExit actions) in this queue execute as. null — the default — means no isolation: tasks run as the worker daemon's own account, identical to a worker with no isolation feature at all. |
run_as_group |
string \| null |
null |
OS group for the same tasks. null means the target user's primary group. |
Both fields are deliberately excluded from the Farm → server-default cascade that the retry-policy fields above use: a farm-wide default would silently apply an OS identity to a queue whose owner never configured one. Every queue's isolation setting is explicit or absent — never inherited.
Permission. Setting either field — including sending an explicit null
to clear a previously-set value — requires the isolation.manage
permission, held only by the admin role; infra.manage (which operator
holds, and which gates every other queue field) is not sufficient. Omitting
both keys from a PUT body preserves whatever is currently stored and
requires no permission — this is a deliberate exception to PUT's normal
full-replace semantics, so that an operator without isolation.manage can
still edit a queue's priority or concurrency limit without silently clearing
an admin's isolation configuration on the same request. See
Task isolation in the auth guide for the full
reasoning, including why enabling isolation raises the worker daemon's
privilege even though it lowers an individual task's.
Validation. run_as_group requires run_as_user to also be set (in the
same request, or already stored and preserved by an omitted key) — the
scheduler only gates isolation on run_as_user, so a group with no user
would select no OS identity at all and be silently ignored. Both POST and
PUT reject that combination with 400 Bad Request.
The scheduler places only the resolved username in the task assignment
sent to the worker over NATS — never a credential — because worker↔server
transport carries no authentication at all (see
Known gaps). The worker resolves that username to a
real OS credential itself. This mechanism runs on both POSIX (Linux/macOS)
and Windows (the worker must run as a LocalSystem service, or hold
SeAssignPrimaryTokenPrivilege, to assume another account's identity); see
docs/worker-configuration.md
for the full worker-side isolation config block, the environment allowlist,
and the per-platform requirements.
Important: Worker upgrade required¶
run_as_user is only enforced by workers that support task isolation. A
worker binary built before isolation support shipped will silently ignore the
isolation field in its task assignments and run job code as the worker
daemon's own OS user. The scheduler does not filter assignments by worker
capability, so an admin who sets run_as_user on a queue while even one
un-upgraded worker remains in the farm gets silent, partial enforcement —
some tasks isolated, some not, with no indication which.
This is the asymmetry worth understanding:
- A worker that supports isolation but is misconfigured fails closed and loudly: it refuses to start, or fails the individual task with an actionable error message.
- An old worker fails open and silently: it accepts the assignment, executes the task unisolated, and reports success. There is no error anywhere.
Guidance: upgrade all workers to a binary that supports task isolation
before enabling run_as_user on any queue. Do not mix binary versions.
A proper solution would require workers to advertise isolation capability and the scheduler to refuse isolation-required tasks to workers lacking it — a protocol change deferred as a future improvement. For now, the only way to ensure consistent enforcement is to roll the farm forward in lockstep.
Worker configuration¶
Worker configuration applies to sqi-worker instances, not the server. Workers
load configuration from the same layered sources as the server (defaults → file →
environment → flags), except that environment variables use the SQI_WORKER_
prefix and the config file is named sqi-worker.yaml.
The full worker key reference — NATS connection, identity, metrics, discovery, log
streaming, and more — lives in
docs/worker-configuration.md, and a fully commented
example is at
config/sqi-worker.example.yaml.
The keys below are summarized here for operators configuring a worker alongside
the server; the same keys are covered in full in that reference. staging
bridges server storage configuration to worker execution, diagnostics mirrors
the server-side diagnostics setting, and capabilities configures the worker's
software auto-detection.
Staging (stage_locally path delivery)¶
Used by workers that run jobs declaring the stage_locally delivery of
SQI_PATH_TRANSLATION.
| Key | Type | Description |
|---|---|---|
staging.scratch_dir |
string | Base directory for per-attempt staged copies. Defaults to <os.TempDir()>/sqi-staging when unset. |
staging.sync_command |
string | Command template invoked per path, with {src}, {dest}, and optional {object_type} placeholders (e.g. rsync -a {src} {dest}). The same template serves copy-in and copy-out. Left unset (or set to builtin), sqi copies the bytes itself instead of shelling out. |
staging.defaults |
bool | Default true. When true, an otherwise-unconfigured worker still runs stage_locally jobs via the TEMP scratch dir and built-in copy above (one-time WARN logged). Set false to make an unconfigured worker fail stage_locally jobs immediately instead. |
staging:
scratch_dir: "/tmp/sqi-staging"
sync_command: "rsync -a {src} {dest}"
defaults: true
Full detail, including the built-in copy's local/dev caveat and the
staging.defaults behavior change, is in
Worker configuration → staging.
Diagnostics (diagnostics.enabled)¶
Controls whether the worker mirrors its own structured (slog) output to
sqi-server over core NATS, where it appears in the web UI alongside server logs.
This is the worker agent's operational log — distinct from task process output,
which is always streamed. This is the worker's counterpart to the server's
diagnostics.buffer_size (workers publish; the server buffers).
| Key | Type | Default | Env var | Description |
|---|---|---|---|---|
diagnostics.enabled |
bool | true |
SQI_DIAGNOSTICS_ENABLED |
When true, the worker's diagnostic-log records are published to sqi-server in addition to local stderr. Set false to keep them local only. |
Note the env var is SQI_DIAGNOSTICS_ENABLED (not SQI_WORKER_…), matching the
server-side diagnostics naming.
diagnostics:
enabled: true
See docs/observability.md for the full diagnostics guide.
EXPR expression limits (expr.*)¶
The worker's own five expr.* keys bound what one assignment may spend
evaluating EXPR expressions on the host, at task-execution time. Four of them
must be at least as generous as this server's openjd.expr_* counterparts
or the scheduler withholds EXPR work from that worker — see
EXPR expression limits, caveat 4
above for the pairings and what an operator sees, and
Worker configuration → expr
for the keys themselves.
Capability auto-detection (capabilities.detect / capabilities.disable)¶
Controls the worker's software capability auto-detection: built-in detectors
for Maya, Nuke, Houdini, Blender, FFmpeg and SGO Mistika (Boutique/Ultima, VR
and Workflows) run automatically at startup and advertise a tag (e.g. maya,
plus maya-2025) with value "true", with no
per-worker configuration — enough on its own to satisfy the reference DCC
products/presets' key=true requirement. See
docs/products.md and
docs/dcc-submitters.md for how these auto-detected
tags relate to the reference DCC products/presets.
| Key | Type | Default | Env var | Description |
|---|---|---|---|---|
capabilities.detect |
[]Detector |
[] |
— (config file only) | Custom detectors for in-house tools, same schema as the built-ins. |
capabilities.disable |
[]string |
[] |
SQI_WORKER_CAPABILITIES_DISABLE (comma-separated, appended) |
Turn off a built-in detector by tag name, e.g. [blender]. |
capabilities:
detect:
- tag: mytool
checks:
- exe: mytool
disable: [blender]
See docs/worker-capabilities.md
for the full auto-detection guide (how it runs, the tag/version model, the
sqi-worker capabilities command) and
docs/worker-capabilities.md
for the detector schema reference.
Quick reference table¶
| Key | Type | Default | Env var | CLI flag |
|---|---|---|---|---|
http.addr |
string | 0.0.0.0:8080 |
SQI_HTTP_ADDR |
--http-addr |
http.enable_pprof |
bool | false |
SQI_HTTP_ENABLE_PPROF |
— |
http.cors_origins |
[]string | [] (= *) |
SQI_HTTP_CORS_ORIGINS |
--http-cors-origins |
nats.addr |
string | 0.0.0.0:4222 |
SQI_NATS_ADDR |
— |
nats.data_dir |
string | data/nats |
SQI_NATS_DATA_DIR |
— |
nats.max_store_mb |
int | 1024 |
SQI_NATS_MAX_STORE_MB |
— |
store.sqlite_path |
string | sqi.db |
SQI_STORE_SQLITE_PATH |
— |
store.checkpoint_interval |
duration | 5m |
SQI_STORE_CHECKPOINT_INTERVAL |
— |
log.level |
string | info |
SQI_LOG_LEVEL |
--log-level |
log.format |
string | json |
SQI_LOG_FORMAT |
--log-format |
scheduler.heartbeat_timeout |
duration | 30s |
SQI_SCHEDULER_HEARTBEAT_TIMEOUT |
— |
scheduler.tick_interval |
duration | 500ms |
SQI_SCHEDULER_TICK_INTERVAL |
— |
scheduler.max_tasks_per_worker |
int | 1 |
SQI_SCHEDULER_MAX_TASKS_PER_WORKER |
— |
scheduler.offline_worker_retention |
duration | 24h |
SQI_SCHEDULER_OFFLINE_WORKER_RETENTION |
— |
scheduler.job_retention |
duration | 168h |
SQI_SCHEDULER_JOB_RETENTION |
— |
scheduler.job_retention_include_failed |
bool | false |
SQI_SCHEDULER_JOB_RETENTION_INCLUDE_FAILED |
— |
scheduler.unschedulable_grace |
duration | 30s |
SQI_SCHEDULER_UNSCHEDULABLE_GRACE |
— |
scheduler.default_max_attempts |
int | 3 |
SQI_SCHEDULER_DEFAULT_MAX_ATTEMPTS |
— |
scheduler.retry_delay |
duration | 30s |
SQI_SCHEDULER_RETRY_DELAY |
— |
scheduler.default_failure_limit |
int | 0 |
SQI_SCHEDULER_DEFAULT_FAILURE_LIMIT |
— |
discovery.enabled |
bool | true |
SQI_DISCOVERY_ENABLED |
— |
discovery.instance_name |
string | sqi-server |
SQI_DISCOVERY_INSTANCE_NAME |
— |
openjd.enforce_limits |
bool | true |
SQI_OPENJD_ENFORCE_LIMITS |
--openjd-enforce-limits |
openjd.expr_operation_limit |
int | 10000 |
SQI_OPENJD_EXPR_OPERATION_LIMIT |
— |
openjd.expr_memory_limit |
int | 1000000 |
SQI_OPENJD_EXPR_MEMORY_LIMIT |
— |
openjd.expr_template_positions |
int | 10000 |
SQI_OPENJD_EXPR_TEMPLATE_POSITIONS |
— |
openjd.expr_template_retained_bytes |
int | 10000000 |
SQI_OPENJD_EXPR_TEMPLATE_RETAINED_BYTES |
— |
openjd.expr_submission_deadline |
duration | 5s |
SQI_OPENJD_EXPR_SUBMISSION_DEADLINE |
— |
diagnostics.buffer_size |
int | 1000 |
SQI_DIAGNOSTICS_BUFFER_SIZE |
— |
preset_library.url |
string | https://uberware.github.io/sqi-presets/index.json |
SQI_PRESET_LIBRARY_URL |
— |
auth.enabled |
bool | false |
SQI_AUTH_ENABLED |
--auth-enabled |
auth.validate_job_owner |
bool | true |
SQI_AUTH_VALIDATE_JOB_OWNER |
--auth-validate-job-owner |
auth.session.ttl |
duration | 168h |
SQI_AUTH_SESSION_TTL |
— |
auth.session.cookie_name |
string | sqi_session |
SQI_AUTH_SESSION_COOKIE_NAME |
— |
auth.session.cookie_secure |
string | auto |
SQI_AUTH_SESSION_COOKIE_SECURE |
— |
auth.bootstrap.username |
string | "" |
SQI_AUTH_BOOTSTRAP_USERNAME |
— |
auth.bootstrap.password |
string | "" |
SQI_AUTH_BOOTSTRAP_PASSWORD |
— |
auth.ldap.enabled |
bool | false |
SQI_AUTH_LDAP_ENABLED |
— |
auth.ldap.url |
string | "" |
SQI_AUTH_LDAP_URL |
— |
auth.ldap.start_tls |
bool | false |
SQI_AUTH_LDAP_START_TLS |
— |
auth.ldap.tls_skip_verify |
bool | false |
SQI_AUTH_LDAP_TLS_SKIP_VERIFY |
— |
auth.ldap.ca_file |
string | "" |
SQI_AUTH_LDAP_CA_FILE |
— |
auth.ldap.timeout |
duration | 10s |
SQI_AUTH_LDAP_TIMEOUT |
— |
auth.ldap.bind_dn |
string | "" |
SQI_AUTH_LDAP_BIND_DN |
— |
auth.ldap.bind_password |
string | "" |
SQI_AUTH_LDAP_BIND_PASSWORD |
— |
auth.ldap.base_dn |
string | "" |
SQI_AUTH_LDAP_BASE_DN |
— |
auth.ldap.user_filter |
string | (sAMAccountName=%s) |
SQI_AUTH_LDAP_USER_FILTER |
— |
auth.ldap.nested_groups |
bool | false |
SQI_AUTH_LDAP_NESTED_GROUPS |
— |
auth.ldap.user_dn_template |
string | "" |
SQI_AUTH_LDAP_USER_DN_TEMPLATE |
— |
auth.ldap.username_attr |
string | sAMAccountName |
SQI_AUTH_LDAP_USERNAME_ATTR |
— |
auth.ldap.display_name_attr |
string | displayName |
SQI_AUTH_LDAP_DISPLAY_NAME_ATTR |
— |
auth.ldap.unique_id_attr |
string | (none — required) | SQI_AUTH_LDAP_UNIQUE_ID_ATTR |
— |
auth.ldap.role_source |
string | directory |
SQI_AUTH_LDAP_ROLE_SOURCE |
— |
auth.ldap.role_map |
list | [] |
— | — |
auth.ldap.default_role |
string | read-only |
SQI_AUTH_LDAP_DEFAULT_ROLE |
— |
auth.oidc.enabled |
bool | false |
SQI_AUTH_OIDC_ENABLED |
— |
auth.oidc.issuer |
string | "" |
SQI_AUTH_OIDC_ISSUER |
— |
auth.oidc.client_id |
string | "" |
SQI_AUTH_OIDC_CLIENT_ID |
— |
auth.oidc.client_secret |
string | "" |
SQI_AUTH_OIDC_CLIENT_SECRET |
— |
auth.oidc.redirect_url |
string | "" |
SQI_AUTH_OIDC_REDIRECT_URL |
— |
auth.oidc.scopes |
[]string | [openid, profile, email] |
SQI_AUTH_OIDC_SCOPES |
— |
auth.oidc.username_claim |
string | preferred_username |
SQI_AUTH_OIDC_USERNAME_CLAIM |
— |
auth.oidc.display_name_claim |
string | name |
SQI_AUTH_OIDC_DISPLAY_NAME_CLAIM |
— |
auth.oidc.groups_claim |
string | groups |
SQI_AUTH_OIDC_GROUPS_CLAIM |
— |
auth.oidc.role_source |
string | directory |
SQI_AUTH_OIDC_ROLE_SOURCE |
— |
auth.oidc.role_map |
list | [] |
— | — |
auth.oidc.default_role |
string | read-only |
SQI_AUTH_OIDC_DEFAULT_ROLE |
— |
auth.oidc.reauth_mode |
string | after_logout |
SQI_AUTH_OIDC_REAUTH_MODE |
— |
auth.oidc.logout_mode |
string | local |
SQI_AUTH_OIDC_LOGOUT_MODE |
— |
auth.oidc.post_logout_redirect_url |
string | "" |
SQI_AUTH_OIDC_POST_LOGOUT_REDIRECT_URL |
— |
auth.oidc.button_label |
string | Sign in with SSO |
SQI_AUTH_OIDC_BUTTON_LABEL |
— |
Minimal production example¶
# /etc/sqi/sqi-server.yaml
http:
addr: "0.0.0.0:8080"
nats:
data_dir: "/var/lib/sqi/nats"
max_store_mb: 4096
store:
sqlite_path: "/var/lib/sqi/sqi.db"
log:
level: "info"
format: "json"
scheduler:
heartbeat_timeout: "60s"
tick_interval: "500ms"
max_tasks_per_worker: 1
discovery:
enabled: false # disable multicast in a server environment
instance_name: "sqi-prod"
See also¶
config/sqi-server.example.yaml— Commented example covering the HTTP, NATS, store, log, scheduler, discovery, OpenJD, diagnostics and preset-library sections. Thehttp.cors_originskey and the wholeauthblock are documented in this reference only.docs/architecture.md— Component layout and how configuration values are consumed.docs/operations.md— Install, upgrade, backup, and log rotation.docs/observability.md— In-UI diagnostics, REST/WS log API, and external log wiring.