Budget and Usage Reporting
Budget Management
Section titled “Budget Management”No spend cap ships in any language. There is no Budget type, no tracker and no
enforcement in sdk/go, sdk/ts or fora_sdk; grepping for “budget” in the Python
client finds content_timeout_sec, which is a per-call TIME budget carried across the
legs of one fetch, not money. Everything in this half of the page is the design for the
deferred layer. The reporting half below it is different — ReportUsage ships, and
Manual Reporting shows its real shape.
The design: three budget layers, checked in order before any network call is made, so a budget-exceeded condition never reaches the wire.
Budget Enforcement Layers
Section titled “Budget Enforcement Layers”| Layer | Scope | Storage | Lifecycle |
|---|---|---|---|
| Per-request | Single Fetch call | Comparison only (no state) | Instant |
| Per-session | Client instance lifetime | In-memory counter | Resets on NewClient |
| Per-period | Calendar period (e.g. 30 days) | Local file or Redis | Survives restarts |
Configuration
Section titled “Configuration”type Budget struct { // Maximum cost per individual request. Transactions above this // are rejected before querying Exchanges. MaxPerRequest float64
// Maximum cumulative spend per session (in-memory, resets on restart). MaxPerSession float64
// Maximum cumulative spend per period (persisted). MaxPerPeriod float64
// Period duration for MaxPerPeriod (e.g. 720h = 30 days). Period time.Duration
// Budget scope identifier for per-period tracking. // E.g. "user:u-12345" for per-user, "team:eng" for per-team. // Required when MaxPerPeriod is set. Scope string
// ISO 4217 currency code. Default: "USD". Currency string}Per-Request Enforcement
Section titled “Per-Request Enforcement”The simplest layer. Before querying any Exchange, the SDK checks whether the MaxPerRequest budget can accommodate the request. After offer selection, it checks the actual offer price:
// Pre-flight: is this request even possible?if !budget.CanSpend(config.Budget.MaxPerRequest) { return BudgetExceededError{Layer: "per_request", ...}}
// Post-selection: does the specific offer fit?if offer.Pricing.Rate > config.Budget.MaxPerRequest { return BudgetExceededError{Layer: "per_request", ...}}Per-Session Enforcement
Section titled “Per-Session Enforcement”Tracks cumulative spend across the lifetime of a Client instance. Resets when the client is created (no persistence).
Thread safety: the session counter uses atomic.AddInt64.
// Before transactionif sessionSpent + offer.Rate > config.Budget.MaxPerSession { return BudgetExceededError{Layer: "per_session", ...}}
// After successful transactionatomic.AddInt64(&sessionSpent, int64(txn.Cost.Amount * 10000)) // fixed-pointPer-Period Enforcement
Section titled “Per-Period Enforcement”Tracks cumulative spend across a calendar period (e.g., 30 days). Persisted to survive restarts.
Default persistence: local JSON file at ~/.fora/budget/<scope>.json:
{ "scope": "user:u-12345", "period_start": "2026-03-01T00:00:00Z", "period_duration": "720h", "currency": "USD", "spent": 4.27, "limit": 50.00}Multi-process agents: configure a shared Redis instance via Config.Budget.RedisURL for shared budget state across processes.
Thread safety: the period tracker uses a mutex for read-check-write.
Budget Exceeded Error
Section titled “Budget Exceeded Error”When any budget layer is exceeded, the SDK returns a typed error before making any network call:
type BudgetExceededError struct { Layer string // "per_request", "per_session", "per_period" Limit float64 Current float64 Requested float64 Currency string}Budget as Security Boundary
Section titled “Budget as Security Boundary”- Budget limits are enforced client-side. They protect the agent operator from runaway spend.
- The Exchange independently enforces its own credit/balance limits server-side.
- Both layers must agree for a transaction to proceed.
- The SDK checks budget BEFORE making any network call to the Exchange.
Budget Tracker Tests
Section titled “Budget Tracker Tests”Written against the planned budget package, which does not exist. It is here as the
design’s own account of what the tracker owes, not as a test anyone can run.
func TestBudgetTracker_PerSession(t *testing.T) { bt := budget.NewTracker(budget.Config{ MaxPerRequest: 0.10, MaxPerSession: 0.50, Currency: "USD", })
// First four requests: ok for i := 0; i < 4; i++ { require.NoError(t, bt.Check(0.10)) bt.Record(budget.Cost{Amount: 0.10, Currency: "USD"}) }
// Fifth request: would exceed session limit err := bt.Check(0.10) require.ErrorAs(t, err, &fora.BudgetExceededError{}) assert.Equal(t, "per_session", err.(*fora.BudgetExceededError).Layer)}Usage Reporting
Section titled “Usage Reporting”The SDK automatically submits usage reports after each successful Fetch. This is the default behavior (Config.Reporting.AutoReport = true).
How Auto-Reporting Works
Section titled “How Auto-Reporting Works”- After a successful content fetch, the SDK enqueues a
UsageReportin an in-memory bounded channel. - A background goroutine drains the channel and submits reports to the appropriate Exchange via
ReportUsageRPC. - Reports approaching their deadline are prioritized.
- Failed submissions are re-enqueued with exponential backoff.
- Reporting never blocks the
Fetchcall — it is entirely non-blocking.
Reporting Configuration
Section titled “Reporting Configuration”type ReportingConfig struct { // Whether to auto-submit usage reports after each Fetch. Default: true. AutoReport bool
// Maximum number of pending reports before blocking. Default: 1000. MaxPendingReports int
// Retry interval for failed report submissions. Default: 30s. RetryInterval time.Duration}Report Queue Internals
Section titled “Report Queue Internals”type pendingReport struct { Report *forav1.UsageReport Deadline time.Time // obligation window end Retries int NextTry time.Time}- Capacity: bounded channel, default 1000 pending reports
- Overflow: if the channel is full, the oldest report is dropped and a warning is logged. This is a soft failure — the Exchange may eventually block the agent for overdue reports (
DENIAL_REASON_REPORTING_OVERDUE), but the current fetch is not affected. - Priority: reports approaching their deadline are submitted first
- Retry: exponential backoff — 30s, 60s, 120s, max 10 minutes
report_id and the Dispute Chain (v1.0)
Section titled “report_id and the Dispute Chain (v1.0)”UsageReportResponse now returns a report_id — an Exchange-assigned identifier for the accepted report. The SDK stores this on FetchResult.ReportID.
The report_id is required for filing disputes. The dispute chain enforces reporting-before-disputing:
UsageReport -> UsageReportResponse{report_id} -> DisputeRequest{report_id}If the agent needs to dispute a transaction, it must have a valid report_id. The SDK tracks this automatically when AutoReport is enabled.
Attribution Details (v1.0)
Section titled “Attribution Details (v1.0)”The Usage message now includes structured attribution reporting via CitationFormat and AttributionDetail:
type Attribution struct { Format CitationFormat // e.g., CITATION_FORMAT_INLINE, CITATION_FORMAT_FOOTNOTE Details []AttributionDetail // per-asset attribution specifics}The SDK populates attribution fields in the UsageReport when the caller provides them via FetchResult or ReportUsage.
Manual Reporting
Section titled “Manual Reporting”Auto-reporting belongs to the deferred layer. ReportUsage itself ships, in all
three languages, and this is its real shape:
// 4. Report what was used. ConsumedQuantity is the billed quantity, so a report// without it bills nothing. Content.Body holds the fetched bytes, and function// tokens come from the generated vocabulary rather than a string literal.resp, err := client.ReportUsage(ctx, &forav1.UsageReport{ Exchange: "exchange.example", // the Exchange that issued the offer TransactionId: item.GetTransactionId(), BillingId: item.GetBillingId(), Usage: &forav1.Usage{ ConsumedQuantity: int32(len(content.Body)), Function: []string{functiontokens.AiInput}, },})if err != nil { return err}// resp.GetReportId() is what a later dispute references.The report is a forav1.UsageReport — the generated wire type — rather than an
SDK-specific struct, and the verb returns a *forav1.UsageReportResponse carrying
report_id. The client routes the report to the Exchange the offer named, resolved
from that Exchange’s own manifest, never to whatever base URL happened to be
configured.
Reporting Obligations
Section titled “Reporting Obligations”Each transaction may carry a ReportingObligation that specifies:
- Whether reporting is required or optional
- The reporting window (deadline by which the report must be submitted)
- What fields are required in the report
Tracking those deadlines belongs to the deferred layer. The obligation itself
ships: it arrives on the transaction item as reporting_obligation, an ordinary
generated wire field you can read. The shipped client starts no timer over it and
keeps no queue, so meeting the window is the application’s job. If an Exchange
denies a later transaction with DENIAL_REASON_REPORTING_OVERDUE, that reason
arrives on the denial and the application decides what to re-send.
Shutdown Behavior
Section titled “Shutdown Behavior”A Close(ctx) that blocks while a background queue drains belongs to the deferred
layer, along with the queue itself. The shipped client holds no report queue: every
ReportUsage call is one RPC that has either been accepted or returned an error by
the time it returns, so there is nothing pending at shutdown and nothing to lose.
The Python client is a context manager (with Client(config) as client:) and closes
its HTTP transport on exit. The Go client borrows the *http.Client you give it
through connect.WithHTTPClient, so its lifetime stays yours.
Observability
Section titled “Observability”Structured Logging
Section titled “Structured Logging”The SDK emits structured log events for every significant operation:
The log events below belong to the deferred layer. The shipped client takes no logger: it emits no log lines of its own, on the view that a library writing to a process’s log stream is the application’s decision rather than the library’s.
Observing the shipped client is done with a Connect interceptor, which sees every RPC on its way out and back:
client := connect.NewClient(baseURL, connect.WithInterceptors(loggingInterceptor), // ... signer, requester, keys, endpoint resolver)What a failed call gives you to log is on
Fetch Flow: CallError.Kind for the
class, ReasonOf() for the peer’s own token, and connect.ErrorDetailFrom for the
typed reason when the peer sent one.
Key Log Events
Section titled “Key Log Events”| Event | Level | Fields |
|---|---|---|
fora.fetch.start | Info | url, domain |
fora.discovery.cache_hit | Debug | domain, exchange_count |
fora.discovery.cache_miss | Info | domain |
fora.discovery.fora_json | Info | domain, exchange_count, latency_ms |
fora.supply.query | Info | exchange, uri_count, latency_ms |
fora.selection.winner | Info | exchange, offer_id, unit_cost, subscription_id |
fora.budget.check | Debug | layer, limit, current, requested |
fora.budget.exceeded | Warn | layer, limit, current, requested |
fora.transaction.execute | Info | exchange, offer_id, latency_ms |
fora.transaction.denied | Warn | exchange, reason |
fora.content.fetch | Info | url, status_code, latency_ms, content_length |
fora.report.enqueue | Debug | transaction_id, deadline |
fora.report.submit | Info | transaction_id, accepted |
fora.report.failed | Warn | transaction_id, error, retry_count |
Prometheus Metrics
Section titled “Prometheus Metrics”The SDK exposes Prometheus-compatible metrics via an optional metrics.Handler:
| Metric | Type | Description |
|---|---|---|
fora_fetch_total | Counter | Fetches by status (success, budget_exceeded, no_exchange, denied, error) |
fora_fetch_duration_seconds | Histogram | End-to-end Fetch latency |
fora_supply_query_duration_seconds | Histogram | Per-Exchange query latency |
fora_transaction_duration_seconds | Histogram | Per-Exchange transaction latency |
fora_budget_spent_total | Counter | Cumulative spend by currency |
fora_budget_remaining | Gauge | Remaining budget by scope |
fora_reports_pending | Gauge | Pending usage reports |
fora_reports_overdue | Gauge | Reports past their deadline |
Security
Section titled “Security”Signing Key Protection
Section titled “Signing Key Protection”- The
SigningKey(Ed25519 private key) is stored in theConfigstruct and never serialized, logged, or included in error messages. - Ed25519 signatures are computed per-request. The private key produces an RFC 9421 HTTP Message Signature in the HTTP headers of each
DiscoverResourcesandExecuteTransactionrequest. - The Exchange verifies signatures using the agent’s registered public key (looked up by the agent’s
domain+ signaturekeyidfrom its WellKnownManifest, not by any billing handle). The private key never leaves the agent.
Transport Security
Section titled “Transport Security”- All Exchange RPCs use HTTPS (TLS 1.2+)
- Content fetch (signed URL) uses HTTPS
- fora.json discovery uses HTTPS
- No plaintext HTTP, even for localhost development (use
http://localhostonly with explicit opt-in)
Test Categories
Section titled “Test Categories”| Category | What It Tests | Tooling |
|---|---|---|
| Unit: selection | unit_cost ranking, subscription preference, dedup | Table-driven tests, no I/O |
| Unit: budget | Per-request/session/period enforcement, edge cases | In-memory tracker |
| Unit: reporting | Queue overflow, deadline priority, retry backoff | Fake clock |
| Integration: mock | Full Fetch flow against MockExchange | testutil.NewMockExchange |
| Integration: reference | Full Fetch flow against reference Exchange | Real Connect server |
| E2E | SDK to reference Exchange to reference CDN to report | Docker compose |