alerts.json
The persisted alert rule set — its schema, its forward-compatibility discipline, where the daemon reads and writes it, and which of its triggers a headless node can actually fire.
alerts.json is not a settings file. It is program-written state, and it sits beside the four
settings TOMLs rather than among them: it holds a rule set an editor
writes, it is loaded by a path its caller supplies, and its absence is the off switch.
crates/vike-alerting/src/persist.rs owns the file conventions and
crates/vike-alerting/src/rule.rs owns the model.
Where it lives
vike-tradehub's alerts_path resolves it: $VIKE_ALERTS when set and non-blank, else
<state_dir>/alerts.json, where the state directory is $VIKE_STATE_ROOT if set and
<project>/settings/state otherwise. A blank override falls through rather than resolving to the
empty string — an empty systemd Environment=VIKE_ALERTS= line would otherwise point the loader at
the working directory. The answer is None when there is no override and no state directory
resolves, and the mount says so rather than reading somewhere else.
The environment read happens in the binary, deliberately: persist.rs's header records that this
module reads no environment variable and resolves no path, that every entry point takes the file as
a &Path, and that ALERTS_FILE — the basename, "alerts.json" — is the single thing the library
states, so the caller composing the directory does not re-spell it. load_path reads and parses;
save_path writes it back pretty-printed.
A missing or corrupt file is never an error
load_path returns None when the file is absent, and on an unparseable file it logs a warning and
returns None too. A bad file must never brick startup — the same never-brick discipline the
workspace loader follows. Both cases mean no rules configured, which is the off state.
The schema
AlertRuleSet is the persisted container: a version (#[serde(default)], so a file predating the
field loads as CURRENT_VERSION, which is 1) and a rules vector. An empty set is the default and
the off state — nothing is evaluated and nothing is delivered, byte-identical to a build without the
alerting engine.
Each AlertRule carries:
id— a stable unique id. It is the per-rule evaluation-state key, the cooldown and once key, and the delivery correlation id, so keep it stable across edits of a rule.name— a human label; it becomes the delivered message's title, falling back to theidwhen empty.enabled— defaults totrue. A disabled rule is persisted but never evaluated, byte-identical to its absence.trigger— the condition, aRuleTrigger.targets— anAlertTargets; absent means the default, which isin_process: trueand no webhooks.cooldown_ms— the minimum milliseconds between re-fires of the same rule,0for none. For a latched trigger it is redundant, since the latch already prevents a re-fire until re-arm; it is the primary rate limit for discrete-event triggers.once— fire at most once per process session, regardless of cooldown.
Forward compatibility is a discipline rather than a migration path: every field added over time
is #[serde(default)], with skip_serializing_if where an absent key is the common case, so an
older alerts.json still loads and a rule that uses none of a field's feature writes no key for it.
id and trigger are the two that carry no default and are required on load — a rule with no
identity or no condition is not a rule.
RuleTrigger variants include Price (a published mark for a venue and symbol crossing a level,
edge-triggered), Indicator (a streaming-indicator output crossing a threshold — the rule stores
only the registry key, the output index and the parameter overrides, so it round-trips without a bar
dependency), Fill, OrderRejected, Drawdown, ReconAlert, Feed, FillRateBreaker,
PolymarketResolution and SeriesStale.
Two carry warnings in their own docs. Drawdown tracks the daemon's own equity curve —
configured capital plus realized and unrealized P&L — drawing down from its running session peak,
latched so it fires once on breach and re-arms only after recovery; it is deliberately not the
cross-venue equity total, because that includes each live venue's wallet, which a third party can
move, and it tracks the same quantity the core's own liquidate-only latch does so the two cannot
disagree. SeriesStale is the writer-stopped-writing condition and is deliberately not Feed:
feed health is venue-scoped and reports a transport transition, while this one is series-scoped and
reports an absence — the socket is established, the subscription returned Ok, no error is raised
and no row is lost, because there are no rows. Its series_prefix is a prefix match rather than an
equality test, because on a venue whose symbols rotate the series an operator wants to watch does
not exist yet when the rule is written.
What a headless node can actually fire
crates/vike-tradehub/src/alerts.rs is the headless mount, and its header is candid about the gap.
The engine accepts four inputs and the daemon has a real source for exactly one of them today, and
the module fabricates none of the others:
- Fed —
on_snapshot, from the daemon's periodic summary tick. Usable triggers:Price,DrawdownandReconAlert. - Unfed —
on_event(Fill,OrderRejected): the daemon has no typed event stream off-fold. - Unfed —
on_indicator(Indicator): a headless daemon computes no streaming indicators. - Unfed —
on_signal(Feed,FillRateBreaker,PolymarketResolution,SeriesStale).SeriesStaleis the one whose producer exists and is simply somewhere else —crates/vike-recorder/src/alerts.rsraises it, and the daemon depends on no recorder crate.
Rules of an unfed kind still load and are still counted; they simply never fire there.
The mount is strictly off-fold: it consumes already-published state and never enters the core fold,
adding one AlertEngine::on_snapshot call per summary tick on a thread the core never waits on.
The rules file is the gate, not a second flag: maybe_mount returns None when there is no
file, when the file has zero rules, and when every rule in it is disabled — and None means no
engine, no sink constructed and no webhook target resolved.
Delivery
crates/vike-alerting/src/delivery.rs defines the seam: a sink is a fire-and-forget consumer, the
engine broadcasts each fired alert to every registered sink, and each sink self-filters on the
alert's AlertTargets, so routing lives in the alert rather than in the dispatcher. Two sinks ship
in the crate — an in-memory buffer a future GUI would drain for toasts, which nothing mounts today,
and a Telegram or generic webhook POST over the workspace's existing HTTP and TLS stack, with the
blocking POST behind an injected transport so the seam never touches the network in CI. The daemon
always adds a log sink, because its stdout is protocol-only; no configured webhook target therefore
still leaves a real headless notification surface.
A webhook secret never reaches Debug or a log line
A bot token or a webhook URL is redacted by a manual Debug impl, and a failed delivery logs the
target name and a coarse error category only — never the endpoint URL, which for Telegram embeds
the token, and never the raw transport error, which can echo the request target. Webhook
configuration is built from a caller-supplied credential map, so nothing in the library can load a
token its caller did not hand it.
Alerting covers the rule model, the evaluator and the delivery seam as
a concept; vike-tradehub covers the daemon that mounts
them.
flags.toml
Operator toggles — every one a bool defaulting to false, every one carrying an owner, a review date and the disposition its review is expected to reach.
Developer guide
How the workspace defends its own claims — what a gate is here, why prose that can rot is replaced by a test, and where each family of gates lives.