The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Rafid Property Intelligence listing page.
Rafid provides deterministic property intelligence for autonomous AI agents, starting with OMR calculations for the Oman market. AI agents are the primary consumer of this product, not human SaaS users. MCP and x402 are the primary interfaces; the X-API-Key REST routes are the underlying transport and a compatibility layer for callers that can't do MCP or x402 yet. This MVP does not fetch market data, provide investment recommendations, or (outside x402) collect payments.
The intended flow for an agent is: discover a capability → select the right tool → pay per call over x402 (or authenticate with an API key) → execute → get a structured, machine-readable result. No account, dashboard or subscription is required for either access model, and none is planned — see "Design constraints" below.
An agent (or an agent marketplace/directory crawler) can start from any of the following; all are public, unauthenticated, always present, and contain no secrets (no wallet private keys, no API keys, no usage data for other customers):
| Endpoint | Purpose |
|---|---|
GET /agent.json | The full agent manifest: product identity, every supported protocol (MCP, x402, REST) and its role, x402 terms, and the complete tool catalog with full input/output JSON Schemas. Start here. |
GET /.well-known/ai-plugin.json | Manifest in the legacy OpenAI ChatGPT-plugin convention, for tooling that still discovers services this way. |
GET /.well-known/agent.json | An Agent Card in the Agent2Agent (A2A) protocol's convention, listing each tool as a skill. |
GET /llms.txt | A plain-text briefing for an LLM-based agent: what Rafid does, every tool and how to call it, pricing, the x402 model, and known limitations — no JSON parsing required. |
GET /api/v1/capabilities | The machine-first capability registry: name, description, whenToUse, price, schemas and examples for every tool — optimized for a model to decide what to call, not for a human to read. |
GET /api/v1/mcp/status | Factual MCP status: which transports are live (stdio always, http only when MCP_REMOTE_ENABLED=true), the tool count, and the remote endpoint path if any. |
GET /api/v1/agent | Legacy service-metadata endpoint (kept for backward compatibility); superseded by /agent.json above. |
GET /api/v1/pricing | The full price list (USD, pay-per-call), sourced from one catalog shared by every code path — never duplicated or out of sync. |
GET /api/v1/tools | Legacy tool catalog (kept for backward compatibility); superseded by /api/v1/capabilities above. |
Every one of these is generated from the single capability registry in src/domain/capabilities.ts — see "Capability registry" below — so they can never disagree with each other or with what a call actually does.
Once a tool is chosen, call it with an X-API-Key header, or — when X402_ENABLED=true — call its /api/v1/x402/... twin with no key and pay per call on-chain instead (see "Pay-per-call via x402" below).
A human visiting / in a browser instead gets a short landing page (agent integration examples, tool list, pricing, links to docs); agents and scripts that send Accept: application/json keep getting the original JSON discovery payload — see "Landing page" below.
This product is deliberately agent-native, not a human SaaS dashboard. It does not have, and is not planned to have, user accounts, a dashboard, subscriptions, Stripe billing, or a billing portal. The X-API-Key route family is a compatibility transport for callers that authenticate that way, not an invitation to build account management around it.
PostgreSQL-backed customers, hashed keys, usage tracking and per-customer limits are now available. See activation and administration. Existing env-key development mode remains unchanged; production requires AUTH_MODE=postgres. No payment collection is implemented outside x402 (see below).
The Express entry point exports the app when VERCEL=1 and keeps the normal port listener for local runs. vercel.json pins the function to iad1 near the current US East Neon database and limits requests to 15 seconds. .vercelignore prevents the local .env, caches and generated files from being uploaded.
Production API: https://api.rafidsystem.com
Landing page: https://api.rafidsystem.com/ (browsers) / same URL with Accept: application/json (agents)
Health: https://api.rafidsystem.com/api/v1/health
OpenAPI: https://api.rafidsystem.com/openapi.json
Agent metadata: https://api.rafidsystem.com/api/v1/agent
Connect the repository directory to a Vercel project, attach the Neon Marketplace database, and configure these Production environment variables:
The Neon integration may inject DATABASE_URL; verify that it points to the rotated credential and pooled endpoint. Mark manually added database values as sensitive. Do not upload .env or use the previously exposed password. Deploy with Vercel CLI or a connected Git repository, then verify /api/v1/health, /openapi.json, /api/v1/agent, an authenticated analysis request, and the PostgreSQL usage record. The local stdio MCP process is not deployed by this Express function.
To also accept pay-per-call crypto payments, set X402_ENABLED=true and X402_WALLET_ADDRESS=<a 0x-prefixed EVM address you control> in Production. Leave X402_ENABLED=false (the default) until you are ready to receive real payments; see Configuration and the "Pay-per-call via x402" section below. On Base mainnet (eip155:8453) also set CDP_API_KEY_ID/CDP_API_KEY_SECRET (see Configuration).
Use Node.js 24+ and npm. From the repository root:
Copy the generated key into RAFID_API_KEYS in .env. Keep it private. A blank or short key prevents HTTP startup. Multiple keys are comma-separated. On macOS/Linux use npm and cp .env.example .env.
Development loads .env and watches TypeScript sources. For a compiled run:
npm test first builds and then runs service, live HTTP, agent-marketplace, OpenAPI contract, and compiled MCP stdio tests. npm start loads .env and runs dist/server.js; dependencies are pinned in package-lock.json.
| Variable | Default / behavior |
|---|---|
| PORT | 8787 |
| NODE_ENV | development; accepts development, test, production |
| RAFID_API_KEYS | Required for REST; random keys of at least 24 characters |
| API_KEY | Legacy fallback when RAFID_API_KEYS is empty |
| LOG_LEVEL | info; error logs only HTTP/tool 5xx completions; silent disables request logs |
| X402_ENABLED | false; true enables pay-per-call crypto payments at /api/v1/x402/... (no API key needed there) and requires X402_WALLET_ADDRESS. Disabled (the default) never affects normal API-key calls — the x402 route family simply doesn't exist and is omitted from discovery//openapi.json |
| X402_NETWORK | eip155:84532 (Base Sepolia testnet); the public facilitator only settles this network for EVM. Any other network, e.g. eip155:8453 (Base mainnet), requires CDP_API_KEY_ID/CDP_API_KEY_SECRET below |
| X402_WALLET_ADDRESS | Empty; required 0x-prefixed EVM address that receives payments when X402_ENABLED=true |
| X402_FACILITATOR_URL | https://x402.org/facilitator; must be an https URL |
| CDP_API_KEY_ID | Empty; Coinbase Developer Platform API key ID (Ed25519 Secret API Key). Required together with CDP_API_KEY_SECRET for any network besides Base Sepolia |
| CDP_API_KEY_SECRET | Empty; Coinbase Developer Platform API key secret. When both CDP vars are set, they take over as the facilitator (X402_FACILITATOR_URL is then ignored) |
| MCP_REMOTE_ENABLED | true; mounts the Streamable HTTP MCP transport at /mcp. false unmounts it entirely (404, and every manifest/llms.txt advertises stdio only) — a rollback switch with no code change |
| RAFID_LOGO_URL | Empty; logo_url in /.well-known/ai-plugin.json. Left blank rather than fabricated |
| RAFID_CONTACT_EMAIL | Empty; contact_email in /.well-known/ai-plugin.json |
| RAFID_LEGAL_INFO_URL | Empty; legal_info_url in /.well-known/ai-plugin.json |
| USAGE_REPOSITORY | console (default: one JSON line to stderr per call); memory (local inspection only, lost on restart); postgres (durable rafid_agent_usage table — requires DATABASE_URL, independent of AUTH_MODE) |
| RATE_LIMIT_ENABLED | true; per-process, IP-keyed rate limiting on discovery, x402 and remote MCP routes (three independent budgets). false disables it (development/tests) |
| RATE_LIMIT_WINDOW_MS | 60000 (one minute) |
| RATE_LIMIT_MAX | 60 requests per window, per IP, per route group |
| OMAN_PROPERTY_DATA_MODE | manual (default); database; composite. Which Oman property data source(s) analyze_oman_property actually queries — see "Production Oman market data" below |
| OMAN_MARKET_DATABASE_URL | Empty; falls back to DATABASE_URL when unset. Lets the market-data database be separate from the customer/billing database if desired |
| OMAN_MARKET_STALE_DAYS | 365; a comparable sample whose median age exceeds this is flagged staleMarketData: true and its confidence score is deterministically reduced. Independent of and shorter than comparables.ts's 540-day hard recency cutoff (which excludes a record from the pool entirely) |
| OMAN_MARKET_CACHE_TTL_MS | Empty/0 (disabled); when set, DatabaseOmanPropertyDataProvider caches a comparable-pool query in memory for this many milliseconds — see "Production Oman market data" below |
| OMAN_RECENT_SALES_DAYS | 730 (~2 years); the window analyze_oman_property's historicalSalesContext.recentComparableSales/recentMedianPricePerSqmOMR treat as "recent" — separate from, and never affecting, comparables.ts's fixed 540-day cutoff used for the current market estimate. See "Historical sales context" below |
| NCSI_API_BASE_URL | https://map.ncsi.gov.om/ODPAPI (verified live base — see "Official market context (NCSI)" below) |
| NCSI_REQUEST_TIMEOUT_MS | 8000 |
| NCSI_CACHE_TTL_MS | 21600000 (6 hours); 0 disables caching official context lookups |
| NCSI_REAL_ESTATE_DATASET_ID | Empty; no default — see "Official market context (NCSI)" below for why this must be operator-verified, never guessed |
| NCSI_FIELD_MAP_JSON | Empty; a JSON object mapping this capability's field names to the configured dataset's actual field names — see "Official market context (NCSI)" below |
| MARKET_DATA_INTERNAL_API_KEY | Empty; no default. Shared secret gating GET /api/v1/internal/market-data/status and .../partners — see "Partner Data Feed" below. Unset means those two routes always return 503, never a silently-open endpoint |
| PARTNER_FEED_STALE_DAYS | 7; a partner whose latest accepted record is older than this is reported stale: true by GET /api/v1/internal/market-data/partners — a per-partner monitoring signal only, never affecting ingestion, analyze_oman_property, or any other partner's staleness |
The app accepts pre-existing environment variables over .env. No live credentials are included. .env, dependencies, build output and npm cache are ignored by Git. Never commit or log CDP_API_KEY_SECRET, X402_WALLET_ADDRESS's private key (never requested or stored by this app), or DATABASE_URL.
Services validate direct calls as well as transport calls. New capabilities belong in the shared catalog with input/output schemas and a service function; the catalog drives REST, MCP and OpenAPI. No calculation logic lives in the HTTP or MCP handlers, and no pricing logic lives in the calculation services — every price is read through BillingService, which reads billing/catalog.ts.
src/domain/capabilities.ts exports one array, capabilities: AgentCapability[], and that array is the only place a tool's name, path, description, whenToUse/useCases (the recommendation-layer hint for which tool answers which situation), input/output schema, example, price, currency, payment protocol, idempotency and side-effect flag are written. Every consumer below reads this array rather than holding a second copy:
api/app.ts) — both the X-API-Key and x402 route familiesapi/openapi.ts) — operationId is the capability's own name (analyze_property, compare_properties, estimate_maintenance, analyze_oman_property), and request/response examples come from capability.example/capability.execute(capability.example)mcp/server.ts) — tool name, schemas, and a description built from capability.description + capability.whenToUseBillingService (billing/x402.ts, billing/service.ts) — billing/catalog.ts's prices object is derived from capabilities (Object.fromEntries(capabilities.map(c => [c.name, c.price]))), never a second literal/api/v1/agent, /api/v1/pricing, /api/v1/tools, /api/v1/capabilities, /agent.json, the two /.well-known/... manifests, /llms.txt)Adding a capability to this one array is what makes it real everywhere at once; nothing else needs to be told about it separately. src/domain/roadmap.ts holds a separate, much smaller list (plannedCapabilities) of future tools — estimate_property_rent, analyze_lease, check_contract_risk, diagnose_maintenance_issue, estimate_repair_cost, generate_property_report — that are metadata only: no route, no MCP registration, no schema, no price, nothing callable. They exist so /agent.json and /llms.txt can tell an agent what's coming without it mistaking a name on a list for a working endpoint; promoting one to a real capability means adding a full entry to capabilities, the only registry that drives actual behavior (analyze_oman_property was itself on this list until this phase; it now has a full entry and has been removed from plannedCapabilities).
REST base: http://localhost:8787.
| Method | Endpoint | Auth |
|---|---|---|
| GET | / | Public — HTML landing page (browsers) or JSON discovery (Accept: application/json) |
| GET | /agent.json | Public — full agent manifest (protocols, x402 terms, complete tool catalog) |
| GET | /.well-known/ai-plugin.json | Public — OpenAI-plugin-style manifest |
| GET | /.well-known/agent.json | Public — A2A-style Agent Card |
| GET | /llms.txt | Public — plain-text briefing for LLM-based agents (text/plain) |
| GET | /api/v1/capabilities | Public — machine-first capability registry (schemas, pricing, when to use) |
| GET | /api/v1/agent | Public — legacy agent-marketplace metadata (superseded by /agent.json) |
| GET | /api/v1/pricing | Public — full pay-per-call price list |
| GET | /api/v1/tools | Public — legacy tool catalog (superseded by /api/v1/capabilities) |
| GET | /api/v1/x402 | Public — x402 protocol/pricing info (always available, independent of X402_ENABLED) |
| GET | /api/v1/x402/status | Public — factual runtime status: enabled, mode, network, asset, facilitator, walletConfigured, paymentEnforcement |
| POST | /api/v1/property/analyze | X-API-Key |
| POST | /api/v1/property/compare | X-API-Key |
| POST | /api/v1/maintenance/estimate | X-API-Key |
| POST | /api/v1/oman/property/analyze | X-API-Key |
| POST | /api/v1/risk/company-reputation-check | X-API-Key (x402: /api/v1/x402/risk/company-reputation-check; L402: /api/v1/l402/risk/company-reputation-check) |
| POST | /api/v1/risk/business-risk-score | X-API-Key (x402: /api/v1/x402/risk/business-risk-score; L402: /api/v1/l402/risk/business-risk-score; MPP: /api/v1/mpp/charge/business_risk_score) |
| POST | /api/v1/documents/facts-extract | X-API-Key (x402: /api/v1/x402/documents/facts-extract; L402: /api/v1/l402/documents/facts-extract; MPP: /api/v1/mpp/charge/document_facts_extract) — 1 MB JSON body limit |
| POST | /api/v1/finance/invoice-anomaly-check | X-API-Key (x402: /api/v1/x402/finance/invoice-anomaly-check; L402: /api/v1/l402/finance/invoice-anomaly-check; MPP: /api/v1/mpp/charge/invoice_anomaly_check) — 1 MB JSON body limit |
| POST | /api/v1/intelligence/research-company | X-API-Key |
| POST | /api/v1/intelligence/find-companies | X-API-Key |
| POST | /api/v1/intelligence/analyze-company-risk | X-API-Key |
| POST | /api/v1/procurement/oman-supplier-check | X-API-Key |
| GET | /api/v1/health | Public liveness |
| GET | /openapi.json | Public raw OpenAPI document |
| POST | /api/v1/market-data/import | X-Partner-Token (Partner Data Feed — infrastructure, not an agent capability; see below) |
| GET | /api/v1/internal/market-data/status | X-Internal-Api-Key (Partner Data Feed; see below) |
| GET | /api/v1/internal/market-data/partners | X-Internal-Api-Key (Partner Data Feed; see below) |
Legacy /v1/... POST routes and /health remain available. Response migration: legacy routes now return the same envelope as canonical routes; read results from response.data. Unknown fields are rejected, names must be unique after trimming, and unauthenticated startup is no longer allowed.
Success:
A tool call (the four POST capabilities, on either the API-key or x402 route family) additionally enriches meta with the tool name, its price and its currency, so an agent can confirm what it was charged without a second lookup:
Error:
Responses include X-Request-ID. Codes cover 400 validation/malformed JSON, 401 authentication, 404 unknown endpoints, 413 bodies over 32kb, 415 unsupported content type/encoding, 500 sanitized internal failures, and 429 for a configured rate-limit adapter. PostgreSQL mode enforces customer minute limits and monthly quotas; env-key development mode has no default limiter. /openapi.json intentionally returns the raw specification for discovery tools.
GET /api/v1/pricing returns { "currency": "USD", "model": "pay-per-call", "tools": { "analyze_property": 0.01, "compare_properties": 0.03, "estimate_maintenance": 0.02, "analyze_oman_property": 0.25 } } — the exact same object billing/catalog.ts defines, with nothing recomputed or duplicated. GET /api/v1/tools returns one entry per capability with a full JSON Schema for its input and a derived output summary, enough for an agent to construct a valid call without reading any documentation.
Human visitors to / get a small, dependency-free HTML page (no build step, no external assets) led by the agent-native positioning ("Property intelligence built for AI agents. Discover. Pay per call. Execute."), an "Agent Integration" section with copyable MCP/x402/OpenAPI/manifest examples, the tool list and pricing, and links to /docs, /openapi.json and /api/v1/health. API-key/REST access is documented as a compatibility option, not the primary message. This is purely a presentation layer over the same data the JSON endpoints return; nothing about the API's behavior changes. Any client that sends Accept: application/json (including a plain fetch()/curl with an explicit header, or any existing integration) is unaffected and keeps receiving the JSON discovery payload it always has.
PowerShell (replace the key placeholder):
The resulting data includes grossYield: 8.47, netYield: 7.24, annualOperatingCost: 1050, and annualNetIncome: 6150.
Optional inputs: serviceCharge, maintenanceCost, otherAnnualCosts, vacancyRatePct (0–100). The old maintenance alias is accepted, but cannot be supplied alongside maintenanceCost.
Formulas:
All money is OMR. Outputs are rounded to two decimals using JavaScript numbers, preserving the original MVP convention (not a settlement ledger). Finite input amounts are bounded to 1e12; property value must be at least 0.01. Negative net income is valid. Legacy output fields grossYieldPct, netYieldPct, and annualOperatingCosts remain. Financing, taxes, transaction fees and appreciation are excluded.
Requires 2–20 properties with unique, nonblank names (up to 120 characters). Returns calculated properties and sortedByNetYield names, descending by rounded net yield, retaining input order for ties. This ordering is a metric comparison, not investment advice.
This example returns annual maintenance 1300 OMR, monthly reserve 108.33 OMR and maintenance percentage 1.3, plus assumptionsUsed.
Default annual rates: age below 5 → 0.6%; 5–under 10 → 0.9%; 10–under 20 → 1.3%; 20+ → 1.8%. Defaults: age 0, units 1. Additional units add 35 OMR/year each. Supported ages are 0–200 and units 1–10000.
Override assumptions per request with "assumptions": { "annualRatePct": 1.5, "additionalUnitCost": 50 }. The returned percentage includes the additional unit allowance. Legacy annualRent is accepted but unused. Property type and area are not modeled yet and are rejected as unknown fields. This preserves the existing heuristic without inventing unsupported local cost factors; it requires calibration before commercial estimates.
analyze_oman_property ($0.25/call) is the first Rafid capability whose value comes from local market evidence, not just arithmetic over the caller's own numbers. It answers "is this Muscat property reasonably priced as a rental investment?" with structured, sourced evidence — never a hard-coded "good"/"bad investment" verdict.
Required: governorate, area, propertyType (apartment | villa | townhouse), sizeSqm, askingPriceOMR. Optional: wilayat, bedrooms, bathrooms, furnished (furnished | semi_furnished | unfurnished), optionalAnnualServiceChargeOMR, optionalAnnualMaintenanceOMR.
Coverage (MVP): Muscat governorate only. Supported areas: Al Mouj, Muscat Hills, Qurum, Bausher, Azaiba, Al Khuwair, Madinat Al Irfan, Ghubrah (domain/oman/locations.ts's MUSCAT_AREAS). English and common Arabic spellings both resolve to the same canonical area (e.g. الموج / Al Mouj / the wave → Al Mouj) — deliberately without fuzzy/phonetic matching, so an unlisted place name is reported matchType: "unmatched" rather than guessed at.
Comparable selection (domain/oman/comparables.ts): never compares across property type; filters by area + property type + a ±20% size tolerance (SIZE_TOLERANCE_PCT) to build the reported comparableCount, then narrows to an exact bedroom match and exact furnished-status match where at least 3 (MIN_COMPARABLES) exist, relaxing to ±1 bedroom or "any furnished status" only when the exact match is too small (reported via bedroomToleranceApplied/furnishedFilterRelaxed and surfaced in riskFlags/assumptions); records older than 540 days are excluded outright as stale before any of this; a standard 1.5×IQR rule then removes statistical outliers from what remains (sampleSizeUsed). Rents quoted annually are normalized to a monthly figure before any comparison.
Confidence (domain/oman/confidence.ts): a fixed, documented weighted formula over sample size, data freshness, size/bedroom similarity to the subject, and price dispersion (coefficient of variation) — never a model-invented number. Below MIN_COMPARABLES used comparables, confidence is always { score: 0, level: "insufficient" }.
Fallback behavior: below MIN_COMPARABLES comparables (rental side) or an unsupported governorate/area, insufficientMarketData: true is returned, unavailableOutputs lists exactly which fields were withheld (e.g. market.estimatedMonthlyRentOMR, investment.grossYieldPct), and deterministic figures that need no market data (pricePosition.askingPricePerSqmOMR, investment.estimatedOperatingCostOMR) are still computed. Nothing is ever fabricated to fill a gap.
Rafid does not scrape property websites, and never will as a way of sourcing this data. OfficialOmanDataProvider and ListingDataProvider (domain/oman/dataProviders.ts) are real, wired-in seams for a future official/licensed feed — they return empty today rather than faking or scraping data. Production market data is expected to come from one of: official government/statistical sources, a licensed real-estate data vendor, a partner data-sharing agreement, or a customer's own listing/transaction export. All of these reach the analysis pipeline the same way — as rows imported into the property_market_records table via the import pipeline below — so no scraping code exists or is planned anywhere in this codebase.
Provenance: every response includes a provenance array — { sourceType, sourceName, sourceDate, recordCount } — so a caller can tell official_statistics, listing_asking_price, partner_feed and manual_benchmark apart, and can tell an asking price from a confirmed completed-transaction price. The bundled ManualDatasetProvider (domain/oman/fixtures.ts) is a small, explicitly curated benchmark dataset, clearly labeled in every sourceName as demo/MVP data — never official statistics and never confirmed transaction prices. riskFlags includes demo_dataset_not_live_market_data only when a manual_benchmark record actually contributed to the comparables used for that specific answer — it is computed per-response, not hard-coded, so an answer built entirely from database-backed production records (official statistics, a licensed listing feed, or a partner feed) does not carry it.
Data freshness: every response includes a dataQuality block — { latestDataDate, dataFreshnessDays, sampleSize, sourceTypes, staleMarketData } — computed from whichever comparables actually drove the answer, even when the sample is too small to produce a full result (freshness is never hidden). dataFreshnessDays beyond OMAN_MARKET_STALE_DAYS (default 365 days) sets staleMarketData: true, adds a stale_market_data risk flag, and applies a fixed, documented penalty inside the confidence formula (domain/oman/confidence.ts) — so an agent consuming this API can decide how much to trust an answer from dataQuality/confidence/riskFlags alone, without needing to inspect raw records.
Output shape: normalizedLocation, subjectProperty, market (estimated monthly/annual rent range, comparable/sample counts, data freshness), investment (gross/net yield, operating cost, net income), pricePosition (asking price/sqm vs. the observed sale-comparable range, below_market/at_market/above_market/insufficient_data), comparablesSummary (rent-per-sqm low/median/high of the sample actually used), dataQuality, historicalSalesContext, riskFlags, confidence, provenance, assumptions, insufficientMarketData, unavailableOutputs. Strict output schema in schemas/omanOutputs.ts.
Historical sales context: every response also includes historicalSalesContext — { available, recordsAvailable, recentComparableSales, medianHistoricalPricePerSqmOMR, recentMedianPricePerSqmOMR, oldestRecordDate, latestRecordDate, sourceTypes, priceSemantics, phaseBreakdown } — a separate, additive field surfacing long-term completed-sale history (e.g. a multi-year partner feed spanning 2006-2026) alongside, but never mixed into, market/pricePosition/comparablesSummary/confidence above, which stay driven exclusively by the current-comparable pool (comparables.ts's 540-day MAX_DATA_AGE_DAYS cutoff, unchanged). recordsAvailable/oldestRecordDate/latestRecordDate/medianHistoricalPricePerSqmOMR are computed with real database aggregation (PropertyMarketRepository.getHistoricalSaleStatistics()) rather than reusing the capped, most-recent-first candidate pool findSaleComparables() returns, so they stay accurate for a history longer than that cap. recentComparableSales/recentMedianPricePerSqmOMR are a genuinely matched subset — area, property type, size similarity (±20%) and bedrooms where available — within the configurable OMAN_RECENT_SALES_DAYS window (default 730 days), computed by comparables.ts's selectRecentSaleRecords(); this is a deliberately separate selection path from selectComparables(), both because the recency window differs and because a completed-sale record routinely has no recorded furnished status (true of every partner-feed sale record observed so far) — selectComparables() requires one and would otherwise silently report zero recent sales for exactly this data. phaseBreakdown (only relevant to a phased-development partner feed like Al Mouj's) lists per-phase { phaseName, recordCount, medianPriceOMR, medianPricePerSqmOMR, oldestRecordDate, latestRecordDate }, but only for phases meeting the same MIN_COMPARABLES sample-size floor used elsewhere — a phase trend is never reported from an insufficient sample — capped to the busiest 10 phases. available: false (with every other field at its empty default) whenever the location is unsupported or the active provider has no genuine transaction-level record store for it (manual/demo mode, official statistics, the not-yet-integrated listing feed) — demo/benchmark data is never presented as real historical sales intelligence. priceSemantics reports only what the source data itself recorded (e.g. "contracted_unit_price") — never verified_conveyance_price, government_registered_transaction or final_title_transfer_price unless the source genuinely supports that claim.
research_company, find_companies, analyze_company_risk)Phase 1 of a broader expansion beyond Oman property/business data: three capabilities that gather evidence about a company from the open web (plus, for identity, Rafid's own Oman company registry data) rather than computing purely from caller-supplied numbers. All three are entirely inert by default — no external network call, no cost — until an operator explicitly configures a provider; see src/intelligence/config.ts and the block of WEB_SEARCH_PROVIDER/TAVILY_API_KEY/INTELLIGENCE_LLM_PROVIDER/ANTHROPIC_API_KEY/INTELLIGENCE_LLM_MODEL/RISK_LIVE_CHECKS_ENABLED/INTELLIGENCE_CACHE_TTL_MS/RISK_CACHE_TTL_MS variables in .env.example. With nothing configured, every one of these still returns a fully schema-valid, honest dataMode: "not_configured" result — an empty/null shape, never a fabricated one — which is also what makes them safe to exercise from the generic per-capability test loops (tests/http.test.ts/tests/mcp.test.ts/tests/x402.test.ts, which call capability.execute(capability.example) with no way to inject a mock network dependency).
research_company (POST /api/v1/intelligence/research-company, $0.15/call) — a structured research brief (overview, products/services, leadership, funding, competitors, technology signals, recent developments, risk flags) built from public web search results (src/intelligence/webSearch/), optionally passed through an LLM synthesis step (src/intelligence/synthesis/) to extract structured fields — every synthesized field is schema-validated before being returned, and any fact the evidence doesn't support is null/empty rather than guessed. Every response carries sources, confidence, dataFreshness and limitations; caching is by normalized company/website/country/depth/focusAreas (src/intelligence/companyResearch/provider.ts).find_companies (POST /api/v1/intelligence/find-companies, $0.05/call) — discovers candidate companies from public web search results matching industry/location/size/keyword criteria. Never fabricates a company to fill a requested limit: without an LLM synthesis provider configured, no structured company records are extracted at all (only raw cited sources are returned) rather than risk inventing one. Results are capped internally at 20 per request regardless of the requested limit (up to 100); requestedLimit vs appliedLimit in the response makes that gap explicit (src/intelligence/companyDiscovery/provider.ts).analyze_company_risk (POST /api/v1/intelligence/analyze-company-risk, $0.35/call) — gathers evidence-tiered risk signals across up to 8 checks (corporate_identity, domain, website, sanctions, adverse_news, security_signals, reputation, legal_signals) and never returns a safe/unsafe verdict — only evidence, each item explicitly tiered confirmed_evidence / public_allegation / automated_indicator / missing_information, for the calling agent or a human to weigh. corporate_identity reuses search_oman_company's own service function directly (no second lookup implementation) and is the only check performed by default; domain/website/sanctions are free public-source checks (RDAP, the public OFAC/Consolidated Screening List) gated behind RISK_LIVE_CHECKS_ENABLED because they make a real outbound call; adverse_news/reputation/legal_signals are web-search-backed and require a web search provider. The website check reuses domain/oman/feedSecurity.ts's and safeFeedFetch.ts's SSRF protections (private/loopback IP blocking, DNS-rebinding-safe resolution, validated redirects) unchanged — the same protection the Production Feed Runner relies on for partner-supplied URLs (src/intelligence/risk/provider.ts, src/intelligence/risk/checks/).Cost/margin visibility (internal only): src/intelligence/costEstimator.ts holds Rafid's own documented, hand-estimated upstream cost assumptions per capability (never a provider's actual confidential pricing) plus a per-process running counter of real provider calls actually made; GET /api/v1/internal/revenue/unit-economics (same REVENUE_INTERNAL_API_KEY gate as the rest of revenueRoutes.ts — no new secret, no new dashboard) exposes revenuePerCallUSD/estimatedProviderCostPerCallUSD/estimatedLlmCostPerCallUSD/estimatedGrossMarginPerCallUSD per priced capability. This is a gross, not net, margin — it excludes hosting, database and facilitator/network fees — and is never exposed on any public endpoint.
Provider architecture: WebSearchProvider (src/intelligence/webSearch/provider.ts) and IntelligenceSynthesizer (src/intelligence/synthesis/synthesizer.ts) are both small interfaces with a NotConfigured* default and one real implementation each (Tavily for search, Anthropic for synthesis) — mirroring business-data/sources/provider.ts's own provider-interface pattern — so swapping either vendor later means writing one new class, never touching a capability's schema, route, MCP registration or pricing.
oman_supplier_check, $0.50/call)POST /api/v1/procurement/oman-supplier-check (API key) · POST /api/v1/x402/procurement/oman-supplier-check (x402, $0.50) · MCP tool oman_supplier_check. For procurement agents that need to screen an Oman supplier before requesting a quotation. It is public-source procurement screening — not KYC/AML, not legal verification, not vendor approval — so it answers with identityConfirmed, procurementSuitability (appears_suitable / review_recommended / insufficient_information / potential_risk) and sanctions potential_match, never "verified", "approved" or "sanctioned".
Input: companyName (required), optional crNumber, website, email, phone, address, requiredProductOrService. Inputs are normalized deterministically (src/supplier-check/normalize.ts): the registry's own company-name normalizer, a small Arabic→English company-word glossary (ش.م.م → LLC, للتجارة → TRADING, ...), Oman phone numbers to E.164, URLs/domains (incl. co.om-style registrable domains), free-mail detection and CR cleanup.
Checks, each assessed independently (src/supplier-check/analysis/): company identity (strong/moderate/weak/unconfirmed, scored from name match + CR + contact corroboration × source trust — demo data can never reach "confirmed"), business-activity relevance (deterministic EN/AR keyword taxonomy; pass/partial/fail/unknown), website (SSRF-safe fetch of the supplied or registry website: name on site, domain vs name/registry, corporate email, phone, address, redirects), contact consistency (email/website/phone vs registry and website; lookalike-domain detection; a Gmail/Outlook address alone is only a low note), address consistency (pass/partial/conflict/unknown vs registry governorate/wilayat/area), sanctions (provider-based: UN Security Council Consolidated List and US Consolidated Screening List incl. OFAC SDN, with a conservative central fuzzy matcher — ≥0.88 similarity and ≥60% distinctive-token overlap; exact-name matches are still only potential_match), and public-risk signals (web results must name the supplier next to a risk term; derived lookalike-domain / contradictory-CR indicators; neutral "potential public-risk signal detected" wording).
Risk model (src/supplier-check/scoring.ts, fully documented in code and returned per call as riskModel.components): adverse evidence adds points (e.g. exact-name sanctions match +60, CR registered to another company +35, fuzzy sanctions match +35, lookalike domain +25, activity mismatch +15, email-domain mismatch +10); missing information adds no risk points and instead lowers confidence (weighted evidence coverage). score ≥45 → high, ≥20 → medium, else low; thin coverage → insufficient_data unless the score is already high.
Data sources and reuse: identity resolves through the existing canonical Oman company registry (getOmanCompanyDataProvider() — same OMAN_BUSINESS_DATA_MODE provider, rankCompanies, mergeCompanyRows and provenance as search_oman_company). No company records are created. Website and sanctions checks reuse RISK_LIVE_CHECKS_ENABLED; public-web signals reuse WEB_SEARCH_PROVIDER. With nothing enabled, those checks report not_checked honestly.
Caching & idempotency: each provider's evidence is cached independently in rafid_supplier_evidence (PostgreSQL when SUPPLIER_EVIDENCE_DATABASE_URL/DATABASE_URL is set — one CREATE TABLE IF NOT EXISTS, upsert on (provider_id, evidence_key); in-memory otherwise). Default TTLs: identity 7 days, website 3 days, sanctions 12 hours, public web 1 day (override with SUPPLIER_*_CACHE_TTL_MS). Only successful evidence is cached (outages are retried), and the final risk result is never cached — it is recomputed from evidence on every call. This is an evidence cache, not a second company/source-evidence model: it is never merged into oman_companies.
company_reputation_check, $0.40/call)POST /api/v1/risk/company-reputation-check (API key) · POST /api/v1/x402/risk/company-reputation-check (x402, $0.40) · POST /api/v1/l402/risk/company-reputation-check (L402, when enabled) · MCP tool company_reputation_check. Category risk_intelligence, idempotent. For an agent that must look into a company in any country before signing a contract, onboarding a vendor, sending money, partnering, investing or approving a marketplace seller. It is evidence-first: every signal cites evidence ids, every evidence item carries its source, authority tier, retrieval time and relevance. It never labels a company "safe", "legitimate", "fraudulent" or "sanctioned". It is not a legal, KYC/AML, credit or compliance determination and does not guarantee legitimacy.
Input: companyName (required); strongly recommended country (ISO-2/ISO-3/English name); optional website, domain, registrationNumber, lei (checksum-validated), legalName, city, industry. Strict schema: unknown fields, unknown countries, invalid LEIs and a website/domain pair that disagree are rejected with 400 INVALID_INPUT — which also means no x402 settlement and no L402 token burn.
Output (abridged): company, resolution (status resolved|probable|ambiguous|unresolved|registry_not_checked, confidence, candidates), reputationScore 0-100, confidenceScore 0-100, trustLevel (insufficient_evidence / significant_concerns / some_concerns / no_major_concerns_found / favorable_public_signals), scores per dimension plus scoreBreakdown (weight, raw score, coverage) and confidenceBreakdown, identity, sanctions (possible vs high-confidence matches, lists checked/unavailable), adverseMedia (events with category, legalStage, established, coverage count, sources), customerSentiment, onlinePresence, legalRiskSignals, businessStabilitySignals, cyberDomainSignals, transparencySignals, positiveSignals, redFlags, evidenceSummary, evidence[], coverage.providers[] (per-provider status incl. not_configured / timeout / stale_cache), warnings[] (COMPANY_NOT_RESOLVED, AMBIGUOUS_COMPANY, INSUFFICIENT_EVIDENCE, PROVIDER_UNAVAILABLE, PROVIDER_TIMEOUT, RATE_LIMITED, DATA_STALE, PROVIDER_NOT_CONFIGURED, COUNTRY_NOT_PROVIDED), limitations, summary, methodology. The registry's documented example (/api/v1/capabilities, OpenAPI) is the real pipeline run over an explicitly synthetic scenario (fictional company, .example publishers) — regenerate with node --import tsx scripts/generateCompanyReputationExample.ts.
Pipeline (src/company-reputation/): input validation → normalization (normalization.ts: global legal forms, ISO countries, domains, URLs, registration numbers, LEI) → providers run concurrently, isolated, time-boxed (providers/runner.ts: per-provider timeout, one retry for free idempotent sources only, never for paid searches) with a cache-first hybrid lookup → evidence dedup and syndication grouping (deduplication.ts) → entity resolution (companyResolver.ts) → relevance filtering of third-party items → analyzers (analyzers/*: identity, sanctions, adverse media, customer sentiment, online presence, business stability, cyber/domain, transparency) produce signals → scoring.ts → confidence.ts. Providers only collect normalized evidence; analyzers never fetch; scoring only sees signals.
Scoring (config.ts, version crc-1.0.0): weights identity 20%, legal/regulatory 20%, adverse media 15%, customer reputation 15%, online presence 10%, business stability 10%, cyber/domain 5%, transparency 5%. Per dimension: signal points = severity points (info 3, low 8, medium 20, high 40, critical 80) × strength (authority × relevance × bounded corroboration), saturating (swing × (1 − e^(−points/40))) so many weak items cannot add up linearly and one weak item moves a dimension only a few points. Each dimension is then shrunk toward a neutral prior of 50 by its evidence coverage: score = 50 + coverage × (raw − 50). Missing data therefore reads as "unknown" (50) with low confidence, never as "good". Documented caps: a high-confidence sanctions match caps the score at 20; an established conviction/judgment for fraud, corruption, sanctions or criminal conduct from an official or major source caps it at 35.
Confidence (separate from reputation): 30% entity-resolution confidence, 20% weighted dimension coverage, 15% independent sources (event groups, not articles), 10% source authority, 10% freshness (stale cache and old coverage count less), 10% evidence diversity, 5% jurisdiction-registry coverage; −3 per provider outage; capped at 40 when identity is ambiguous and 50 when it is unresolved. 78/91 and 78/34 mean very different things.
Safety properties (tested): a similar sanctioned name is not a match (shared conservative matcher from src/shared/sanctions/, ≥0.88 similarity and ≥60% token overlap); an exact name without a corroborating country/registration identifier is only possible; one anonymous complaint cannot cause a major penalty; ten syndicated copies are one event; missing data and provider timeouts are never negative signals; allegations stay allegations (legal stage is taken only from explicit source wording); same-name companies in other jurisdictions (different country, different legal form in the text) are excluded, and with an ambiguous identity only identifier-backed items are attributed. External text is sanitized and instruction-like text is removed; nothing is sent to an LLM.
Providers:
| Provider | Category | Enabled by | Cost |
|---|---|---|---|
| GLEIF Global LEI Index (global, official) | registry | RISK_LIVE_CHECKS_ENABLED=true | free |
| UK Companies House | registry (GB only) | COMPANIES_HOUSE_API_KEY | free key |
| Rafid Oman company registry | registry (OM only) | OMAN_BUSINESS_DATA_MODE=database/composite (demo data never used) | own DB |
| UN Security Council Consolidated List | sanctions | RISK_LIVE_CHECKS_ENABLED=true | free |
| US Consolidated Screening List (incl. OFAC SDN) | sanctions | RISK_LIVE_CHECKS_ENABLED=true (+ optional SANCTIONS_CSL_API_KEY) | free |
| EU Financial Sanctions Files | sanctions | EU_SANCTIONS_LIST_URL (tokenised URL issued by the EU) | free |
| OpenSanctions (UN/EU/UK/US and national lists) | sanctions | OPENSANCTIONS_API_KEY | paid licence for commercial use |
| News / adverse media (2 searches) | news | WEB_SEARCH_PROVIDER=tavily + TAVILY_API_KEY | ~$0.016 |
| Review platforms / forums (1 search) | reviews | same as news | ~$0.008 |
| Company website (1 page, SSRF-safe) | website | RISK_LIVE_CHECKS_ENABLED=true | free |
| RDAP domain registration | domain | RISK_LIVE_CHECKS_ENABLED=true | free |
Any provider can be switched off with COMPANY_REPUTATION_DISABLED_PROVIDERS (ids or categories). With nothing enabled the call still returns a valid, clearly limited result (trustLevel: insufficient_evidence).
Cache: provider evidence is cached per (provider, normalized company identity) in rafid_company_evidence_cache (PostgreSQL via COMPANY_REPUTATION_DATABASE_URL or DATABASE_URL, one CREATE TABLE IF NOT EXISTS; in-memory otherwise). TTLs: registry 7d, sanctions 12h, news 1d, reviews 3d, website 3d, domain 7d (COMPANY_REPUTATION_*_TTL_MS). Cached evidence keeps its original retrieval time (fetchedAt/observedAt, fromCache: true). If a refresh fails, expired evidence up to COMPANY_REPUTATION_MAX_STALE_MS (default 14 days) is served flagged stale_cache + DATA_STALE and lowers confidence. Outages are never cached, and the final score is never cached.
Unit economics: worst case uncached ~3 web searches (≈ $0.024 at Tavily-class pricing); everything else is free public data. Estimated gross margin at $0.40 ≈ $0.37/call before hosting and facilitator fees. OpenSanctions, if enabled, adds its own per-call licence cost. Per-provider calls, cache hits, stale serves, outages and latency are recorded in-process (src/company-reputation/telemetry.ts) and returned by the internal GET /api/v1/internal/revenue/unit-economics route (companyReputationTelemetry).
Future product ladder: oman_supplier_check ($0.50, Oman procurement) and company_reputation_check ($0.40, global reputation) are independent capabilities. They share only generic sanctions-list code (src/shared/sanctions/). A future company_due_diligence (~$1.50+: ownership/UBO, financials, litigation, PEP, corporate structure) can reuse the normalized evidence model, the evidence cache table and the provider interface without changing either.
business_risk_score, $0.50/call)POST /api/v1/risk/business-risk-score (API key) · POST /api/v1/x402/risk/business-risk-score (x402, $0.50 = 500000 USDC atomic units) · POST /api/v1/l402/risk/business-risk-score (L402, when enabled) · POST /api/v1/mpp/charge/business_risk_score and MPP session calls (when enabled) · MCP tool business_risk_score (remote /mcp, stdio, and paid /mcp/mpp when enabled). Category risk_intelligence, idempotent. Answers one question for procurement, finance, vendor-onboarding, B2B sales, marketplace, insurance, lending, compliance and autonomous purchasing agents: is it risky to do business with this company? — anywhere in the world, from evidence rather than model knowledge.
Input (strict; unknown fields → 400 INVALID_INPUT, no payment): companyName (required) plus optional country (ISO 3166 alpha-2/alpha-3/English name — strongly recommended), registrationNumber, lei, website, address, city, industry, knownAliases (≤ 5), includeNews (default true), includeDigitalSignals (default true). Names are normalized on their distinctive core (legal forms compared separately), websites to an https:// origin + registrable domain, countries to ISO-2.
Output: status (assessed | insufficient_data), assessmentId, business (name, legalName, country, registrationNumber, registrationStatus, incorporationDate, registry, website, entityMatchConfidence, resolutionStatus), riskScore, riskLevel (0–20 low · 21–40 moderate · 41–60 elevated · 61–80 high · 81–100 critical), confidence, components (corporate/financial/compliance/reputation/operational/digital risk; null = no data coverage), scoreBreakdown (per-category contributions, effective weights, floors applied), riskFlags[] and positiveSignals[] (code, category, severity, weight, confidence, points, evidenceIds, factStatus observed|reported|alleged, requiresVerification), sanctionsScreening (lists checked/unavailable, names screened, matches with matchStrength possible|high and list type sanctions vs export-control/debarment), evidence[] (type, evidenceClass — official_record / regulatory_action / court_record / credible_journalism / other_publication / user_generated / allegation / self_published — sourceName, sourceUrl, publishedAt, observedAt, retrievedAt, ageDays, stale, freshness, claim, reliability, relevance, fromCache; lookup_result items document clean checks), recommendation (proceed / proceed_with_monitoring / enhanced_due_diligence / manual_review / avoid_automated_transaction + reason codes), dataCoverage, confidenceBreakdown, providers[], warnings[], assumptions[], limitations[], methodology, evaluatedAt. The documented example (OpenAPI, /api/v1/capabilities, x402 Bazaar metadata) is the real pipeline over an explicitly synthetic scenario — regenerate with node --import tsx scripts/generateBusinessRiskExample.ts (a test fails if it drifts).
Structured errors (no charge on x402/L402/MPP — only successful calls settle): AMBIGUOUS_ENTITY 409 (details.candidates with registration numbers — retry with one), ENTITY_NOT_FOUND 404 (the jurisdiction's registry has no such company and nothing else shows it exists), PROVIDER_TIMEOUT 504 / PROVIDER_UNAVAILABLE 503 / RATE_LIMITED 429 (every identity source failed — the minimum identity check could not be performed), INVALID_INPUT 400, INTERNAL_ERROR 500. INSUFFICIENT_DATA is a 200 result (status: "insufficient_data", null score) returned only when the deployment has no evidence source for the company at all.
Pipeline (src/business-risk/): normalization → stage 1 (registries GLEIF / UK Companies House / Rafid Oman registry, company website, RDAP — concurrent, isolated, time-boxed, cache-first) → entity resolution (companyResolver.ts from company_reputation_check: registration number/LEI decisive, other countries never match, near-equal candidates are ambiguous — deep scoring and every paid search are skipped for ambiguous/unknown entities) → stage 2 with the resolved identifiers (sanctions & restricted-party lists screening the name, supplied aliases and former registry names; news; reviews; regulator publications; Companies House filing status; Google Safe Browsing) → dedup + relevance filtering (same-name companies in other jurisdictions are excluded, never merged) → six detectors (signals.ts) → scoring.ts → confidence.ts → recommendation.ts. It reuses company_reputation_check's provider contract, runner, evidence cache, normalizers, resolver and sanctions/adverse-media classifiers; only the risk model is new.
Scoring (config.ts, version brs-1.0.0, echoed in every response's methodology): every signal contributes SEVERITY_POINTS[severity] (info 0 · low 8 · medium 20 · high 40 · critical 70) × rule weight × confidence. Category score = baseline − baseline·(1−e^(−positive/30)) + (100−baseline)·(1−e^(−negative/45)) — positives only erode the residual baseline, they never cancel a red flag; categories without coverage are null. Overall = weighted mean of the covered categories with weights corporate 20% · financial 20% · compliance 25% · reputation 15% · operational 10% · digital 10% (renormalized; override with BUSINESS_RISK_CATEGORY_WEIGHTS), raised to documented floors when a confident severe signal exists (high-confidence sanctions match ≥ 85, dissolved company or malware/phishing listing ≥ 81, insolvency proceedings or restricted-party match ≥ 70, any critical ≥ 61, any high ≥ 35), clamped 0–100. Missing data lowers confidence and coverage only; it raises risk solely where the absence contradicts the request (company not in its jurisdiction's registry, supplied registration number not found, supplied website unreachable, supplied domain unregistered, reachable site with no contact information) — listed in methodology.missingDataRiskRules. LLMs are not used anywhere in scoring.
Confidence (separate from risk): 30% entity-match confidence, 25% weighted category coverage, 15% source reliability, 10% authoritative registry records, 10% evidence freshness (media decays with a half-life; stale cache ×0.5), 10% consistency (−0.25 per contradiction); capped at 0.55 when identity is not verified against a registry.
Providers:
| Provider | Role | Enabled by | Cost |
|---|---|---|---|
| GLEIF Global LEI Index | corporate | RISK_LIVE_CHECKS_ENABLED=true | free |
| UK Companies House (profile + filing status) | corporate + financial | COMPANIES_HOUSE_API_KEY | free key |
| Rafid Oman company registry | corporate | OMAN_BUSINESS_DATA_MODE=database/composite | own DB |
| UN Consolidated List, US CSL (OFAC SDN + BIS/DDTC export-control & debarment) | sanctions | RISK_LIVE_CHECKS_ENABLED=true | free |
| EU FSF / OpenSanctions | sanctions | EU_SANCTIONS_LIST_URL / OPENSANCTIONS_API_KEY | free / paid licence |
| News + review-platform search (3 searches) | news | WEB_SEARCH_PROVIDER=tavily + TAVILY_API_KEY | ~$0.024 |
| Regulator / enforcement publications (1 search restricted to regulator domains) | regulatory | same | ~$0.008 |
| Company website (SSRF-safe), RDAP | digital | RISK_LIVE_CHECKS_ENABLED=true | free |
| Google Safe Browsing | digital | GOOGLE_SAFE_BROWSING_API_KEY (+ RISK_LIVE_CHECKS_ENABLED=true) | free key |
Any provider or role can be switched off with BUSINESS_RISK_DISABLED_PROVIDERS (ids or roles, e.g. news,regulatory).
Caching and persistence: provider evidence goes through the SAME evidence cache as company_reputation_check (rafid_company_evidence_cache, same provider ids ⇒ evidence is fetched once and shared; TTLs registry 7d, sanctions 12h, news 1d, reviews 3d, website 3d, domain 7d; keys include the normalized identity, so same-name companies in different countries never share rows; failed refreshes may serve flagged stale evidence). Each assessment is recomputed from evidence (deterministic) and persisted best-effort to rafid_business_risk_assessments (PostgreSQL via BUSINESS_RISK_DATABASE_URL or DATABASE_URL, created on first use; memory otherwise; BUSINESS_RISK_ASSESSMENT_STORE=memory|off), deduplicated on (identity, scoring version, evidence fingerprint) — the audit trail for "why was this company scored 67?".
Unit economics: worst case uncached 4 web searches ≈ $0.032; everything else free. Estimated gross margin at $0.50 ≈ $0.47/call before hosting and facilitator fees (internal GET /api/v1/internal/revenue/unit-economics).
x402 challenge size: the x402 Bazaar discovery declaration is embedded in the PAYMENT-REQUIRED header; declarations are now size-bounded (MAX_DISCOVERY_DECLARATION_CHARS, dropping the output example and then the output schema when needed) so every 402 stays readable by Node-based x402 clients (16 KB header limit). Full schemas/examples remain in /openapi.json.
document_facts_extract, $0.25/call)POST /api/v1/documents/facts-extract (API key) · POST /api/v1/x402/documents/facts-extract (x402, $0.25 = 250000 USDC atomic units) · POST /api/v1/l402/documents/facts-extract (L402, when enabled) · POST /api/v1/mpp/charge/document_facts_extract and MPP session calls (when enabled) · MCP tool document_facts_extract. Category document_intelligence, idempotent. Converts contracts, invoices, purchase orders, quotations, tenders/RFPs, leases, policies, financial reports, legal documents, CVs and company profiles into facts another agent can act on — not a summary: every fact carries a 0–1 extraction confidence and source evidence.
Input (strict; exactly one of documentUrl / text): documentUrl (public https; PDF with a text layer, DOCX, HTML, text, Markdown, CSV) or text (≤ 200,000 characters; separate pages with \f for page-level evidence), optional documentType (auto default, or contract | invoice | purchase_order | quotation | tender | policy | financial_report | legal_document | lease | resume | company_profile | other), requestedFacts (≤ 20 plain-language questions), mode (auto | requested_only), language (ISO 639), includeSourceEvidence (default true). Limit: 25 pages — larger documents are rejected (DOCUMENT_TOO_LARGE), never truncated.
Output: documentType / documentSubtype / documentTypeConfidence, language, title, facts[] (key, label, value, normalizedValue, confidence, method = labeled_field | definition | pattern | clause | table | llm_verified, sourceEvidence), requestedFacts[] (one per request, found / not_found — absence is reported, never filled from model knowledge), entities[], dates[], amounts[], percentages[], obligations[] (party, obligation, must/must_not, deadline, condition), requirements[] (category, mandatory), deadlines[], riskFlags[], metadata (pageCount, characterCount, format, source, pageProvenance, extraction mode/method, LLM-assist status), warnings[], overallConfidence, limitations[]. sourceEvidence = { page?, section?, text, startOffset, endOffset } — a short verbatim excerpt plus offsets into the extracted text; page appears only when the source has real pages (PDF pages, \f-separated text, DOCX rendered page breaks).
Normalization (only when unambiguous; the original is always returned): dates → ISO 8601 (textual months in English/French/Spanish/German/Portuguese/Italian/Arabic; numeric dd/mm vs mm/dd only when the document itself proves the order); amounts → {amount, currency, frequency} (ISO codes, unambiguous symbols, currency words incl. Arabic; $, ¥, bare "dollars" leave currency unset with currencySymbol; 3-decimal currencies like OMR/KWD/BHD handled); percentages → ratio; durations → {value, unit, iso8601}. Nothing is computed, converted or annualized.
Document-type-aware facts: contract (parties, effective/expiry dates, term, value, payment terms, renewal, termination, notice period, penalties, SLA, governing law, liability, warranties) · invoice (supplier, customer, number, dates, subtotal, tax, total, currency, payment details, line items verified by qty × unit price) · purchase order · quotation · tender (issuer, number, submission deadline, eligibility/technical/financial requirements, bid/performance bonds, mandatory documents, evaluation criteria) · lease (landlord, tenant, property, unit, dates, rent + frequency, deposit, renewal, notice) · policy · financial report · legal document · CV · company profile · generic.
Risk flags are observable conditions with a rule-defined severity (src/document-facts/extract/risk.ts), never legal conclusions — e.g. automatic_renewal, short_termination_notice, missing_expiry_date, missing_currency, contradictory_amounts, totals_do_not_reconcile, penalty_clause_detected, unlimited_liability_language, missing_signature, ambiguous_payment_terms, embedded_instructions_detected, macros_present, hidden_html_content_removed.
Security — the document is untrusted data: SSRF-safe single download (https only, private/loopback/link-local/metadata targets and redirects into them refused — the shared safeFeedFetch), size/time bounded; no link inside the document is ever fetched; PDF parsed text-only with pdf.js eval disabled; DOCX macros detected and never executed; HTML scripts and human-hidden elements removed; zero-width/bidi-override characters stripped. Passages addressed to AI systems ("ignore previous instructions…") are detected, flagged (embedded_instructions_detected) and excluded from facts, obligations and requirements. The optional LLM assist (DOCUMENT_FACTS_LLM=auto + the intelligence synthesizer) is used only for requested/priority facts the deterministic extractor could not establish; it gets a system prompt declaring the document data-only, never sees detected injection passages, has no tools, and every answer must quote the document verbatim — the quote is located by our code (which also derives the page), and answers whose quote is missing, lies in an injection passage, does not support the value, or contains a secret environment value are discarded.
Structured errors (no charge on x402/L402/MPP): MISSING_DOCUMENT / CONFLICTING_DOCUMENT_SOURCES / EMPTY_DOCUMENT / INVALID_URL 400, DOCUMENT_TOO_LARGE 413, UNSUPPORTED_FORMAT 415, UNREADABLE_DOCUMENT 422 (corrupt, password-protected, scanned without a text layer), DOCUMENT_FETCH_FAILED 502, DOCUMENT_TIMEOUT 504, EXTRACTION_FAILED 500, plus the shared INVALID_INPUT / INVALID_JSON.
Example output in OpenAPI / /api/v1/capabilities is the real pipeline over a synthetic agreement — regenerate with node --import tsx scripts/generateDocumentFactsExample.ts (a test fails if it drifts). Unit economics: deterministic extraction has no per-call provider cost; LLM assist (when enabled and needed) ≈ ≤ $0.02.
invoice_anomaly_check, $0.25/call)POST /api/v1/finance/invoice-anomaly-check (API key) · POST /api/v1/x402/finance/invoice-anomaly-check (x402, $0.25 = 250000 USDC atomic units) · POST /api/v1/l402/finance/invoice-anomaly-check (L402, when enabled) · POST /api/v1/mpp/charge/invoice_anomaly_check and MPP sessions (when enabled) · MCP tool invoice_anomaly_check. Category finance_risk, OpenAPI tag "Finance", idempotent, no side effects. Use before approving, paying, booking, reconciling or auditing an invoice, especially when an agent needs to determine whether the invoice requires human review.
The engine (src/invoice-anomaly/) is deterministic and in-process: no LLM, no external API, no credentials, nothing stored or logged. Money is parsed from its decimal text into scaled BigInts (never compared as binary floating point) and every arithmetic comparison has an explicit rounding tolerance of one currency minor unit per rounding step (0.01 USD, 0.001 OMR, 1 JPY; overridable with options.roundingTolerance).
Two modes. Standalone — only invoice (the only required field is invoice.total): arithmetic, dates/terms, repeated lines, invoice-number sanity, currency-code validity, missing business fields. Context-aware — add any of historicalInvoices (≤ 1,000, any supplier), supplierProfile (ids, aliases, bankAccounts with verified/addedDate, currencies, terms, status, createdDate), purchaseOrder (supplier, currency, totalAmount, invoicedToDate / remainingAmount, amountsIncludeTax, issueDate, status, line items), contract (supplier, currency, maxAmount, maxInvoiceAmount, invoicedToDate, period, terms), approvalContext (approvalThreshold, currency, splitWindowDays) and paymentHistory (≤ 1,000). options.asOfDate pins the reference date (default: today, UTC) for fully reproducible output.
Response (abridged):
(The full, generated example — a near-duplicate with a changed bank account billed against another supplier's PO, critical / hold — is in OpenAPI and /api/v1/capabilities; regenerate it with node --import tsx scripts/generateInvoiceAnomalyExample.ts, a test fails if it drifts.)
Anomaly codes (one record per code; sub-findings in evidence.issues):
| Family | Codes | What triggers it |
|---|---|---|
| Arithmetic | LINE_TOTAL_MISMATCH, SUBTOTAL_MISMATCH, TAX_MISMATCH, TOTAL_MISMATCH | quantity × unitPrice − discount ≠ line total; Σ lines ≠ subtotal; stated tax ≠ line/invoice rate × base; subtotal − discount + tax + shipping ≠ total (beyond tolerance). ≤ 0.5% of the total → low, else medium |
| Duplicates | DUPLICATE_INVOICE, POSSIBLE_DUPLICATE | confirmed only for same supplier + same (or same-digit) invoice number + same amount, or an already-recorded payment; possible for a number variant (suffix/typo), same amount within duplicateWindowDays (14) plus a corroborating signal (similar number, same line items, same PO), same amount within 3 days, a reused number with a different amount, a resubmitted cancelled/rejected invoice, or the same number and amount under another supplier record. Sequential numbering and recurring same-amount billing are not treated as corroboration. The matched record is returned |
| Supplier behavior (≥ 3 prior invoices) | UNUSUAL_AMOUNT, UNUSUAL_CURRENCY, UNUSUAL_PAYMENT_TERMS, INVOICE_NUMBER_ANOMALY, SUPPLIER_PATTERN_DEVIATION | above the historical max AND ≥ 2× the median AND robust z (median/MAD) ≥ 3.5 (≥ 5× → high); currency new for the supplier / outside the profile / differs from the contract / not ISO 4217; terms ≤ 50% and ≥ 10 days shorter than contract, profile or history; placeholder numbers, a format deviating from the supplier's ≥ 80%-dominant format, out-of-sequence numbers; blocked/suspended/inactive/unverified supplier, supplier id/name differing from the profile, a supplier record created ≤ 30 days before the invoice, ≥ 3 invoices in 30 days at ≥ 3× the prior monthly rate |
| Payment details | BANK_ACCOUNT_CHANGED, UNKNOWN_BANK_ACCOUNT | account not among the supplier's profile/history/payment accounts (high), or on the profile but added ≤ 30 days ago and never paid (medium); a profile without the account, or the account only on file unverified (high); critical when the account also appears on another supplier's records. Always masked |
| PO / contract / approval | PO_MISMATCH, PO_AMOUNT_EXCEEDED, CONTRACT_LIMIT_EXCEEDED, APPROVAL_THRESHOLD_EXCEEDED (info) | PO reference, supplier, currency, closed/cancelled status, quantity above ordered, unit price above PO, unmatched lines; amount above the remaining PO balance (> 10% of the PO → high); cumulative invoicing (caller-supplied or summed from history in the contract period) above the contract cap, or above the per-invoice maximum; above the approval threshold (routing info, 0 points) |
| Split invoices | SPLIT_INVOICE_PATTERN | same-supplier invoices within splitWindowDays (7) each ≤ the approval threshold but together above it (confidence rises with same-day dates, similar lines/same PO, all ≥ 85% of the limit), or ≥ 3 invoices within 90 days at 90–100% of the threshold. A risk indicator, never an allegation |
| Dates | DATE_ANOMALY, DUE_DATE_ANOMALY | dated > 1 day in the future, > 365 days old, before the PO issue date or outside the contract period; due before the invoice date, due date > 3 days off the stated terms, terms > 1 year. Same-day due dates, past due dates and next-day invoice dates are not flagged |
| Line items | DUPLICATE_LINE_ITEM | the same description/SKU at the same price and quantity twice (medium), or near-identical descriptions at the same price (low) |
| Data quality | MISSING_REQUIRED_FIELD | business fields missing (invoice number, supplier, invoice date, currency): low, medium when ≥ 3. Recommended fields (due date/terms, lines, subtotal, tax, bank account) are listed in dataCompleteness only |
Risk score (model iac-1.0.0, src/invoice-anomaly/scoring.ts + config.ts): each anomaly contributes base points × confidence — info 0, low 5, medium 20, high 35, critical 50, with overrides DUPLICATE_INVOICE 50/60, POSSIBLE_DUPLICATE 20/40, BANK_ACCOUNT_CHANGED 25/55/70, UNKNOWN_BANK_ACCOUNT 20/40/60, SPLIT_INVOICE_PATTERN 20/40, MISSING_REQUIRED_FIELD 3/8. Low/info contributions are capped at 10 in total and medium ones at 45. Correlation bonus: +8 per additional risk family with a ≥ medium anomaly (max +24) and +10 when a ≥ high payment-detail anomaly coincides with a duplicate, PO/contract or split anomaly. Severity ceiling: only-low findings score ≤ 14, a maximum of medium ≤ 64 — so trivial warnings can never produce a critical result. Levels / advisory decision: 0–14 low → continue; 15–39 medium → review; 40–64 high → review; 65–100 critical → hold. The scoring object returns every component, cap, bonus and the raw score.
Errors (not charged on x402 / L402 / MPP): INVALID_INPUT (schema / unknown fields) and INVALID_JSON 400, INVALID_MONETARY_VALUE 400, INVALID_DATE 400, UNSUPPORTED_CURRENCY_FORMAT 400 (each with details.path, never the value), ANALYSIS_FAILED 500 (sanitized). Unit economics: no upstream provider or LLM cost.
Limitations. The capability identifies invoice anomalies and risk indicators. It does not independently establish fraud or replace accounting, audit, compliance, or payment-authorization controls. decision is advisory, not a payment approval or rejection. Findings are limited to the data supplied (no ERP, bank, registry or third-party fraud source is consulted; connectors are a future phase). Supplier baselines need ≥ 3 prior invoices from the supplier; amounts in different currencies are never converted or compared; checkedAt is day-granular (the as-of date) so identical inputs give identical output.
By default (OMAN_PROPERTY_DATA_MODE=manual, or unset) analyze_oman_property uses only the bundled curated fixtures — no database is required to run or evaluate the capability. Production deployments instead populate a real PostgreSQL-backed data layer and switch modes:
OMAN_PROPERTY_DATA_MODE=manual (default) — the curated ManualDatasetProvider fixtures only. No database connection is attempted.OMAN_PROPERTY_DATA_MODE=database — production records only, read through DatabaseOmanPropertyDataProvider (domain/oman/dataProviders.ts) from PostgresPropertyMarketRepository (db/marketStore.ts). Requires OMAN_MARKET_DATABASE_URL (or DATABASE_URL) to be set; the manual fixtures never contribute to an answer in this mode.OMAN_PROPERTY_DATA_MODE=composite — database records first, falling back to the manual fixtures only where the database has nothing to offer. Useful while a real feed is still being onboarded for some areas but not others.Schema and migrations: db/marketSchema.ts defines property_market_records (governorate, wilayat, area, normalizedArea, propertyType, bedrooms, bathrooms, sizeSqm, transactionType, priceOMR, rentPeriod, furnished, sourceType, sourceName, sourceRecordId, sourceUrl, observedAt, ingestedAt, metadata JSONB), indexed on normalizedArea/propertyType/transactionType/bedrooms/sizeSqm/observedAt, plus a partial unique index on (sourceName, sourceRecordId) for records that carry a source record id. PostgresPropertyMarketRepository.migrate() applies this schema idempotently using its own migration ledger (rafid_market_migrations, advisory lock 74382002) — entirely independent of the customer/billing store's migrations, so market-data schema changes never contend with or depend on customer data migrations. Records are upserted (INSERT ... ON CONFLICT (source_name, source_record_id) DO UPDATE) when a sourceRecordId is present, so re-importing the same feed is safe and idempotent; records with no sourceRecordId are always inserted fresh, since they cannot be safely deduplicated.
Importing a dataset:
This runs PostgresPropertyMarketRepository.migrate() (safe to run repeatedly) and then imports the file, printing a JSON summary of { totalRows, imported, updated, skipped, errors }. Both CSV and JSON are accepted ({ "records": [...] } or a bare array); see data/example-oman-market-import.csv for the expected columns. Every row is validated against a strict schema (unknown columns are rejected outright), locations are normalized through the same English/Arabic area resolution analyze_oman_property itself uses, rent periods/furnished status/transaction type aliases are normalized, and text fields are sanitized — a malformed row is skipped and reported individually rather than aborting the whole import. Import is bounded (10 MB file size, 20,000 rows) and never executes anything found in the file; there is no web UI, only the CLI and the underlying importMarketRecords() service for programmatic use.
Size validation is property-type-specific, not one generic range: SIZE_BOUNDS_BY_PROPERTY_TYPE in domain/oman/importPipeline.ts — apartment 20-1500 sqm, townhouse 40-2000 sqm, villa 50-10,000 sqm — centralized in one place rather than a single bound tuned for a typical apartment (which previously rejected genuine ultra-luxury villas well over 3,000 sqm while being far looser than warranted for an apartment). Still finite in both directions per type: widening the villa band never means "accept anything."
Caching: an optional ComparableCache (domain/oman/cache.ts) can front repeated comparable lookups against the database — keyed by area, property type, bedrooms, a bucketed property size, furnished status and transaction type — with a configurable TTL (OMAN_MARKET_CACHE_TTL_MS, default disabled). The bundled MemoryComparableCache is process-local; the interface is deliberately not coupled to Redis so a distributed cache can be added later (e.g. a future RedisComparableCache) without touching DatabaseOmanPropertyDataProvider or any analysis logic.
What stays the same regardless of mode: comparable selection, outlier removal, confidence scoring and provenance labeling (domain/oman/comparables.ts/confidence.ts) are untouched by any of this — the database provider only retrieves a broader candidate pool (matching area, property type, transaction type and recency); every downstream decision about which candidates actually get used is made in the same place regardless of where the records came from.
The Oman property intelligence engine (comparable selection, confidence scoring, official NCSI context) is complete on its own; this layer exists solely to make it easy — and safe — for a real estate company, brokerage or property manager in Oman to supply Rafid with authorized market data, instead of every production record coming only from a manually curated fixture or an operator hand-loading a file. It is infrastructure, not an agent capability: none of it is registered in domain/capabilities.ts, so none of it appears in /agent.json, /api/v1/capabilities, the MCP tool catalog, or the x402 payment-gated route family — an agent calling analyze_oman_property has no way to reach or even discover these routes, and they carry no price. analyze_oman_property's own request/response shape is completely unchanged by any of this.
Partner model. A PropertyDataPartner (domain/oman/partners.ts) is deliberately minimal — partnerId (a short slug, e.g. gulf-realty-om), partnerName, feedType (csv | json | http_feed), sourceType (almost always partner_feed), enabled, dataLicenseReference, contactReference (a free-text pointer — an email alias or contract number — never a person's name or phone number), and createdAt. It deliberately stores no personal data about individuals at the partner. PartnerRepository is the storage abstraction, exactly like PropertyMarketRepository — MemoryPartnerRepository for tests, PostgresPartnerRepository (db/partnerStore.ts) for production, sharing the same Pool/database as PostgresPropertyMarketRepository rather than opening a second connection pool.
Authentication model — three distinct credentials, each scoped to what it's for:
X-API-Key (existing) — the normal customer credential for the four billed capabilities. Never accepted by the partner-feed routes.X-Partner-Token — a per-partner bearer token (rafid_partner_<64 hex chars>), generated once at partner:create time and returned to the operator exactly once; only its SHA-256 digest is ever stored (data_partners.token_digest), mirroring how rafid_keys.digest protects a customer API key. Authenticates the ingestion endpoint below as exactly one partner — a token can never act as, or see, another partner's data.X-Internal-Api-Key — a single shared secret (MARKET_DATA_INTERNAL_API_KEY) gating the two read-only internal endpoints below. Deliberately distinct from every partner token (a partner must never see cross-partner aggregate data) and from the customer API key. Unset means those routes always answer 503, never silently open.Ingestion modes. CSV file upload, JSON file/array upload, and an authenticated HTTP feed — all three go through the exact same POST /api/v1/market-data/import endpoint and the exact same validating pipeline (domain/oman/importPipeline.ts) the operator-run market:import CLI already used; there is no separate write path and no scraping of any kind.
POST /api/v1/market-data/import — never public. Requires X-Partner-Token. The request body is either:
Content-Type: text/csv (or text/plain) with the CSV text as the body, in the exact column format below; orContent-Type: application/json with a bare JSON array of records, or { "records": [...] }.Response: { success: true, data: { partnerId, totalRows, imported, updated, skipped, rejected, averageDataQualityScore, rejections: [{ row, code, message }] } }. A malformed row never aborts the batch — it is rejected individually with its 1-based row number, a stable error code (see Rejection codes below), and a safe message; your original row data is never echoed back. Two rows in the same batch sharing a sourceRecordId but differing in content are treated as a legitimate same-batch update (last one wins, exactly as before); two rows that are otherwise byte-identical are treated as a true duplicate — the earlier occurrence is rejected with code DUPLICATE_RECORD and only the later one is written.
CSV format (identical columns to data/example-oman-market-import.csv; see data/example-partner-feed.csv for a full fictional example):
JSON format (identical field names as the CSV header; see data/example-partner-feed.json):
Required per row: governorate, area (must resolve to a supported Muscat area — English or Arabic spelling), propertyType (apartment/villa/townhouse), sizeSqm, transactionType (rental/sale), priceOMR, rentPeriod (required for rental, must be blank for sale), sourceType, sourceName, observedAt. Optional: wilayat, bedrooms, bathrooms, furnished, sourceRecordId, sourceUrl, metadata (a small JSON object). Never include a partnerId column — the row schema is strict and rejects unrecognized columns outright, so a file cannot even attempt to claim a partner identity; attribution comes only from the authenticated X-Partner-Token (or, for a CLI-run bulk import, an explicit operator-supplied partner id) and silently overwrites whatever sourceType/sourceName the row itself claims. This is deliberate: a partner's own file can never spoof another partner's identity or claim a provenance (e.g. official_statistics) it hasn't earned. sourceType/sourceName are still required columns for schema validation to pass — fill them with your own values (typically sourceType=partner_feed, sourceName=<your company name>) even though the server overwrites them, so the file is self-consistent if inspected on its own.
Validation — "reject impossible or suspicious values", not just malformed ones. Beyond type/enum checks, application-level bounds (tighter than the database's own wide CHECK constraints, which remain a last-resort safety net): sizeSqm between 10 and 3,000; a sale priceOMR between 3,000 and 20,000,000 OMR; a rental's monthly-equivalent price (annual rents divided by 12 first) between 30 and 15,000 OMR; observedAt no more than a day in the future and not before the year 2000. A row failing any of these is rejected individually, with a plain-English reason, exactly like a malformed or unrecognized-area row.
Deduplication strategy. Idempotent upsert on (sourceName, sourceRecordId) — the same partial unique index and INSERT ... ON CONFLICT ... DO UPDATE the base import pipeline already used. Because sourceName is always forced to the authenticated partner's own name, this is effectively (partnerId, sourceRecordId) in practice: re-sending the same sourceRecordId (whether in a later request or twice within the same batch) updates the existing record rather than duplicating it. A row with no sourceRecordId can never be safely matched against a prior import and is always inserted as new — exactly as documented for the base pipeline.
Data-quality scoring — deterministic, never an LLM. Every accepted record gets a dataQualityScore in [0, 1] (domain/oman/dataQualityScore.ts), a fixed weighted sum computed the same way every time: completeness of optional fields — bedrooms/bathrooms/furnished/sourceUrl/metadata (30%), size plausibility (15%), price plausibility (20%), freshness — linear decay to 0 at a two-year-old record (20%), and source identity — 1.0 for an authenticated partner or official statistics, 0.7 for the manual benchmark, 0.5 for an unattributed listing (15%). The import response's averageDataQualityScore is the batch mean; the CLI and HTTP paths both compute and store it identically.
Partner-feed statistics. data_partners tracks recordsReceived/recordsAccepted/recordsRejected/recordsUpdated/latestObservationDate per partner, updated after every import (HTTP or CLI) — see Partner Operations layer below for the full monitoring/health/audit surface built on top of this.
GET /api/v1/internal/market-data/status (X-Internal-Api-Key) returns exactly:
Cross-partner aggregates only — no individual partner's token, digest, statistics or identity is ever exposed here (that detail lives behind .../partners, gated by the same key).
Provenance. analyze_oman_property's existing provenance/dataQuality.sourceTypes grouping (unchanged code — services/omanProperty.ts) already distinguishes partner_feed from official_statistics, manual_benchmark and listing_asking_price by (sourceType, sourceName), because sourceType/sourceName were already part of every MarketRecord; this layer only had to make sure partner-ingested records are honestly attributed at write time, which the forced server-side attribution above guarantees.
Onboarding the first Oman real-estate data partner — quick version (the full, actionable checklist is docs/FIRST_PARTNER_ONBOARDING.md):
dataLicenseReference/contactReference, never a person's name or phone number.http_feed feedType), or hands Rafid a file for an operator-run bulk import attributed to them:
GET /api/v1/internal/market-data/status (aggregate), GET /api/v1/internal/market-data/partners (per-partner health) and GET /api/v1/internal/market-data/partners/:partnerId/quality (data-quality summary), all behind MARKET_DATA_INTERNAL_API_KEY.npm run admin -- partner:disable gulf-realty-om (its token immediately stops authenticating); partner:enable resumes it.Testing. tests/partner-feed.test.ts covers partner authentication (valid/unknown/disabled/rotated tokens), CSV ingestion, JSON ingestion (array and {records:[...]} forms), idempotent upsert (across requests, within one batch, and true in-batch duplicates), invalid/rejected rows and their codes, the deterministic data-quality score and per-partner quality summary, partner provenance flowing into analyze_oman_property, stale-partner detection (including that one stale partner never marks another partner or the market stale), unauthorized-import rejection, token rotation, onboarding-package generation, the ingestion audit log, and the exact /api/v1/internal/market-data/status/.../partners response shapes — all against in-memory repositories, plus an opt-in PostgreSQL integration test (gated behind TEST_DATABASE_URL, matching tests/market-data.test.ts's pattern) covering the migration schema, PostgresPartnerRepository/PostgresPartnerIngestionAuditRepository, and the admin/market:import CLI commands against a live database. npm run test:partner-e2e (scripts/partner-e2e.ts) is a standalone, self-contained end-to-end rehearsal of the entire first-partner flow — create, issue token, ingest, re-ingest (idempotency), check health, run analyze_oman_property, disable, verify ingestion is then rejected — using only in-memory repositories, so it needs no database or production secrets and can run in CI.
Built directly on top of the Partner Data Feed layer above, to make onboarding and operating a real partner safe and practical without adding any new public capability, dashboard, or scraping. Every piece here is either the existing internal-key-gated JSON routes, or an operator-run CLI command — nothing is registered in domain/capabilities.ts.
Onboarding package. npm run admin -- partner:package <partnerId> [baseUrl] (domain/oman/partnerPackage.ts) writes partner-packages/<partnerId>/{README.md, sample.csv, sample.json, schema.json, curl-example.txt} (git-ignored) — a complete, self-contained explanation of the endpoint, required header, CSV/JSON formats, field meanings, allowed property/transaction types, rentPeriod/observedAt rules, max batch size, rejection codes, and retry/idempotency behavior. Never contains the partner's real token — every example uses a <PARTNER_TOKEN> placeholder; deliver the real token separately, through a channel appropriate for a credential.
Token rotation. npm run admin -- partner:rotate-token <partnerId> invalidates the current token immediately (a single UPDATE ... SET token_digest — no window where both the old and new token work), issues exactly one new token, prints it once, and records a token_rotated event in data_partner_audit_log (also recorded for created/enabled/disabled).
Ingestion audit log. partner_ingestion_audit (db/partnerOpsSchema.ts, migration v3; domain/oman/partnerAudit.ts's PartnerIngestionAuditRepository) records exactly one row per ingestion attempt that reaches an authenticated partner — success or failure: id, partnerId, requestId, receivedAt, recordsReceived, recordsAccepted, recordsRejected, recordsUpdated, httpStatus, durationMs, errorCode. It never stores the partner's token, the raw request body, or any individual record's fields — the row shape has no room for any of those. This is what powers the windowed figures in the health endpoint below without ever re-reading anything sensitive.
Partner health. GET /api/v1/internal/market-data/partners (X-Internal-Api-Key) now returns, per partner:
latestIngestionAt (most recent ingestion attempt, from the audit log) is deliberately distinct from latestObservationDate (freshest accepted record's observedAt, from PartnerFeedStats as before) — a partner can have a very recent latestIngestionAt while every attempt was rejected, which is exactly what an operator needs to see. Never exposes a token, digest, contact detail, or license reference.
Staleness policy. PARTNER_FEED_STALE_DAYS (default 7; replaces the prior phase's MARKET_PARTNER_STALE_DAYS, default 30) — a partner is stale once no accepted fresh observation has arrived within the threshold. Staleness is always per-partner — one partner going stale never marks any other partner, or the market as a whole, stale; it never gates ingestion or analyze_oman_property either.
Rejection codes. Every rejected row's code is one of: INVALID_FORMAT, INVALID_AREA, INVALID_PROPERTY_TYPE, INVALID_TRANSACTION_TYPE, INVALID_SIZE, INVALID_PRICE, INVALID_BEDROOMS, INVALID_BATHROOMS, INVALID_RENT_PERIOD, INVALID_FURNISHED, INVALID_SOURCE_TYPE, INVALID_SOURCE_NAME, INVALID_URL, INVALID_DATE, INVALID_METADATA, DUPLICATE_RECORD. The accompanying message is a safe, human-readable summary — the rejected row's original values are never echoed back.
Data-quality summary. GET /api/v1/internal/market-data/partners/:partnerId/quality (X-Internal-Api-Key) returns { partnerId, recordCount, averageDataQualityScore, percentageAbove80, percentageBelow50, missingBedroomRate, missingBathroomRate, missingFurnishedRate }, computed with database-side aggregation (a single SELECT ... count(*) FILTER (WHERE ...) query in PostgresPropertyMarketRepository.getPartnerQualitySummary()) rather than reading every record into memory.
Security. The internal API key is compared with timingSafeEqual (unchanged from the prior phase); partner-token authentication is a SHA-256 digest lookup, never a raw-token comparison, so the value that would need to be timing-attacked is already a cryptographic digest, not the secret itself. Disabled partners fail X-Partner-Token auth immediately (already tested); a rotated-away token fails immediately too (new tests). Neither the internal API key nor any partner token, nor any raw ingested payload, is ever logged — the request logger records only the matched route template, status and duration — and the ingestion audit table's row shape structurally excludes all three.
First partner checklist. See docs/FIRST_PARTNER_ONBOARDING.md for the full, step-by-step operational checklist: agree data rights, create the partner, generate the onboarding package, securely deliver the token, send a test batch, verify accepted/rejected records and provenance, confirm analyze_oman_property surfaces partner_feed (and no demo-data flag when only partner data contributes), monitor the first 24 hours, then enable the partner's production schedule.
Built directly on top of the Partner Operations layer above, so the first real Oman partner feed can run on a schedule instead of a human pushing files by hand. Like everything else in the Partner Data Feed/Operations layers, this is infrastructure — domain/oman/partnerFeedRunner.ts, its CLI entry points, and its own database columns/table are never registered in domain/capabilities.ts and never appear in /agent.json, the tool catalog, remote MCP, or the x402 route family. It never touches analyze_oman_property itself, x402 pricing, or any other public capability.
Feed configuration model. Six new, all-optional columns on data_partners (migration v4, db/partnerFeedSchema.ts): feedUrl, feedFormat ("json" | "csv"), scheduleEnabled, scheduleIntervalMinutes, lastSuccessfulRunAt, lastAttemptAt, plus consecutiveFailures. Every partner created before this migration simply has no schedule configured — nothing to backfill. Set with npm run admin -- partner:set-feed <partnerId> <feedUrl> <json|csv> <true|false> [intervalMinutes] — a full replace of the schedule config each time (mirrors setEnabled()'s "one explicit value" simplicity), never a partial patch.
Security model (credentials). Feed authentication is never stored on PropertyDataPartner itself. A separate table, partner_feed_credentials (also migration v4; one row per partner: authType — bearer | api_key_header | none — plus secretRef and headerName), stores only a credential's shape. secretRef is a reference — an environment variable name — never a secret value; the actual value is resolved only at fetch time, in memory, for the duration of one outbound request, via an injectable SecretProvider abstraction (domain/oman/partnerFeedCredentials.ts). The bundled EnvSecretProvider reads process.env[secretRef]; a future deployment can swap in a real secrets-manager-backed provider without touching the feed runner or the credential schema. Set with npm run admin -- partner:set-feed-credential <partnerId> <bearer|api_key_header|none> [secretEnvVarName] [headerName] — the secret's actual value is never an argument to this command, only the name of the environment variable holding it.
SSRF controls. Every feed URL — and every redirect target, independently re-validated before being followed, never blindly trusted — must pass domain/oman/feedSecurity.ts's validateFeedUrl(): HTTPS-only by default (a test/dev-only allowInsecureHttp escape hatch exists but is never partner-influenced), rejects localhost/*.localhost/0.0.0.0, rejects private/loopback/link-local/reserved IPv4 and IPv6 ranges (checked both as a literal address and via DNS resolution of a hostname, through an injectable HostResolver so this is fully unit-testable without real DNS), and supports an optional exact-hostname allowlist. domain/oman/safeFeedFetch.ts layers the actual bounded HTTP GET on top: a timeout (AbortController), a streamed response-size cap (aborts mid-download rather than buffering an oversized body), and manual (redirect: "manual") redirect handling so a redirect to a different, unsafe host can never be followed silently.
Retry policy. domain/oman/feedRetry.ts retries only: a network/connection error, a request timeout, or an HTTP 502/503/504 — bounded exponential backoff (200ms, 400ms, 800ms, capped at 4s), maximum 3 attempts total. A 400, 401, 403, 404, or any validation/parse failure is never retried — repeating an already-wrong request would only fail identically.
Scheduling model. computeNextDueAt()/isFeedDue()/computeFeedHealth() (domain/oman/partners.ts) are pure, stateless functions shared by both the scheduling pass and the health endpoint, so "which partner is due" and "what does the health endpoint report" can never disagree. A partner that has never been attempted is due immediately, not after waiting a full interval from "now".
CLI commands.
npm run partner:feeds — one scheduling pass: identifies every enabled, due partner, runs each safely, prints a safe JSON summary ({"attempted": N, "successful": N, "failed": N, "results": [...] }), and exits. Never an always-running process — intended for an external scheduler (Vercel Cron, a GitHub Actions schedule: trigger, Windows Task Scheduler, plain cron).npm run admin -- partner:run-feed <partnerId> — a single, on-demand run, for testing a partner before enabling its schedule. Prints only {partner, httpStatus, recordsReceived, recordsAccepted, recordsRejected, recordsUpdated, durationMs, auditId, ok, errorCode} — never the resolved credential header or a raw record.npm run admin -- partner:set-feed / partner:set-feed-credential — see above.Failure isolation (Section 7). PartnerFeedRunner.runDueFeeds() wraps each partner's run in its own try/catch — one partner's failure (however it fails) can never abort the loop or affect any other partner's run. Returns {attempted, successful, failed, results}.
Data safety. The feed runner calls the exact same importMarketRecords() every other ingestion path (the manual HTTP endpoint, market:import) already uses — never a duplicated or parallel validation/upsert implementation. Existing idempotent upsert rules, rejection codes, and the narrow DUPLICATE_RECORD semantics are entirely unchanged. A rejected batch never deletes existing records; raw partner data is never logged; credentials are redacted everywhere by construction (there is structurally no code path that stores or logs a secret value).
Feed health fields. GET /api/v1/internal/market-data/partners now additionally returns, per partner: lastFeedAttemptAt, lastFeedSuccessAt, consecutiveFailures, nextDueAt, and feedHealth ("healthy" | "degraded" | "stale" — consecutiveFailures >= 3 → "degraded", checked first; else the existing per-partner stale flag → "stale"; else "healthy"). Every existing Partner Operations field on this endpoint is unchanged. A partner's feed health never affects any other partner, or analyze_oman_property's availability globally.
Observability. Every completed run (success or failure) emits one structured JSON log line: {partnerId, requestId, attempt, status, httpStatus, durationMs, recordsReceived, recordsAccepted, recordsRejected} — no raw records, no tokens, no auth headers; every field is safe by construction, with nothing left to redact.
Tests. tests/partner-feed-runner.test.ts covers, entirely with mock HTTP (an injected fetchImpl and HostResolver — no real network or DNS): a successful JSON feed, a successful CSV feed, an HTTP timeout, HTTP 503 retried to success and retried to exhaustion (exactly maxAttempts calls, never more), HTTP 401 never retried (exactly one call), an oversized response, an unexpected content type, a malformed body, SSRF rejection of localhost, SSRF rejection of a hostname that resolves to a private IP, a redirect to a blocked host never followed, partner isolation in runDueFeeds(), idempotent re-fetch (no duplicate records), one audit row per attempt with a safe field set, consecutiveFailures/feedHealth transitions (including reset to 0 on the next success), secret redaction (a fake secret is sent to the mock feed endpoint but never appears in the outcome, logs, or audit rows), a misconfigured credential, and the three operator-misconfiguration guard cases (partner not found / disabled / unconfigured) that throw rather than recording a silent audit failure.
First live partner runbook. See docs/FIRST_LIVE_PARTNER_RUNBOOK.md for the full, step-by-step checklist: create the partner, generate the token/package, agree the feed URL/auth with the partner, configure the secret, test with partner:run-feed, inspect the audit log and data quality, verify provenance, run analyze_oman_property, verify no demo flag, enable the schedule, and monitor the first 24 hours.
analyze_oman_property's response includes an officialMarketContext object sourced from Oman's National Centre for Statistics and Information (NCSI) Open Data Portal API — the first, and so far only, live external network integration this capability makes. It is deliberately not another comparable data source: NCSI publishes governorate-level aggregate statistics (a price index, traded value, contract counts), never individual listing or transaction records, so this integration never feeds NCSI data into comparablesSummary, market, pricePosition or the comparable-based confidence score — those remain exactly as documented above, unaffected by whether NCSI is configured at all. officialMarketContext carries its own, separate confidence (unavailable/low/medium/high) so an agent can't mistake "official statistics exist" for "the comparable estimate is more trustworthy."
Rafid does not scrape NCSI or any other property/statistics website. This integration calls NCSI's own documented REST API (services/ncsi/ncsiClient.ts) — the "Explore Api" served from map.ncsi.gov.om/ODPAPI — nothing else. NCSI's data is published under the Open Government License – Sultanate of Oman, which supports API/web-service reuse; this integration only reads NCSI's own published aggregate statistics and always attributes them back to NCSI (officialMarketContext.provenance.source/license) — Rafid claims no ownership of NCSI's data.
What was actually verified live, and what wasn't (read before configuring this): before writing NcsiClient, the live API was inspected directly rather than guessed at. Its OpenAPI/Swagger document (https://map.ncsi.gov.om/ODPAPI/swagger/v1/swagger.json) was successfully retrieved and confirms a real, working "Explore Api" v1 with no authentication required and exactly the endpoints this client implements: GET /catalog/datasets (list), GET /catalog/datasets/{id} (metadata), GET /catalog/datasets/{id}/records (query, with Select/Where/order_by/offset/limit/language parameters), and GET /catalog/datasets/{id}/download. However, every attempt to actually call the catalog-listing or records endpoints during development returned HTTP 500 — including a request for a deliberately nonexistent dataset id, which a healthy API would 404 on rather than 500 — indicating the live backend was temporarily broken at the time, not that a parameter or dataset id was missing. api.ncsi.gov.om (the second URL commonly referenced for this API) serves only a bare Swagger UI shell with no working spec or data routes found at any standard path. Because of this, no real dataset id and no real record field names could be verified against live data, and none are hardcoded anywhere in this codebase as a result.
Configuration is therefore intentionally inert until an operator verifies it:
npm run ncsi:discover (retries the live catalog and filters titles/descriptions for real-estate keywords — English and Arabic). If NCSI's catalog backend is healthy when you run it, it prints candidate dataset ids/titles; if it 500s the way it did during development, it says so plainly rather than pretending to have found something.GET /catalog/datasets/{id}/records (via the discovery output, curl, or NCSI support) to find the actual field names for governorate, period, price index, traded value, and sale/mortgage contract counts.NCSI_REAL_ESTATE_DATASET_ID to that id, and NCSI_FIELD_MAP_JSON to a JSON object mapping this capability's concepts to those exact field names, e.g.:
Only governorate is required; any concept you omit from the map is reported as null in the response rather than guessed — "Only populate fields actually supported by retrieved data" is enforced structurally, not by convention.Until both are set, officialMarketContext.available is always false with reason: "ncsi_not_configured" and no network call is attempted at all — this is the example output shown in /openapi.json and the MCP tool catalog today.
Failure behavior: a timeout, a 5xx, a malformed response, or no record matching the requested governorate all resolve to officialMarketContext.available: false with a specific reason (ncsi_timeout / official_source_temporarily_unavailable / ncsi_malformed_response / no_data_for_governorate) — never a fabricated fallback figure, and never a failed analyze_oman_property call as a whole; the comparable-based analysis always completes independently (see tests/ncsi.test.ts's "an NCSI outage does not prevent the comparable analysis from completing").
Caching: a successful lookup is cached per governorate (domain/oman/officialContextCache.ts's MemoryOfficialMarketContextCache) for NCSI_CACHE_TTL_MS (default 6 hours, since official statistics are published quarterly/annually, not continuously) — a failed lookup is never cached, so the next call retries NCSI rather than pinning "unavailable" for the full TTL.
Testing: tests/ncsi.test.ts covers catalog/record parsing against a mocked HTTP layer (an injectable fetchImpl, never the network), field-map-driven parsing (missing metrics, stale records), every failure mode (timeout/5xx/4xx/malformed response), cache hit/expiry/no-cache-on-failure, and full-pipeline integration (NCSI down vs. healthy, zero-record dataset, the early-return unsupported-area path) — none of it depends on live NCSI availability. A separate, opt-in npm run test:ncsi-live (gated behind NCSI_LIVE_TEST=1 in .env, exactly like test:db is gated behind a live database) smoke-tests the real API when you want to re-verify it.
Known limitation: because the live catalog could not be queried successfully during development, this integration has not yet been exercised against a single real NCSI record — everything above is verified against the documented contract and mocked responses, not a live payload. Re-running npm run ncsi:discover and npm run test:ncsi-live once NCSI's backend is healthy (or once you have a dataset id/field list from NCSI directly) is the remaining step before this integration is proven end-to-end against real data.
GET /api/v1/x402 is always available, regardless of X402_ENABLED, and returns the protocol, scheme, whether payments are currently accepted, the network/receiving address (only when enabled — never a secret, just the public address), the facilitator in use, and per-tool price/endpoint. An agent can read this before deciding whether to pay, with no side effects.
GET /api/v1/x402/status is a smaller, always-on companion endpoint whose only job is to state the runtime facts plainly, so nothing has to be inferred from this README or the landing page's copy: { enabled, mode, network, asset, facilitator, walletConfigured, paymentEnforcement }. mode is "disabled", "testnet" (Base Sepolia — the public facilitator settles it, but no real value moves), or "production" (any other configured network, where a verified payment is a real on-chain transfer). enabled and paymentEnforcement are the same value by construction: this app only ever mounts the real @x402/express payment gate when X402_ENABLED=true, and loadConfig() fails startup closed if that gate's required configuration is missing or invalid — there is no state where the app is running with X402_ENABLED=true but the gate isn't actually enforcing. Never includes a secret.
When X402_ENABLED=true, the same four capabilities are also available, unauthenticated, under /api/v1/x402/...:
| Method | Endpoint | Price |
|---|---|---|
| POST | /api/v1/x402/property/analyze | $0.01 |
| POST | /api/v1/x402/property/compare | $0.03 |
| POST | /api/v1/x402/maintenance/estimate | $0.02 |
| POST | /api/v1/x402/oman/property/analyze | $0.25 |
These routes accept the same JSON request/response bodies as their authenticated counterparts above, but skip the X-API-Key header entirely. Authorization is a valid on-chain payment instead: a request with no X-PAYMENT header (or an invalid/insufficient one) receives an HTTP 402 response. Per the x402 v2 protocol, the accepted payment options (accepts, prices, network) are carried in a base64-encoded JSON PAYMENT-REQUIRED response header, not the JSON body (the body is intentionally {}); decode that header (JSON.parse(Buffer.from(header, "base64").toString())) to read it. An x402-aware HTTP client does this automatically: it attaches a valid X-PAYMENT header for the quoted amount and network, and the request then proceeds normally. The price quoted always comes from BillingService.getToolPrice(), i.e. the same catalog /api/v1/pricing reads — the x402 gate cannot drift from the advertised price.
Payments settle in USDC on the network set by X402_NETWORK (default: Base Sepolia testnet, eip155:84532). By default, verification/settlement goes through the public facilitator at X402_FACILITATOR_URL (https://x402.org/facilitator, no account needed) -- but that free facilitator only settles Base Sepolia for EVM "exact" payments. To use any other network, including Base mainnet (eip155:8453), set both CDP_API_KEY_ID and CDP_API_KEY_SECRET (from a free Coinbase Developer Platform account, under API Keys — an Ed25519 Secret API Key, whose downloaded JSON file has id/privateKey fields that map to CDP_API_KEY_ID/CDP_API_KEY_SECRET): when both are set, the server authenticates to CDP's facilitator (https://api.cdp.coinbase.com/platform/v2/x402) instead, via the official @coinbase/x402 package, and X402_FACILITATOR_URL is ignored. Startup fails fast with a clear error if X402_NETWORK is anything but eip155:84532 and the CDP variables are not both set, rather than accepting payment requests it cannot actually settle. X402_WALLET_ADDRESS is the receiving wallet and must be set to an address you control before enabling this in production; the testnet default network is safe for development but moves no real money. These routes are entirely separate from the API-key/PostgreSQL customer system: no customer account, quota or usage record is created or consulted for x402 calls (though every call, on either route family, is still recorded through UsageRepository — see below). Disabling X402_ENABLED removes the routes entirely: they 404, and they are omitted from /openapi.json and the / discovery response — normal API-key calls are completely unaffected by this flag either way.
Current production status — stated precisely rather than as a blanket "live": the payment gate is real and operating on Base mainnet (eip155:8453) via the Coinbase CDP facilitator — production has returned a genuine HTTP 402 with correct terms (payTo matching the configured X402_WALLET_ADDRESS, network eip155:8453, price matching the catalog), which is only possible if the real @x402/express middleware is mounted, configured, and actually talking to the real facilitator. What has not yet been exercised in production is a completed mainnet payment settling all the way through to a tool executing — that would require sending real USDC. Base Sepolia testnet, where no real value moves, has been exercised fully end-to-end (a real funded test wallet completing a real payment against the public facilitator). Until a real mainnet payment has actually been completed and observed, the landing page deliberately says "x402 ready" rather than "live" for this reason, and GET /api/v1/x402/status is the authoritative place to check the current enforcement state rather than trusting any prose (this README included). There is no automated CI coverage for either live payment flow (the live tests are opt-in: RUN_X402_LIVE_TESTS=true npm test, which requires real internet access to the facilitator).
A second, independent payment rail next to x402 for agents that pay in bitcoin over the Lightning Network, implementing Lightning Labs' L402 protocol (formerly LSAT). Off unless L402_ENABLED=true; when off, /api/v1/l402/<tool> 404s and is left out of /openapi.json, /, /agent.json and /llms.txt. GET /api/v1/l402 (terms and USD prices per tool) and GET /api/v1/l402/status ({ enabled, mode, network, asset, backend, lightningBackendConfigured, rootKeyConfigured, paymentEnforcement }, never a secret) are always available.
Flow, for every capability (POST /api/v1/l402/property/analyze, /api/v1/l402/intelligence/research-company, …):
Authorization header → 402 with WWW-Authenticate: L402 macaroon="<base64>", invoice="<bolt11>", plus the same details in the JSON body (l402.amountSats, priceUsd, btcUsdRate, tokenValidUntil).Authorization: L402 <macaroon>:<preimage-hex> → 200 and the normal tool response.BillingService.getToolPrice(), the same source as x402 and /api/v1/pricing), converted to sats at the live BTC/USD rate when the challenge is issued and rounded up. Rate sources: Coinbase spot, then Kraken, then a cached rate up to 15 minutes old, then L402_BTC_USD_FALLBACK if set. With none available the route returns 503 L402_PRICING_UNAVAILABLE; it never guesses a price.L402_ROOT_KEY. Its caveats (service, capability, expires, amount_sats, price_usd, btc_usd) must all be present and valid, and sha256(preimage) must equal the payment hash signed into the macaroon identifier. Macaroons are standard V2 binary and byte-compatible with go-macaroon/js-macaroon, so standard L402 clients work unchanged.rafid_l402_redemptions, PRIMARY KEY on the payment hash). A successful call burns it. A failed call (4xx/5xx) releases it so the payer can retry what they already paid for. A reused, expired, tampered or wrong-tool token gets a fresh 402 challenge plus an X-L402-Error header naming the reason, which L402 clients already handle by paying again.POST /v1/invoices), authenticated with the least-privileged invoice macaroon, which cannot spend funds. Works with a self-hosted node (set LND_TLS_CERT for its self-signed cert) or a hosted one such as Voltage. If the node is unreachable, the route returns 503 L402_LIGHTNING_UNAVAILABLE; the error never leaks the node's host.L402_BACKEND=voltage): instead of an LND node, invoices come from a hosted Voltage Payments wallet. Rafid sends POST /organizations/{org}/environments/{env}/payments (payment_kind: "bolt11", amount in msats, x-api-key header), then polls GET …/payments/{id} until the BOLT11 is ready (up to ~12 s). The payment hash is read from the invoice itself using a built-in, checksum-verified BOLT11 decoder, and the invoice's encoded amount must equal the amount requested or it is refused. It needs VOLTAGE_API_KEY, VOLTAGE_ORGANIZATION_ID, VOLTAGE_ENVIRONMENT_ID and VOLTAGE_WALLET_ID. Use a staging (Mutinynet) environment with L402_NETWORK=signet for testing. Ledger rows record facilitator: "voltage".settlement_succeeded row to the same revenue ledger as x402. It has network: "lightning:<L402_NETWORK>", currency/asset: "BTC", sats in amountAtomic, BTC in amountDecimal, the payment hash as transactionHash (never the preimage) and facilitator: "lnd". It is deduplicated on the payment hash. Revenue totals keep BTC and USDC separate, and the internal dashboard shows BTC to 8 decimals. Reconciliation counts paid executions across both rails (analytics channel l402) and skips the USD price check for BTC rows. The amount_decimal column is widened automatically to numeric(24,10) on startup, which is lossless for existing rows.LND_REST_URL (https), LND_INVOICE_MACAROON (hex)) or the four VOLTAGE_* settings with L402_BACKEND=voltage, plus L402_ROOT_KEY (openssl rand -hex 32), and DATABASE_URL or L402_DATABASE_URL for cross-instance single-use enforcement. loadConfig() refuses to start if any of these is missing. See .env.example.MPP is a third payment rail. It runs alongside x402 and L402 and replaces neither. MPP is the Machine Payments Protocol co-authored by Tempo and Stripe. On the wire it uses the IETF "Payment" HTTP authentication scheme (WWW-Authenticate: Payment … / Authorization: Payment … / Payment-Receipt). Rafid implements it with the official TypeScript SDK, mppx (^0.11.0), and does not reimplement any of the protocol.
MPP is off unless MPP_ENABLED=true. When it is off, every /api/v1/mpp/* payment route returns 404 MPP_DISABLED and is left out of OpenAPI and discovery. Two routes are always available: GET /api/v1/mpp (terms and per-tool prices) and GET /api/v1/mpp/status (secret-free runtime status).
| Rail | What it is | Unit of payment |
|---|---|---|
| x402 | Pay-per-request, USDC on Base | one on-chain payment per call |
| L402 | Lightning pay-per-request | one BOLT11 invoice per call |
| MPP charge | One-time machine payment (tempo/charge on Tempo, optional evm/charge = Base USDC through the existing CDP facilitator) | one MPP credential per call |
| MPP session | Reusable authorization + metered machine usage (tempo/session: a TIP-1034 payment channel) | one deposit per session, one signed voucher per call, settled once |
Architecture. The code lives in src/billing/mpp/ and is kept apart from the domain services. The capabilities stay protocol-agnostic: MPP runs the same capability.execute() as REST, x402, L402 and MCP.
| File | Role |
|---|---|
config.ts | MPP_* parsing; fails closed at startup |
provider.ts | the MppProvider interface (createCharge, verifyCharge, settleCharge, createSession, verifySession, sessionCallChallenge, verifySessionCall, recordUsage, settleSession, closeSession) |
mppx.ts | the only file that talks to the SDK |
service.ts | orchestration |
sessions.ts | persistence |
store.ts | the SDK's AtomicStore on Postgres |
metering.ts | metering |
settlement.ts | revenue-ledger rows |
routes.ts, middleware.ts | HTTP |
mcp.ts | optional MCP binding |
audit.ts | audit log |
openapi.ts | OpenAPI paths |
To add another MPP method later (Stripe SPT, Solana, another EVM chain), write a new provider adapter. The API does not change.
Pricing. Prices always come from the capability registry (BillingService.getToolPrice()), so x402, L402 and /api/v1/pricing see the same numbers. Internally every amount is integer micro-USD. That equals one raw unit of the 6-decimal USD stablecoins MPP settles in. The server is authoritative: the SDK derives each route's payment request itself and rejects any credential whose amount, recipient, currency, realm, scope (rafid:charge:<tool>) or expiry differs. A client-supplied price, spent or remaining is never read.
Charge flow (POST /api/v1/mpp/charge/{tool}):
400 with no challenge and no payment.402 with one WWW-Authenticate: Payment challenge per configured method. The body contains { protocol: "mpp", mode: "charge", tool, amount, currency: "USD", paymentRequired: true, challenges, problem }.validateCredential).mpp_charge_redemptions).broadcastCredential). The response carries the normal tool output, a payment block and a Payment-Receipt header.402 MPP_SETTLEMENT_FAILED). This is the same rule x402 follows.402 MPP_PAYMENT_REPLAYED.Session flow. In the real protocol, a session is a Tempo payment channel.
Open. POST /api/v1/mpp/sessions with { "maxBudget": 20, "currency": "USD", "allowedTools": [...] }:
pending session (mpp_<32 hex>) and returns a 402 asking the payer to open a channel. The suggested deposit is the budget.meta. If they are changed, the request gets 409 MPP_TERMS_MISMATCH.201, and the session is active with maxBudget = min(requested, deposit).Call. POST /api/v1/mpp/sessions/{id}/tools/{tool} with an Idempotency-Key header runs these steps in order:
SELECT … FOR UPDATE plus a DB CHECK (spent + reserved <= max_budget) means concurrent calls can never overspend. An unaffordable call returns 402 MPP_SESSION_BUDGET_EXCEEDED { required, remaining } before it executes.402 with a voucher challenge for this channel.tempo.session.charge()).spent, remaining, calls and per-tool usage are updated.The response is the normal tool output plus usage: { sessionId, tool, charge, spent, remaining, calls, status } (kept outside data). A failed tool call is never charged. Retrying the same Idempotency-Key returns the stored result and charges nothing. The same key with a different tool or input returns 422 MPP_IDEMPOTENCY_CONFLICT.
Meter. GET /api/v1/mpp/sessions/{id} returns status, spent, remaining, calls, usageByTool, and the channel view (deposit, accepted voucher, metered, settled on-chain).
Close. POST /api/v1/mpp/sessions/{id}/close is idempotent. It stops calls, finalizes metering and settles:
close credential, the channel closes on-chain capturing exactly the metered spend, and the rest of the deposit is refunded. The response carries the SDK's Payment-Receipt. mppx's sessionManager.close() sends this credential as a body-less POST to the last URL it used (often a tool route), so every session route accepts a close credential before body parsing and never runs a tool for it. topUp credentials are refused (400): open a new session instead.tempo.session.settle(), but only when the signed voucher equals the metered spend. That function captures the signed voucher, so it is never used when that would over-capture. Otherwise settlement.status is pending_payer_close.toolName: "mpp_session". The per-tool reconciliation leaves these rows out because they are not per-call settlements.States. A session is one of pending, active, exhausted, closed, expired (MPP_SESSION_TTL_SECONDS) or failed. Calls are rejected unless the session is active: exhausted → 402, closed → 409, expired → 410.
Settlement states. settlement.status is one of:
| Status | Meaning |
|---|---|
not_started | Not attempted yet |
pending | An attempt holds a lease (MPP_SETTLEMENT_LEASE_SECONDS), or its outcome is unknown (RPC failure after broadcast). Never reported as success |
pending_payer_close | No safe server settle exists; only the payer's close credential can capture exactly the spend |
settled | The settle/close transaction's receipt was confirmed, or the chain already shows settled ≥ spent |
nothing_to_settle | Nothing was metered |
failed | A definitive failure (settlement_error holds the reason); the reconciler retries |
Every attempt first claims the row with a conditional UPDATE (a lease), so concurrent closes and maintenance runs can never both submit. On-chain settlement is cumulative, so even a retry after an unknown outcome cannot capture twice, and the ledger only records the delta the chain reports. The settlement reference (tx hash), attempt count and last error are stored on the session.
Pending-session cleanup and abuse limits.
A pending session expires at challenge TTL + MPP_PENDING_GRACE_SECONDS (default 300 + 60 s). After that it can never be activated: activation is a conditional UPDATE … WHERE status = 'pending' AND expires_at > now. If a channel opens on-chain after expiry, the session is marked failed with the orphan channel id recorded, the payer gets 410 MPP_SESSION_EXPIRED with the channelId, and nothing is metered (close the channel to recover the deposit).
POST /api/v1/mpp/sessions is limited per client address (MPP_SESSION_CREATE_RATE_LIMIT_MAX per minute, the existing fixed-window limiter, on when RATE_LIMIT_ENABLED=true). Each client may also hold at most MPP_MAX_PENDING_SESSIONS_PER_CLIENT unpaid pending sessions (429 MPP_TOO_MANY_PENDING_SESSIONS). The client key is HMAC(MPP_SECRET_KEY, IP): the raw IP isn't stored, and a wallet address is never used because it is unproven before the channel opens.
Maintenance (GET or POST /api/v1/mpp/internal/maintenance, Authorization: Bearer <MPP_MAINTENANCE_SECRET or CRON_SECRET>, not in OpenAPI) does four things. Each is idempotent and safe on several instances at once (FOR UPDATE SKIP LOCKED, leases):
MPP_PENDING_RETENTION_DAYS (audit rows of opened sessions are kept)Without a secret it answers 503. There is no in-process scheduler. To schedule it on Vercel, set CRON_SECRET and add a cron to vercel.json, for example {"crons":[{"path":"/api/v1/mpp/internal/maintenance","schedule":"*/10 * * * *"}]}. Hobby plans only allow daily crons, so this repository doesn't ship one; any external scheduler that sends the bearer header works too. Lazy expiry on each request still applies in between runs.
GET /api/v1/mpp/status, when MPP is enabled, also reports:
configuredprovider.reachable (a cached eth_chainId probe; never a payment)database.ready, plus session and settlement countscharge, session, network (chainId, observedChainId, testnet)Persistence. Tables are created automatically, like every other store in this repository, in one DDL transaction under an advisory lock:
| Table | Holds | Keys and constraints |
|---|---|---|
mpp_sessions | sessions | unique external_session_id (one channel backs one session); named CHECKs: spent + reserved ≤ max, spent ≤ max, remaining ≥ 0, max ≤ requested, non-negative counters, valid settlement status, a channel required once active/exhausted/closed |
mpp_usage_events | usage events | unique (session_id, request_id) = idempotency |
mpp_charge_redemptions | single-use charge credentials | primary key on the challenge id; in_flight / redeemed / settlement_unknown (only a stale in_flight claim is ever re-claimable) |
mpp_kv | the SDK's own channel state and replay markers | a linearizable read-modify-write under a per-key advisory lock |
MPP uses MPP_DATABASE_URL if set and falls back to DATABASE_URL. Production refuses to start MPP without a database.
Security.
Credentials are HMAC-bound, route-scoped and expiring. The SDK validates the challenge, signature, amount, recipient and scope.
Charge credentials are single-use.
A session's open credential cannot be replayed into a second session, and its terms cannot be tampered with.
Vouchers must come from the session's own channel.
Budget enforcement is atomic.
Settlement never captures unmetered authorization.
Structured audit events (one JSON line each) are emitted:
mpp.charge.challenge|verified|settled|replay_rejectedmpp.session.pending|pending_rejected|activated|call|usage_recorded|exhausted|budget_rejected|expired|closed|settlement_pending|settled|settlement_failedmpp.payment.failed and mpp.maintenance.runThey carry only allow-listed public fields (ids, amounts, tx hashes, reasons). Credentials, signed vouchers, receipts, raw payment headers, keys and secrets are never logged, and anything that looks like one is dropped.
A charge whose settlement outcome is unknown (network failure while broadcasting) keeps its credential claimed (settlement_unknown), withholds the result and answers 502 MPP_SETTLEMENT_UNCONFIRMED. It is never reported as success or as "not charged".
MCP. Normal MCP stays at /mcp, free and unchanged: MCP handles tool discovery and invocation, and MPP handles payment. With MPP_MCP_ENABLED=true, /mcp/mpp serves the MPP MCP transport binding:
tools/call without payment fails with JSON-RPC -32042 and data.challenges.params._meta["org.paymentauth/credential"]._meta["org.paymentauth/receipt"].The official mppx/mcp/client McpClient.wrap() handles this automatically, and tests/mpp.test.ts exercises it end to end. For session mode over MCP, open the session over HTTP and pass _meta["com.rafidsystem/mpp-session"] = { sessionId, idempotencyKey } along with the voucher credential. initialize and tools/list are passed straight through to the standard MCP server. More detail is in docs/mpp.md.
Configuration (see .env.example):
| Variable | Default | Purpose |
|---|---|---|
MPP_ENABLED | false | master switch |
MPP_PROVIDER | mppx | provider adapter |
MPP_NETWORK | tempo | tempo (chain 4217, USDC) or tempo-testnet (Moderato 42431, pathUSD) |
MPP_MODES | charge,session | enabled intents |
MPP_CHARGE_METHODS | tempo | tempo and/or evm |
MPP_CURRENCY | USD | pricing currency (USD only) |
MPP_SECRET_KEY | — | ≥32 random bytes, HMAC for challenges (openssl rand -base64 32) |
MPP_REALM | api.rafidsystem.com | stable realm bound into challenges |
MPP_TEMPO_PRIVATE_KEY | — | payee key; required for session mode (server-submitted close/settle) |
MPP_TEMPO_RECIPIENT | derived from the key | Tempo payee address |
MPP_TEMPO_CURRENCY, MPP_TEMPO_RPC_URL | chain defaults | overrides |
MPP_EVM_NETWORK | eip155:8453 | evm/charge network; mainnet needs CDP_API_KEY_*, like x402 |
MPP_EVM_RECIPIENT | X402_WALLET_ADDRESS | evm/charge payee |
MPP_SESSION_TTL_SECONDS | 3600 | session lifetime |
MPP_MAX_SESSION_BUDGET_USD / MPP_MIN_SESSION_BUDGET_USD | 1000 / 0.01 | budget bounds |
MPP_REQUIRE_IDEMPOTENCY | true | require Idempotency-Key on session calls |
MPP_CHALLENGE_TTL_SECONDS | 300 | challenge lifetime |
MPP_MCP_ENABLED | false | /mcp/mpp binding |
MPP_DATABASE_URL | DATABASE_URL | persistence |
Examples. examples/mpp-client/ contains charge.ts, session.ts (a $10 session, two tools, read the remaining budget, close) and bulk-supplier-check.ts (screen suppliers until the budget runs out, then close and settle).
MCP is one of Rafid's two primary agent interfaces (alongside x402), not an afterthought bolted onto the REST API. The installed MCP SDK v2 is retained; see the official SDK documentation. Two transports are available, and both are built by the exact same createMcpServer() factory (src/mcp/server.ts) reading the exact same capabilities registry — there is no second tool registry, schema set, description set, or execute() path to keep in sync by hand:
npm.cmd run mcp for a manual local process.POST /mcp on the deployed server, mounted whenever MCP_REMOTE_ENABLED=true (the default). Stateless (sessionIdGenerator: undefined): required, not just simpler, since a serverless deployment (Vercel) can route consecutive requests to different warm instances with no shared memory, so no server-side session state can be relied on. GET /api/v1/mcp/status always reports which transports are actually live: { enabled, transport: ["stdio","http"], tools: 4, endpoint: "/mcp" } (or endpoint: null/transport: ["stdio"] when remote is disabled).For local stdio, launch Node directly so npm banners cannot contaminate protocol stdout:
Use your own absolute project path. For remote Streamable HTTP, point an MCP client at the deployed URL instead of a local command, for example:
Or drive it directly with JSON-RPC 2.0 over HTTP:
Tools on both transports: analyze_property, compare_properties, estimate_maintenance, analyze_oman_property. Tool results include JSON text and structuredContent, with strict input/output schemas, validation errors and read-only annotations; each tool's MCP description is its registry description plus its whenToUse recommendation sentence, verbatim — never a second copy of that prose. Custom cross-field constraints (alias exclusivity, unique names) are enforced at runtime, and every error on either transport goes through the same publicError() sanitizer as REST, so an internal failure never leaks internals remotely any more than it does locally.
MCP and REST are not two implementations that happen to agree: all three (stdio, remote MCP, REST) call the exact same capabilities catalog (src/domain/capabilities.ts) and the exact same services/property.ts functions, so a formula or price change made in one place is correct everywhere, and there is nothing to keep in sync by hand.
Stdio is local to the launching OS user; REST API keys are not an authentication mechanism for stdio, and stdio calls remain deliberately unmetered — an MCP client's own OS user launched that process directly, with no shared server resource to protect. Remote MCP is different: it is a shared, public server resource, so every remote tool call is recorded through the same UsageRepository as REST/x402 calls (accessMode: "mcp-remote", billableAmount: 0 — remote MCP is not a paid channel in this phase) and is protected by the same rate limiting as the other public agent endpoints (see "Security, observability and billing boundary" below). Logs go to stderr on both transports; stdout is reserved for MCP stdio framing. Remote auth (beyond rate limiting) is not implemented yet — see "Next five production priorities" below.
API-key middleware compares SHA-256 digests with constant-time comparisons. Keys come from PostgreSQL in postgres mode and from configuration in legacy development mode. Request logs contain only generated request ID, matched route template/tool name, status, timestamp and duration; bodies, headers, query strings, raw API keys and unknown URL paths are not logged. Internal exceptions return a generic message. Every response — success or error — carries a Cache-Control: no-store, X-Content-Type-Options: nosniff, X-Frame-Options: DENY and Referrer-Policy: no-referrer header, plus a permissive-but-safe CORS policy (Access-Control-Allow-Origin: *) that is safe here because every route authorizes itself with a request header (X-API-Key or X-PAYMENT) rather than an ambient browser cookie or session — there is no credentialed state a cross-origin page could ride on. Request bodies are capped at 32kb (413 PAYLOAD_TOO_LARGE) before parsing.
createApp accepts an injectable rateLimiter middleware after authentication and before body parsing, for authenticated PostgreSQL-mode customer traffic. Separately, middleware/rateLimit.ts's createRateLimiter() protects the public, unauthenticated agent endpoints — discovery (/, /agent.json, the .well-known manifests, /llms.txt, /api/v1/agent, /api/v1/pricing, /api/v1/tools, /api/v1/capabilities, /api/v1/mcp/status), the whole /api/v1/x402/... family, and remote MCP (/mcp) — each as its own independent, IP-keyed, fixed-window budget (RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX, both configurable; RATE_LIMIT_ENABLED=false to disable for local development). This is an honest, per-process, in-memory default, not a distributed limiter: on a serverless platform running several concurrent instances (Vercel), each instance enforces the limit independently, so the effective ceiling across a whole deployment can exceed RATE_LIMIT_MAX under traffic spread across instances — a production deployment expecting many concurrent instances should swap in a shared-store limiter (e.g. Upstash Redis) behind the same RequestHandler shape; nothing else in this app needs to change to do that. Proxy trust is not enabled. Deploy behind HTTPS; do not publicly expose the MVP without these controls.
The isolated billing catalog (billing/catalog.ts) has indicative USD prices: analysis $0.01, comparison $0.03, maintenance $0.02, Oman property analysis $0.25. It is the single source of truth: BillingService (billing/service.ts) is the only code that reads it, and every other module — route handlers, the x402 gate, the agent/pricing/tools endpoints — asks BillingService for a price rather than reading the catalog or a literal directly. REST calls the injectable BillingGate.authorize after validation and before calculation; its default implementation does nothing and must not be interpreted as billing. This seam allows future credit or usage-billing adapters without changing calculation services. PostgreSQL mode supplies a durable customer UUID; env-key mode retains a configuration-local identity. Stdio remains unmetered local execution.
Every capability call — API-key, x402, or remote MCP — and regardless of outcome (success, validation failure, auth failure, rate limit), is recorded through a UsageRepository (billing/usage.ts): request ID, a redacted identifier (the existing customer ID, an x402:<network> tag, or mcp-remote — never a raw API key, wallet secret, or payment proof/signature), tool name, accessMode ("api-key" / "x402" / "mcp-remote"), status, duration, billable amount and currency. USAGE_REPOSITORY selects the store: console (default — one JSON line to stderr per call, wired by server.ts's ConsoleUsageRepository), memory (local inspection/tests only, lost on restart), or postgres (PostgresUsageRepository, a durable, queryable rafid_agent_usage table — its own table, independent of the customer store in db/store.ts, created on first use with CREATE TABLE IF NOT EXISTS, reusing DATABASE_URL). Swapping repositories requires no change to any route handler or to mcp/remote.ts.
When X402_ENABLED=true, @x402/express, @x402/core and @x402/evm gate a separate, unauthenticated /api/v1/x402/... route family (see "Pay-per-call via x402" above). Payment verification and settlement are delegated entirely to the configured facilitator (the public one, or CDP's when CDP_API_KEY_ID/CDP_API_KEY_SECRET are set -- see above); this repo does not implement wallet custody, settlement or replay protection itself. Never commit or log CDP_API_KEY_SECRET; store it the same way as DATABASE_URL.
Customer keys and abuse controls: persistent customer identities, hashed keys, issuance/rotation/revocation, shared per-customer quotas, and edge limits before authentication. A per-process IP-keyed rate limiter now protects discovery, x402 and remote MCP (see "Security, observability and billing boundary" above); still needed: a distributed limiter for multi-instance deployments, and per-customer quotas for the x402/remote-MCP route families specifically.
Durable usage and billing: x402 pay-per-call is implemented and live on both Base Sepolia and Base mainnet; UsageRepository now has a durable PostgreSQL-backed implementation (USAGE_REPOSITORY=postgres) alongside console/memory. Still needed: x402 receipt reconciliation, and a second billing adapter (e.g. credits/subscriptions) for customers who prefer not to pay per call in crypto, plus monetizing remote MCP itself (it is currently a free, unauthenticated channel).
Deployment and operations: HTTPS deployment, secret management, CI checks, dependency scanning, metrics/alerts, load testing, backups and recovery drills.
Authenticated, distributed remote MCP: the Streamable HTTP transport now exists at /mcp (stateless, sharing the exact same registry/service layer as stdio — see "MCP usage" above) with basic IP rate limiting and usage recording. Still needed: durable identity/authorization for remote callers, origin/host validation appropriate for a public (not localhost-bound) deployment, and a distributed rate limiter for multi-instance production traffic.
Trusted property intelligence: analyze_oman_property now has a production-ready data architecture: async capability execution throughout REST/x402/MCP, a PropertyMarketRepository abstraction with a PostgreSQL-backed implementation and its own migration ledger, a validated CSV/JSON import pipeline (npm run market:import), configurable provider modes (OMAN_PROPERTY_DATA_MODE=manual|database|composite), per-response data freshness/staleness reporting, dynamic (not hard-coded) demo-data risk flagging, an optional comparable cache, a real (though not yet live-verified) integration with NCSI's official statistics API for governorate-level market context (officialMarketContext), and — as of this phase — a production-ready Partner Data Feed layer (authenticated CSV/JSON/HTTP ingestion, per-partner tokens, idempotent upsert, deterministic non-LLM data-quality scoring, partner-feed health/staleness, and internal aggregate-status reporting, entirely outside the agent-capability registry) that finally gives a real Oman real-estate company, broker or property manager a documented, authorized path to supply market data — see "Oman property analysis (Muscat)", "Production Oman market data", "Partner Data Feed" and "Official market context (NCSI)" above. Comparable selection, outlier removal and confidence scoring are unchanged regardless of data source, and NCSI context is kept structurally separate from comparable-based figures. Still needed: NCSI's live catalog endpoint to actually respond (it returned HTTP 500 for every request during development — see "Official market context (NCSI)" for the full finding) so a real NCSI_REAL_ESTATE_DATASET_ID/NCSI_FIELD_MAP_JSON can be verified and this integration exercised against real data; actually onboarding a first real (not fictional) Oman real-estate data partner through the new Partner Data Feed layer (ListingDataProvider remains a real, wired-in, but always-empty seam pending that); broader Muscat/Oman area coverage; and calibrated maintenance assumptions with provenance/freshness/formula versions for estimate_maintenance before adding further ROI, service-charge, lease or facility tools.
Rafid Agent Intelligence, Phase 2/3: Phase 1 (research_company, find_companies, analyze_company_risk — see "Rafid Agent Intelligence" above) is implemented, tested and inert-by-default. Still needed: Phase 2 (research_web, deep_research) and Phase 3 (extract_document, analyze_document, architecture prep only) from the same expansion spec; this README's REST API table above still does not list the four pre-existing Oman business capabilities (search_oman_company, get_oman_company_profile, analyze_oman_company, due_diligence_oman_company) either — a pre-existing documentation gap, not introduced by this phase, worth closing in the same pass as Phase 2.
See implementation review for the initial findings, exact scope and verification.