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.

SurfaceYou use it toSection

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

ComponentVersionNotes

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:3100

setup 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.0

Runs as a non-root user, around 339 MB, with a HEALTHCHECK on /api/health.

CommandDoes

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.

VariableRequiredPurpose

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 separately

This 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.

FieldTypeCapNotes

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_null

The 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.

RoleRankCan

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=Lax

The 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"] }
  }
}
StatusCodeMeans

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.

BucketLimitWindowKeyed 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

MethodPathPurpose

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 sourceNotes

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 billed

ar_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.

ModuleOwnsMust 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.

VariableControls

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

EndpointAnswers

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.

Tessera RP is live.

It runs at erp.par2labs.com, with a seeded demo workspace you can sign into without a password. Self-hosting is a Docker image and one command.