Skip to content
Docs Operations

Operations

How to run, deploy, monitor, and shut down the Agent Passport service. Source-of-truth for: environment variables, deployment, observability (metrics, SLOs, alerts, runbooks), rate limiting, idempotency, system-exposure cap, graceful shutdown, and load testing.

1. Environment variables

The canonical env-var table. Every variable the service reads is listed here, sourced from .env.example and src/config.ts. If you add a new env var, update this page, .env.example, and src/config.ts together.

Copy .env.example to .env and edit. The service calls dotenv.config() on startup so .env is loaded automatically.

Service

VariableTypeDefaultDescription
PORTint3000HTTP listen port
NODE_ENVenumdevelopmentSet to production to disable metrics collectors in tests
LOG_LEVELenuminfodebug \info \warn \error
LOG_FILEpathJSON log file (optional)
LOG_ERROR_FILEpathError-only log file (optional)
CORS_ALLOWED_ORIGINSstring*Comma-separated origins, or *
REQUEST_TIMEOUT_MSint10000Per-request Algorand/x402 timeout

Algorand

VariableTypeDefaultDescription
ALGOD_URLURLhttps://testnet-api.algonode.cloud:443algod v2 endpoint
ALGOD_TOKENstring""API token (AlgoNode free tier does not require one)
INDEXER_URLURLhttps://testnet-idx.algonode.cloud:443Indexer v2 endpoint
INDEXER_TOKENstring""API token
ALGO_NETWORKstringtestnetDisplay only

For mainnet, point at AlgoNode mainnet, a hosted provider (Nodely, BCC), or your own node. See § 4 for latency trade-offs.

Smart contracts

VariableTypeDefaultDescription
REGISTRY_APP_IDint0App ID of registry.teal. Set to 0 to disable /delegate and /revoke
REPUTATION_APP_IDint0App ID of reputation.teal. Set to 0 to disable /reputation/record on-chain writes
OPERATOR_MNEMONICstring25-word Algorand mnemonic for the runtime operator wallet
DEPLOYER_MNEMONICstring25-word mnemonic used only by the deploy scripts

See security.md for the operator mnemonic handling and KMS guidance.

x402

VariableTypeDefaultDescription
X402_ENABLEDboolfalseWhen true, every premium endpoint requires an x-payment header
X402_FACILITATOR_URLURLhttps://x402.org/facilitatorx402 facilitator endpoint
X402_PAYMENT_RECIPIENTstringAlgorand address that receives USDC payments (required when x402 is enabled)
X402_NETWORKstringalgorand-testnetSupported x402 network identifier

Rate limiting

VariableTypeDefaultDescription
RATE_LIMIT_MAXint600Per-IP requests per minute
RATE_LIMIT_TRUSTED_IPSstringComma-separated IPs exempt from the limit
RATE_LIMIT_PERSISTENCE_PATHpathdata/rate-limit.jsonWhere to persist state across restarts
RATE_LIMIT_OVERRIDESJSONPer-endpoint override, e.g. '{"POST /delegate":{"max":5}}'

Persistence

VariableTypeDefaultDescription
EXPOSURE_PERSISTENCE_PATHpathdata/system-exposure.jsonWhere to persist cumulative system exposure
WEBHOOKS_PERSISTENCE_PATHpathdata/webhooks.jsonWhere to persist webhook subscribers

Idempotency

The idempotency store is configured entirely in code (no env vars):

SettingValueSource
Idempotency-Key length8–255 chars, [A-Za-z0-9_\-:]+src/lib/idempotency.ts:5-7
Default TTL24 hourssrc/lib/idempotency.ts:8
Sweeper interval5 minutessrc/lib/idempotency.ts:9
Max store size10 000src/lib/idempotency.ts:10

Auth (HMAC)

VariableTypeDefaultDescription
HMAC_SECRETstringIf set (≥ 32 chars), state-changing endpoints require HMAC-SHA256 auth
HMAC_TIMESTAMP_SKEW_MSint60000Max clock skew between client and server

Sanctions

VariableTypeDefaultDescription
SANCTIONS_EXTRA_DENYstringComma-separated wallets to add to the default deny list

System exposure cap (hard-coded)

ConstantValueSource
MAX_SYSTEM_EXPOSURE100_000 USDCsrc/lib/system-exposure.ts:23
MAX_WALLET_SHARE10_000 USDC (10% of total)src/lib/system-exposure.ts:24

Constants (hard-coded, no env var)

ConstantValuePurpose
WALLET_REGEX/^[A-Z2-7]{58}$/Algorand address validator
MICRO_ALGO1_000_000Algo → microAlgo
SECONDS_PER_BLOCK3.3Average Algorand block time
TESTNET_GENESIS_ROUND64_600_000Testnet genesis round
MAX_ROUNDS_LOOKBACK1_000_000Cap for indexer queries

x402 pricing (X402_PRICING in src/lib/constants.ts)

EndpointPrice (USDC)
/score0.001
/delegation0.001
/counterparty-check0.002
/credit-estimate0.002
/sybil-check0.003
/reputation0.001
/reputation/record0.005
/underwrite0.01
/trust-graph0.005
/passport0.005

2. Rate limiting

Per-IP fixed-window rate limiter at src/lib/security.ts. Default 600 req/min/IP.

Configuration

Env varDefaultPurpose
RATE_LIMIT_MAX600Override the per-IP limit
RATE_LIMIT_TRUSTED_IPSComma-separated IPs exempt from the limit
RATE_LIMIT_PERSISTENCE_PATHdata/rate-limit.jsonWhere to persist state across restarts
RATE_LIMIT_OVERRIDESPer-endpoint override as JSON

Bypass lists

The middleware short-circuits to next() in three cases:

/metrics, /registry/status are never rate-limited.

use; never in production).

limit. Use for internal services, the operator host, or your monitoring agent.

  1. Operational endpoints. /health, /ready, /health/deep,
  2. LOAD_TEST_MODE=1. All rate limiting is disabled (k6 suite
  3. Trusted IPs. Any IP in RATE_LIMIT_TRUSTED_IPS bypasses the

Response headers

HeaderValue
X-RateLimit-LimitThe configured max (e.g. 600)
X-RateLimit-Remainingmax - count (clamped to 0)
X-RateLimit-ResetUnix-seconds when the current window expires

When the limit is exceeded, the response is 429 with body:

{ "error": "Too many requests. Try again later." }

State is persisted to data/rate-limit.json via the shared json-store.ts write-queue mutex (race-free on concurrent saves).

3. Idempotency

The Idempotency-Key middleware (src/lib/idempotency.ts) makes mutating calls safe to retry. It applies only to non-GET / non- HEAD / non-OPTIONS requests; safe methods pass through with no key required.

Flow

For each request with a valid Idempotency-Key header:

idempotent-replay: true.

reused with different request body` and record a metric.

middleware no longer auto-generates one). The request is processed and the response cached.

  1. Look up the key in the in-memory store.
  2. On hit + same body hash → return the cached response with
  3. On hit + different body hash → return `409 Idempotency-Key
  4. On miss → require the client to send an Idempotency-Key (the

Header format

Idempotency-Key: <8-255 chars, [A-Za-z0-9_\-:]+>

POST /reputation/record — missing key returns 400

  • 8–255 characters
  • Allowed: ASCII letters, digits, underscore, hyphen, colon
  • Anything else returns 400 Invalid Idempotency-Key format
  • Required on POST /delegate, POST /revoke, and

Body hashing

Body hash is sha256(canonicalJson(body)) where canonicalJson sorts keys recursively. So {"a":1,"b":2} and {"b":2,"a":1} produce the same digest. Same key + same body → cached response. Same key + different body → 409.

In-memory store

Map<string, IdempotencyRecord> at src/lib/idempotency.ts:21. Each record:

interface IdempotencyRecord {
  key: string;
  bodyHash: string;     // sha256 hex
  status: number;       // HTTP status code (200-299)
  body: unknown;        // Response body
  createdAt: number;    // Unix ms
  expiresAt: number;    // Unix ms (createdAt + 24h)
}

The sweeper runs every 5 minutes:

oldest keys in insertion order (FIFO overflow)

  • Removes any record where expiresAt <= now
  • If the store is over MAX_STORE_SIZE = 10 000, evicts the

The store is not persisted to disk. A process restart loses all in-flight idempotency state. Clients that retry with the same key after a server restart will re-execute the request.

Metrics

MetricTypeLabelsWhen
agent_passport_idempotency_hits_totalcounterpathReplay served from cache
agent_passport_idempotency_conflicts_totalcounterpathSame key, different body — 409

Endpoints requiring Idempotency-Key

EndpointRequired?Notes
POST /delegateYesOn-chain call — network fee on every retry
POST /revokeYesOn-chain call
POST /reputation/recordYesOn-chain call
POST /counterparty-checkNoIdempotent by nature (read-only)
POST /credit-estimateNoIdempotent by nature
GET /score, GET /passport, etc.N/AGETs are not idempotency-protected

Multi-replica

For deployments with > 1 replica, back the idempotency store with Redis. The Idempotency-Key contract guarantees at-most-once execution; without a shared store, two replicas can both serve the same key and both execute the underlying operation.

4. Deployment

Quick start

npm install
cp .env.example .env
npm start

By default this points at the public Algorand testnet — no setup beyond the env file is needed.

Going to production — checklist

1. Choose your Algorand network

OptionWhen to useLatencySetup
Testnet (AlgoNode)Dev, staging, low-traffic production, MVP launches200-800ms per round-tripNone — defaults are set
Mainnet via public endpointProduction with relaxed SLOs (matches testnet numbers)200-800ms per round-tripUpdate ALGOD_URL and INDEXER_URL to mainnet
Mainnet via hosted provider (Nodely, BCC, AlgoNode paid tier)Production with stricter SLOs and zero node ops50-200ms per round-tripSubscribe to provider, set URLs
Mainnet via local Algorand nodeProduction needing the tightest SLOs (500ms P95)5-20ms per round-tripRun your own node — see Algorand node docs

The measured k6 testnet baseline (P95 < 1.5s, 99% availability) is what you should expect with any of the first three options. The local-node option is an upgrade path if you need the stricter prod-strict SLOs (P95 < 500ms, 99.9% availability). See § 7.

2. Set ALGOD_URL and INDEXER_URL

# Testnet (default — no change needed)
ALGOD_URL=https://testnet-api.algonode.cloud:443
INDEXER_URL=https://testnet-idx.algonode.cloud:443

# Mainnet via AlgoNode
ALGOD_URL=https://mainnet-api.algonode.cloud:443
INDEXER_URL=https://mainnet-idx.algonode.cloud:443

3. Set the operator mnemonic

OPERATOR_MNEMONIC="word1 word2 ... word25"

Or load from a secret manager at startup. See security.md for KMS guidance.

HMAC_SECRET="$(openssl rand -hex 32)"   # 64 hex chars = 256 bits

Any state-changing endpoint will then require HMAC-SHA256 authentication. Public reads and the operational endpoints remain unauthenticated in development. Production requires HMAC. See security.md.

5. Build the Docker image

docker build -t agent-passport:0.1.0 .
docker run --rm -p 3000:3000 --env-file .env agent-passport:0.1.0

The Dockerfile is multi-stage, runs as non-root, includes a healthcheck, and uses tini as PID 1 for proper signal forwarding.

6. Kubernetes probe example

livenessProbe:
  httpGet: { path: /health, port: 3000 }
  initialDelaySeconds: 10
  periodSeconds: 30
readinessProbe:
  httpGet: { path: /ready, port: 3000 }
  initialDelaySeconds: 5
  periodSeconds: 10

5. Observability

The service exposes Prometheus-format metrics at GET /metrics. This endpoint is:

  • Exempt from rate limiting (operational)
  • Always returns 200 unless the process is severely broken
  • Refreshes process gauges on every scrape (memory, CPU, uptime)

Metric inventory

API metrics

MetricTypeLabelsDescription
agent_passport_http_requests_totalcountermethod, path, status_classTotal HTTP requests
agent_passport_http_request_duration_secondshistogrammethod, path, status_classRequest duration in seconds
agent_passport_http_request_errors_totalcountermethod, path, status_class, error_type4xx/5xx errors

status_class is 2xx/3xx/4xx/5xx (not the raw status code) to bound label cardinality. Buckets for http_request_duration_seconds: 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10.

Trust engine metrics

MetricTypeLabelsDescription
agent_passport_trust_score_generations_totalcounterrisk_levelTrust score computations, by risk bucket
agent_passport_trust_score_duration_secondshistogramTrust score computation duration
agent_passport_graph_traversal_duration_secondshistogramGraph traversal duration
agent_passport_graph_traversal_depthhistogramGraph traversal depth (hops)
agent_passport_graph_traversal_depth_sumcounterCumulative graph traversal depth

x402 payment metrics

MetricTypeLabelsDescription
agent_passport_x402_payments_verified_totalcounterstatus, pathSuccessful verifications
agent_passport_x402_payment_failures_totalcounterreason, pathFailed verifications
agent_passport_x402_replay_attempts_totalcounterpathReplay attack attempts
agent_passport_x402_settlement_failures_totalcounterreasonSettlement failures
agent_passport_x402_verification_duration_secondshistogramVerification latency

Contract metrics

MetricTypeLabelsDescription
agent_passport_contract_endorsements_totalcounternetworkOn-chain endorsements
agent_passport_contract_revocations_totalcounternetworkOn-chain revocations
agent_passport_contract_disputes_totalcounternetworkOn-chain disputes
agent_passport_contract_success_events_totalcounternetworkOn-chain success events
agent_passport_contract_event_stall_secondsgaugeSeconds since last contract event

Cache metrics

MetricTypeLabelsDescription
agent_passport_cache_hits_totalcountercache_nameCache hits
agent_passport_cache_misses_totalcountercache_nameCache misses
agent_passport_cache_evictions_totalcountercache_nameCache evictions
agent_passport_cache_sizegaugecache_nameCurrent cache size

Business metrics

MetricTypeLabelsDescription
agent_passport_passports_generated_totalcounterTotal passports generated
agent_passport_paid_requests_totalcounterpathx402-paid requests
agent_passport_unique_walletsgaugeDistinct wallets seen since process start
agent_passport_trust_checks_totalcountertypeTrust check operations
agent_passport_underwriting_decisions_totalcounteroutcome (approved, denied)Underwriting outcomes
agent_passport_counterparty_checks_totalcounteroutcome (allow, deny)Counterparty check outcomes
agent_passport_verify_checks_totalcounterflag, resultPer-flag /verify outcomes
agent_passport_discovery_searches_totalcounterquery_class, result_count/discovery/search calls
agent_passport_idempotency_hits_totalcounterpathReplays served from idempotency cache
agent_passport_idempotency_conflicts_totalcounterpathSame key, different body — 409

Infrastructure metrics

MetricTypeLabelsDescription
agent_passport_process_cpu_usage_seconds_totalgaugeProcess CPU time in seconds
agent_passport_process_cpu_usage_ratiogaugeProcess CPU as ratio of one core (0-1)
agent_passport_process_memory_usage_bytesgaugetypeProcess memory (rss, heapUsed, heapTotal, external, arrayBuffers)
agent_passport_process_uptime_secondsgaugeProcess uptime in seconds
agent_passport_system_memory_bytesgaugetype (total, free, used)Host memory
agent_passport_system_load_averagegaugewindow (1m, 5m, 15m)Host load average

Label cardinality rules

To keep Prometheus healthy:

req.path which can include wallet addresses

  • path is normalized to the route template — never use the raw
  • method is GET/POST/etc. (bounded)
  • status_class is 2xx/3xx/4xx/5xx (bounded)
  • wallet is never a label — use unique_wallets gauge
  • error_type is client_error/server_error (bounded)
  • outcome is approved/denied or allow/deny (bounded)
  • risk_level is low/medium/high/critical (bounded)
  • network is testnet/mainnet (bounded)
  • flag is funded/active/empty/lookup_failed (bounded)

Scrape configuration

scrape_configs:
  - job_name: agent-passport
    metrics_path: /metrics
    scrape_interval: 30s
    scrape_timeout: 10s
    static_configs:
      - targets: ['agent-passport:3000']

6. SLOs

The SLO files are split by deployment target:

DeploymentSLO FileUse for
Testnet, public mainnet endpoint, or any deployment using a public Algorand endpointalerts/slo-prod-relaxed.ymlDefault — recommended for most deployments
Mainnet via local Algorand node or premium hosted provider with low latencyalerts/slo-prod-strict.ymlFor deployments needing 500ms P95

Prod-relaxed SLOs (default, measured)

Based on the k6 load test run against the public Algorand testnet (AlgoNode free tier):

SLOProd-relaxed targetMeasured baseline30d window
Availability99.0%99.55% under 1000 VUyes
Latency P95< 1.5s1.15s (100 VU), 2.27s (1000 VU)yes
Latency P99< 3.0s2.19s (100 VU), 4.13s (1000 VU)yes
Throughput> 100 rps1,829 rps sustained (500 VU)rolling 5m

The prod-relaxed SLOs are realistic for any deployment using a public Algorand endpoint because:

produces natural 429s

call

bounded by 5 × 800ms = 4s in the worst case

  • AlgoNode's free tier rate-limits at ~1,000 req/s per IP, which
  • Round-trips to a remote indexer/algod add 200-800ms latency per
  • /underwrite makes 4-5 Algorand round-trips, so its P95 is

Prod-strict SLOs (aspirational)

SLOProd-strict targetNotes
Availability99.9% over 30dAchievable with low-latency Algorand endpoint
Latency P95< 500ms over 30dAchievable with a local node, premium hosted provider, or geographic co-location
Latency P99< 1.5s over 30d
Throughput> 1,500 rpsMeasured under cache-friendly load

How to hit the prod-strict targets: any combination of:

— the single biggest lever)

  • Local Algorand node (drops per-round-trip from 200-800ms to 5-20ms
  • Premium hosted mainnet provider (Nodely, BCC, AlgoNode paid tier)
  • Geographic co-location with an Algorand relay

The prod-relaxed targets are real, measured, and production-grade. Switch to prod-strict only if you need 500ms P95 and are willing to operate the infrastructure for it.

Per-endpoint latency projections

EndpointTestnet P95 (measured)Prod-strict P95 (projected)Algorand calls
/score1.1s200ms2-3
/delegation1.5s300ms3-4
/passport (cached)2.4ms<5ms0
/passport (cold)1.5s400ms6-8
/underwrite2.2s500ms8-12
/trust-graph2.0s600ms10+
/credit-estimate1.5s350ms4-5
/counterparty-check1.1s300ms3-4
/reputation1.0s250ms2
/verify<10ms<10ms0 (cache hit)
/discovery/search<5ms<5ms0 (in-memory)

7. Alert-to-runbook map

AlertRunbook
AgentPassportAPIDownalerts/runbooks/agent-passport-api-down.md
AlgorandDependencyDownalerts/runbooks/agent-passport-api-down.md
X402PaymentVerificationFailingalerts/runbooks/x402-verification-failure.md
ContractIndexingFailurealerts/runbooks/contract-indexing-failure.md
ContractEventStallalerts/runbooks/contract-indexing-failure.md
HighErrorRatealerts/runbooks/elevated-error-rate.md
High5xxRatealerts/runbooks/elevated-error-rate.md
ElevatedErrorRatealerts/runbooks/elevated-error-rate.md
ElevatedLatencyP95alerts/runbooks/elevated-latency.md
ElevatedLatencyP99alerts/runbooks/elevated-latency.md
ReplayAttackSpikealerts/runbooks/replay-attack-spike.md
UnusualTrafficPatternalerts/runbooks/unusual-traffic.md
UnusualGraphGrowthalerts/runbooks/graph-growth.md

Runbooks live in alerts/runbooks/<name>.md.

8. Dashboard

The Grafana dashboard JSON is at alerts/grafana-dashboard.json. It includes:

  • API Request Rate (overall and per-endpoint)
  • API Latency (P50/P95/P99)
  • Error Rate
  • Trust Score Latency
  • Graph Traversal Latency
  • x402 Payments (verified/failures/replay)
  • Contract Events (endorsements/revocations/disputes/success)
  • Process Memory (heap/RSS)
  • Process Uptime
  • Passports Generated
  • Unique Wallets
  • Cache Performance (hits/misses/evictions)

9. System exposure cap

write-queue mutex

globalRemaining, walletRemaining)` — the final amount reserved

  • MAX_SYSTEM_EXPOSURE = 100_000 USDC (hard-coded)
  • Per-wallet cap: MAX_SYSTEM_EXPOSURE / 10 = 10_000 USDC
  • Persisted to data/system-exposure.json via json-store.ts
  • capToSystemCapacity(wallet, limit) returns `min(limit,

Multi-replica needs Redis (or accept the overshoot) — the JSON file is per-process.

10. Load testing

k6 suite under load-tests/. Run via:

brew install k6
cd load-tests
LOAD_TEST_MODE=1 ./run-all.sh

Four scenarios: 100 VU, 500 VU, 1000 VU, and sustained 12 rps. The sustained scenario is a smoke test that always passes; the VU scenarios require a local Algorand node to hit P95 < 1.5s.

Thresholds:

  • Error rate: < 1%
  • P95 latency: < 1.5s (prod-relaxed), < 500ms (prod-strict)
  • Throughput: documented per scenario

11. Graceful shutdown

The service registers signal handlers in src/index.ts:38-46 and a matching cleanup hook in src/app.ts.

Signal handlers

SignalHandlerEffect
SIGTERMgracefulShutdown('SIGTERM')Drain in-flight HTTP, force exit after 10s
SIGINTgracefulShutdown('SIGINT')Same as SIGTERM
unhandledRejectionlogLog only — does not exit
uncaughtExceptionlog + process.exit(1)Log and exit immediately

Shutdown flow for SIGTERM / SIGINT

and drains in-flight requests.

complete, log "Forced shutdown after timeout" and call process.exit(1).

process.exit(0).

  1. Log "Received SIGTERM/SIGINT, shutting down gracefully".
  2. Call server.close() — Express stops accepting new connections
  3. Set a 10-second setTimeout — if server.close() does not
  4. On server.close() callback, log "HTTP server closed" and call

Resources stopped on shutdown

stopMetricsCollectors — clears the 15s setInterval for process gauges. stopRateLimiter — clears the 5-min cleanup interval. stopIdempotencySweeper — clears the 5-min idempotency sweeper. stopDedupCleanup — clears the reputation dedup cleanup. closeLoggerStreams — closes the log file streams.

All setInterval timers are .unref()-ed so they do not block process exit on their own.

Kubernetes shutdown order

Kubernetes sends SIGTERM first, then SIGKILL after terminationGracePeriodSeconds (default 30s). Configure the pod:

terminationGracePeriodSeconds: 30

to give the service time to drain. The 10s setTimeout in gracefulShutdown is the inner bound; the 30s K8s limit is the outer bound.