diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6dd925e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,42 @@
+name: CI
+
+on:
+ pull_request:
+ branches:
+ - main
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ name: validate
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Test
+ run: npm test
+
+ - name: Build
+ run: npm run build
+
+ - name: Audit production dependencies
+ run: npm audit --omit=dev
diff --git a/README.md b/README.md
index a23ed7a..5a4bdb7 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,29 @@
# AgentLedger
-**The financial control plane for autonomous agents.**
+
+
+
+
+The financial control plane for autonomous agents.
+
+Financial state and policy in; machine-readable recommendations and capital decisions out.
+
+
+
+
+
> Financial execution requires financial governance.
-AgentLedger converts financial state and policy into machine-readable recommendations and capital decisions for autonomous agents. It is an API-only, deterministic, explainable service that sits between agent intent and capital movement.
+Autonomous agents can already spend, trade and purchase services. AgentLedger provides the deterministic, explainable financial decision layer between agent intent and capital movement.
-```text
-Calling agent
- ↓
-financial state + proposed action + policy
- ↓
-AgentLedger
- ↓
-normalize → analyze → evaluate policy
- ↓
-recommendation / APPROVE / CAUTION / REJECT
-```
+| Service | Question | Price |
+| --- | --- | --- |
+| Financial Health | How am I? | Free |
+| Recommendations | What should I do next? | 0.01 USDT/call |
+| Policy Guard | Should this capital move? | 0.02 USDT/call |
-Autonomous agents can already spend, trade and purchase services. AgentLedger supplies the financial decision layer that helps them understand operating health and govern spending before money moves.
+Quick links: [Production API](https://agentledger-one.vercel.app) · [OpenAPI](https://agentledger-one.vercel.app/openapi.json) · [GitHub](https://github.com/chin0312/agentledger) · [OKX.AI ASP #11336](https://www.okx.ai/agents/11336)
AgentLedger is not a wallet, accounting system, tax system, investment adviser, portfolio manager, trading bot or execution engine. It never signs or executes capital movement.
@@ -42,15 +47,6 @@ AgentLedger is not a wallet, accounting system, tax system, investment adviser,
`POST /api/evaluate-action` evaluates `service_purchase` and `capital_allocation` actions against caller-supplied financial state and policy. It returns an explainable `approve`, `caution` or `reject` decision. The hosted route uses x402 on X Layer.
-Compatibility primitives remain available and free:
-
-```text
-POST /api/can-i-spend
-POST /api/can-i-allocate
-```
-
-They are not initial Marketplace services.
-
## Agent-native architecture
AgentLedger does not need control of an agent's wallet. A calling agent can use Agentic Wallet, exchange APIs, wallet tools, accounting tools, execution systems or internal strategy state to gather factual financial state and send it to AgentLedger.
@@ -107,6 +103,17 @@ curl -X POST http://localhost:3000/api/demo/company-health \
The seeded company has marketplace, research-service and client inflows, repeated payments to agent vendors, stablecoin activity across USDT/USDC/USDG, spend acceleration and one failed transaction.
+## Compatibility API
+
+The original specialist primitives remain available and free for compatibility:
+
+```text
+POST /api/can-i-spend
+POST /api/can-i-allocate
+```
+
+They are not initial Marketplace services; use Policy Guard for the Marketplace-facing generalized control flow.
+
## API examples
### Financial Health from caller-provided state
diff --git a/SECURITY.md b/SECURITY.md
index e9a7b95..d021292 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,7 +2,7 @@
## Reporting a vulnerability
-Please report suspected vulnerabilities privately to the repository maintainers before opening a public issue. Include the affected endpoint or file, reproduction steps, impact and a suggested mitigation when available. Do not include credentials, private keys or other sensitive material in the report.
+Please use GitHub's private [Report a vulnerability](https://github.com/chin0312/agentledger/security/advisories/new) flow from the repository Security tab before opening a public issue. Include the affected endpoint or file, reproduction steps, impact and a suggested mitigation when available. Do not include credentials, private keys or other sensitive material in the report. If the private reporting form is unavailable, open a minimal public issue asking maintainers for a secure contact path without including exploit details.
## Credential handling
diff --git a/app/openapi.json/route.ts b/app/openapi.json/route.ts
index f782e86..1f9d0db 100644
--- a/app/openapi.json/route.ts
+++ b/app/openapi.json/route.ts
@@ -245,18 +245,18 @@ export function GET(request: Request): Promise {
monthlySpendLimit: { type: "number", minimum: 0 }, reservePct: { type: "number", minimum: 0, maximum: 100, default: 20 }, cautionBudgetUtilizationPct: { type: "number", minimum: 0, maximum: 100, default: 75 }, criticalBudgetUtilizationPct: { type: "number", minimum: 0, maximum: 100, default: 90 }, maxProviderConcentrationPct: { type: "number", minimum: 0, maximum: 100, default: 35 }, maxSingleActionAmount: { type: "number", minimum: 0 }, maxStrategyAllocationPct: { type: "number", minimum: 0, maximum: 100, default: 35 }, maxStrategyAllocationAbsolute: { type: "number", minimum: 0 }, dailyLossLimit: { type: "number", minimum: 0 }, cautionLossUtilizationPct: { type: "number", minimum: 0, maximum: 100, default: 70 }, criticalLossUtilizationPct: { type: "number", minimum: 0, maximum: 100, default: 90 },
},
},
- FinancialProvider: { type: "object", required: ["provider", "spend", "transactions"], additionalProperties: false, properties: { provider: { type: "string", minLength: 1, maxLength: 256 }, spend: { type: "number", minimum: 0 }, transactions: { type: "integer", minimum: 0 }, failedTransactions: { type: "integer", minimum: 0 } } },
+ FinancialProvider: { type: "object", required: ["provider", "spend", "transactions"], additionalProperties: false, description: "Provider-level successful outgoing activity. failedTransactions counts failed outgoing attempts separately and may be greater than transactions.", properties: { provider: { type: "string", minLength: 1, maxLength: 256 }, spend: { type: "number", minimum: 0 }, transactions: { type: "integer", minimum: 0, description: "Successful outgoing payments to this provider." }, failedTransactions: { type: "integer", minimum: 0, description: "Failed outgoing attempts to this provider; not included in transactions or spend." } } },
ProvidedFinancialState: {
type: "object",
required: ["periodDays", "cashFlow"],
additionalProperties: false,
- description: "Caller-supplied factual financial state. AgentLedger derives conclusions and does not independently attest to these values.",
+ description: "Caller-supplied factual financial state. AgentLedger derives conclusions and does not independently attest to these values. Provider lists may be partial. Provider spend cannot exceed total outflows by more than $0.01; successful provider transaction counts cannot exceed explicit successful outgoingTransactions; and explicit failedTransactions must include all provider failedTransactions.",
properties: {
periodDays: { type: "integer", minimum: 1, maximum: 90 },
- cashFlow: { type: "object", required: ["inflows", "outflows"], additionalProperties: false, properties: { inflows: { type: "number", minimum: 0 }, outflows: { type: "number", minimum: 0 }, incomingTransactions: { type: "integer", minimum: 0 }, outgoingTransactions: { type: "integer", minimum: 0 } } },
- spendVelocity: { type: "object", additionalProperties: false, properties: { firstHalf: { type: "number", minimum: 0 }, secondHalf: { type: "number", minimum: 0 } } },
- providers: { type: "array", maxItems: 100, items: { $ref: "#/components/schemas/FinancialProvider" } },
- failedTransactions: { type: "integer", minimum: 0 },
+ cashFlow: { type: "object", required: ["inflows", "outflows"], additionalProperties: false, description: "Inflows and outflows for the period. Transaction counts describe successful transactions; failed attempts are tracked separately.", properties: { inflows: { type: "number", minimum: 0 }, outflows: { type: "number", minimum: 0 }, incomingTransactions: { type: "integer", minimum: 0, description: "Successful incoming transactions." }, outgoingTransactions: { type: "integer", minimum: 0, description: "Successful outgoing transactions. Provider transaction totals cannot exceed this value when supplied." } } },
+ spendVelocity: { type: "object", additionalProperties: false, description: "Observed spend in the two half-periods. The values are used for velocity and are not required to equal total outflows when the caller reports a partial observation.", properties: { firstHalf: { type: "number", minimum: 0 }, secondHalf: { type: "number", minimum: 0 } } },
+ providers: { type: "array", maxItems: 100, description: "Successful outgoing provider payments. Entries with the same provider name are aggregated case-insensitively; the list may be a subset of total spending.", items: { $ref: "#/components/schemas/FinancialProvider" } },
+ failedTransactions: { type: "integer", minimum: 0, description: "Total failed transactions. If omitted, known provider-level failures are derived; if supplied, it must be at least their sum." },
},
},
ProvidedCompanyHealthRequest: { type: "object", required: ["source", "financialState"], properties: { source: { type: "string", enum: ["provided_state"] }, financialState: { $ref: "#/components/schemas/ProvidedFinancialState" }, address: { type: "string", description: "Optional caller label; it is not verified by AgentLedger." }, monthlyBudget: { type: "number", minimum: 0 }, policy: { $ref: "#/components/schemas/FinancialPolicy" } } },
diff --git a/docs/assets/agentledger.png b/docs/assets/agentledger.png
new file mode 100644
index 0000000..ac98755
Binary files /dev/null and b/docs/assets/agentledger.png differ
diff --git a/lib/financial-context.ts b/lib/financial-context.ts
index 0866651..d7ee532 100644
--- a/lib/financial-context.ts
+++ b/lib/financial-context.ts
@@ -1,5 +1,8 @@
import type { NormalizedTransaction, ProvidedFinancialState } from "./types";
+export const FINANCIAL_STATE_TOLERANCE = 0.01;
+export const MAX_AGGREGATE_TRANSACTION_COUNT = 1_000_000;
+
export type NormalizedProviderState = {
provider: string;
spend: number;
@@ -83,12 +86,33 @@ export function normalizeFinancialState(state: ProvidedFinancialState): Normaliz
}
}
- const failedTransactions = state.failedTransactions ?? 0;
+ const providerSpendTotal = [...providers.values()].reduce((sum, provider) => sum + provider.spend, 0);
+ if (providerSpendTotal > state.cashFlow.outflows + FINANCIAL_STATE_TOLERANCE) {
+ throw new Error("financialState.providers.spend total must not exceed cashFlow.outflows");
+ }
+
+ const providerSuccessfulTransactionTotal = [...providers.values()].reduce((sum, provider) => sum + provider.transactions, 0);
+ if (state.cashFlow.outgoingTransactions !== undefined && providerSuccessfulTransactionTotal > state.cashFlow.outgoingTransactions) {
+ throw new Error("financialState.providers.transactions total must not exceed cashFlow.outgoingTransactions");
+ }
+ if (providerSuccessfulTransactionTotal > MAX_AGGREGATE_TRANSACTION_COUNT) {
+ throw new Error("financialState provider transaction totals are unreasonably large");
+ }
+
+ const providerFailedTotal = [...providers.values()].reduce((sum, provider) => sum + provider.failedTransactions, 0);
+ if (providerFailedTotal > MAX_AGGREGATE_TRANSACTION_COUNT) {
+ throw new Error("financialState provider failed transaction totals are unreasonably large");
+ }
+ if (state.failedTransactions !== undefined && state.failedTransactions < providerFailedTotal) {
+ throw new Error("financialState.failedTransactions must include all provider failedTransactions");
+ }
+
+ const failedTransactions = state.failedTransactions ?? providerFailedTotal;
assertCount(failedTransactions, "failedTransactions");
const incomingTransactions = state.cashFlow.incomingTransactions ?? (state.cashFlow.inflows > 0 ? 1 : 0);
const outgoingTransactions = state.cashFlow.outgoingTransactions ?? (
providers.size > 0
- ? [...providers.values()].reduce((sum, provider) => sum + provider.transactions, 0)
+ ? providerSuccessfulTransactionTotal
: state.cashFlow.outflows > 0
? 1
: 0
diff --git a/lib/recommendations.ts b/lib/recommendations.ts
index facbe7d..145f780 100644
--- a/lib/recommendations.ts
+++ b/lib/recommendations.ts
@@ -1,12 +1,14 @@
import type {
CompanyHealthReport,
FinancialRecommendation,
+ FinancialRiskSignalType,
FinancialStatus,
FinancialPolicy,
RecommendationsReport,
} from "./types";
import { APP_NAME, APP_VERSION } from "./types";
import { resolveFinancialPolicy } from "./policy";
+import { deriveFinancialRiskSignals } from "./risk";
type Candidate = {
score: number;
@@ -23,65 +25,36 @@ function display(value: number): string {
return round(value).toFixed(2).replace(/\.00$/, "");
}
-function percentage(value: number, total: number): number {
- if (total <= 0) {
- return 0;
- }
-
- return round((value / total) * 100);
-}
-
function severityScore(severity: FinancialRecommendation["severity"]): number {
return { critical: 100, high: 70, medium: 40, low: 20 }[severity];
}
export function getFinancialStatus(report: CompanyHealthReport, policy?: FinancialPolicy): FinancialStatus {
- const resolved = resolveFinancialPolicy(policy);
- const budgetPct = report.budget?.usedPct ?? 0;
- const concentrationPct = report.topCounterparties[0]?.shareOfSpendPct ?? 0;
- const hardConcentrationPct = Math.min(100, resolved.maxProviderConcentrationPct + 15);
- const hasCriticalRisk = budgetPct > resolved.criticalBudgetUtilizationPct || concentrationPct > hardConcentrationPct;
-
- if (hasCriticalRisk) {
+ const signals = deriveFinancialRiskSignals(report, policy);
+ if (signals.some((signal) => signal.severity === "critical")) {
return "critical";
}
-
- const hasModerateRisk =
- budgetPct > resolved.cautionBudgetUtilizationPct ||
- concentrationPct > resolved.maxProviderConcentrationPct ||
- report.spendVelocity.changePct > 50 ||
- report.cashFlow.net < 0 ||
- report.transactions.failed > 0;
-
- return hasModerateRisk ? "caution" : "healthy";
+ return signals.length > 0 ? "caution" : "healthy";
}
function buildExecutiveSummary(report: CompanyHealthReport, status: FinancialStatus, policy?: FinancialPolicy): string {
- const resolved = resolveFinancialPolicy(policy);
- const hardConcentrationPct = Math.min(100, resolved.maxProviderConcentrationPct + 15);
if (status === "healthy") {
return "Financial activity is healthy, with controlled spending and no material budget, concentration, cash-flow, or velocity risks identified.";
}
const drivers: string[] = [];
- if ((report.budget?.usedPct ?? 0) > resolved.criticalBudgetUtilizationPct) {
- drivers.push("critical budget utilization");
- } else if ((report.budget?.usedPct ?? 0) > resolved.cautionBudgetUtilizationPct) {
- drivers.push("elevated budget utilization");
- }
- if ((report.topCounterparties[0]?.shareOfSpendPct ?? 0) > hardConcentrationPct) {
- drivers.push("high provider concentration");
- } else if ((report.topCounterparties[0]?.shareOfSpendPct ?? 0) > resolved.maxProviderConcentrationPct) {
- drivers.push("provider concentration");
- }
- if (report.cashFlow.net < 0) {
- drivers.push("negative operating cash flow");
- }
- if (report.spendVelocity.changePct > 50) {
- drivers.push("accelerating spend");
- }
- if (report.transactions.failed > 0) {
- drivers.push("failed transactions");
+ for (const signal of deriveFinancialRiskSignals(report, policy)) {
+ drivers.push(
+ signal.type === "budget_utilization"
+ ? signal.severity === "critical" ? "critical budget utilization" : "elevated budget utilization"
+ : signal.type === "provider_concentration"
+ ? signal.severity === "critical" ? "high provider concentration" : "provider concentration"
+ : signal.type === "negative_cash_flow"
+ ? "negative operating cash flow"
+ : signal.type === "spend_acceleration"
+ ? "accelerating spend"
+ : "failed transactions",
+ );
}
const driverText = drivers.length > 0 ? drivers.slice(0, 3).join(", ") : "moderate financial-control signals";
@@ -100,62 +73,50 @@ function addCandidate(candidates: Candidate[], order: number, recommendation: Om
export function buildFinancialRecommendations(report: CompanyHealthReport, policy?: FinancialPolicy): FinancialRecommendation[] {
const resolved = resolveFinancialPolicy(policy);
+ const riskSignals = deriveFinancialRiskSignals(report, policy);
const cautionBudgetPct = resolved.cautionBudgetUtilizationPct;
- const criticalBudgetPct = resolved.criticalBudgetUtilizationPct;
const providerLimitPct = resolved.maxProviderConcentrationPct;
- const hardProviderLimitPct = Math.min(100, providerLimitPct + 15);
const reservePct = resolved.reservePct;
const candidates: Candidate[] = [];
let order = 0;
const budget = report.budget;
const largestCounterparty = report.topCounterparties[0];
+ const riskFor = (type: FinancialRiskSignalType) => riskSignals.find((signal) => signal.type === type);
- if (budget && budget.usedPct > criticalBudgetPct) {
+ if (budget && riskFor("budget_utilization")) {
+ const budgetRisk = riskFor("budget_utilization");
+ const isCritical = budgetRisk?.severity === "critical";
addCandidate(
candidates,
order++,
{
type: "reduce_budget_pressure",
- severity: "critical",
- title: "Reduce discretionary spending immediately",
- finding: `Normalized monthly spend is ${display(budget.usedPct)}% of the configured budget.`,
- implication: "The operating plan is close to exhausting its approved spending capacity.",
- recommendation: "Freeze or sharply reduce discretionary spending until budget utilization returns below a safer threshold.",
- action: {
- type: "set_monthly_spend_cap",
- recommendedValue: round(budget.limit * (1 - reservePct / 100)),
- unit: "USD per month",
- },
- target: { metric: "budgetUtilizationPct", current: budget.usedPct, desired: round(100 - reservePct) },
- expectedImpact: "Create budget headroom and restore a more resilient operating reserve.",
- },
- budget.usedPct - criticalBudgetPct,
- );
- } else if (budget && budget.usedPct > cautionBudgetPct) {
- addCandidate(
- candidates,
- order++,
- {
- type: "reduce_budget_pressure",
- severity: "high",
- title: "Protect remaining operating budget",
+ severity: isCritical ? "critical" : "high",
+ title: isCritical ? "Reduce discretionary spending immediately" : "Protect remaining operating budget",
finding: `Normalized monthly spend is ${display(budget.usedPct)}% of the configured budget.`,
- implication: "Only a limited share of the approved monthly budget remains for non-essential work.",
- recommendation: "Prioritize essential services and reduce discretionary spending.",
+ implication: isCritical
+ ? "The operating plan is close to exhausting its approved spending capacity."
+ : "Only a limited share of the approved monthly budget remains for non-essential work.",
+ recommendation: isCritical
+ ? "Freeze or sharply reduce discretionary spending until budget utilization returns below a safer threshold."
+ : "Prioritize essential services and reduce discretionary spending.",
action: {
type: "set_monthly_spend_cap",
- recommendedValue: round(budget.limit * (cautionBudgetPct / 100)),
+ recommendedValue: isCritical ? round(budget.limit * (1 - reservePct / 100)) : round(budget.limit * (cautionBudgetPct / 100)),
unit: "USD per month",
},
- target: { metric: "budgetUtilizationPct", current: budget.usedPct, desired: cautionBudgetPct },
- expectedImpact: "Preserve budget capacity for essential operating activity.",
+ target: { metric: "budgetUtilizationPct", current: budget.usedPct, desired: isCritical ? round(100 - reservePct) : cautionBudgetPct },
+ expectedImpact: isCritical
+ ? "Create budget headroom and restore a more resilient operating reserve."
+ : "Preserve budget capacity for essential operating activity.",
},
- budget.usedPct - cautionBudgetPct,
+ Math.max(0, budget.usedPct - (budgetRisk?.threshold ?? cautionBudgetPct)),
);
}
- if (largestCounterparty && largestCounterparty.shareOfSpendPct > providerLimitPct) {
- const isCritical = largestCounterparty.shareOfSpendPct > hardProviderLimitPct;
+ const concentrationRisk = riskFor("provider_concentration");
+ if (largestCounterparty && concentrationRisk) {
+ const isCritical = concentrationRisk.severity === "critical";
addCandidate(
candidates,
order++,
@@ -185,8 +146,9 @@ export function buildFinancialRecommendations(report: CompanyHealthReport, polic
);
}
- if (report.spendVelocity.changePct > 50 && report.spendVelocity.secondHalf > report.spendVelocity.firstHalf) {
- const isCritical = report.spendVelocity.changePct > 100;
+ const accelerationRisk = riskFor("spend_acceleration");
+ if (accelerationRisk) {
+ const isCritical = accelerationRisk.severity === "critical";
const temporaryCap = report.spendVelocity.firstHalf > 0 ? round(report.spendVelocity.firstHalf * 1.5) : 0;
addCandidate(
candidates,
@@ -210,7 +172,7 @@ export function buildFinancialRecommendations(report: CompanyHealthReport, polic
);
}
- if (report.cashFlow.net < 0) {
+ if (riskFor("negative_cash_flow")) {
const deficit = round(Math.abs(report.cashFlow.net));
addCandidate(
candidates,
@@ -259,7 +221,7 @@ export function buildFinancialRecommendations(report: CompanyHealthReport, polic
);
}
- if (report.transactions.failed > 0) {
+ if (riskFor("failed_transactions")) {
const failedProvider = report.failedCounterparties[0];
const providerText = failedProvider && failedProvider.transactions > 1
? ` Multiple failures share provider ${failedProvider.address}.`
@@ -282,8 +244,8 @@ export function buildFinancialRecommendations(report: CompanyHealthReport, polic
}
const budgetIsLow = budget && budget.usedPct <= cautionBudgetPct;
- const concentrationIsHealthy = !largestCounterparty || largestCounterparty.shareOfSpendPct <= providerLimitPct;
- const velocityIsHealthy = report.spendVelocity.changePct <= 50;
+ const concentrationIsHealthy = !riskFor("provider_concentration");
+ const velocityIsHealthy = !riskFor("spend_acceleration");
if (report.cashFlow.net > 0 && budgetIsLow && concentrationIsHealthy && velocityIsHealthy) {
const reserveTarget = round((budget?.limit ?? 0) * (reservePct / 100));
const availableCapacity = round(Math.max(0, (budget?.limit ?? 0) * (1 - reservePct / 100) - (budget?.normalizedUsed ?? 0)));
diff --git a/lib/risk.ts b/lib/risk.ts
new file mode 100644
index 0000000..2c0d3bf
--- /dev/null
+++ b/lib/risk.ts
@@ -0,0 +1,66 @@
+import type { CompanyHealthReport, FinancialPolicy, FinancialRiskSignal } from "./types";
+import { resolveFinancialPolicy } from "./policy";
+
+/**
+ * Derives material financial-control signals shared by status and
+ * recommendations. Informational and positive observations are intentionally
+ * excluded because they should not downgrade a healthy financial status.
+ */
+export function deriveFinancialRiskSignals(
+ report: CompanyHealthReport,
+ policy?: FinancialPolicy,
+): FinancialRiskSignal[] {
+ const resolved = resolveFinancialPolicy(policy);
+ const signals: FinancialRiskSignal[] = [];
+ const budgetPct = report.budget?.usedPct;
+ if (budgetPct !== undefined && budgetPct > resolved.cautionBudgetUtilizationPct) {
+ signals.push({
+ type: "budget_utilization",
+ severity: budgetPct > resolved.criticalBudgetUtilizationPct ? "critical" : "high",
+ metric: budgetPct,
+ threshold: budgetPct > resolved.criticalBudgetUtilizationPct
+ ? resolved.criticalBudgetUtilizationPct
+ : resolved.cautionBudgetUtilizationPct,
+ });
+ }
+
+ const concentrationPct = report.topCounterparties[0]?.shareOfSpendPct ?? 0;
+ if (concentrationPct > resolved.maxProviderConcentrationPct) {
+ const hardLimit = Math.min(100, resolved.maxProviderConcentrationPct + 15);
+ signals.push({
+ type: "provider_concentration",
+ severity: concentrationPct > hardLimit ? "critical" : "high",
+ metric: concentrationPct,
+ threshold: concentrationPct > hardLimit ? hardLimit : resolved.maxProviderConcentrationPct,
+ });
+ }
+
+ if (report.spendVelocity.secondHalf > report.spendVelocity.firstHalf && report.spendVelocity.changePct > 50) {
+ signals.push({
+ type: "spend_acceleration",
+ severity: report.spendVelocity.changePct > 100 ? "critical" : "high",
+ metric: report.spendVelocity.changePct,
+ threshold: report.spendVelocity.changePct > 100 ? 100 : 50,
+ });
+ }
+
+ if (report.cashFlow.net < 0) {
+ signals.push({
+ type: "negative_cash_flow",
+ severity: "high",
+ metric: report.cashFlow.net,
+ threshold: 0,
+ });
+ }
+
+ if (report.transactions.failed > 0) {
+ signals.push({
+ type: "failed_transactions",
+ severity: "medium",
+ metric: report.transactions.failed,
+ threshold: 0,
+ });
+ }
+
+ return signals;
+}
diff --git a/lib/types.ts b/lib/types.ts
index 740e245..1f938ab 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -1,5 +1,5 @@
export const APP_NAME = "AgentLedger" as const;
-export const APP_VERSION = "0.3.2" as const;
+export const APP_VERSION = "0.3.3" as const;
export const SUPPORTED_ASSETS = ["USDT", "USDC", "USDG"] as const;
@@ -66,22 +66,43 @@ export type ProvidedFinancialState = {
cashFlow: {
inflows: number;
outflows: number;
+ /** Successful incoming transactions in the selected period. */
incomingTransactions?: number;
+ /** Successful outgoing provider transactions in the selected period. */
outgoingTransactions?: number;
};
spendVelocity?: {
+ /** Spend observed in the first half of the selected period. */
firstHalf: number;
+ /** Spend observed in the second half of the selected period. */
secondHalf: number;
};
providers?: Array<{
provider: string;
+ /** Successful outgoing payments to this provider. */
spend: number;
transactions: number;
+ /** Failed outgoing attempts; separate from successful transactions. */
failedTransactions?: number;
}>;
+ /** Total failed transactions, including failures outside the provider subset. */
failedTransactions?: number;
};
+export type FinancialRiskSignalType =
+ | "budget_utilization"
+ | "provider_concentration"
+ | "spend_acceleration"
+ | "negative_cash_flow"
+ | "failed_transactions";
+
+export type FinancialRiskSignal = {
+ type: FinancialRiskSignalType;
+ severity: "critical" | "high" | "medium";
+ metric: number;
+ threshold: number;
+};
+
export type CompanyHealthRequest = {
source?: "provided_state" | "okx";
address?: string;
diff --git a/lib/validation.ts b/lib/validation.ts
index 0756516..bb3c142 100644
--- a/lib/validation.ts
+++ b/lib/validation.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
+import { FINANCIAL_STATE_TOLERANCE, MAX_AGGREGATE_TRANSACTION_COUNT } from "./financial-context";
import { financialPolicySchema, MAX_FINANCIAL_VALUE } from "./policy";
const daysSchema = z.number().int("days must be an integer").min(1, "days must be at least 1").max(90, "days must be at most 90");
@@ -35,22 +36,59 @@ const providedFinancialStateSchema = z
transactions: transactionCountSchema("financialState.providers.transactions"),
failedTransactions: transactionCountSchema("financialState.providers.failedTransactions").optional(),
})
- .strict()
- .superRefine((provider, context) => {
- if ((provider.failedTransactions ?? 0) > provider.transactions) {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["failedTransactions"],
- message: "financialState.providers.failedTransactions must not exceed transactions",
- });
- }
- }),
+ .strict(),
)
.max(100, "financialState.providers must contain at most 100 entries")
.optional(),
failedTransactions: transactionCountSchema("financialState.failedTransactions").optional(),
})
- .strict();
+ .strict()
+ .superRefine((state, context) => {
+ const providers = state.providers ?? [];
+ const providerSpendTotal = providers.reduce((sum, provider) => sum + provider.spend, 0);
+ if (providerSpendTotal > state.cashFlow.outflows + FINANCIAL_STATE_TOLERANCE) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["providers"],
+ message: "financialState.providers.spend total must not exceed cashFlow.outflows",
+ });
+ }
+
+ const providerSuccessfulTransactionTotal = providers.reduce((sum, provider) => sum + provider.transactions, 0);
+ if (providerSuccessfulTransactionTotal > MAX_AGGREGATE_TRANSACTION_COUNT) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["providers"],
+ message: "financialState provider transaction totals are unreasonably large",
+ });
+ }
+ if (
+ state.cashFlow.outgoingTransactions !== undefined &&
+ providerSuccessfulTransactionTotal > state.cashFlow.outgoingTransactions
+ ) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["cashFlow", "outgoingTransactions"],
+ message: "financialState.providers.transactions total must not exceed cashFlow.outgoingTransactions",
+ });
+ }
+
+ const providerFailedTotal = providers.reduce((sum, provider) => sum + (provider.failedTransactions ?? 0), 0);
+ if (providerFailedTotal > MAX_AGGREGATE_TRANSACTION_COUNT) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["providers"],
+ message: "financialState provider failed transaction totals are unreasonably large",
+ });
+ }
+ if (state.failedTransactions !== undefined && state.failedTransactions < providerFailedTotal) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["failedTransactions"],
+ message: "financialState.failedTransactions must include all provider failedTransactions",
+ });
+ }
+ });
export const companyHealthSchema = z
.object({
diff --git a/package-lock.json b/package-lock.json
index 83a448a..d7e7c75 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "agentledger",
- "version": "0.3.2",
+ "version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agentledger",
- "version": "0.3.2",
+ "version": "0.3.3",
"dependencies": {
"@okxweb3/x402-core": "^0.1.0",
"@okxweb3/x402-evm": "^0.2.1",
diff --git a/package.json b/package.json
index 1451f5f..2a0db55 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,11 @@
{
"name": "agentledger",
- "version": "0.3.2",
+ "version": "0.3.3",
"private": true,
"description": "The financial control plane for autonomous agents.",
"scripts": {
- "dev": "WATCHPACK_POLLING=true next dev",
+ "dev": "next dev",
+ "dev:poll": "WATCHPACK_POLLING=true next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
diff --git a/tests/hardening.test.ts b/tests/hardening.test.ts
new file mode 100644
index 0000000..82211e7
--- /dev/null
+++ b/tests/hardening.test.ts
@@ -0,0 +1,242 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { POST as companyHealthPost } from "../app/api/company-health/route";
+import { analyzeProvidedFinancialState, buildSpendDecision } from "../lib/analysis";
+import { buildAllocationDecision } from "../lib/allocation";
+import { normalizeFinancialState } from "../lib/financial-context";
+import { buildFinancialRecommendations, getFinancialStatus } from "../lib/recommendations";
+import { deriveFinancialRiskSignals } from "../lib/risk";
+import { evaluateCapitalAllocationPolicy, evaluateServicePurchasePolicy } from "../lib/policy";
+import { companyHealthSchema } from "../lib/validation";
+
+const NOW = Date.parse("2026-08-30T00:00:00.000Z");
+
+function state(overrides: Record = {}) {
+ return {
+ periodDays: 30,
+ cashFlow: { inflows: 200, outflows: 100 },
+ ...overrides,
+ } as Parameters[0];
+}
+
+function report(overrides: Record = {}, monthlyBudget?: number) {
+ return analyzeProvidedFinancialState({
+ wallet: "provided-state",
+ financialState: state(overrides),
+ monthlyBudget,
+ now: NOW,
+ });
+}
+
+function request(body: unknown): Request {
+ return new Request("http://localhost/api/company-health", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+test("provider spend above outflows is rejected by request validation", async () => {
+ const body = state({ providers: [{ provider: "provider-a", spend: 100.02, transactions: 1 }] });
+ assert.equal(companyHealthSchema.safeParse(body).success, false);
+ const response = await companyHealthPost(request({ source: "provided_state", financialState: body }));
+ assert.equal(response.status, 400);
+ assert.match((await response.json()).error.message, /must not exceed cashFlow\.outflows/);
+});
+
+test("partial provider spend below outflows is accepted", () => {
+ const normalized = normalizeFinancialState(state({
+ providers: [{ provider: "provider-a", spend: 20, transactions: 2 }],
+ }));
+ assert.equal(normalized.outflows, 100);
+ assert.equal(normalized.providers[0]?.spend, 20);
+});
+
+test("duplicate providers aggregate case-insensitively including failed attempts", () => {
+ const normalized = normalizeFinancialState(state({
+ providers: [
+ { provider: "Provider-A", spend: 20, transactions: 2, failedTransactions: 3 },
+ { provider: " provider-a ", spend: 10, transactions: 1, failedTransactions: 2 },
+ ],
+ }));
+ assert.deepEqual(normalized.providers, [{ provider: "Provider-A", spend: 30, transactions: 3, failedTransactions: 5 }]);
+ assert.deepEqual(normalized.failedProviders, [{ provider: "Provider-A", transactions: 5 }]);
+ assert.equal(normalized.failedTransactions, 5);
+});
+
+test("failed provider attempts are independent from successful transactions", () => {
+ const normalized = normalizeFinancialState(state({
+ providers: [{ provider: "provider-a", spend: 20, transactions: 1, failedTransactions: 4 }],
+ }));
+ assert.equal(normalized.providers[0]?.transactions, 1);
+ assert.equal(normalized.providers[0]?.failedTransactions, 4);
+});
+
+test("explicit outgoing transaction total must cover known successful provider transactions", () => {
+ const result = companyHealthSchema.safeParse(state({
+ cashFlow: { inflows: 200, outflows: 100, outgoingTransactions: 2 },
+ providers: [{ provider: "provider-a", spend: 20, transactions: 3 }],
+ }));
+ assert.equal(result.success, false);
+});
+
+test("known provider failures must be included in an explicit top-level failure count", () => {
+ const result = companyHealthSchema.safeParse(state({
+ providers: [{ provider: "provider-a", spend: 20, transactions: 1, failedTransactions: 2 }],
+ failedTransactions: 1,
+ }));
+ assert.equal(result.success, false);
+});
+
+test("provider failures are derived when top-level failures are omitted", () => {
+ const normalized = normalizeFinancialState(state({
+ providers: [{ provider: "provider-a", spend: 20, transactions: 1, failedTransactions: 2 }],
+ }));
+ assert.equal(normalized.failedTransactions, 2);
+});
+
+test("financial risk signals expose critical budget risk consistently", () => {
+ const current = report({}, 100);
+ const signals = deriveFinancialRiskSignals(current);
+ assert.equal(signals.find((signal) => signal.type === "budget_utilization")?.severity, "critical");
+ assert.equal(getFinancialStatus(current), "critical");
+ assert.equal(buildFinancialRecommendations(current)[0]?.severity, "critical");
+});
+
+test("critical provider concentration is shared by status and recommendations", () => {
+ const current = report({
+ cashFlow: { inflows: 200, outflows: 100 },
+ providers: [
+ { provider: "provider-a", spend: 60, transactions: 1 },
+ { provider: "provider-b", spend: 40, transactions: 1 },
+ ],
+ });
+ assert.equal(getFinancialStatus(current), "critical");
+ assert.equal(buildFinancialRecommendations(current)[0]?.severity, "critical");
+});
+
+test("extreme spend acceleration produces a critical status and recommendation", () => {
+ const current = report({
+ cashFlow: { inflows: 400, outflows: 235 },
+ spendVelocity: { firstHalf: 65, secondHalf: 170 },
+ });
+ assert.equal(getFinancialStatus(current), "critical");
+ assert.equal(buildFinancialRecommendations(current).find((item) => item.type === "control_spend_acceleration")?.severity, "critical");
+});
+
+test("moderate spend acceleration produces caution rather than critical status", () => {
+ const current = report({ spendVelocity: { firstHalf: 50, secondHalf: 90 } });
+ assert.equal(getFinancialStatus(current), "caution");
+ assert.equal(buildFinancialRecommendations(current).find((item) => item.type === "control_spend_acceleration")?.severity, "high");
+});
+
+test("negative cash flow is a caution signal unless another risk is critical", () => {
+ const current = report({ cashFlow: { inflows: 20, outflows: 100 } });
+ assert.equal(getFinancialStatus(current), "caution");
+ assert.equal(deriveFinancialRiskSignals(current).find((signal) => signal.type === "negative_cash_flow")?.severity, "high");
+});
+
+test("failed transactions produce a caution signal", () => {
+ const current = report({ failedTransactions: 1 });
+ assert.equal(getFinancialStatus(current), "caution");
+ assert.equal(deriveFinancialRiskSignals(current).find((signal) => signal.type === "failed_transactions")?.severity, "medium");
+});
+
+test("positive controlled activity remains healthy", () => {
+ const current = report({ cashFlow: { inflows: 100, outflows: 20 } }, 100);
+ assert.equal(getFinancialStatus(current), "healthy");
+ assert.equal(buildFinancialRecommendations(current)[0]?.type, "use_available_financial_capacity");
+});
+
+test("recommendations remain capped at three and ordered deterministically", () => {
+ const current = report({
+ cashFlow: { inflows: 20, outflows: 100 },
+ spendVelocity: { firstHalf: 10, secondHalf: 90 },
+ providers: [
+ { provider: "provider-a", spend: 60, transactions: 3 },
+ { provider: "provider-b", spend: 40, transactions: 1 },
+ ],
+ failedTransactions: 1,
+ }, 100);
+ const first = buildFinancialRecommendations(current).map((item) => `${item.priority}:${item.type}:${item.severity}`);
+ const second = buildFinancialRecommendations(current).map((item) => `${item.priority}:${item.type}:${item.severity}`);
+ assert.deepEqual(first, second);
+ assert.ok(first.length <= 3);
+});
+
+test("zero total capital produces finite deterministic allocation output", () => {
+ const result = evaluateCapitalAllocationPolicy({
+ action: { type: "capital_allocation", amount: 0, strategy: "zero" },
+ currentStrategyAllocation: 0,
+ totalCapital: 0,
+ dailyLossUsed: 0,
+ }, { maxStrategyAllocationPct: 35 });
+ assert.equal(result.decision, "approve");
+ assert.equal(Number.isFinite(result.strategyAllocationPct), true);
+ assert.equal(Number.isFinite(result.strategyLimitUtilizationPct), true);
+});
+
+test("zero budget service purchase remains finite and deterministic", () => {
+ const current = report({ cashFlow: { inflows: 0, outflows: 0 } });
+ const result = buildSpendDecision({ report: current, proposedSpend: 0, monthlyBudget: 0 });
+ assert.equal(result.decision, "approve");
+ assert.equal(result.projectedBudgetUsedPct, 0);
+ assert.equal(Number.isFinite(result.budgetRemainingAfterSpend), true);
+});
+
+test("budget caution and critical equality boundaries remain non-crossing", () => {
+ const caution = evaluateServicePurchasePolicy({
+ action: { type: "service_purchase", amount: 0, provider: "provider-a" },
+ currentSpend: 75,
+ projectedSpend: 75,
+ currentNormalizedSpend: 75,
+ projectedNormalizedSpend: 75,
+ currentProviderConcentrationPct: 0,
+ projectedProviderConcentrationPct: 0,
+ }, { monthlySpendLimit: 100, reservePct: 0, cautionBudgetUtilizationPct: 75, criticalBudgetUtilizationPct: 90 });
+ assert.equal(caution.decision, "approve");
+
+ const critical = evaluateServicePurchasePolicy({
+ action: { type: "service_purchase", amount: 0, provider: "provider-a" },
+ currentSpend: 90,
+ projectedSpend: 90,
+ currentNormalizedSpend: 90,
+ projectedNormalizedSpend: 90,
+ currentProviderConcentrationPct: 0,
+ projectedProviderConcentrationPct: 0,
+ }, { monthlySpendLimit: 100, reservePct: 0, cautionBudgetUtilizationPct: 75, criticalBudgetUtilizationPct: 90 });
+ assert.equal(critical.decision, "caution");
+});
+
+test("loss caution and critical equality boundaries remain deterministic", () => {
+ const caution = evaluateCapitalAllocationPolicy({
+ action: { type: "capital_allocation", amount: 0, strategy: "loss" },
+ currentStrategyAllocation: 0,
+ totalCapital: 1000,
+ dailyLossUsed: 70,
+ }, { dailyLossLimit: 100, cautionLossUtilizationPct: 70, criticalLossUtilizationPct: 90 });
+ assert.equal(caution.decision, "approve");
+
+ const critical = evaluateCapitalAllocationPolicy({
+ action: { type: "capital_allocation", amount: 0, strategy: "loss" },
+ currentStrategyAllocation: 0,
+ totalCapital: 1000,
+ dailyLossUsed: 90,
+ }, { dailyLossLimit: 100, cautionLossUtilizationPct: 70, criticalLossUtilizationPct: 90 });
+ assert.equal(critical.decision, "reject");
+});
+
+test("allocation recommended maximum never becomes negative when current state is over limit", () => {
+ const result = buildAllocationDecision({
+ agent: "agent",
+ strategy: "strategy",
+ proposedCapital: 0,
+ currentStrategyAllocation: 100,
+ totalCapital: 50,
+ strategyCapitalLimit: 75,
+ dailyLossUsed: 0,
+ dailyLossLimit: 100,
+ });
+ assert.equal(result.recommendedAction.recommendedMaximum, 0);
+});
diff --git a/tests/upgrade.test.ts b/tests/upgrade.test.ts
index 7fed224..6b733d1 100644
--- a/tests/upgrade.test.ts
+++ b/tests/upgrade.test.ts
@@ -421,7 +421,7 @@ test("recommendations report contains a concise summary and at most three action
], 100),
new Date(NOW).toISOString(),
);
- assert.equal(result.version, "0.3.2");
+ assert.equal(result.version, "0.3.3");
assert.ok(result.executiveSummary.length > 0);
assert.ok(result.recommendedActions.length <= 3);
});