diff --git a/README.md b/README.md index 369bcad..531c1c4 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Key resources for contributors: - [Bulk Compliance Review](docs/bulk-compliance-review.md) — Bulk compliance review table with action confirmation modal - [Environment Mismatch Blocking Screen](docs/environment-mismatch-blocking.md) — Full-page blocking screen when the wallet network does not match the dashboard target network - [Investor Onboarding Eligibility](docs/investor-onboarding-eligibility.md) — Investor onboarding eligibility page, evaluation precedence, and SDK mapping +- [Performance Budget Review](docs/performance-budget-review.md) — Typed budget threshold evaluation, edge cases, and reviewer checklist > **Note:** All pull requests must follow the [PR Evidence Checklist](docs/pr-evidence-checklist.md) and be audited against the [Evaluation Readiness Dashboard](docs/evaluation-readiness.md) before requesting review. diff --git a/docs/README.md b/docs/README.md index fbb96ee..cb52817 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,7 @@ Reference material for contributors implementing new functionality. | [audit-log.md](audit-log.md) | Audit log data model, filtering, safe CSV/JSON export, redaction | | [compliance-reviewer-workflow.md](compliance-reviewer-workflow.md) | Compliance operator workflow for reviewing investor eligibility | | [compliance-safe-wording.md](compliance-safe-wording.md) | Compliance-safe wording guidance, shared disclaimer helper, and reviewer checklist | +| [performance-budget-review.md](performance-budget-review.md) | Typed budget threshold evaluation, edge cases, and reviewer checklist | | [feature-flags.md](feature-flags.md) | Feature flag hook, panel location, flag naming conventions | | [form-idempotency.md](form-idempotency.md) | Content-derived idempotency key, double-submit guard, TTL | | [sdk-error-recovery.md](sdk-error-recovery.md) | Error categories, retry policy, compliance wording | diff --git a/docs/performance-budget-review.md b/docs/performance-budget-review.md new file mode 100644 index 0000000..ff57601 --- /dev/null +++ b/docs/performance-budget-review.md @@ -0,0 +1,137 @@ +# Performance Budget Review + +Issue: [#44](https://github.com/Axionvera/aegis-dashboard/issues/44) + +> **Disclaimer:** This document describes the protocol-level performance budget review mechanics in the Aegis Dashboard. It is **not legal, regulatory, or financial advice**. The dashboard implements protocol-level budget threshold evaluation only; it does not determine whether a portfolio or asset is suitable for any particular investor. All compliance and investment decisions must be made by qualified personnel in accordance with applicable laws and regulations. + +## Overview + +The Performance Budget Review feature provides a typed, testable workflow for evaluating portfolio assets against configurable performance budgets. Budgets define thresholds for metrics such as concentration ratio, liquidity, and jurisdiction exposure. The review engine evaluates each metric against its threshold and produces a status (`compliant`, `warning`, `breached`, or `unknown`) that the dashboard can render. + +This feature is designed to be safe for RWA/compliance use cases and consistent with the SDK, dashboard, and contract boundaries. + +## Data Model + +The review engine is framework-agnostic and lives in `src/lib/performanceBudget.ts`: + +| Type | Purpose | +|---|---| +| `BudgetCheck` | A single metric evaluated against a threshold (`key`, `label`, `result`, `actual`, `threshold`, `detail?`) | +| `BudgetReviewResult` | The outcome of running all checks for one budget on one subject | +| `BudgetReviewState` | Aggregate state (results + derived `selectedCount`/`allSelected`) | +| `BudgetReviewRule` | Declarative rule mapping check results to a recommended status | +| `BudgetBulkAction` | `approve \| reject \| flag-for-review \| clear` | + +### BudgetCheck fields + +- `key` — Stable machine key (e.g. `concentration_ratio`). +- `label` — Human-readable label shown in the table. +- `result` — `pass | fail | warn | unknown`. +- `actual` — The measured value, or `null` when unavailable. +- `threshold` — The threshold the metric was compared against. +- `detail?` — Optional free-form explanation (no PII). +- `evaluatedAt?` — ISO 8601 timestamp of last evaluation. + +### BudgetReviewResult fields + +- `budgetId` — Stable machine key for the budget. +- `budgetName` — Human-readable budget name. +- `status` — `compliant | warning | breached | unknown`. +- `checks` — Ordered list of `BudgetCheck` results. +- `selected?` — Whether the row is selected for bulk action. +- `meta?` — Arbitrary non-PII metadata (e.g. portfolio name, currency). + +## Behaviour + +### Status derivation + +`deriveBudgetStatus` maps a set of check results to a recommended status via a declarative `BudgetReviewRule`: + +| Condition | Status | +|---|---| +| Any check `fail` | `breached` | +| Any check `unknown` (and no fails) | `unknown` | +| Any check `warn` (and no fails/unknowns) | `warning` | +| All checks `pass` | `compliant` | +| No checks | `unknown` | + +The default rule (`DEFAULT_BUDGET_REVIEW_RULE`) is fail-closed: `fail` and `unknown` never resolve to `compliant`. A budget is only compliant when every check explicitly passes. + +### Severity ranking + +`budgetStatusRank` orders statuses so the highest-risk items surface first (`breached > warning > unknown > compliant`). + +### Filtering + +`filterBudgetResults` is a case-insensitive substring match over `budgetId`, `budgetName`, and `meta` values — deliberately non-PII. + +### Bulk actions + +`applyBudgetBulkAction` — `approve | reject | flag-for-review | clear` — operates only on selected rows and clears selection afterward (standard table UX). Selection counts are centralised via `recomputeBudgetSelection` so they can never drift out of sync. + +### Tally + +`tallyBudgetResults` powers the summary chips, counting results per status. + +## Edge Cases & Failure States + +| Case | Behaviour | +|---|---| +| No checks on a budget result | status defaults to `unknown` (never silently `compliant`) | +| All checks `pass` | status `compliant` | +| Any check `fail` | status `breached` (fail wins over warn/unknown) | +| Any check `unknown` | status `unknown` (awaiting data, never assumed safe) | +| Any check `warn` | status `warning` | +| No rows selected | bulk buttons disabled; `applyBudgetBulkAction` is a no-op | +| Empty filter query | returns all results | +| Filter matches nothing | table shows an empty-state message | +| `actual` is `null` | metric data unavailable; check result should be `unknown` | +| Duplicate check keys | `validateBudgetChecks` reports an error | +| Missing check key or label | `validateBudgetChecks` reports an error | + +## Security & Compliance Assumptions + +- **Fail-closed:** `unknown` and `fail` never resolve to `compliant`. A budget is only compliant when every check explicitly passes. +- **No PII in the table:** only identifiers and reference codes (portfolio name, currency) are stored; no sensitive financial data is kept in the client. +- **Admin-gated UI:** bulk action controls render only when `canAct` is `true`; the page also requires a connected wallet. The authoritative enforcement remains on-chain (see `aegis-contracts` compliance module). +- **Deterministic, testable rules:** the `BudgetReviewRule` is a plain object so behaviour can be unit-tested and reused across the ecosystem without divergence. +- **Protocol-level only:** budget review results reflect protocol-level threshold evaluation, not legal or financial determinations. The `COMPLIANCE_DISCLAIMER` must accompany all user-facing copy derived from this module. + +## Tests, Fixtures & Review Checklist + +### Tests + +- `src/lib/performanceBudget.test.ts` — Unit tests covering: + - Status derivation for all four outcomes (`compliant`, `warning`, `breached`, `unknown`) + - Custom rule override + - Severity ranking + - Filtering by ID, name, and meta + - Tally counts + - Selection helpers (toggle, select all, recompute) + - Bulk actions (approve, reject, flag, clear, no-op, explicit `selectedIds`) + - Value formatting (`formatBudgetValue`) + - Compliance-safe label generation (`budgetResultLabel`) + - Check validation (`validateBudgetChecks`) + - Fixture sanity (all derived statuses are valid) + +### Fixtures + +- `src/lib/__fixtures__/performanceBudget.ts` — A 4-row example queue exercising all four statuses and a mix of check results. + +### Reviewer Checklist + +- [ ] Every check result is covered by a `BudgetReviewRule` branch. +- [ ] `unknown` never collapses to `compliant`. +- [ ] Bulk actions clear selection and preserve unselected rows. +- [ ] No PII is introduced into `meta` or `detail`. +- [ ] `PerformanceBudgetPanel` shows `COMPLIANCE_DISCLAIMER` in the header. +- [ ] `validateBudgetChecks` catches duplicate keys and missing fields. +- [ ] Protocol-level budget review is never presented as legal or financial advice. + +## Compatibility + +- Uses the repo's existing aliases (`@/*`), Tailwind brand classes, and the `useWallet` connection gate — consistent with `pages/diagnostics.tsx`. +- Exposed via the `PerformanceBudgetPanel` component in `src/features/diagnostics/components/`. +- The pure core has no React dependency, so the same logic can back an SDK helper or a different surface without duplication. +- The `getPerformanceBudget` method is added to `IAegisProvider`, `MockAegisProvider`, and `LiveAegisProvider`, following the existing SDK abstraction pattern. +- The `performanceBudgetReview` feature flag controls whether the panel is surfaced in the diagnostics section. \ No newline at end of file diff --git a/src/features/diagnostics/components/PerformanceBudgetPanel.tsx b/src/features/diagnostics/components/PerformanceBudgetPanel.tsx new file mode 100644 index 0000000..cf12c03 --- /dev/null +++ b/src/features/diagnostics/components/PerformanceBudgetPanel.tsx @@ -0,0 +1,372 @@ +import { FileText } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { + type BudgetReviewResult, + type BudgetReviewState, + type BudgetBulkAction, + filterBudgetResults, + tallyBudgetResults, + recomputeBudgetSelection, + toggleBudgetSelection, + setBudgetSelectionAll, + applyBudgetBulkAction, + type BudgetCheck, +} from '@/lib/performanceBudget'; +import { COMPLIANCE_DISCLAIMER } from '@/lib/complianceReview'; +import { EmptyState } from '@/components/states'; +import { + TableSearch, + TableSortHeader, + StatusFilter, + SavedViewManager, +} from '@/components/table'; +import { useTableFilters } from '@/hooks/useTableFilters'; +import type { TransactionResult } from '@/components/transactions/types'; + +const STATUS_BADGE: Record< + BudgetReviewResult['status'], + string +> = { + compliant: 'bg-emerald-100 text-emerald-800', + warning: 'bg-amber-100 text-amber-800', + breached: 'bg-rose-100 text-rose-800', + unknown: 'bg-slate-100 text-slate-800', +}; + +const CHECK_DOT: Record< + BudgetCheck['result'], + string +> = { + pass: 'bg-emerald-500', + fail: 'bg-rose-500', + warn: 'bg-amber-500', + unknown: 'bg-slate-400', +}; + +const ACTION_LABELS: Record = { + approve: 'Approve', + reject: 'Reject', + 'flag-for-review': 'Flag for review', + clear: 'Clear selection', +}; + +function shortId(id: string): string { + if (id.length <= 16) return id; + return `${id.slice(0, 12)}…${id.slice(-4)}`; +} + +function StatusBadge({ + status, +}: { + status: BudgetReviewResult['status']; +}) { + return ( + + {status} + + ); +} + +export interface PerformanceBudgetPanelProps { + /** Seed budget review results. Typically loaded from the SDK/contract layer. */ + initialResults: BudgetReviewResult[]; + /** Whether the connected wallet has admin/compliance authority. */ + canAct: boolean; + /** Optional callback fired after a bulk action is applied. */ + onAction?: (action: BudgetBulkAction, affectedIds: string[]) => void; +} + +/** + * Reusable performance budget review panel. + * + * Pure state lives in `src/lib/performanceBudget.ts`; this component + * only renders and wires user intent. Filtering, sorting, and saved + * views are provided by the reusable `useTableFilters` hook and + * table UI components. + * + * Safe-by-default: bulk actions are disabled unless rows are selected. + */ +export default function PerformanceBudgetPanel({ + initialResults, + canAct, + onAction, +}: PerformanceBudgetPanelProps) { + const [state, setState] = useState(() => + recomputeBudgetSelection( + initialResults.map((r) => ({ ...r, selected: false })), + ), + ); + const [pendingAction, setPendingAction] = useState( + null, + ); + + const tableFilters = useTableFilters({ + namespace: 'performance-budget-review', + }); + + const visible = useMemo(() => { + return filterBudgetResults(state.results, tableFilters.state.query); + }, [state.results, tableFilters.state.query]); + + const tally = useMemo( + () => tallyBudgetResults(state.results), + [state.results], + ); + + const statusOptions = useMemo( + () => + ( + ['compliant', 'warning', 'breached', 'unknown'] as const + ).map((s) => ({ + value: s, + label: s.charAt(0).toUpperCase() + s.slice(1), + count: tally[s], + })), + [tally], + ); + + const handleToggle = (id: string) => + setState((s) => toggleBudgetSelection(s, id)); + const handleSelectAll = (value: boolean) => + setState((s) => setBudgetSelectionAll(s, value)); + + const handleAction = (action: BudgetBulkAction) => { + setState((s) => { + const affected = s.results + .filter((x) => x.selected) + .map((x) => x.budgetId); + const next = applyBudgetBulkAction(s, action); + onAction?.(action, affected); + return next; + }); + }; + + const handleConfirmAction = (action: BudgetBulkAction): TransactionResult => { + const ids = state.results + .filter((s) => s.selected) + .map((s) => s.budgetId); + handleAction(action); + setPendingAction(null); + return { + status: 'success', + message: 'Budget review updated', + detail: `${ACTION_LABELS[action]} applied to ${ids.length} budget result(s).`, + }; + }; + + const activeView = tableFilters.savedViews.find( + (v) => + v.query === tableFilters.state.query && + JSON.stringify(v.filters) === JSON.stringify(tableFilters.state.filters) && + v.sort.field === tableFilters.state.sort.field && + v.sort.direction === tableFilters.state.sort.direction, + ); + + return ( +
+
+
+

Performance Budget Review

+

+ {COMPLIANCE_DISCLAIMER} +

+
+
+ +
+
+ +
+
+ +
+
+ tableFilters.toggleFilter('status', v)} + /> +
+ + {(tableFilters.state.query || + Object.values(tableFilters.state.filters).some( + (v) => v.length > 0, + ) || + tableFilters.state.sort.direction) && ( +
+ + {visible.length} of {state.results.length} budget result(s) + {tableFilters.state.sort.direction && + ` · sorted by ${tableFilters.state.sort.field}`} + + +
+ )} +
+ + {canAct && ( +
+ + + + +
+ )} + + {pendingAction && ( +
+

+ {ACTION_LABELS[pendingAction]} — {state.selectedCount} selected +

+

+ {COMPLIANCE_DISCLAIMER} +

+
+ + +
+
+ )} + +
+ + + + + + + + + + + {visible.map((r) => ( + + + + + + + ))} + {visible.length === 0 && ( + + + + )} + +
+ handleSelectAll(e.target.checked)} + /> + BudgetStatusChecks
+ handleToggle(r.budgetId)} + /> + + {shortId(r.budgetId)} + {r.meta?.portfolio && ( + + {r.meta.portfolio} + + )} + + + +
+ {r.checks.map((c) => ( + + ))} + + {r.checks.length} check(s) + +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/fixtures/index.ts b/src/fixtures/index.ts index 9827fbc..172170f 100644 --- a/src/fixtures/index.ts +++ b/src/fixtures/index.ts @@ -14,3 +14,4 @@ export { transactionHistoryFixtures, transactionHistoryFixtureInputs } from './t export { mockDiagnosticsFixture } from './diagnostics'; export { mockIssuanceRequests } from './issuer'; export type { IssuanceRequest } from './issuer'; +export { sampleBudgetResults } from '@/lib/__fixtures__/performanceBudget'; diff --git a/src/hooks/useAegis.ts b/src/hooks/useAegis.ts index 95372e7..6a61ed3 100644 --- a/src/hooks/useAegis.ts +++ b/src/hooks/useAegis.ts @@ -5,6 +5,7 @@ import type { RawTransactionOutcome, TransactionPhase, } from '@/components/transactions/types'; +import type { BudgetReviewResult } from '@/lib/performanceBudget'; import { resolveWalletRole } from '@/features/auth/resolveRole'; import { useTransactionHistoryStore } from '@/features/transactions/store'; import { useWallet } from '@/hooks/useWallet'; @@ -127,11 +128,23 @@ export const useAegis = () => { } }; + const getPerformanceBudget = async ( + portfolioId: string, + ): Promise => { + setIsLoading(true); + try { + return await getAegisProvider().getPerformanceBudget(portfolioId); + } finally { + setIsLoading(false); + } + }; + return { checkWhitelist, transfer, mint, getPortfolio, + getPerformanceBudget, getWalletRole: resolveWalletRole, isLoading, }; diff --git a/src/hooks/useFeatureFlags.ts b/src/hooks/useFeatureFlags.ts index 3bdb0ca..19012d1 100644 --- a/src/hooks/useFeatureFlags.ts +++ b/src/hooks/useFeatureFlags.ts @@ -17,7 +17,8 @@ export type FeatureFlagKey = | 'newMintFlow' | 'complianceBanner' | 'darkMode' - | 'mockMode'; + | 'mockMode' + | 'performanceBudgetReview'; export interface FeatureFlagMeta { label: string; @@ -42,15 +43,19 @@ export const FLAG_METADATA: Record = { description: 'Uses local fixture data instead of live SDK calls. For local development only — never enable on testnet or mainnet.', }, + performanceBudgetReview: { + label: 'Performance Budget Review', + description: + 'Enables the performance budget review panel in the diagnostics section. For testing budget threshold evaluation.', + }, }; const DEFAULT_FLAGS: Record = { newMintFlow: false, complianceBanner: true, darkMode: false, - // Seed from the env var so the Diagnostics page and FeatureFlagsPanel reflect - // the true initial state without the user having to toggle manually. mockMode: isMockModeEnabled(), + performanceBudgetReview: false, }; interface FeatureFlagsState { diff --git a/src/lib/__fixtures__/performanceBudget.ts b/src/lib/__fixtures__/performanceBudget.ts new file mode 100644 index 0000000..021baed --- /dev/null +++ b/src/lib/__fixtures__/performanceBudget.ts @@ -0,0 +1,104 @@ +import type { BudgetReviewResult } from '@/lib/performanceBudget'; + +/** + * Example fixture representing a realistic performance budget review + * queue for the Aegis Dashboard. Identifiers are illustrative — do NOT + * treat as real accounts or real budget IDs. No PII is included. + */ +export const sampleBudgetResults: BudgetReviewResult[] = [ + { + budgetId: 'budget-concentration-001', + budgetName: 'Concentration Limit', + status: 'compliant', + meta: { currency: 'USD', portfolio: 'Core Portfolio' }, + checks: [ + { + key: 'concentration_ratio', + label: 'Concentration Ratio', + result: 'pass', + actual: 0.35, + threshold: 0.5, + detail: 'Single-asset exposure is within the 50% limit.', + evaluatedAt: '2026-07-28T10:00:00Z', + }, + { + key: 'sector_diversification', + label: 'Sector Diversification', + result: 'pass', + actual: 0.6, + threshold: 0.8, + detail: 'Portfolio spans 5 sectors; limit is 80% concentration.', + evaluatedAt: '2026-07-28T10:00:00Z', + }, + ], + }, + { + budgetId: 'budget-liquidity-001', + budgetName: 'Liquidity Threshold', + status: 'warning', + meta: { currency: 'USD', portfolio: 'Core Portfolio' }, + checks: [ + { + key: 'daily_liquidity_ratio', + label: 'Daily Liquidity Ratio', + result: 'warn', + actual: 0.85, + threshold: 0.8, + detail: 'Liquidity buffer is close to the minimum threshold.', + evaluatedAt: '2026-07-28T10:05:00Z', + }, + { + key: 'redemption_capacity', + label: 'Redemption Capacity', + result: 'pass', + actual: 0.95, + threshold: 0.9, + detail: 'Sufficient capacity to cover projected redemptions.', + evaluatedAt: '2026-07-28T10:05:00Z', + }, + ], + }, + { + budgetId: 'budget-exposure-001', + budgetName: 'Jurisdiction Exposure Limit', + status: 'breached', + meta: { currency: 'USD', portfolio: 'Global Portfolio' }, + checks: [ + { + key: 'us_exposure', + label: 'US Exposure', + result: 'pass', + actual: 0.4, + threshold: 0.5, + detail: 'US assets are within the 50% cap.', + evaluatedAt: '2026-07-28T10:10:00Z', + }, + { + key: 'eu_exposure', + label: 'EU Exposure', + result: 'fail', + actual: 0.55, + threshold: 0.4, + detail: 'EU assets exceed the 40% regulatory cap.', + evaluatedAt: '2026-07-28T10:10:00Z', + }, + ], + }, + { + budgetId: 'budget-unknown-001', + budgetName: 'Emerging Market Exposure', + status: 'unknown', + meta: { currency: 'USD', portfolio: 'Global Portfolio' }, + checks: [ + { + key: 'em_exposure', + label: 'Emerging Market Exposure', + result: 'unknown', + actual: null, + threshold: 0.3, + detail: 'Market data feed is temporarily unavailable.', + evaluatedAt: '2026-07-28T10:15:00Z', + }, + ], + }, +]; \ No newline at end of file diff --git a/src/lib/aegis/client.ts b/src/lib/aegis/client.ts index adcebd6..8e44b04 100644 --- a/src/lib/aegis/client.ts +++ b/src/lib/aegis/client.ts @@ -3,6 +3,8 @@ import type { RawTransactionOutcome, TransactionPhase, } from '@/components/transactions/types'; +import type { BudgetReviewResult } from '@/lib/performanceBudget'; +import { sampleBudgetResults } from '@/lib/__fixtures__/performanceBudget'; /** * Stand-in for `@aegis/sdk`. The real SDK is not published to this @@ -199,3 +201,16 @@ export async function mint( await simulateSubmission(onPhase); return mockOutcome(amount, 'mock_tx_hash_0987654321'); } + +/** + * Mocks fetching performance budget review results for a portfolio. + * Returns the sample fixture data so the PerformanceBudgetPanel can + * render meaningful results in mock mode. + */ +export async function getPerformanceBudget( + portfolioId: string, +): Promise { + void portfolioId; + await wait(500); + return sampleBudgetResults; +} diff --git a/src/lib/performanceBudget.test.ts b/src/lib/performanceBudget.test.ts new file mode 100644 index 0000000..380054d --- /dev/null +++ b/src/lib/performanceBudget.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect } from 'vitest'; +import { + deriveBudgetStatus, + DEFAULT_BUDGET_REVIEW_RULE, + budgetStatusRank, + filterBudgetResults, + tallyBudgetResults, + recomputeBudgetSelection, + toggleBudgetSelection, + setBudgetSelectionAll, + applyBudgetBulkAction, + formatBudgetValue, + budgetResultLabel, + validateBudgetChecks, + type BudgetCheck, + type BudgetReviewResult, + type BudgetReviewState, +} from '@/lib/performanceBudget'; +import { sampleBudgetResults } from '@/lib/__fixtures__/performanceBudget'; + +const checks = (results: BudgetCheck['result'][]): BudgetCheck[] => + results.map((r, i) => ({ + key: `c${i}`, + label: `Check ${i}`, + result: r, + actual: r === 'fail' ? 1.5 : r === 'warn' ? 0.9 : 0.5, + threshold: 1.0, + })); + +describe('deriveBudgetStatus', () => { + it('returns compliant when all checks pass', () => { + expect(deriveBudgetStatus(checks(['pass', 'pass']))).toBe('compliant'); + }); + + it('returns breached when any check fails', () => { + expect(deriveBudgetStatus(checks(['pass', 'fail', 'pass']))).toBe('breached'); + }); + + it('returns warning when any check warns (and none fail)', () => { + expect(deriveBudgetStatus(checks(['pass', 'warn']))).toBe('warning'); + }); + + it('returns unknown when any check is unknown', () => { + expect(deriveBudgetStatus(checks(['pass', 'unknown']))).toBe('unknown'); + }); + + it('defaults to unknown with no checks', () => { + expect(deriveBudgetStatus([])).toBe('unknown'); + }); + + it('honours a custom rule', () => { + const rule = { + ...DEFAULT_BUDGET_REVIEW_RULE, + onAnyWarn: 'compliant' as const, + }; + expect(deriveBudgetStatus(checks(['pass', 'warn']), rule)).toBe('compliant'); + }); +}); + +describe('budgetStatusRank', () => { + it('orders compliant < warning < breached < unknown', () => { + expect(budgetStatusRank('compliant')).toBeLessThan(budgetStatusRank('warning')); + expect(budgetStatusRank('warning')).toBeLessThan(budgetStatusRank('breached')); + expect(budgetStatusRank('breached')).toBeLessThan(budgetStatusRank('unknown')); + }); +}); + +describe('filterBudgetResults', () => { + it('returns all when query is empty', () => { + expect(filterBudgetResults(sampleBudgetResults, ' ')).toBe(sampleBudgetResults); + }); + + it('matches by budgetId substring (case-insensitive)', () => { + const id = sampleBudgetResults[0].budgetId; + const q = id.slice(0, 8).toLowerCase(); + const out = filterBudgetResults(sampleBudgetResults, q); + expect(out).toHaveLength(1); + expect(out[0].budgetId).toBe(id); + }); + + it('matches by budgetName', () => { + const out = filterBudgetResults(sampleBudgetResults, 'concentration'); + expect(out.length).toBeGreaterThan(0); + }); + + it('matches by meta value', () => { + const out = filterBudgetResults(sampleBudgetResults, 'USD'); + expect(out.length).toBeGreaterThan(0); + }); + + it('returns empty when nothing matches', () => { + expect(filterBudgetResults(sampleBudgetResults, 'zzzz-no-match')).toHaveLength(0); + }); +}); + +describe('tallyBudgetResults', () => { + it('counts results per status', () => { + const tally = tallyBudgetResults(sampleBudgetResults); + expect(tally.compliant).toBeGreaterThanOrEqual(0); + expect(tally.warning).toBeGreaterThanOrEqual(0); + expect(tally.breached).toBeGreaterThanOrEqual(0); + expect(tally.unknown).toBeGreaterThanOrEqual(0); + }); +}); + +describe('selection helpers', () => { + const base = (): BudgetReviewState => + recomputeBudgetSelection(sampleBudgetResults.map((r) => ({ ...r }))); + + it('toggleBudgetSelection flips one row and recomputes counts', () => { + const s0 = base(); + expect(s0.selectedCount).toBe(0); + const id = s0.results[0].budgetId; + const s1 = toggleBudgetSelection(s0, id); + expect(s1.selectedCount).toBe(1); + expect(s1.results[0].selected).toBe(true); + const s2 = toggleBudgetSelection(s1, id); + expect(s2.selectedCount).toBe(0); + }); + + it('setBudgetSelectionAll selects/deselects everything', () => { + const s0 = base(); + const all = setBudgetSelectionAll(s0, true); + expect(all.selectedCount).toBe(all.results.length); + expect(all.allSelected).toBe(true); + const none = setBudgetSelectionAll(all, false); + expect(none.selectedCount).toBe(0); + expect(none.allSelected).toBe(false); + }); +}); + +describe('applyBudgetBulkAction', () => { + const selectFirstTwo = (): BudgetReviewState => { + let s = recomputeBudgetSelection(sampleBudgetResults.map((x) => ({ ...x }))); + s = toggleBudgetSelection(s, s.results[0].budgetId); + s = toggleBudgetSelection(s, s.results[1].budgetId); + return s; + }; + + it('approves selected rows and clears selection', () => { + const s = selectFirstTwo(); + const next = applyBudgetBulkAction(s, 'approve'); + expect(next.selectedCount).toBe(0); + expect(next.results[0].status).toBe('compliant'); + expect(next.results[1].status).toBe('compliant'); + // Unselected rows untouched + expect(next.results[2].status).toBe('breached'); + }); + + it('rejects selected rows', () => { + const s = selectFirstTwo(); + const next = applyBudgetBulkAction(s, 'reject'); + expect(next.results[0].status).toBe('breached'); + expect(next.results[1].status).toBe('breached'); + }); + + it('flags selected rows for review', () => { + const s = selectFirstTwo(); + const next = applyBudgetBulkAction(s, 'flag-for-review'); + expect(next.results[0].status).toBe('warning'); + }); + + it('clear only removes selection, not status', () => { + const s = selectFirstTwo(); + const next = applyBudgetBulkAction(s, 'clear'); + expect(next.selectedCount).toBe(0); + expect(next.results[0].status).toBe('compliant'); + expect(next.results[1].status).toBe('warning'); + }); + + it('no-op when nothing selected', () => { + const s = recomputeBudgetSelection(sampleBudgetResults.map((x) => ({ ...x }))); + const next = applyBudgetBulkAction(s, 'approve'); + expect(next).toBe(s); + }); + + it('respects explicit selectedIds', () => { + const s = recomputeBudgetSelection(sampleBudgetResults.map((x) => ({ ...x }))); + const id = s.results[3].budgetId; + const next = applyBudgetBulkAction(s, 'approve', [id]); + expect(next.results[3].status).toBe('compliant'); + expect(next.selectedCount).toBe(0); + }); +}); + +describe('formatBudgetValue', () => { + it('formats a number to two decimal places', () => { + expect(formatBudgetValue(0.75)).toBe('0.75'); + }); + + it('returns N/A for null', () => { + expect(formatBudgetValue(null)).toBe('N/A'); + }); +}); + +describe('budgetResultLabel', () => { + it('includes the compliance disclaimer', () => { + const label = budgetResultLabel('pass', 'concentration'); + expect(label).toContain('Protocol-level compliance information'); + }); + + it('uses the correct prefix for each result type', () => { + expect(budgetResultLabel('pass', 'exposure')).toContain('within budget'); + expect(budgetResultLabel('fail', 'exposure')).toContain('exceeds budget'); + expect(budgetResultLabel('warn', 'exposure')).toContain('approaching budget'); + expect(budgetResultLabel('unknown', 'exposure')).toContain('data unavailable'); + }); +}); + +describe('validateBudgetChecks', () => { + it('returns no errors for valid checks', () => { + const validChecks: BudgetCheck[] = [ + { key: 'k1', label: 'Check 1', result: 'pass', actual: 0.5, threshold: 1.0 }, + { key: 'k2', label: 'Check 2', result: 'pass', actual: 0.3, threshold: 1.0 }, + ]; + expect(validateBudgetChecks(validChecks)).toEqual([]); + }); + + it('reports duplicate keys', () => { + const dupChecks: BudgetCheck[] = [ + { key: 'k1', label: 'Check 1', result: 'pass', actual: 0.5, threshold: 1.0 }, + { key: 'k1', label: 'Check 1b', result: 'pass', actual: 0.5, threshold: 1.0 }, + ]; + const errors = validateBudgetChecks(dupChecks); + expect(errors.some((e) => e.includes('Duplicate'))).toBe(true); + }); + + it('reports missing key', () => { + const noKeyCheck: BudgetCheck = { + key: '', + label: 'Check', + result: 'pass', + actual: 0.5, + threshold: 1.0, + }; + const errors = validateBudgetChecks([noKeyCheck]); + expect(errors.some((e) => e.includes('missing'))).toBe(true); + }); + + it('reports missing label', () => { + const noLabelCheck: BudgetCheck = { + key: 'k1', + label: '', + result: 'pass', + actual: 0.5, + threshold: 1.0, + }; + const errors = validateBudgetChecks([noLabelCheck]); + expect(errors.some((e) => e.includes('missing'))).toBe(true); + }); +}); + +describe('fixtures sanity', () => { + it('sample results have consistent derived statuses', () => { + for (const r of sampleBudgetResults as BudgetReviewResult[]) { + const derived = deriveBudgetStatus(r.checks); + expect(['compliant', 'warning', 'breached', 'unknown']).toContain(derived); + } + }); +}); \ No newline at end of file diff --git a/src/lib/performanceBudget.ts b/src/lib/performanceBudget.ts new file mode 100644 index 0000000..72f4d31 --- /dev/null +++ b/src/lib/performanceBudget.ts @@ -0,0 +1,285 @@ +/** + * Performance budget review for the Aegis Dashboard. + * + * A performance budget defines acceptable thresholds for portfolio + * metrics (concentration, exposure, liquidity, etc.). The review + * engine evaluates assets or portfolios against these budgets and + * produces a typed result that the dashboard can render. + * + * This module is intentionally framework-agnostic (no React) so the + * review engine can be unit-tested without a DOM and reused by both + * the dashboard UI and any future SDK/dashboard boundary. + * + * NOTE: This implements *protocol-level* performance budget mechanics + * only. User-facing copy derived from this module should use + * {@link COMPLIANCE_DISCLAIMER} (or {@link withDisclaimer}) to ensure + * the mandated "not legal, regulatory, or financial advice" message + * is always present. + */ + +import { COMPLIANCE_DISCLAIMER, withDisclaimer } from './complianceReview'; + +/** The outcome of a single budget check against a threshold. */ +export type BudgetCheckResult = 'pass' | 'fail' | 'warn' | 'unknown'; + +/** A single named budget check evaluated against a metric. */ +export interface BudgetCheck { + /** Stable machine key for the check (e.g. "concentration_ratio"). */ + key: string; + /** Human-readable label shown in the table. */ + label: string; + result: BudgetCheckResult; + /** The actual measured value, if available. */ + actual: number | null; + /** The threshold value the metric was compared against. */ + threshold: number; + /** Longer explanation surfaced in tooltips/detail views. */ + detail?: string; + /** When the check was last evaluated (ISO 8601). Optional. */ + evaluatedAt?: string; +} + +/** The overall review outcome for a single budget applied to a subject. */ +export type BudgetReviewStatus = 'compliant' | 'warning' | 'breached' | 'unknown'; + +/** A single budget review result for one subject (e.g. one portfolio). */ +export interface BudgetReviewResult { + /** Stable machine key for the budget. */ + budgetId: string; + /** Human-readable budget name. */ + budgetName: string; + status: BudgetReviewStatus; + /** Ordered list of checks for this budget. */ + checks: BudgetCheck[]; + /** Whether the connected admin has locally selected this row for bulk action. */ + selected?: boolean; + /** Arbitrary, non-PII metadata (e.g. portfolio name, currency). Optional. */ + meta?: Record; +} + +/** The aggregate state of the budget review table. */ +export interface BudgetReviewState { + results: BudgetReviewResult[]; + /** Count of results selected for a bulk action. */ + selectedCount: number; + /** Whether every visible result is selected. */ + allSelected: boolean; + lastUpdated?: string; +} + +/** Mutually exclusive bulk actions an admin can apply to selected rows. */ +export type BudgetBulkAction = 'approve' | 'flag-for-review' | 'reject' | 'clear'; + +/** Decision rule describing how a set of check results maps to a recommended status. */ +export interface BudgetReviewRule { + /** If any check `fail`s, force this status. */ + onAnyFail: BudgetReviewStatus; + /** If any check `warn`s (and none failed), use this status. */ + onAnyWarn: BudgetReviewStatus; + /** If all checks `pass`, use this status. */ + onAllPass: BudgetReviewStatus; + /** If any check is `unknown`, use this status. */ + onAnyUnknown: BudgetReviewStatus; +} + +export const DEFAULT_BUDGET_REVIEW_RULE: BudgetReviewRule = { + onAnyFail: 'breached', + onAnyWarn: 'warning', + onAllPass: 'compliant', + onAnyUnknown: 'unknown', +}; + +/** + * Derive a recommended budget review status from a set of checks. + * Pure — no side effects, fully testable. + */ +export function deriveBudgetStatus( + checks: BudgetCheck[], + rule: BudgetReviewRule = DEFAULT_BUDGET_REVIEW_RULE, +): BudgetReviewStatus { + if (checks.length === 0) return rule.onAnyUnknown; + const results = checks.map((c) => c.result); + if (results.some((r) => r === 'fail')) return rule.onAnyFail; + if (results.some((r) => r === 'unknown')) return rule.onAnyUnknown; + if (results.some((r) => r === 'warn')) return rule.onAnyWarn; + return rule.onAllPass; +} + +const BUDGET_STATUS_ORDER: Record = { + compliant: 0, + warning: 1, + breached: 2, + unknown: 3, +}; + +/** + * Rank budget review status for sorting/triage. Higher number == higher priority. + */ +export function budgetStatusRank(s: BudgetReviewStatus): number { + return BUDGET_STATUS_ORDER[s]; +} + +/** + * Filter budget review results by a free-text query. Matches against + * `budgetId`, `budgetName`, and any `meta` values (case-insensitive). + * Non-PII by design. + */ +export function filterBudgetResults( + results: BudgetReviewResult[], + query: string, +): BudgetReviewResult[] { + const q = query.trim().toLowerCase(); + if (!q) return results; + return results.filter((r) => { + if (r.budgetId.toLowerCase().includes(q)) return true; + if (r.budgetName.toLowerCase().includes(q)) return true; + if (!r.meta) return false; + return Object.values(r.meta).some((v) => v.toLowerCase().includes(q)); + }); +} + +/** + * Tally how many results fall into each status. Useful for table summary + * chips and for assertions in tests. + */ +export function tallyBudgetResults( + results: BudgetReviewResult[], +): Record { + const tally: Record = { + compliant: 0, + warning: 0, + breached: 0, + unknown: 0, + }; + for (const r of results) tally[r.status] += 1; + return tally; +} + +/** + * Produce the next state after applying a bulk action to the currently + * selected rows. Selection is cleared after the action (standard table UX). + * Pure — returns a new array, does not mutate the input. + * + * `selectedIds` lets callers reuse this for partial selections; if omitted, + * all `selected` rows are acted on. + */ +export function applyBudgetBulkAction( + state: BudgetReviewState, + action: BudgetBulkAction, + selectedIds?: string[], +): BudgetReviewState { + const ids = new Set( + selectedIds ?? state.results.filter((r) => r.selected).map((r) => r.budgetId), + ); + if (ids.size === 0) return state; + + const nextResults = state.results.map((r) => { + if (!ids.has(r.budgetId)) return r; + switch (action) { + case 'approve': + return { ...r, status: 'compliant' as BudgetReviewStatus, selected: false }; + case 'reject': + return { ...r, status: 'breached' as BudgetReviewStatus, selected: false }; + case 'flag-for-review': + return { ...r, status: 'warning' as BudgetReviewStatus, selected: false }; + case 'clear': + return { ...r, selected: false }; + default: + return r; + } + }); + + return recomputeBudgetSelection(nextResults); +} + +/** + * Recompute derived selection fields. Centralised so callers never have to + * keep `selectedCount`/`allSelected` in sync manually. + */ +export function recomputeBudgetSelection( + results: BudgetReviewResult[], +): BudgetReviewState { + const selectedCount = results.filter((r) => r.selected).length; + return { + results, + selectedCount, + allSelected: results.length > 0 && selectedCount === results.length, + lastUpdated: new Date().toISOString(), + }; +} + +/** + * Toggle a single row's selection. Pure. + */ +export function toggleBudgetSelection( + state: BudgetReviewState, + id: string, +): BudgetReviewState { + const next = state.results.map((r) => + r.budgetId === id ? { ...r, selected: !r.selected } : r, + ); + return recomputeBudgetSelection(next); +} + +/** + * Set the selection state of every row. `value=true` selects all; `false` + * clears all. Pure. + */ +export function setBudgetSelectionAll( + state: BudgetReviewState, + value: boolean, +): BudgetReviewState { + const next = state.results.map((r) => ({ ...r, selected: value })); + return recomputeBudgetSelection(next); +} + +/** + * Format a numeric value for display. Returns "N/A" when the value is + * null (metric could not be measured). + */ +export function formatBudgetValue(value: number | null): string { + return value === null ? 'N/A' : value.toFixed(2); +} + +/** + * Build a compliance-safe label for a budget check result. + * Uses the shared COMPLIANCE_DISCLAIMER pattern so protocol-level + * results never imply legal or financial authority. + */ +export function budgetResultLabel( + result: BudgetCheckResult, + metric: string, +): string { + const map: Record = { + pass: `${metric}: within budget`, + fail: `${metric}: exceeds budget threshold`, + warn: `${metric}: approaching budget limit`, + unknown: `${metric}: data unavailable`, + }; + return withDisclaimer(map[result]); +} + +/** + * Validate a set of budget checks for internal consistency. + * Returns an array of human-readable error strings. Empty when valid. + */ +export function validateBudgetChecks(checks: BudgetCheck[]): string[] { + const errors: string[] = []; + const seenKeys = new Set(); + + for (const check of checks) { + if (!check.key) { + errors.push('A budget check is missing its required `key`.'); + } else if (seenKeys.has(check.key)) { + errors.push(`Duplicate budget check key: "${check.key}".`); + } else { + seenKeys.add(check.key); + } + + if (!check.label) { + errors.push(`Budget check "${check.key}" is missing its required label.`); + } + } + + return errors; +} \ No newline at end of file diff --git a/src/lib/sdk/IAegisProvider.ts b/src/lib/sdk/IAegisProvider.ts index 1aec5b5..5bb77ad 100644 --- a/src/lib/sdk/IAegisProvider.ts +++ b/src/lib/sdk/IAegisProvider.ts @@ -12,6 +12,7 @@ import type { RawTransactionOutcome, TransactionPhase, } from '@/components/transactions/types'; +import type { BudgetReviewResult } from '@/lib/performanceBudget'; /** Called as the transaction moves from wallet signature to network submission. */ export type PhaseListener = (phase: TransactionPhase) => void; @@ -45,4 +46,12 @@ export interface IAegisProvider { amount: number, onPhase?: PhaseListener, ): Promise; + + /** + * Fetch the performance budget review results for a portfolio. + * Returns an empty array when no budgets are configured. + */ + getPerformanceBudget( + portfolioId: string, + ): Promise; } diff --git a/src/lib/sdk/LiveAegisProvider.ts b/src/lib/sdk/LiveAegisProvider.ts index 8356856..7f46d2d 100644 --- a/src/lib/sdk/LiveAegisProvider.ts +++ b/src/lib/sdk/LiveAegisProvider.ts @@ -17,6 +17,7 @@ import type { IAegisProvider, PhaseListener } from './IAegisProvider'; import type { PortfolioReadModel } from '@/lib/aegis/types'; import type { RawTransactionOutcome } from '@/components/transactions/types'; +import type { BudgetReviewResult } from '@/lib/performanceBudget'; import * as aegisClient from '@/lib/aegis/client'; export class LiveAegisProvider implements IAegisProvider { @@ -45,4 +46,10 @@ export class LiveAegisProvider implements IAegisProvider { ): Promise { return aegisClient.mint(to, amount, onPhase); } + + getPerformanceBudget( + portfolioId: string, + ): Promise { + return aegisClient.getPerformanceBudget(portfolioId); + } } diff --git a/src/lib/sdk/MockAegisProvider.ts b/src/lib/sdk/MockAegisProvider.ts index beb4fa5..422ad31 100644 --- a/src/lib/sdk/MockAegisProvider.ts +++ b/src/lib/sdk/MockAegisProvider.ts @@ -20,7 +20,9 @@ import type { IAegisProvider, PhaseListener } from './IAegisProvider'; import type { PortfolioReadModel } from '@/lib/aegis/types'; import type { RawTransactionOutcome } from '@/components/transactions/types'; +import type { BudgetReviewResult } from '@/lib/performanceBudget'; import { mockPortfolioFixture } from '@/fixtures/portfolio'; +import { sampleBudgetResults } from '@/lib/__fixtures__/performanceBudget'; const MOCK_LATENCY_MS = 600; @@ -97,4 +99,12 @@ export class MockAegisProvider implements IAegisProvider { await simulateSubmission(onPhase); return mockOutcome(amount, 'mock_tx_hash_mint_0987654321'); } + + async getPerformanceBudget( + portfolioId: string, + ): Promise { + void portfolioId; + await wait(400); + return sampleBudgetResults; + } }