Skip to content

Extensions Surface Overview

Joseph T. French edited this page Jun 11, 2026 · 1 revision

Extensions Surface Overview

The extensions surface is the read/write API layer for RoboSystems product extensions (RoboLedger and RoboInvestor). It sits beside the core /v1 platform API under a single /extensions/* prefix, and everything on it is graph-scoped at the URL.

Table of Contents

What the Extensions Surface Is

The core platform API lives under /v1 and covers graphs, billing, auth, and shared repositories. Product extensions — RoboLedger (accounting) and RoboInvestor (investment management) — are served separately under /extensions/*. The split is deliberate: the platform API manages identity, infrastructure, and metadata, while the extensions surface is where you read and write the domain data for a specific graph.

The extensions surface has a single organizing rule: the graph (tenant) is always in the URL path. Every route below is scoped to a graph_id, and FastAPI validates the user's access to that graph before any handler runs.

The Three Sub-Surfaces

Everything on the extensions surface falls into one of three sub-surfaces, split by transport rather than by HTTP verb. Reads go through GraphQL, writes go through named command operations, and graph-backed analytics go through named view operations.

Sub-surface Method + Path Read/Write Backs onto
GraphQL typed reads POST /extensions/{graph_id}/graphql Read PostgreSQL extensions OLTP database
Command writes POST /extensions/{roboledger|roboinvestor}/{graph_id}/operations/{op_name} Write PostgreSQL extensions OLTP database
Analytical view operations POST /extensions/roboledger/{graph_id}/operations/{view_name} Read Materialized LadybugDB XBRL hypercube

A few details worth pinning down:

  • GraphQL typed reads are served by a single Strawberry endpoint per graph. The schema is composed dynamically per deployment (see Feature Flags). In dev, this endpoint renders GraphiQL with introspection.
  • Command writes are explicit, named operations (close-period, share-report, create-security) rather than resource CRUD. They match the multi-step domain operations they actually perform. Each returns an OperationEnvelope.
  • Analytical view operations share the same envelope contract as command writes but are read-only and routed to the materialized graph rather than the OLTP database. build-fact-grid (multi-dimensional pivot tables over the XBRL hypercube) is the canonical example. These are gated independently of the OLTP domains, so a deployment without RoboLedger tenants can still use them.

For the full, machine-readable endpoint surface — every operation name, request schema, and response shape — see the live OpenAPI spec at https://api.robosystems.ai/docs (or http://localhost:8000/docs locally). This page covers the concepts and tasks; the OpenAPI spec is the authoritative endpoint reference.

Graph-Scoping at the URL

The graph_id is always a URL path parameter, never a query argument or request-body field. The path accepts both parent graph IDs (kg + 16 or more hex chars, e.g. kg1a2b3c4d5e6f7890) and subgraph IDs ({parent_id}_{subgraph_name}, e.g. kg1a2b3c4d5e6f7890_dev).

This matters most for GraphQL. The graph is determined by the URL, and resolvers read it from request context — so GraphQL queries do not take a graphId argument:

Wrong:  { entity(graphId: "kg1a2b3c4d5e6f7890") { name } }
Right:  { entity { name } }

Putting the tenant in the URL rather than the query body eliminates a whole class of "I queried the wrong tenant" bugs: the scope is fixed before the resolver runs, and there is no way to override it from inside the query.

Auth Runs Before the Handler

Authentication and per-graph access are enforced by FastAPI dependencies that run before any resolver, command runner, or view handler executes. A request that lacks access to the graph never reaches business logic.

  • GraphQL requests resolve the user and graph through the context dependency, then check graph access before the query is executed.
  • Command and view operations resolve the user and validate per-graph access via the operation dependency before the runner is invoked.

For local and backend testing, authenticate with the X-API-Key header. The key (format rfs followed by 64 hex characters) is written to .local/config.json after you run just demo-user. Read it inline with jq rather than stashing it in a shell variable:

-H "X-API-Key: $(jq -r .api_key .local/config.json)"

Authorization: Bearer (JWT) is a frontend concern — the apps use an HTTP-only cookie. Do not use Bearer tokens for backend curl testing.

Feature Flags and the Dynamically Built Schema

Each sub-surface is gated by a feature flag, and the gating happens at schema-construction time, not request time. Disabled domains are simply absent from the API rather than mounted and throwing runtime errors.

Flag Gates Default
ROBOLEDGER_ENABLED RoboLedger command operations + reads + GraphQL ledger fields true
ROBOINVESTOR_ENABLED RoboInvestor command operations + GraphQL investor fields true
EXTENSIONS_GRAPHQL_ENABLED Kill switch for the /extensions/{graph_id}/graphql endpoint true
FACT_GRID_ENABLED Graph-backed analytical view operations (build-fact-grid, etc.) true

EXTENSIONS_ENABLED is a derived property — it is ROBOLEDGER_ENABLED OR ROBOINVESTOR_ENABLED, not a standalone environment variable. It controls whether the extensions database engine is active at all. (The legacy LEDGER_ENABLED / INVESTOR_ENABLED names have been retired; use the ROBO* names.)

The GraphQL schema is composed per deployment. A baseline set of query fields is always present (information blocks, taxonomy blocks, and the library), and the domain-specific field sets are added only when their flag is on:

  • ROBOLEDGER_ENABLED adds the ledger query fields (roughly three dozen — entity, trialBalance, fiscalCalendar, reports, mappings, and more).
  • ROBOINVESTOR_ENABLED adds the investor query fields (portfolios, securities, positions, holdings, and related).

Because the schema is built at construction time, a ledger-only deployment literally has no portfolios field — introspection never reveals it, and clients should branch on the introspected schema rather than discovering disabled domains through trial-and-error runtime errors. Note that analytical view operations are gated by FACT_GRID_ENABLED independently of ROBOLEDGER_ENABLED: they are mounted under the /extensions/roboledger/... path but stay available on deployments (such as SEC-only setups) that have no RoboLedger tenants.

The OperationEnvelope Contract

Both command writes and analytical view operations return an OperationEnvelope. This is the same envelope/idempotency/SSE infrastructure used by graph lifecycle writes under /v1/graphs/{graph_id}/operations/{op_name}. On the wire, fields use camelCase aliases:

Field Meaning
operation The kebab-case operation name (e.g. close-period)
operationId A ULID prefixed with op_; the bridge to SSE progress streaming
status "completed" (HTTP 200, ran synchronously), "pending" (HTTP 202, async), or "failed"
result The operation's response payload, or null
at ISO-8601 UTC timestamp
createdBy The user ID that issued the operation
idempotentReplay true when the response was served from the idempotency cache without re-executing

Two behaviors fall out of this contract:

  • Idempotency. Every operation accepts an Idempotency-Key header for safe retries. Replaying an identical request returns the cached result with idempotentReplay: true and does not re-execute. Reusing the same key with a different request body returns HTTP 409 (a fingerprint conflict).
  • Async operations stream progress. Long-running commands return status: "pending" with HTTP 202; monitor them via Server-Sent Events at /v1/operations/{operationId}/stream rather than expecting the result inline.

One Invariant: The Ops Layer Is the Single Source of Truth

All three sub-surfaces — plus the MCP tool layer — delegate to the same domain functions in operations/{domain}/{reads,commands,views}. Those functions are the only place domain logic lives:

  • GraphQL resolvers delegate reads to operations/{domain}/reads/*.
  • Command operation routers delegate writes to operations/{domain}/commands/*.
  • Analytical view handlers delegate to operations/{domain}/views/*.
  • MCP tools (the operational and analytical retrieval planes) call the same ops-layer modules directly.

This invariant is load-bearing: whether a request arrives through GraphQL, a named operation, or an MCP tool, the same function runs and the same business rules are enforced. Adding logic anywhere else — in a router, a resolver, or an MCP handler — is a mistake.

Worked Examples

All examples assume a local stack (just start) and a graph provisioned via just demo-roboledger (which writes the API key to .local/config.json). Set GRAPH_ID to the graph you want to target.

GraphQL Liveness and Auth Probe

The hello field is an auth probe that returns no business data — it works on any extensions-enabled graph and is the quickest way to confirm credentials and reachability:

curl -X POST "http://localhost:8000/extensions/$GRAPH_ID/graphql" \
  -H "X-API-Key: $(jq -r .api_key .local/config.json)" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ hello }"}'

GraphQL Typed Read

The graph is in the URL, so the query carries no graphId argument. Field names are camelCase on the wire:

curl -X POST "http://localhost:8000/extensions/$GRAPH_ID/graphql" \
  -H "X-API-Key: $(jq -r .api_key .local/config.json)" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ fiscalCalendar { closedThrough closeTarget } entity { name } }"}'

GraphQL list resolvers take limit (per-resolver default, must be 1–1000) and offset (default 0, must be ≥ 0). A success returns {"data": {...}}; a typed error returns {"errors": [{"extensions": {"code": "..."}}]}. Clients should branch on extensions.code (for example UNAUTHENTICATED, FORBIDDEN, INVALID_PAGINATION, LEDGER_NOT_INITIALIZED, INVESTOR_NOT_INITIALIZED), not on the human-readable message.

Command Write — Close a Period

A RoboLedger command write returns an OperationEnvelope and supports Idempotency-Key:

curl -X POST "http://localhost:8000/extensions/roboledger/$GRAPH_ID/operations/close-period" \
  -H "X-API-Key: $(jq -r .api_key .local/config.json)" \
  -H "Idempotency-Key: $(date +%s)" \
  -H "Content-Type: application/json" \
  -d '{"period": "2026-03", "allow_stale_sync": false}'

Analytical View Operation — build-fact-grid

A graph-backed analytical view that pivots over the XBRL hypercube. It hits the materialized LadybugDB graph rather than the OLTP database:

curl -X POST "http://localhost:8000/extensions/roboledger/$GRAPH_ID/operations/build-fact-grid" \
  -H "X-API-Key: $(jq -r .api_key .local/config.json)" \
  -H "Content-Type: application/json" \
  -d '{
        "canonical_concepts": ["revenue"],
        "entities": ["NVDA", "AMD"],
        "form": "10-K",
        "fiscal_year": 2024,
        "fiscal_period": "FY",
        "view_config": {"rows": [{"type": "entity"}]}
      }'

Important: build-fact-grid requires a scoping filter. You must supply elements and/or canonical_concepts, and at least one of periods / period_type / fiscal_year. Omitting both classes of filter returns HTTP 400.

RoboInvestor Command Write — Create a Security

RoboInvestor writes go through the same operation surface under the roboinvestor path segment, and require a graph with ROBOINVESTOR_ENABLED:

curl -X POST "http://localhost:8000/extensions/roboinvestor/$GRAPH_ID/operations/create-security" \
  -H "X-API-Key: $(jq -r .api_key .local/config.json)" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Common Stock", "security_type": "common_stock"}'

For the exact request-body fields of any operation, consult the OpenAPI spec at http://localhost:8000/docs rather than guessing.

Gotchas

  • GraphQL takes no graphId argument. Use { entity { … } }, never { entity(graphId: "kg_x") }. The graph is the URL.
  • Use X-API-Key for local and backend testing, not Authorization: Bearer. Read the key from .local/config.json with jq -r .api_key .local/config.json. Do not put the key in a TOKEN= shell variable or in the URL.
  • Analytical views are gated by FACT_GRID_ENABLED, not ROBOLEDGER_ENABLED. They live under the /extensions/roboledger/... path but are flagged independently, so they remain available on SEC-only deployments.
  • build-fact-grid requires a scoping filterelements and/or canonical_concepts, plus at least one of periods / period_type / fiscal_year, or you get HTTP 400.
  • A GraphQL "error" can still return HTTP 200. Anonymous introspection is allowed, so an unauthenticated request can return a 200 with an UNAUTHENTICATED error code in extensions.code; HTTP 401 means credentials were presented but invalid.
  • Idempotency conflicts return HTTP 409 when the same key is reused with a different request body. Identical replays return idempotentReplay: true without re-executing.
  • Async commands return HTTP 202 with status: "pending". Monitor them over SSE at /v1/operations/{operationId}/stream; do not expect the result inline.
  • Disabled-domain fields are simply absent. Branch on the introspected schema, not on trial-and-error runtime errors.
  • Pagination bounds are enforced. limit must be 1–1000 or the resolver returns INVALID_PAGINATION.

Related Documentation

Wiki Guides:

Codebase Documentation:

  • GraphQL Extensions - Strawberry GraphQL surface, Pydantic auto-derivation, and resolver patterns
  • Operations - The reads/commands/views domain kernel that all sub-surfaces delegate to
  • Authentication - API key and JWT authentication
  • API Models - Pydantic request/response models for the operation surface
  • API Documentation - Live OpenAPI spec with the full endpoint surface

Support

Clone this wiki locally