diff --git a/dashboard/app/lib/oracle-staleness.test.ts b/dashboard/app/lib/oracle-staleness.test.ts
new file mode 100644
index 0000000..5696f04
--- /dev/null
+++ b/dashboard/app/lib/oracle-staleness.test.ts
@@ -0,0 +1,109 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { calculateOracleStaleness } from './oracle-staleness.ts';
+import type { HistoryEntry } from './contract.ts';
+
+describe('calculateOracleStaleness', () => {
+ it('returns zeros for empty history', () => {
+ const summary = calculateOracleStaleness([]);
+ assert.deepEqual(summary, {
+ assessedRuns: 0,
+ staleRuns: 0,
+ stalePercentage: 0,
+ totalRuns: 0,
+ });
+ });
+
+ it('correctly calculates staleness across ok runs', () => {
+ const history: HistoryEntry[] = [
+ {
+ status: 'ok',
+ safetyScore: 80,
+ methodologyVersion: 2,
+ computedAt: '2026-08-16T12:00:00Z',
+ runAt: '2026-08-16T12:00:00Z',
+ factors: {
+ oracleSafety: {
+ value: 100,
+ weight: 0.25,
+ detail: 'fresh',
+ components: [
+ { id: 'priceFreshness', label: 'Freshness', value: 100, detail: 'current' },
+ ],
+ },
+ },
+ },
+ {
+ status: 'ok',
+ safetyScore: 40,
+ methodologyVersion: 2,
+ computedAt: '2026-08-16T11:00:00Z',
+ runAt: '2026-08-16T11:00:00Z',
+ factors: {
+ oracleSafety: {
+ value: 20,
+ weight: 0.25,
+ detail: 'stale prices',
+ components: [{ id: 'priceFreshness', label: 'Freshness', value: 0, detail: 'stale' }],
+ },
+ },
+ },
+ {
+ status: 'failed',
+ error: 'network error',
+ runAt: '2026-08-16T10:00:00Z',
+ },
+ ];
+
+ const summary = calculateOracleStaleness(history);
+ assert.equal(summary.totalRuns, 3);
+ assert.equal(summary.assessedRuns, 2);
+ assert.equal(summary.staleRuns, 1);
+ assert.equal(summary.stalePercentage, 50);
+ });
+
+ it('correctly tracks high-staleness scenario (e.g. K2 93% stale runs)', () => {
+ const history: HistoryEntry[] = [];
+ // 93 stale runs
+ for (let i = 0; i < 93; i++) {
+ history.push({
+ status: 'ok',
+ safetyScore: 30,
+ methodologyVersion: 2,
+ computedAt: new Date(1760000000000 + i * 300000).toISOString(),
+ runAt: new Date(1760000000000 + i * 300000).toISOString(),
+ factors: {
+ oracleSafety: {
+ value: 0,
+ weight: 0.25,
+ detail: 'dead prices',
+ components: [{ id: 'priceFreshness', label: 'Freshness', value: 0, detail: 'dead' }],
+ },
+ },
+ });
+ }
+ // 7 fresh runs
+ for (let i = 0; i < 7; i++) {
+ history.push({
+ status: 'ok',
+ safetyScore: 90,
+ methodologyVersion: 2,
+ computedAt: new Date(1760000000000 + (93 + i) * 300000).toISOString(),
+ runAt: new Date(1760000000000 + (93 + i) * 300000).toISOString(),
+ factors: {
+ oracleSafety: {
+ value: 100,
+ weight: 0.25,
+ detail: 'fresh prices',
+ components: [{ id: 'priceFreshness', label: 'Freshness', value: 100, detail: 'fresh' }],
+ },
+ },
+ });
+ }
+
+ const summary = calculateOracleStaleness(history);
+ assert.equal(summary.assessedRuns, 100);
+ assert.equal(summary.staleRuns, 93);
+ assert.equal(summary.stalePercentage, 93);
+ });
+});
diff --git a/dashboard/app/lib/oracle-staleness.ts b/dashboard/app/lib/oracle-staleness.ts
new file mode 100644
index 0000000..4292e00
--- /dev/null
+++ b/dashboard/app/lib/oracle-staleness.ts
@@ -0,0 +1,56 @@
+import type { HistoryEntry, RiskFactorComponent } from './contract';
+
+export interface OracleStalenessSummary {
+ /** Total runs with a valid factor breakdown in the historical set */
+ assessedRuns: number;
+ /** Number of runs where the oracle was detected as stale (score < 50 or priceFreshness < 50) */
+ staleRuns: number;
+ /** Percentage of observed runs where the oracle was stale (0–100) */
+ stalePercentage: number;
+ /** Total runs evaluated in history */
+ totalRuns: number;
+}
+
+/**
+ * Derives oracle staleness metrics across historical runs.
+ *
+ * Reuses the canonical oracleSafety factor from each historical run:
+ * A run's oracle is considered stale when:
+ * 1. An 'ok' run evaluates oracleSafety with a value < 50, OR
+ * 2. The priceFreshness component sub-score is < 50.
+ *
+ * Runs that don't assess oracleSafety (e.g. Dex protocols without an oracle) are safely excluded.
+ */
+export function calculateOracleStaleness(history: HistoryEntry[]): OracleStalenessSummary {
+ let assessedRuns = 0;
+ let staleRuns = 0;
+ for (const entry of history) {
+ if (entry.status !== 'ok') continue;
+
+ const oracleFactor = entry.factors?.oracleSafety;
+ if (!oracleFactor) continue;
+
+ assessedRuns += 1;
+
+ // Check if the priceFreshness component sub-score or overall factor score is degraded (< 50)
+ const freshnessComp = oracleFactor.components?.find(
+ (c: RiskFactorComponent) => c.id === 'priceFreshness',
+ );
+ const isFreshnessStale =
+ freshnessComp && freshnessComp.value !== null ? freshnessComp.value < 50 : false;
+ const isOverallFactorStale = oracleFactor.value < 50;
+
+ if (isFreshnessStale || isOverallFactorStale) {
+ staleRuns += 1;
+ }
+ }
+
+ const stalePercentage = assessedRuns > 0 ? Math.round((staleRuns / assessedRuns) * 100) : 0;
+
+ return {
+ assessedRuns,
+ staleRuns,
+ stalePercentage,
+ totalRuns: history.length,
+ };
+}
diff --git a/dashboard/app/protocol/[id]/page.tsx b/dashboard/app/protocol/[id]/page.tsx
index 2ade520..b666317 100644
--- a/dashboard/app/protocol/[id]/page.tsx
+++ b/dashboard/app/protocol/[id]/page.tsx
@@ -25,6 +25,7 @@ import { FactorBreakdown } from '../../../components/factor-breakdown';
import { Reveal, RevealGroup, RevealItem } from '../../../components/reveal';
import { RunHistory } from '../../../components/run-history';
import { ScoreHistoryChart } from '../../../components/score-history-chart';
+import { OracleStalenessView } from '../../../components/oracle-staleness-view';
export const dynamic = 'force-dynamic';
@@ -96,6 +97,9 @@ export default async function ProtocolDetailPage({ params }: { params: Promise<{
)}
+ {isClean + ? 'Oracle prices remained fresh across all evaluated historical runs.' + : isHighStale + ? `Oracle prices were stale in ${staleRuns} of the last ${assessedRuns} runs (${stalePercentage}% of time). Persistent staleness indicates feed refresh gaps or unaligned TTLs.` + : `Oracle prices experienced intermittent staleness in ${staleRuns} of ${assessedRuns} runs (${stalePercentage}%).`} +
+