Operating Syntra
September 7, 2026 · View on GitHub
This is the operator playbook for a running Syntra appliance: what each
lifecycle state means, how to interpret learned state, what to do when a
capsule isn't behaving the way you expect, and how to back up and restore.
For the API surface see api.md; for deployment shapes see
deployment.md; for what shipped in each phase see
../CHANGELOG.md.
The operating principle is that Syntra is inspectable. When a capsule appears to learn the wrong thing, the data trail is in the store — read it before changing the capsule.
Lifecycle states
Every capsule moves through three lifecycle states, persisted in
warmup.json next to the graph and returned in every /decide response
under the warmup field.
Warmup is the first ~30 feedback rounds. Syntra runs uniform random selection, watches the rewards arrive, and characterizes the problem as binary, continuous, or sparse-continuous. The first active algorithm is picked from this characterization automatically. Refusal is never engaged during Warmup — refusing every decision in this phase would starve the calibrator and deadlock the cold start. Treat Warmup as a known-noisy period and do not draw conclusions from the weights yet.
Active is the steady state. The rate-adaptive meta-bandit runs six candidate algorithms in parallel (Thompson, UCB1, EpsilonGreedy, Weighted, Greedy, and — for feature-context capsules — LinUCB) and converges on whichever one is doing best on the actual traffic. Each candidate keeps its own per-context bucket so the meta-bandit can compare like with like. This is the state your dashboards and SLOs care about.
Frozen is an operator-triggered hold. The bandit stops mutating weights
and serves decisions from whatever it learned last. Use this for change
freezes, incident response, or to A/B a learned policy against a static
baseline. There is currently no HTTP route to freeze a capsule; freeze a
capsule by setting safety.freezeLearning = true in learning.json via
PUT /learning.
A capsule reverts from Active back to Warmup automatically when the capsule-level ADWIN detector fires (see Drift detection below).
Inspecting learned state
For a capsule at tenants/{tenant}/jobs/{job}/capsules/{capsule}, the
inspection endpoints in order of increasing cost are:
GET /report is the cheap path. It returns each strategy node with current
weights, per-option tries / correct counts, average latency, and the SHA-256
hash of the installed graph binary. This is what your dashboards should poll.
The graph hash lets you correlate weights against the install audit entry —
useful when a capsule has been reinstalled and you need to know whether you
are looking at the new graph's learning or the old graph's residual.
GET /contexts returns one row per (nodeId, contextKey) with weights, total
tries, and last-update timestamp. Use it to confirm requests are landing in
the contexts you expect. High-cardinality keys (raw user IDs, request IDs)
usually indicate a mistake — Syntra learns nothing per-bucket if no bucket
sees more than one decision.
GET /memory returns the full memory.json sidecar: per-context buckets,
the meta-bandit state for each strategy node, candidate-context buckets, per-
context ADWIN detectors, and the OOD detectors. The most useful sub-structure
is strategies[nodeId].metaBandit.candidates[]: one entry per candidate
algorithm with its trials and rolling reward. When the meta-bandit picks
something surprising, this is where you confirm whether the surprise is
"it's actually better on your data" or "it's mis-attributed because one
candidate has far fewer trials than the others".
GET /decisions and GET /audits return the JSONL logs as text. Decisions
carry the decisionId, the chosen option, the context, refusal flags, and
the OOD score; audits carry installs, policy changes, deletes, warmup
transitions, change-detection events, and decision-refused records.
Common failure modes
Capsule stuck in Warmup
The lifecycle only transitions out of Warmup once 30 feedback rounds have
arrived. If a capsule looks frozen in Warmup, the answer is almost always
that not enough feedback is being sent. Confirm feedback.jsonl is growing
at the rate decision.jsonl is growing; if it isn't, fix the calling side.
The most common bug is a feedback path that drops on error rather than
retrying.
A secondary cause is feedback with zero reward — a reward: 0.0 payload
counts as a sample for warmup but contributes nothing to the
characterization. If every reward is zero, audit your reward computation;
positive rewards should mean "do more of this", negative rewards should mean
"do less of this".
Too many refusals
When refusal.enabled = true, the response carries refused: true whenever
the OOD score exceeds oodThreshold or the conformal interval width exceeds
maxIntervalWidth. If refusal rate climbs above what your integration
expects, check the refusal reason distribution in decision.jsonl (filter
on refused: true and bucket by refusalReason).
"ood" means the input is far from anything seen during training; either
raise oodThreshold to widen tolerance, or accept the refusal and let your
service fall back. "interval_too_wide" means the predictor isn't confident
yet; raise maxIntervalWidth for a looser bound, or wait for more data to
flow in. "insufficient_calibration_data" means the calibrator hasn't seen
enough residuals for the requested coverage; usually a transient state right
after Warmup completes.
Unexpected algorithm pick
When the meta-bandit picks an algorithm you didn't expect, do not change
the algorithm — that defeats the whole point of having a meta-bandit. Inspect
/memory and look at strategies[nodeId].metaBandit.candidates[]. Each
candidate has a trials count and a rolling reward; the meta-bandit favors
whichever candidate has the highest rolling reward subject to a UCB
exploration bonus over trials.
Three things to check. First, candidate trial counts: if one candidate has
ten times the trials of another, the picks are dominated by exploration
balance, not by mean reward. Second, candidate-context bucket counts under
candidateContexts: if a candidate has not seen the current context yet,
it falls back to its prior, which can look like aggressive exploration.
Third, the candidate's residual width via the conformity calibrator —
candidates with wide residuals are running on noisy data and should not yet
be trusted as winners.
Only override the meta-bandit's pick (by setting algorithm in
learning.json) when you have reason to believe the meta-bandit is being
fooled — for example, when one candidate has a learnability advantage you
can prove independently. Even then, prefer to let the meta-bandit converge.
Wrong context is learning
The bandit can only learn per-context weights for the contexts it actually
sees. If a context that should exist isn't appearing in /contexts, your
calling side is sending a different contextKey than you think. Tail
decision.jsonl for a minute and look at the contextKey field on recent
entries; compare to what the caller is logging on its side.
Avoid raw user IDs and request IDs as context keys — those produce one bucket per request and learn nothing. Group into the meaningful axis (tier, region, time-of-day bucket, recent-failure-rate band) instead.
Weights are not moving
If feedback is flowing but weights are not changing, check safety.freezeLearning
in /learning. If frozen, that explains it. Otherwise, confirm rewards are
non-zero and confirm feedback is targeting the right decisionId — feedback
against a decisionId that doesn't exist in decision.jsonl returns 404.
Tail audit.jsonl for feedback_on_refused entries; feedback against
refused decisions is recorded but does not mutate the bandit.
Drift detection
Drift is detected at two scopes. The capsule-level ADWIN detector watches
the rolling reward distribution across the whole capsule. When it fires, the
capsule reverts from Active back to Warmup, the meta-bandit state is
preserved, and an audit.jsonl entry records the trigger:
{"event":"change_detected","previousAlgorithm":"Thompson","oldMean":0.71,"newMean":0.42,...}
Re-warmup is the right response to global regime shifts — a deployment, a new upstream, a holiday traffic pattern — because the meta-bandit's previous trial counts no longer reflect current dynamics. The previous algorithm choice is remembered in the audit log so you can verify the post-drift re-pick.
Per-context ADWIN detectors run alongside, one per (nodeId, contextKey).
When one fires, only the affected context bucket is reset; the rest of the
capsule continues serving without interruption. This is the right response
to narrow shifts — a single tenant moves to a different upstream, one route
suddenly gets slower — and avoids re-warming the entire capsule for changes
that touched a small fraction of traffic.
ADWIN's drift threshold is currently hard-coded to delta = 0.002 and is
not yet configurable via learning.json. This is tracked in known debt; for
now, treat it as a fixed sensitivity.
Decision-log forensics
To trace a single decisionId end to end:
- Find the decision in
decision.jsonl. The record carries the contextKey, chosen option, the algorithm (or candidate) that picked it, the refusal flag, the OOD score, and the conformal interval width. - Find any matching record in
feedback.jsonlkeyed by the samedecisionId. The feedback record carries the observed reward and the weightsbeforeandafterthe update. - Check
audit.jsonlfor events bracketing the decision's timestamp: installs, policy changes, warmup transitions, drift triggers, and refused decisions. The audit log is the global truth for "what was Syntra doing at this moment".
The decision and feedback logs are append-only JSONL; use jq or any
standard JSONL processor. Decision records carry a 16-character prefix of
the input SHA-256 (inputSha256) and a 16-character graph hash
(graphHash), so you can pin a decision to a specific request and a
specific installed graph version.
Backup and restore
The entire state of a Syntra appliance is its store directory. Back up the
store, you have backed up everything. The recommended pattern is a
file-system snapshot (LVM, ZFS, EBS volume snapshot) for live backups, or
cp -r of the store root with the appliance stopped for a fully consistent
file-level copy:
systemctl stop syntra
cp -r /var/lib/syntra /var/backups/syntra-$(date +%F)
systemctl start syntra
For Docker, use docker volume against the syntra-store named volume;
for Kubernetes, snapshot the PersistentVolume. Restore is symmetric: stop
the appliance, replace the store directory, start the appliance. There is
no schema-migration step; the memory.json reader is backward-compatible
across schema versions 2 through 7.
A first-class HTTP backup endpoint that produces a single restorable artifact per capsule is planned for Phase 1E. Until that ships, the volume-copy pattern is the supported backup story.
Shadow-mode checklist
Before letting Syntra influence production behaviour, run it in shadow mode for a meaningful slice of traffic. The minimum bar before flipping the switch:
- Confirm decision and feedback rates are roughly equal in the logs (every decision gets feedback within your delayed-outcome window).
- Confirm context buckets in
/contextslook like what you expect — the right number of buckets, the right distribution of traffic across them. - Confirm the meta-bandit has converged on a candidate in
/memory— one candidate should have meaningfully more trials than the others and a higher rolling reward. - Compare Syntra's suggested option against your existing production choice. The disagreement rate should be non-trivial (otherwise Syntra isn't adding value) but the disagreements should be defensible when spot-checked against the feature vector.
- Back up the store volume before enabling any mutation-heavy workflow.