Tessera RP
PX-09
For Engineers
Set it up, call it, extend it.
Twenty-three sections: installing and configuring it, the QuerySpec contract every read path goes through, all thirty-odd HTTP endpoints, the interface to implement if you are adding a source, and what has to change before you run a second replica. Written from the source — including the parts that do not exist yet.
1.1
What you are integrating with
Tessera RP is a Next.js application over Postgres. It reads your systems of record on a schedule, lands the raw payloads, maps them into an ERP-agnostic star schema, and serves a governed metric catalog on top. There is one HTTP surface for reading data and it does not accept SQL.
Two things follow from that and shape every integration you will write. Data comes out through a QuerySpec, which names metric and dimension ids rather than tables. Data goes in through a Connector, which knows about a source API and nothing about the warehouse.
| Surface | You use it to | Section |
|---|---|---|
HTTP API | Read metrics, run the assistant, manage alerts, views and connections | 3.5 |
Connector SDK | Add a source system nobody has written a connector for yet | 4.1 |
Metric catalog | Define a number once and have every surface resolve the same definition | 4.4 |
CLI | Migrate, seed, and run a sync outside the scheduler | 1.3 |
1.2
Requirements
| Component | Version | Notes |
|---|---|---|
Node | 20.19+ or 22.12+ | The Docker image pins 22.20 |
Postgres | 16 | Bundled via Docker for local work |
Docker | Any current | For the bundled Postgres, and optional Redis |
Redis | Optional | Required only past a single replica — see 5.3 |
DATABASE_URL must point at a non-superuser role. Postgres skips row-level security entirely for SUPERUSER and BYPASSRLS roles, which would silently disable tenant isolation. The migration command warns loudly when this is violated.
1.3
Install and run
local
npm install
npm run setup # starts Postgres, migrates, seeds 24 months of demo data
npm run dev # http://localhost:3100setup takes about a minute and ends by printing demo sign-in credentials, so the first run has something in it to query.
docker
docker build -t erp-ai-layer:0.1.0 .
docker run -p 3100:3100 \
-e DATABASE_URL='postgres://user:pass@host:5533/erpai' \
-e SESSION_SECRET="$(openssl rand -hex 32)" \
erp-ai-layer:0.1.0Runs as a non-root user, around 339 MB, with a HEALTHCHECK on /api/health.
| Command | Does |
|---|---|
npm run dev | Dev server on :3100 |
npm run setup | Postgres, migrate and seed in one step |
npm run db:migrate | Apply migrations — idempotent |
npm run db:seed -- --reset | Regenerate the demo dataset |
npm run sync | Run a connector sync outside the scheduler |
npm test | Full suite |
npm run verify | The whole quality gauntlet |
1.4
Configuration
Names and purpose below; values belong in your own environment file. Only the first three are required to boot.
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL | Yes | Application role. Must not be superuser or BYPASSRLS |
SESSION_SECRET | Yes | Signs session tokens. Generate with openssl rand -hex 32 |
APP_BASE_URL | Yes | Absolute base for links in invitations and scheduled email |
ADMIN_DATABASE_URL | Migrations | Owner role used by db:migrate, never at runtime |
CREDENTIALS_SECRET | Connectors | Encrypts stored source credentials at rest |
WAREHOUSE_ENGINE | No | Selects the warehouse adapter. Postgres is the default |
ANTHROPIC_API_KEY | No | Without it the assistant degrades to deterministic matching |
ASSISTANT_MONTHLY_BUDGET_MICROS | No | Hard ceiling on assistant model spend |
REDIS_URL | Past 1 replica | Shared rate-limit counters and query cache — see 5.3 |
SMTP_URL · MAIL_FROM | For email | Scheduled reports, alerts and invitations |
SYNC_INTERVAL_MINUTES | No | How often the scheduler runs connector syncs |
ALERT_INTERVAL_MINUTES | No | How often alert rules are evaluated |
1.5
Connect a source
1
Create a connection
POST /api/connections with the source system and its credentials. They are encrypted with CREDENTIALS_SECRET before they touch the database.
2
Test it
The connector's check() runs and returns a message safe to show a user — it never contains a secret. This is the Test button in the Connections UI.
3
Run the first sync
Full mode pulls history; every run after that is incremental from the persisted cursor. npm run sync does the same thing outside the scheduler.
4
Watch it land
Rows arrive in the raw tier first, then the mappers canonicalise them. Anything unmappable becomes a dead-letter row and the sync carries on.
5
Query it
Nothing above the canonical tier needed to change. The catalog's metrics already resolve against the new rows.
2.1
The one rule
Every read path — a dashboard tile, the Explore workbench, an alert evaluation, the assistant — builds a QuerySpec and hands it to the compiler. There is no endpoint that accepts SQL, because there is no code path in the product that writes any.
the only path to the database
QuerySpec
→ zod validate shape and types
→ resolve ids vs catalog every metric and dimension must exist
→ append caller scope added after the caller's own filters
→ emit SQL engine-neutral, params bound separatelyThis is why the assistant is safe to expose. The worst a prompt injection can produce is a QuerySpec, which is then checked against the catalog and against the caller's own data scope before it runs. There is no path from model output to SQL text.
2.2
The QuerySpec
The one object you construct. Validated by zod on the way in; unknown keys are rejected.
| Field | Type | Cap | Notes |
|---|---|---|---|
metrics | string[] | 1–12 | Catalog metric ids. At least one is required |
dimensions | string[] | max 4 | Group-by ids. Empty gives a single total row |
filters | Filter[] | max 20 | Applied before the caller's mandatory scope filters |
timeRange | TimeRange | — | Required. Relative or absolute — see below |
grain | enum | — | day · week · month · quarter · year. Only meaningful when a date dimension is grouped |
comparison | enum | — | none · previous_period · previous_year. Defaults to none |
orderBy | OrderBy[] | max 4 | { field, direction } — direction defaults to desc |
limit | int | max 10000 | Defaults to 500 |
offset | int | max 10000 | Page N is (N-1) × limit. Optional |
TimeRange · Filter
// Relative — resolved against the tenant's clock at query time, so a
// saved dashboard means "last 30 days" forever rather than freezing a date.
{ "kind": "relative", "range": "last_90_days" }
today · yesterday · last_7_days · last_14_days · last_28_days
last_30_days · last_90_days · last_180_days · last_365_days
week_to_date · month_to_date · quarter_to_date · year_to_date
previous_week · previous_month · previous_quarter · previous_year
all_time
// Absolute — both bounds YYYY-MM-DD.
{ "kind": "absolute", "from": "2026-01-01", "to": "2026-03-31" }
// Filter
{ "dimension": "channel", "operator": "in", "value": ["DTC-US"] }
eq · neq · in · not_in · gt · gte · lt · lte
between · contains · starts_with · is_null · is_not_nullThe compiler asks the database for limit + 1 rows so it can tell you whether a page was truncated. A result is flagged rather than silently cut.
2.3
Tenancy and data scope
Isolation is enforced in the database, not in application code. Every tenant-scoped table has row-level security with ENABLE and FORCE; policies compare against app.current_tenant(), which reads a transaction-local setting opened by withTenant().
A forgotten WHERE tenant_id = … in application code therefore returns zero rows rather than another customer's data. That is the difference between trying to scope queries and cross-tenant reads being impossible.
Row-level data_scope is a second, finer layer. A membership row can carry {"channel":["DTC-US"]}, and the compiler appends those as mandatory filters after any the caller supplied — so a caller cannot widen their own scope by editing a payload.
No request field influences the tenant or the data scope. Both come from the session and nowhere else.
2.4
Roles and actions
Role rank gates mutations; data scope gates rows. The two are independent.
| Role | Rank | Can |
|---|---|---|
owner | 40 | Everything, including billing and workspace deletion |
admin | 30 | Members, invitations, connections, settings |
analyst | 20 | Explore, the assistant, saved views, alerts, dashboards |
viewer | 10 | Read the dashboards assigned to them |
POST /api/query requires explore.use, which is analyst and above. Dashboards are unaffected: their tiles run server-side inside the React Server Component rather than through that route, so gating it takes nothing away from a viewer.
3.1
Authentication
The API is authenticated by session cookie. Log in once and send the cookie with subsequent calls.
POST /api/auth/login
curl -i -X POST https://your-host/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"…"}'
# → Set-Cookie: erpai_session=…; HttpOnly; Secure; SameSite=LaxThe cookie holds an opaque random token; only its SHA-256 hash is stored server-side. The session carries active_tenant_id, so switching workspace is a session update rather than a re-login.
There are no API tokens, personal access tokens or service accounts today. Machine-to-machine integration means logging in as a real member and reusing the cookie for the session's lifetime. If you need a non-human identity, scope it to its own member with the narrowest role and data_scope that works. The user model already carries sso_subject, so SAML and OIDC drop in without a migration.
3.2
Conventions
Content type | application/json for every request and response body |
Base path | /api |
Dates | ISO 8601. Panchang-style local reasoning is never applied — the tenant's timezone is resolved server-side |
Empty group-by | A QuerySpec with no dimensions returns exactly one total row |
Truncation | Results carry a flag rather than being silently cut |
Caching | Analytical routes are force-dynamic and never statically rendered |
3.3
Errors
Failures return a JSON body with a stable code and a message safe to show a user.
error shape
{
"error": {
"code": "VALIDATION",
"message": "That query is not valid.",
"details": { "metrics": ["At least one metric is required"] }
}
}| Status | Code | Means |
|---|---|---|
400 | VALIDATION | The body failed schema validation. details carries per-field issues |
401 | UNAUTHENTICATED | No session cookie, or it has expired |
403 | FORBIDDEN | Authenticated, but the role lacks the action |
404 | NOT_FOUND | No such resource in this tenant — which is also what another tenant's id returns |
409 | CONFLICT | The write collided with existing state |
429 | RATE_LIMITED | Retry-After header carries the seconds to wait |
504 | TIMEOUT | statement_timeout fired. The message asks you to narrow the range |
3.4
Rate limits
Two tiers. An address-keyed bucket runs before the session lookup so an anonymous flood stops at the edge, then a user-keyed bucket protects the tenant from one broken client.
| Bucket | Limit | Window | Keyed on |
|---|---|---|---|
preAuth | 100 | 1 min | Client address, before any session lookup |
query | 60 | 1 min | User — one analyst cannot take down their company's dashboards |
assistant | 20 | 1 min | User |
mutation | 120 | 1 min | User |
connector | 10 | 1 min | Connection |
auth | 10 | 5 min | Address plus hashed email |
authAddress | 30 | 5 min | Address |
In-process token buckets by default, which is correct and faster for a single instance. Set REDIS_URL before scaling out or each replica keeps its own counters and 60/min quietly becomes 60/min × replicas.
3.5
Endpoint reference
| Method | Path | Purpose |
|---|---|---|
POST | /api/auth/login | Exchange credentials for a session cookie |
POST | /api/auth/logout | Invalidate the session |
POST | /api/auth/register | Create an account and its first workspace |
POST | /api/auth/workspace | Switch the session's active tenant |
POST | /api/auth/forgot-password · reset-password | Password reset flow |
POST | /api/query | Run a QuerySpec — see 3.6 |
POST | /api/assistant | Natural language in, QuerySpec and result out — see 3.7 |
GET | /api/metrics | The governed catalog: metric and dimension ids |
GET | /api/insights | Computed insights over the current tenant |
GET | /api/search · /api/search/history | Search across the catalog and saved objects |
GET POST | /api/connections | List and create source connections |
GET POST PATCH DELETE | /api/connections/[id] | Inspect, sync, edit and remove one connection |
GET POST | /api/dashboards | List and create dashboards |
GET PATCH DELETE | /api/dashboards/[id] | One dashboard |
POST | /api/dashboards/[id]/share | Share a dashboard |
POST | /api/dashboards/generate | Generate a dashboard from a description |
GET POST PUT DELETE | /api/alerts | Threshold and anomaly rules |
GET POST | /api/alerts/events | Alert firings |
GET POST | /api/views | Saved Explore views |
GET PUT DELETE | /api/views/[id] | One saved view |
POST | /api/reports | Generate a governed report |
POST | /api/onboarding/upload | CSV upload path for what the ERP does not hold |
GET PATCH DELETE | /api/account | The signed-in user |
GET PUT | /api/account/digest | Digest email preferences |
POST | /api/account/password | Change password |
GET POST PATCH | /api/admin/members | Membership and roles |
GET POST DELETE | /api/admin/invites | Invitations |
GET PATCH DELETE | /api/admin/settings | Workspace settings |
GET | /api/admin/settings/impact | What a settings change would affect |
GET POST | /api/admin/fx | Currency rates |
GET | /api/health · /api/ready | Liveness and readiness — see 5.2 |
3.6
Running a query
POST /api/query
POST /api/query
Content-Type: application/json
Cookie: erpai_session=…
{
"metrics": ["contribution_margin", "contribution_margin_pct"],
"dimensions": ["channel"],
"timeRange": { "kind": "relative", "range": "last_90_days" },
"comparison": "previous_period",
"orderBy": [{ "field": "contribution_margin", "direction": "desc" }],
"limit": 50
}200 OK
{
"result": {
"columns": [
{ "key": "channel", "label": "Channel", "type": "dimension" },
{ "key": "contribution_margin", "label": "Contribution Margin", "type": "metric" }
],
"rows": [ … ],
"truncated": false
}
}Requires explore.use — analyst and above. The tenant and data scope come from the session; no field in this body can widen them.
3.7
Asking in English
The assistant plans a QuerySpec and runs it through the same compiler as everything else. It returns the spec it built, so an answer can always be audited or pinned to a dashboard.
POST /api/assistant
{
"question": "which channels lost contribution margin last quarter?"
}Rate limited to 20 per minute per user. Without ANTHROPIC_API_KEY the endpoint still works, falling back to deterministic keyword matching over the same catalog — fewer phrasings understood, identical numbers.
4.1
Writing a connector
Six members, no inheritance. A connector talks to a source API and deliberately knows nothing about the warehouse schema — which is what makes adding an ERP a contained change.
lib/connectors/types.ts
export interface Connector {
readonly system: SourceSystem;
readonly displayName: string;
/** Streams this connector can supply, in dependency order. */
readonly streams: StreamDescriptor[];
/** Validates credentials without pulling data. Powers the Test button. */
check(ctx: ConnectorContext): Promise<ConnectionCheckResult>;
/**
* Pulls one page. The runner calls this repeatedly, persisting the
* returned cursor after each page, so an interrupted sync resumes
* rather than restarts.
*/
fetchPage(
ctx: ConnectorContext,
stream: StreamName,
cursor: SyncCursor | null,
mode: SyncMode
): Promise<StreamPage>;
}what you return
interface RawRecord {
sourceId: string; // stable PK within (tenant, source, stream)
updatedAt: Date | null; // the incremental watermark
payload: Record<string, unknown>; // untouched source payload
deleted?: boolean; // set on a hard-delete tombstone
}
interface SyncCursor {
watermark: string | null; // high-water mark on updated-at
pageToken?: string | null; // for token-paginated sources
offset?: number | null; // for offset-paginated sources
}Return the payload untouched. Canonicalisation is the transform layer's job, and keeping the raw shape is what makes a mapping fix a re-run rather than a re-extract.
| Registered source | Notes |
|---|---|
business_central | OData v2 |
d365_fo | A different product from Business Central — AX lineage, dimensional inventory |
netsuite | SuiteQL |
acumatica | Contract-based REST |
sap_b1 · odoo | Schema-ready — already values in the source enum |
shopify | GraphQL Admin API |
amazon_sp | SP-API, including the settlement report |
csv · rest_generic | For everything the ERP does not hold |
ConnectionCheckResult.message is shown to a user, so it must never contain a secret. Connector calls are rate limited to 10 per minute per connection.
4.2
Stream reference
The logical record types every connector maps its source objects onto — the contract between pull and transform. A connector declares which it can supply, in dependency order.
StreamName
// Commercial
customers · products · sales_orders · sales_order_lines
invoices · invoice_lines · credit_memos · returns · payments
purchase_orders · purchase_order_lines · suppliers · shipments
inventory_levels · channels · currencies · fees · gl_entries
// ERP structural — an e-commerce source has none of these
legal_entities · sites · warehouses · locations
inventory_dimensions · inventory_positions · inventory_transactions
// Kept separate, deliberately
pos_transactions // a till receipt has no order behind it
gl_accounts // account TYPE separates P&L from balance sheet
ar_open · ap_open // what is still OWED, not what was billedar_open and ap_open are distinct from invoices because an ageing report or a working-capital figure can only be built from what is still owed as of a date.
4.3
Mapping to canonical
A mapper is a pure function from one raw payload to canonical rows. It lives in lib/transform, which knows nothing about HTTP or metrics, and it is the only place a source's field names appear.
Return null for a record you cannot map. It becomes a dead-letter row, the sync continues, and the volume is surfaced in Data Admin — schema drift costs a row rather than a run.
The seven cost columns on fact_order_line are where margin accuracy comes from: product cost, inbound freight, duty, outbound freight, payment fee, marketplace fee and returns provision. Map what the source gives you and leave the rest at zero — landed_cost and contribution_margin are generated columns, so no mapper or query can compute them a second way.
4.4
Adding a metric
A metric is a catalog entry in lib/semantic, not a query. Define it once and every surface — tiles, Explore, alerts, exports, the assistant — resolves the same definition, which is what makes two reports unable to disagree.
| Module | Owns | Must not know about |
|---|---|---|
lib/connectors | Talking to source APIs | The canonical schema |
lib/transform | raw → canonical mapping | HTTP, metrics |
lib/semantic | Metric definitions, SQL emission | React, HTTP |
lib/analytics | Forecast, anomaly, cohort | The database |
lib/personas | Declarative dashboard specs | SQL |
lib/assistant | NL → QuerySpec | SQL |
5.1
Sync and alert scheduling
An in-process scheduler runs connector syncs and evaluates alert rules on an interval. Sync is batch and incremental — freshness is surfaced rather than hidden, and sub-minute latency would require change data capture and a different storage tier.
| Variable | Controls |
|---|---|
SYNC_INTERVAL_MINUTES | How often connector syncs run |
ALERT_INTERVAL_MINUTES | How often alert rules are evaluated |
SCHEDULER_CONCURRENCY | How many jobs run at once |
SCHEDULER_SYNC_JITTER_MS | Spreads simultaneous tenants off the same instant |
SCHEDULER_MIN_RETRY_MS | Floor on retry backoff |
SCHEDULER_SHUTDOWN_GRACE_MS | How long a deploy waits for in-flight jobs |
SCHEDULER_HEARTBEAT_FILE | Liveness file for an external supervisor |
5.2
Health and observability
| Endpoint | Answers |
|---|---|
GET /api/health | Liveness — the process is up. What the Docker HEALTHCHECK hits |
GET /api/ready | Readiness — the database is reachable. Use this one for load-balancer rotation |
Logs are structured single-line JSON with deep key-based redaction, carrying requestId and tenantId on every request-scoped line. app.usage_event doubles as the metering and the coarse telemetry stream.
RED/USE metrics, OpenTelemetry traces and alerting on the service itself are a later phase. What ships today is the request-scoped log line and the two probes.
5.3
Deployment and scaling
1
One replica
Nothing else required. Rate limiting and the query cache run in-process, which is the faster and correct choice at this size.
2
Before scaling out
Set REDIS_URL. Without it each replica keeps its own rate-limit counters, so a 60/min limit quietly becomes 60/min × replicas — a quota that changes when someone scales the deployment is not a quota.
3
If Redis goes away
Never fatal. The limiter falls back to its in-process buckets and the cache simply misses.
4
Migrations
Run db:migrate with ADMIN_DATABASE_URL. It is idempotent, and it refuses quietly to let a superuser role become the runtime role.
5
Rotation
Point the load balancer at /api/ready, not /api/health — the first says the database is reachable, the second only says the process is alive.
