Skip to main content
Back to research
ADR-0019Accepted2026-04-28

Anomaly response policy and confidence scoring — per-asset statistical baselines

View source on GitHub
Amendment (2026-07-26, N-F6) — the freeze DURATION now exists, and its initial hold is scaled by corroboration rather than flat. §"Freeze duration" below specifies a state machine: a 30-minute initial hold, re-evaluation at expiry, extension by 30 min up to 4 times, escalation to operator review after that, and an auto-unfreeze trigger. None of it was implemented. What shipped was cachekeys.FreezeTTL = 5min plus a marker re-written on every bucket the 3-signal AND fired for. That made the RELEASE condition the negation of the FIRE condition, evaluated on a SINGLE bucket:

>

- The release band was strictly *wider* than the fire band. A freeze fires at z > 5.0, so it released at z <= 5.0 — not at this ADR's z < 3.0 — and it released on confidence >= 0.45 rather than > 0.30. The hysteresis gap the two pairs of numbers exist to create was inverted into an overlap, so a signal hovering at the trigger flapped the pair frozen/unfrozen bucket after bucket, publishing on each unfreeze the value it had just refused. - source_count <= 1 is a leg of the AND, so one trade on a second venue cleared the freeze outright, at any price. That is a one-trade unfreeze primitive available to the attacker whose manipulation is in force. - There was no bound and no escalation. A freeze could run for days with no operator ever being told, and the only signal was a rate() on a counter that also fires for one-tick blips.

>

internal/aggregate/freeze.Policy now implements the section as written, as a pure function of (previous state, this bucket's signal), with the state carried in the durable freeze marker. Three points where this amendment resolves an ambiguity in the text rather than merely implementing it:

>

1. The initial hold is corroboration-scaled: 30 minutes, or 10 minutes for a pair with no corroborating lens at all. A deliberate deviation from the flat 30. A freeze serves the last-known-good price for its entire duration, so a FALSE freeze is its own money bug — the MNY-22 class, where a stale leg was laundered into a derived pair. The false-freeze rate is not uniform across the index: it concentrates on thin books, where a single venue's own history is the only reference the 3-signal AND has and where a $19/hour book can produce a z > 5 bucket from one ordinary trade. Charging that population the full 30-minute stale-price bill for a decision taken on one lens is the wrong trade; charging it 10 minutes is not. 10 rather than 5 because it must comfortably outlast the longest default window that can carry the spike (5m) and several 30s ticks, so a one-bucket spike can never be waited out. Extensions are NOT scaled — an uncorroborated pair whose anomaly persists climbs the same 30-minute ladder to the same 2-hour escalation — so the shortened hold only shortens the FIRST decision, which is exactly where the false-positive risk sits.

>

"Corroborated" means a second lens produced a READING this bucket (triangulation_checked OR cross_oracle_checked), not that the lens agreed. The agreement-only reading was considered and rejected as perverse: a composite or an external reference that *disagrees* is the strongest available evidence that a freeze is true, and would have been handed the shortest hold. Agreement is already priced in elsewhere — it raises the confidence score, which is a leg of the fire condition, so a well-corroborated healthy pair mostly never reaches the freeze path at all.

>

2. Auto-unfreeze is a continuous trigger, gated on the INITIAL hold only. The text lists auto-unfreeze as a "trigger" but places re-evaluation "at expiry", which can be read as "release is checked only at a ladder expiry". That reading makes a pair that recovers one bucket after an extension was granted serve up to 30 further minutes of stale price for no security benefit — the streak is what proves recovery, and it is no harder to satisfy at an expiry instant than between two. So: the initial hold is a hard minimum, and from the end of it the two-consecutive-bucket condition is evaluated every bucket. The extension ladder still advances at each expiry; its job is to decide when to ESCALATE, not to gate the release. The minimum hold remains load-bearing on volatile pairs: the freeze fires at z > 5 and the streak needs only z < 3, so on a wide-MAD asset a price still well away from the last-known-good can read "healthy" two buckets running, possibly 60 seconds after the freeze.

>

3. An escalated freeze does not auto-unfreeze. "Freeze stays active until manual unfreeze" is read literally: once the ladder is spent, the auto-unfreeze path is suppressed and only an operator ends it. The ladder already spent two hours asking whether the pair had recovered, and a human has been paged.

>

Amendment (2026-08-24) — auto-unfreeze additionally requires a corroborating lens that AGREES with the release candidate. The two-calm-buckets condition (confidence > 0.30 AND z < 3.0) is necessary but no longer sufficient. Once mid-freeze buckets score per-tick returns (the shadow comparator that fixed the drift-since-freeze ratchet), ANY price level an attacker simply holds reads calm, and the cached divergence result is computed against the SERVED price — the pinned last-known-good — so it is evidence about the LKG, not about the candidate. Calm therefore cannot distinguish "the market repriced" from "the manipulation is parked". The added leg: a streak bucket must also carry release_corroborated — a corroborating lens reading from this bucket that agrees within 5% with the bucket's own fresh price (the cross-oracle reference median compared against the candidate directly; 5% mirrors the divergence lens's own firing threshold). Note the asymmetry with the fire-side "corroborated" above, which deliberately means "a lens was CONSULTED": for selecting the hold, a disagreeing lens is the strongest evidence the freeze is true; for ending the freeze, only agreement with the candidate is evidence of recovery. A pair with no lens (single reference, no chain output) can no longer auto-release at all — it serves the LKG up the ladder to the 2-hour escalation and a human. That is the fail-closed cost of the calmness legs being gameable, it is bounded by the existing escalation page + runbook, and on the current default-pair set it applies to the EUR/GBP-quoted pairs whose only reference is CoinGecko. (The triangulation composite is nominally a second agreement lens, but composites are not recorded for a frozen target and go stale in ~60s, so in practice it cannot certify a release mid-hold; the cross-oracle median is the operative lens.)

>

Two mechanics worth recording because they are not obvious from the section: the `prevVWAP` comparator is pinned for the duration of a freeze (the orchestrator does not advance it on a refused bucket), so a manipulated price that is simply HELD keeps scoring a large z against the pre-freeze value and can never satisfy the auto-unfreeze condition — that pinning, not the hold, is what defeats the park-the-price evasion, and the hold is what defeats the flap. And the sustained-drift statistic stays out of the fire, extend and release decisions (it latches for ~30 days and cannot self-clear); it reaches the release decision only through confidence, which is graded and self-correcting.

>

Operator surface. Every duration is tunable under [anomaly.phase2] with the ADR values as defaults. Force-unfreeze is DEL freeze:<asset>:<quote>: the aggregator detects the missing marker on its next tick, drops the ladder, and counts the release as stellarindex_anomaly_freeze_released_total{mode="operator"}. The Redis marker's TTL is now "remaining hold + a 5-minute silence grace", so it is a liveness backstop rather than the duration policy, and the ladder is re-hydrated from the marker after a restart instead of silently restarting the escalation clock. New alerts: stellarindex_anomaly_freeze_escalated (P1), stellarindex_anomaly_freeze_extension_rate (P3), stellarindex_anomaly_freeze_active (informational).
Amendment (2026-07-26, migration 0119 — the freeze ladder is durable, and `DEL` is no longer the override). The "Operator surface" paragraph above says force-unfreeze is DEL freeze:<asset>:<quote>, and that the ladder "is re-hydrated from the marker after a restart". Both were true and both are now superseded, because the premise underneath them was wrong: Redis is a cache. It is deployed without persistence and it is flushed during incidents, so "the marker is missing" was never the deliberate signal this ADR treated it as.

>

The consequence was the exact inverse of what this ADR exists to guarantee. The orchestrator read a missing marker under a live freeze as the operator override, so a Redis flush did not merely forget how far a pair had climbed the ladder — it released every live freeze, including ones that had spent the whole 2-hour ladder to escalated, which this ADR holds "until manual unfreeze" and which has already paged a human. An aggregator restart after the flush instead re-froze from extensions_used = 0, restarting the escalation clock, so a restart cadence shorter than two hours could hold a pair frozen indefinitely while never escalating it to anyone.

>

Migration 0119 gives the lifecycle a durable home — hold_until, extensions_used, escalated, corroborated on freeze_events — written on every transition and read back whenever the marker is missing, bounded by hold_until + the marker grace so a long-dead aggregator cannot resurrect a stale freeze. "Marker missing" is now disambiguated against recovered_at: an OPEN row means Redis lost the marker (rehydrate), a CLOSED row means the freeze genuinely ended (stay unfrozen).

>

The override is therefore `stellarindex-ops freeze-unfreeze`, which does both halves — clears the marker AND stamps recovered_at — and requires a -reason. A bare redis-cli DEL is no longer an override at all: it leaves recovered_at NULL, so the next tick rehydrates and re-writes the marker. Against an escalated freeze it is permanently inert. The override this ADR requires is still always available; it is now typed, logged and mirrored, which the raw DEL never was.

>

Two further consequences worth recording, because both were discovered as defects in the first cut of this change:

>

* The recovery worker is a THIRD marker-miss consumer. Its sweep stamps recovered_at, i.e. the very predicate the rehydrate tests, so it had to learn the same hold_until + grace bound — otherwise it destroys the ladder before the orchestrator can read it, and /v1/anomalies records that the freeze "recovered normally". * Writer.Clear retires the durable ladder in the same call that deletes the marker, so an auto-release cannot be re-hydrated by a restart landing inside the recovery worker's 60-second poll window.
Amendment (2026-07-24, audit-2026-07-23 R-003 / COR-14). The confidence formula block under §"Multi-factor confidence score" writes the weighted product without its normalisation exponent, while the prose one paragraph above it says the factors are combined "via weighted geometric mean" — which *is* the normalised form by definition. The shipped combiner (internal/aggregate/confidence.Compute) is the normalised one:

>

`` confidence = ( z_score_factor(z_score) ^ w_z * source_count_factor(n_sources) ^ w_src * diversity_factor(class_count) ^ w_div * liquidity_factor(bucket_volume) ^ w_liq * cross_oracle_factor(divergence_pct) ^ w_xoracle * baseline_quality_factor(days_history) ^ w_qual ) ^ (1 / (w_z + w_src + w_div + w_liq + w_xoracle + w_qual)) ``

>

i.e. prod(factor_i ^ weight_i) ^ (1 / sum(weights)). The code is authoritative; the formula block's omission of the exponent is the error. Three of this ADR's own commitments only hold in the normalised form: (a) weights are described as tunable knobs of *relative* influence, but in a bare product doubling every weight squares the score; (b) cross_oracle_factor returns 0.7 as an explicitly *neutral* no-data value, yet as a bare product that one term would cap every score without an external reference at 0.7 — a penalty, not neutrality; (c) the bootstrap rule caps confidence at 0.5 "regardless of other factors", which presumes ordinary buckets normally score above 0.5. The 0.92 in the worked-example JSON is illustrative of the response *shape* and is closer to the bare product's value — it is not a mathematical anchor, and the internal/aggregate/confidence tests pin it as a range, not a point.

>

What `confidence < 0.10` means on this scale (unchanged by this amendment, but stated so the number isn't read as product units). With all weights 1.0, confidence < 0.10 ⟺ the raw product of the six factors < 1e-6. Worked example for the USTRY-shape corner this ADR targets — single source (source_count_factor 0.119), one source class (0.5), mature baseline (1.0), no cross-oracle data (0.7), and $12K bucket liquidity (0.540, i.e. just over the min_usd_volume = 10000 floor that already gates publication): the non-z factors multiply to 0.0225, so the confidence condition needs z_score_factor < 4.4e-5, i.e. z ≈ 15 — far past this ADR's z_score > 5.0 sub-condition, which makes the confidence term the binding one of the three. Under a bare product the same window sits at 0.0225 before z is considered at all, so the confidence term would instead be *vacuous* (any single-source window satisfies it). Neither reading gives three genuinely independent signals at the current constant: re-calibrating `[anomaly.phase2] .confidence_max_freeze` to the normalised scale is a live operator decision and is deliberately NOT made here — this amendment only records which combiner ships. Filed alongside R-003 in the audit-2026-07-23 remediation.
Amendment (2026-07-25) — the liquidity ceiling is $1M, not $100K, and a SEVENTH factor exists. Two changes to §"Multi-factor confidence score" below, shipped together because both move the same combiner.

>

1. `liquidity_factor`'s ceiling: $100K → $1,000,000. The bullet below says "near-0 below $1K bucket volume, near-1.0 above $100K". $100K is not a deep bucket, it is roughly a typical one: BTC/USD's 5m bucket volume has a measured p50 of $123,678, so the MEDIAN bucket of the index's deepest pair already saturated the factor at 1.0. A ceiling the median clears carries no information across the top half of its own population — and, worse on the security side, it priced $100K of wash volume at the same full credit as $10M of real depth. The shape is unchanged (log-saturating between floor and ceiling); only the ceiling moves, so LiquidityFactor(123_678) now reads 0.697 and the curve's 0.5 point moves from $10,000 to sqrt(1e3 × 1e6) ≈ $31,623.

>

Freeze impact, re-derived on the shipped combiner for the population that can actually freeze (single source, one class, no cross-oracle data, mature baseline, confidence_max_freeze = 0.45): the z at which the CONFIDENCE leg crosses moves from z ≈ 5.54 (at $12K measured volume) / 6.39 ($100K) to z ≈ 4.79 / 5.85. This does not move the freeze's trigger: z_score > 5.0 is a separate, independently evaluated leg of the same AND, so nothing freezes below z = 5 whatever confidence says. What moves is which leg binds — below ≈ $15.6K of measured volume the confidence leg now goes true before z reaches 5, and the AND leans on z + source_count for that thin slice. The 8 non-USD-quoted default pairs are unaffected: they pass the LiquidityUnmeasured sentinel and read LiquidityUnmeasuredFactor, deliberately left at 0.5 rather than tracked down to the new floor-value of 0.333 — following the curve would have lowered confidence for the population at highest false-freeze risk, for a reason that says nothing about those pairs.

>

2. `triangulation_agreement_factor` — a seventh factor this ADR predates. When a pair has a configured triangulation chain, the aggregator now compares its DIRECT price against the COMPOSITE the chain implies (XLM/EUR direct vs XLM/USD × USD/EUR) and feeds the divergence to a new factor with the same piecewise shape as cross_oracle_factor: 1.0 within 2% (the wider tolerance absorbs a chained-fiat leg snapped to a DAILY fx_quotes bucket), halving every 4 percentage points beyond, 0.7 as the no-data neutral. It is a manipulation signal in the disagreeing direction and corroboration in the agreeing one.

>

Two properties are load-bearing and deliberately unlike the six original factors:

>

- Default weight 0.5, not 1.0. A composite re-uses our own leg VWAPs, filters and upstream venues, so it corroborates at roughly half the evidentiary weight of an independent external reference. The discount lives in the weight so the served factor value stays directly comparable to cross_oracle. - Weight 0 when unchecked. The combiner is normalised, so adding any constant "neutral" seventh value would re-score every pair in the index — including every pair with no chain — and silently move the Phase 2 confidence leg for them. Only zeroing the weight is a true no-op, and an un-triangulated pair therefore scores bit-for-bit what it scored before this factor existed.

>

It does not feed source_count. A composite is corroboration, not a second venue; counting it as a source would let configuring a chain disarm the source_count <= 1 leg of the freeze AND on exactly the thin single-venue pairs chains are deployed for. See internal/aggregate/orchestrator/triangulate_corroborate.go.
Amendment (2026-07-24, audit-2026-07-23 wave5 AGT-08). The "Factor shapes" bullet list below (source_count_factor: 1/(1+exp(-(n-3))) — caps confidence at ~0.3 for single-source assets) is imprecise. The shipped default (sourceCountInflectionN = 3.0 in internal/aggregate/confidence/factors.go) computes SourceCountFactor(1) ≈ 0.119, not ~0.3 — a ~2.5x difference, already the number used (not flagged as a correction at the time) in the R-003/COR-14 worked example above. No behaviour change is made here: retuning the inflection constant so n=1 → ~0.3 (e.g. k ≈ 1.85) — or accepting 0.119 as the correct shipped shape and updating the bullet instead — is a live tuning decision for internal/aggregate/confidence's owner, not made in this doc pass. Until that decision lands, read ~0.3 in the bullet list below as the ORIGINAL authoring-time intent, not the shipped value.
Amendment (2026-07-24, audit-2026-07-23 wave5 DOC-05). The "Bootstrap (warmup) policy for new assets" section below describes Option C (hybrid): compute z-scores against a peer-class average baseline and cap confidence at 0.5. This is not what ships. Orchestrator.computeConfidence (internal/aggregate/orchestrator/confidence.go) returns early with NO score at all — confidence.Compute (which contains BootstrapConfidenceCap) is never reached — whenever Baselines.LatestBaseline errors (no baseline row yet for the pair) or MultiBaseline.MaxZScore reports !valid (no window has enough samples). There is no peer-class-average synthesis anywhere in internal/aggregate/baseline. Practical effect: a genuinely new or low-history asset publishes with no `confidence` field and no Phase-2 freeze eligibility — the freeze condition (confidence < 0.10 AND z_score > 5.0 AND source_count <= 1) can never evaluate true because it never evaluates at all — which is the exact single-source, no-history corner this ADR was written to catch. docs/architecture/launch-readiness-backlog.md L2.9 marks this item "✅ shipped"; that status is likewise inaccurate for the zero-baseline case. Whether to build the peer-class bootstrap path, or synthesize a capped score with ZScore=0 when no baseline exists, is an implementation decision for internal/aggregate/orchestrator / internal/aggregate/confidence's owner — not made in this doc pass.

Context

ADR-0017 protects us against missing data. ADR-0018 protects us against confused consumption (three URLs, three explicit consistency contracts). Neither addresses what happens when the data we publish is observably wrong — a manipulated or otherwise anomalous price feed, where the data is "fresh" and "complete" but does not reflect fair market value.

The October 2024 / 2026 series of oracle-manipulation incidents in the broader ecosystem (Polter Finance, the USTRY/Reflector attack on Stellar, Mango / Cream / Inverse / Harvest before them) all share one structural feature: a thin-liquidity asset price is manipulated on a single venue, an oracle reports the manipulated value, a downstream protocol consumes it for collateral pricing or liquidations, and the attacker walks away with the spread. See `docs/architecture/oracle-manipulation-defense.md` for the full case catalogue.

Our pre-existing defenses (multi-source consensus, source-class exclusion, closed-bucket policy, TWAP availability) protect well when an asset has multiple liquid sources. They fail for assets with a single venue and no multi-source agreement to average against. USTRY is the canonical example.

The naïve response is "set a threshold — refuse prices that move more than X% in Y minutes." This is wrong:

  • Different asset classes have wildly different normal volatility

(stablecoins 0.05%, memecoins 50%+ per bucket are both routine). A single threshold can't cover both.

  • Real market events DO produce 20%+ moves in minutes (asset

delistings, exchange-hack news, flash crashes). A fixed- threshold freeze would lock up rates during legitimate moves.

  • An attacker can keep manipulation just under threshold, slowly

drifting the baseline upward. Over a week, the apparent "normal" volatility creeps up, and the attacker eventually moves freely.

The correct abstraction is per-asset statistical baselines — score each asset's typical volatility from its own history, and flag deviations *from that asset's own normal*, not from an operator-picked global percentage.

Decision

Anomaly response is a continuous confidence score plus a freeze policy on the closed-bucket surface, not a binary published/not- published decision based on a fixed threshold. Three pieces:

  1. Per-asset rolling statistical baseline of volatility,

updated continuously as new buckets close.

  1. Multi-factor `confidence` score on every published price,

combining baseline-deviation, source coverage, liquidity, and cross-oracle agreement (when available).

  1. Freeze policy that fires only at the extreme corner — low

confidence AND high statistical anomaly AND single-source — and applies asymmetrically per consistency surface (ADR-0018).

Per-asset statistical baseline

For each (base, quote) pair, compute robust statistics over a rolling 30-day window:

  • `return_median` — median of bucket-to-bucket % change in VWAP
  • `return_mad` — median absolute deviation of those returns,

scaled by 1.4826 to be σ-equivalent for normally-distributed data

  • `source_count_p50` — typical number of contributing sources

per bucket

  • `liquidity_p50_usd` — typical bucket liquidity in USD

Why MAD, not σ. σ is itself sensitive to outliers; if we trained on a window containing the previous USTRY attack, σ would inflate and hide the next attack. MAD is computed from medians and is robust against outliers in the training window. Mature oracles (Pyth, MakerDAO OSM) use this exact substitution.

A new bucket's z-score against the baseline:

z_score = abs(return_pct - return_median) / return_mad

A z-score of 5+ is "anomalous" by the asset's own standards, regardless of absolute percentage. This automatically scales:

Asset classtypical return_mad5σ trigger
Stablecoin (USDC, USDT, PYUSD)~0.05%~0.25%
Treasury token (USTRY)~0.05%~0.25%
Major crypto (XLM, BTC, ETH)~2%~10%
Governance token (AQUA, ULTRA)~10%~50%
Memecoin / new listing~50%~250%

Multi-factor confidence score

Combine into a single confidence ∈ [0, 1] value on every published price. Each factor returns a value in [0, 1]; combine via weighted geometric mean (so any one factor near zero pulls the whole score down — the dominating-factor behaviour we want):

confidence = (
  z_score_factor(z_score)            ^ w_z       *
  source_count_factor(n_sources)     ^ w_src     *
  diversity_factor(class_count)      ^ w_div     *
  liquidity_factor(bucket_volume)    ^ w_liq     *
  cross_oracle_factor(divergence_pct) ^ w_xoracle *
  baseline_quality_factor(days_history) ^ w_qual
)

Factor shapes:

  • z_score_factor: 1.0 at z=0, decays smoothly to ~0 at z=10. Sigmoid.
  • source_count_factor: 1/(1+exp(-(n-3))) — caps confidence at ~0.3

for single-source assets; reaches near-1.0 at n≥6.

  • diversity_factor: 0.5 for one class, 1.0 for ≥2 classes (CEX + DEX,

for example).

  • liquidity_factor: log-saturating, near-0 below $1K bucket

volume, near-1.0 above $100K.

  • cross_oracle_factor: 1.0 when within 1% of cross-oracle median;

decays with divergence. Returns 0.7 (neutral) when no cross-oracle data is available.

  • baseline_quality_factor: 0.5 with no baseline data, ramps to 1.0

over the first 30 days of an asset's history.

Weights w_* are operator-tunable in [anomaly.weights] config but default to all 1.0 (equal influence, geometric mean).

The wire response carries the score plus its decomposition (so customers and on-call operators can see WHY confidence dropped):

{
  "data": {
    "price": "1.00",
    "confidence": 0.92,
    "confidence_factors": {
      "z_score": 0.3,
      "source_count": 6,
      "source_diversity": 2,
      "liquidity_usd": 250000,
      "cross_oracle_divergence_pct": 0.4,
      "baseline_age_days": 187
    }
  }
}

Freeze policy

Freeze fires only when all three of the following hold:

freeze_condition = (
  confidence < 0.45          # amended 2026-07-25; was 0.10 — see below
  AND z_score > 5.0
  AND source_count <= 1
)

Three signals must agree. Catches USTRY-shape attacks; does NOT fire on legitimate market events (those have multi-source coverage, so source_count > 1).

Amendment 2026-07-25 — the confidence bound is 0.45, not 0.10.

>

The z_score > 5.0 above is the intent, and it was not being met. confidence is a weighted geometric mean, so it decays *gently* in z: the freeze's real trigger point is emergent from (threshold × combiner × factor set), and no single one of those states it. Measured on the shipped combiner for a single-source bucket at the $12K publish floor:

>

| z | confidence (mature) | confidence (sparse baseline) | |---|---|---| | 0 | 0.5308 | 0.4804 | | 5 | 0.4734 | 0.4285 | | 6 | 0.4269 | 0.3864 | | 8 | 0.3197 | 0.2894 |

>

At 0.10 the freeze needed z ≈ 15 — about a 30% move inside one 1-minute bucket for XLM (return_mad ≈ 2%). The control was decorative. 0.45 puts the trigger at z ≈ 5.5–6 across all three populations (mature, sparse, and non-USD-quoted), which is what the z > 5 line above always meant.

>

This was taken as ONE decision together with the COR-14 fix, as the audit-2026-07-23 remediation ledger required. Before COR-14, the 8 non-USD-quoted default pairs had confidence pinned to exactly 0 (a bug: approxUSDVolume returned 0 for pairs it could not value in USD, and 0 is indistinguishable from "worst possible"), so this leg was permanently true for them and the three-signal AND above silently collapsed to two signals. Fixing that alone would have moved every pair to the dormant z ≈ 15 regime — hence the coupling.

>

On the false-fire history recorded in markPhase2Freeze ("Phase 2 false-fires across many pairs"): that is *not* evidence against 0.45. Those were the conf≡0 pairs, freezing on z > 5 AND source_count <= 1 with no third signal at all. With COR-14 fixed the confidence leg is a genuine third gate, so this configuration is strictly stricter than the one that false-fired.

>

Both directions are hazards and the calibration sits between them: a freeze that never fires serves manipulated prices; one that fires too readily serves a stale last-known-good price, which is its own money bug (see MNY-22, where a frozen leg was laundered into a derived pair via triangulation). TestPhase2FreezeFires_CalibratedToADRZBand pins the band in both directions.

When freeze fires:

  • The closed-bucket surface (/v1/price) returns the

last-known-good price with flags.frozen: true, flags.divergence_warning: true, and the original observed_at from when the LKG bucket was fresh.

  • The tip surface (/v1/price/tip) ignores freeze — returns the

observed value with confidence: <low>. Tip's contract is "what's happening right now" and a manipulation IS happening.

  • The observations surface (/v1/observations) ignores freeze —

returns raw per-source data unchanged.

Freeze duration:

  • Initial: 30 minutes
  • Re-evaluation at expiry: if freeze condition still holds, extend

by 30 min, up to 4 extensions (2 hours total)

  • After 4 extensions: escalate to operator review (P1 alert);

freeze stays active until manual unfreeze

  • Operator override always available: force unfreeze, force

extend, manually set price

Auto-unfreeze trigger: confidence rises above 0.30 AND z_score falls below 3.0 for two consecutive buckets.

Per-surface policy summary

SurfaceConfidence in responseFreeze honouredAnomaly visibility
/v1/price (closed-bucket)✅ in data.confidenceLKG with flags
/v1/price/tip (live)✅ in data.confidence❌ (live data is the contract)Low confidence + flags
/v1/observations (raw)Per-source ages instead❌ (raw data is the contract)Raw values

Phased rollout

The full statistical machinery is meaningful work. We ship in three phases:

Phase 1 — operator-set per-asset-class thresholds (transitional). While the baseline machinery is built, use a small TOML config:

[anomaly_detection.thresholds]
stablecoin = { warn_pct = 1.0,  freeze_pct = 3.0 }
treasury   = { warn_pct = 1.0,  freeze_pct = 3.0 }
crypto     = { warn_pct = 20.0, freeze_pct = 50.0 }
governance = { warn_pct = 50.0, freeze_pct = 100.0 }
default    = { warn_pct = 30.0, freeze_pct = 75.0 }

Operator classifies each asset; thresholds apply per class. Confidence in this phase is binary (warn/freeze/clear) rather than continuous. Crude but ships fast and protects against extreme single-source attacks. MUST ship before the API is used for oracle anchoring at scale.

Phase 2 — statistical baselines. The full per-asset MAD-based baseline. volatility_baseline_1m CAGG. Continuous confidence score. Replaces Phase 1's per-class thresholds with per-asset learned thresholds. Removes operator burden of classification (the asset's own data classifies it).

Phase 3 — cross-oracle integration. When internal/divergence/ ships, cross_oracle_factor becomes a real input rather than the 0.7 default. Confidence score fully reflects external consensus.

Each phase is incrementally better. ADR-0019 commits to all three; the work-list pins the sequencing.

Multi-window safeguard against frog-boiling

A single 30-day rolling baseline can be slowly drifted by a sustained low-grade manipulation. To prevent that:

  • Compute return_mad at three time scales (1d, 7d, 30d)
  • Anomaly fires on the smallest z-score across the three

(i.e. if any window flags the bucket as anomalous, it's anomalous)

  • A slow drift may pass the 1d and 7d windows but eventually trips

the 30d window once the drift is large enough vs the original baseline

This stays robust to legitimate regime changes (asset matures, gains liquidity) — those happen across all three windows proportionally, so no single window reports them as anomalous.

Bootstrap (warmup) policy for new assets

Newly-listed assets have no baseline. Three options were considered (Option A: peer-class default; Option B: lock low confidence; Option C: hybrid). Decision: Option C (hybrid).

For an asset with < 30 days of history:

  • Use the average baseline of similar-class assets for the math

(so z-scores are computable)

  • Cap confidence at 0.5 regardless of other factors
  • Set confidence_factors.baseline_age_days to actual days of

history; customers can gate on this directly

  • Auto-classify based on issuer-domain metadata when available;

fallback to operator-set classification

After 30 days, transition to learned per-asset baseline automatically.

Consequences

  • Positive — no operator picks "the right percentage." The

baseline is what the asset's own history says is normal. The threshold is what 5σ from MAD computes to. Self-tuning.

  • Positive — single algorithm covers all asset classes.

Stablecoins to memecoins, all use the same formulas with different baselines. No per-class table to maintain (after Phase 1).

  • Positive — confidence is graded, not binary. Sophisticated

consumers (lending protocols) gate on confidence; UI consumers display anyway. Both get appropriate behaviour from the same wire.

  • Positive — the per-surface policy from ADR-0018 generalises.

Strict surface (closed-bucket) honours freeze; lax surfaces (tip, observations) don't. No new policy axis introduced.

  • Negative — meaningful engineering investment. Phase 2

alone is ~1.5 weeks of work; Phase 3 depends on internal/divergence/ which is its own multi-day project. Phase 1 stop-gap ships in <1 day but is admittedly crude.

  • Negative — confidence requires customer education. A

customer that ignores data.confidence could still consume a bad value and lose money. We document loudly, but ultimately the customer's contract is responsible. The freeze policy on /v1/price is the safety net for customers who don't read the wire.

  • **Negative — no defense against well-resourced multi-source

manipulation.** If an attacker can manipulate prices across N CEXes simultaneously, multi-source consensus fails too. This is outside the threat model — defending against it requires cross-oracle reference, which is Phase 3.

  • Operational impact — new alert + runbook. Freeze events

fire a P2 (or P1 after escalation) alert; an operator must review and confirm or override. A new runbook anomaly-freeze-engaged.md walks through the review process.

  • **Downstream design impact — internal/divergence/ becomes

load-bearing.** Phase 3 of this ADR depends on it. Cross-oracle agreement is the strongest single defense; without it, our confidence score is intrinsic-only (we can disagree with the world and not know it).

  • **Downstream design impact — chained-asset confidence is a

product.** When pricing AQUA/COP via AQUA → USDC → USD → COP, the chained confidence is the geometric mean of each leg's confidence. A high-confidence DEX leg × low-confidence forex leg = appropriately-modest chained confidence. Algorithm identical, recursively applied.

Alternatives considered

  1. **Fixed-percentage threshold per asset (operator-set

permanently).** Rejected: doesn't adapt to regime changes; different operators pick different numbers; doesn't handle warm-up gracefully.

  1. **Hard reject (return 503) on anomaly, no last-known-good

served.** Rejected: forces every downstream consumer to handle the no-price case explicitly. UI consumers see "no data" instead of a sensible value. Customers who don't handle it silently break. Freezing-with-LKG is a softer failure mode.

  1. Confidence interval (Pyth-style range pricing). Considered:

instead of a single price + confidence, return a range like [$0.998, $1.002] widening with uncertainty. Rejected as the default wire shape because most consumers can't handle range pricing — would need a separate compatibility surface. The confidence scalar gives 80% of the value with no wire-shape change. MAY revisit in a future ADR if customers request it.

  1. **Always publish, never freeze; let the customer decide via

confidence.** Rejected: the freeze policy on /v1/price is the safety net for customers who don't read confidence. Without it we'd silently feed manipulated data to lending protocols whose integration was written before our confidence field existed.

  1. z-score using σ instead of MAD. Rejected: σ inflates after

the FIRST manipulation, hiding subsequent ones. MAD is robust by construction. Same reason mature oracles (Pyth, MakerDAO OSM) use MAD.

  1. Single rolling-window baseline only (no multi-window).

Rejected: vulnerable to frog-boiling — sustained low-grade manipulation slowly drifts the baseline. Multi-window catches slow drifts at the longer scale.

  1. Skip Phase 1, ship Phase 2 directly. Rejected: Phase 2 is

~1.5 weeks of work; we want the freeze safety net before the API enters production oracle-anchoring use. Phase 1 ships in <1 day; the cost of two-step rollout is small vs the protection it provides during the gap.

References

model. This ADR specifies the per-surface freeze application.

completeness; underlies the data integrity this ADR builds on.

classification (exchange / aggregator / oracle / authority_sanity); the foundation of class-based exclusion inputs to confidence scoring.

attack catalogue and defensive layers; this ADR specifies the policy for layers 4 (outlier detection) and 9 (anomaly response).

(TBD) — operator runbook for freeze-engaged events.

the existing CAGG infrastructure that volatility_baseline_1m parallels.

  • Pyth Network confidence interval methodology

(https://docs.pyth.network/price-feeds/best-practices) — design inspiration for confidence scoring.

  • MakerDAO Oracle Security Module documentation — design

inspiration for the OSM-style 1-hour delay (we don't use OSM delay but the failure-mode reasoning is similar).