Skip to content

Architecture overview

Turbobase is an ERP operations and development platform for Oracle NetSuite. Your NetSuite records land in one Postgres your instance owns, so an AI agent (or any app you build) reads them from a single place instead of paying for a round-trip through the source’s API on every question. That is what lets one operator build, automate, diagnose, and manage an instance like an entire team of specialists.

The connector contract underneath is deliberately generic, so the same engine carries every other system you connect. NetSuite and Stripe are the live source connectors today. A broader set is registered against that same contract and offered in the console (QuickBooks, Xero, Salesforce, Zoho CRM, Shopify, HubSpot, Workday, Dynamics 365 Business Central, Jira, Asana, Notion, GitHub, Zendesk, Intercom, Ramp, Bill.com, GetSales.io, DocuSeal, PrintNode, and the knowledge sources Google Workspace, Fireflies.ai, Granola, FeedHive and Azure Blob Storage), each enabled per instance. Standalone instances (no upstream source) follow the same contract. See Connecting additional sources below.

New to the vocabulary? The Glossary defines every word, and it is the one place each is defined. Key concepts then explains how the named pieces of the platform fit together (connectors, the canonical mirror, sync events, hooks, guardrails, the audit log, scheduled tasks, channels, Atlas, and the named agents) in one page. If you haven’t provisioned yet, Getting started is the front door.

Source systems
──────────────
NetSuite Stripe QuickBooks / Salesforce / … (per instance)
Standalone (no upstream)
│ Webhooks (source → your Postgres)
│ REST / SDK (your Postgres → source)
▼
┌──────────────────────────────────────────────────────┐
│ Your Turbobase instance │
│ │
│ Bidirectional sync │
│ delta cursors · line fan-out · conflict resolver │
│ every row stamped with who wrote it last │
│ │
│ Named agents (managed AI model) │
│ Conductor · Mechanic · Pilot · Concierge │
│ per-instance guardrails for what AI may do │
│ │
│ Your Postgres (turbobase schema) │
│ Postgres reads · Realtime subscriptions · RLS │
└──────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────┐
│ Consumers you build │
│ Web apps (SvelteKit, Next.js, React, …) │
│ Mobile apps via PostgREST + JWT │
│ Realtime subscribers, dashboards, AI agents │
└──────────────────────────────────────────────────────┘

From the source into your Postgres. When a record changes upstream — a sales order saved in NetSuite, a charge captured in Stripe — the source emits a signed webhook. Turbobase verifies the signature, normalises the record onto the canonical schema, and upserts it into your turbobase schema. Realtime publishes the row change to anything subscribed. For sources without webhooks, the sync engine pulls deltas on a cursor and produces the same result.

From your Postgres back to the source. A write to a canonical table marked originating_system = 'hybrid' queues a write-back. Hooks run first (validate, optionally rewrite the patch), guardrails decide whether the operation is allowed for the caller, and the connector pushes the patch to the source’s API. On success, the sync event commits. On failure, a sync_failure row is written and the Mechanic agent picks it up.

Write-back is gated per source and ships off: until it’s enabled for a source on your instance, the canonical tables still accept your writes but nothing is pushed upstream.

Deleting a record. You delete through a dedicated boundary — del(recordType, { pgRowId }) (or { externalId }) in the client, which calls DELETE /v1/data/:recordType. The instance soft-deletes (tombstones) the canonical row and, for a delete-capable bidirectional source, queues the delete to the source on the same audited, gated path as a write. A hard DELETE on a canonical table is rejected — an app has no delete grant there, and stamping deleted_at yourself is not the delete path — so the boundary is the only way to remove a record. A pgRowId that is not a Turbobase row id is refused with a typed 422 naming the field, before the instance attempts the delete — the same answer the write boundary gives for the same mistake, rather than a generic server error. (externalId is free-form text and is never screened.) It returns { deleted, enqueued }; enqueued is false for a row that never synced upstream (a purely local tombstone). Delete-to-source is live for NetSuite; other sources are parked and their deletes are rejected (never silently upserted) until finished.

Guardrails and rate caps. Write-back is governed by guardrails your admin manages (Compliance → Guardrails). The instance has a global posture — deny-by-default (nothing writes back unless a rule allows it — an allow-list) or allow-by-default (everything writes back unless a rule denies it — a deny-list). Deletes are treated as a distinct, destructive operation: opening a record for writes does not open it for deletes (a <record>.delete rule is separate from <record>.write), and a delete is denied by default under deny-by-default. Circuit-breakers cap throughput so a runaway app can’t mass-mutate a live source: max updates and a tighter max deletes, each enforced per minute, per hour, and per day. Past any window’s cap, the excess is denied and recorded on the audit trail, and current usage against each cap is visible on the Guardrails page (with a 24-hour graph and an operator reset).

When the source refuses a delete. Some sources require more before they’ll delete — NetSuite, for example, can require a deletion reason on transactions, and refuses to delete a record other records depend on. Those come back as sync_failure rows (deletion_reason_required, has_dependencies), not silent failures, so you can supply the reason or clear the dependents.

The record comes back when the delete does not happen. The tombstone is committed before the delete is attempted, so a refusal used to leave the record hidden from your app forever while the source still held it. It no longer does: when the delete ends for good without the source performing it — refused, denied by a guardrail, or held for a confirmation nobody gave — the tombstone is reverted and the record is readable again. On every ending that records a write state it comes back carrying write_status = 'blocked' and a record_write_state row that says why; the one that does not is a delete stranded because we could not sign in to the source at all, which comes back with no write state of its own — reconnecting the source is the signal there, and the record is visible in the meantime rather than hidden. For a refusal about linked paperwork the reason code is delete_blocked_by_dependents and the message names the documents holding it (up to five, then a count), read out of your own mirror rather than from the source. A delete the source performs still removes the record exactly as before.

Path Mechanism
Instance operators Email + password → RS256 JWT in an HttpOnly session cookie
NetSuite portlet → Turbobase A NetSuite portlet hands the browser a one-time code (or, on an account not yet moved over, a signed bridge JWT); Turbobase redeems it with NetSuite and exchanges it for a session cookie
Sync engine ↔ NetSuite OAuth 2.0 with a rotated refresh token (PKCE supported)

Row-level security scopes every Postgres read to the calling user via has_capability(), scopes_match(), and per-table policies. The anon key never bypasses RLS — a user without the right capability gets zero rows back, never someone else’s.

A Turbobase session is a sliding RS256 cookie, and how long it lasts is yours to set: a whole number of days from 1 to 30, instance-wide under Settings → Sign-in → Sessions and overridable for one role on the role editor. It is one day unless you change it. The capabilities + scopes minted into that JWT come from the connected source’s authoritative role (read at sign-in) and stay frozen for the cookie’s lifetime — there is no continual reconcile job that polls the source for role-permission edits. The refresh point is the next sign-in: when the cookie expires (or the user signs out and back in), the next mint reads the current source role and the user picks up any role-permission delta automatically.

The deliberate choice not to reconcile in the background is governance-driven. NetSuite, the lead source, does not let us deploy a User Event script on the role record — there is no cheap realtime signal for a role edit. A periodic poll over every linked user would chew connector governance for marginal benefit: signing out and back in applies a permission change immediately, which is what to do when you cannot wait for the session to run out — and the shorter you set the session, the smaller that wait is. For sources that do expose a role-change webhook in the future, the connector is free to wire one in; nothing in the schema or RLS layer assumes the JWT is the only mutator of source_user_permissions.

A NULL row value for a scope dimension is treated as “this row doesn’t carry this dimension” and is visible regardless of the user’s restriction. Non-NetSuite connectors (Stripe, Shopify, and so on) that don’t populate location_id / department_id / class_id need this so their rows stay readable under NetSuite-flavoured class / location / department scopes.

Turbobase runs on NetSuite and can bring other systems into the same canonical layer. This is an extension of the architecture above, not a separate one: a second source writes the same canonical tables, in the same Postgres, under the same RLS, so one query spans NetSuite and everything else.

What is live and what is registered. NetSuite and Stripe are the live source connectors. A broader set is registered against the same contract and offered in the console (QuickBooks, Xero, Salesforce, Zoho CRM, Shopify, HubSpot, Workday, Dynamics 365 Business Central, Jira, Asana, Notion, GitHub, Zendesk, Intercom, Ramp, Bill.com, GetSales.io, DocuSeal, PrintNode, and the knowledge sources Google Workspace, Fireflies.ai, Granola, FeedHive and Azure Blob Storage), each enabled per instance. Slack is not in that list: it is a notification channel, not a source.

Cross-source identity. When the same real-world entity appears in two sources, Atlas consolidates them onto one canonical entity. The matcher is deliberately connection-agnostic (it resolves on email, phone, tax id, domain, sku), which is what lets the same customer seen in two different accounts resolve to a single record rather than two.

More than one connection of the same source. An instance can hold several connections of one source: two NetSuite accounts, two QuickBooks companies, Stripe sub-accounts. Each is its own connection carrying its own opaque connection id, which is part of the natural key on every projection table, so a customer with internal id 123 in one account and a different customer 123 in another stay two rows and are never falsely merged.

Systems with no connector. Conduits pull from a system that has no connector at all and land the result on the same canonical tables, through the same adapter projection path.

Scope dimensions across sources. Rows from a source that doesn’t carry NetSuite’s class / location / department dimensions stay readable under those scopes. See How permission edits propagate above for the NULL rule that makes this work.

A connector implements a small, fixed contract: verify webhook, parse envelope, list changes, write back, introspect the source’s schema. The sync engine, conflict resolver, agents, hooks, guardrails, and your apps are unchanged. NetSuite is the reference for stateful sources (deployed bundle, OAuth, governance limits); Stripe is the reference for stateless webhook-led sources. New connectors join via a managed onboarding flow, so talk to support@turbobase.io if you need a source that isn’t live yet.

Every connector sits inside the same set of generic subsystems. Each has a one-pager in Key concepts; the diagram below shows where they sit on the write path.

Postgres row write (originating_system='hybrid')
│
▼
Realtime listener ──► queue a write-back sync_event
│
▼
Write-back worker
│
├─► Hooks (validate / rewrite)
├─► Guardrails (allow / audit / confirm / deny per op)
│ │
│ └─► Channels (Slack / log — for confirmations)
│
├─► Audit log (every privileged op)
│
▼
Connector pushes to the source ──► success → event committed
failure → sync_failure ──► Mechanic
Subsystem What it does
Hooks Validate or rewrite outbound writes before they leave for the source.
Guardrails Policy engine over privileged operations (allow / audit / confirm / deny), layered per source × per op-pattern × per actor.
Audit log Append-only record of every privileged op, guardrail decision, and auth event. Visible in the operator dashboard. The system of record — kept for at least a year.
Event log One queryable, tailable stream (turbobase logs, GET /v1/events) across guardrails, hooks, tasks, conduits and sync, threaded by one correlation id. Redacted at write, sampled, and expires — a diagnostic, not a system of record.
Channels Pluggable notification fan-out (Slack, log) for confirmations and alerts.
Tasks Cron-driven scheduled jobs — chat prompts, saved views, system maintenance jobs.
Apps Chat-built apps that render over the canonical mirror. Embedded with a chat sidecar, or chromeless for dashboards and single-purpose links.
Atlas Cross-source identity resolution — one canonical customer / vendor / contact when two sources both reference the same real-world entity.
Mechanic Subscribes to sync_failure; applies pattern-specific resolutions inside your per-instance permission tiers.

Adding a new connector means writing the connector — never extending the engine.