This is the full developer documentation for Turbobase # Turbobase Docs > An ERP operations and development platform for Oracle NetSuite. Connect your instance and Turbobase mirrors it into a Postgres you own, with bidirectional sync, row-level security, realtime, and a named-agent cast on top (Pilot for chat-driven ops, Mechanic for sync healing). One operator gets the reach of a whole team of specialists. Turbobase is an ERP operations and development platform for Oracle NetSuite. It gives a single NetSuite operator the power to build, automate, diagnose, and manage an instance like an entire team of specialists. Turbobase is a managed service. Sign up at [console.turbobase.io](https://console.turbobase.io/). Sign-up is invite-gated today, so if you don’t have an invite yet, [request early access](https://console.turbobase.io/early-access). The platform provisions your instance, connects NetSuite, and runs the stack for you. These docs cover the platform and the canonical schema; the console drives provisioning. ## Where to start [Section titled “Where to start”](#where-to-start) * **Start here.** [Getting started](/getting-started/what-is-turbobase/) is the front door: what Turbobase is, then the three steps to a live instance — provision your instance, connect your first source, and open your app. * **Connecting NetSuite?** [NetSuite OAuth 2.0 setup](/netsuite-oauth-setup/) is the click-by-click reference. The console runs these steps for you when you connect from the onboarding flow. * **Want the vocabulary?** The [Glossary](/getting-started/glossary/) defines every word you’ll meet, in plain language, and it is the one place each is defined. [Key concepts](/concepts/) is the longer read: how the named pieces of the platform fit together (connectors, the canonical mirror, sync events, hooks, guardrails, the audit log, scheduled tasks, channels, Atlas, proposals and solutions, Studio, the Analyzer, apps, the knowledge base, and permission tiers) in one page. * **Driving the platform.** [The console](/console/overview/) covers sign-up, orgs, instances, billing, and adding sources; [your Turbobase app](/tenant/data-model/) covers the app every instance ships with: data model, [Pilot](/tenant/pilot/), [knowledge base](/tenant/knowledge-base/), [Development](/tenant/development/) (proposals, solutions, releases), the [Analyzer](/tenant/analyzer/), analytics, permissions, access tokens, and more. * **Extending Turbobase.** [Writing hooks](/extending/writing-hooks/) and [conduits](/extending/writing-conduits/) cover the authoring surface. * **On the command line.** The [CLI](/cli/overview/) drives a live instance: hosted apps, extensions, promoting a configuration between environments, and the files an AI coding agent reads. Every command is in the [CLI reference](/cli/reference/). * **Connecting another source?** [Add sources](/console/add-sources/) brings Stripe, QuickBooks, Salesforce, Shopify, HubSpot, and the rest of the registered set into the same canonical layer. * **Building an app on your instance?** [Build apps on Turbobase](/build-apps/) shows you how to point a SvelteKit, Next.js, React, or mobile app at your instance’s PostgREST and Realtime endpoints. ## Machine-readable bundles [Section titled “Machine-readable bundles”](#machine-readable-bundles) Every page on this site is published as Markdown. The [`llms.txt`](/llms.txt) index and the single-file [`llms-full.txt`](/llms-full.txt) bundle are generated from the same source, so an automated reader gets the canonical text. ## Need help? [Section titled “Need help?”](#need-help) Email for onboarding, billing, and anything the docs don’t answer. # Connect your first source > Connect Oracle NetSuite so Turbobase can mirror it into your Postgres, and see what else you can connect. Shipped Once your instance reaches **Ready** (see [Provision your instance](/getting-started/provision-your-instance/)), the console drops you into the **setup wizard** at `/instances//setup`. This is where you choose your first source, connect it, create your app’s first admin, and finalize, after which your live Turbobase app opens. Most instances start with **Oracle NetSuite**, and the rest of this page assumes that path. Everything else you can connect is covered under [Connecting additional sources](#connecting-additional-sources). ## The setup wizard [Section titled “The setup wizard”](#the-setup-wizard) The wizard has four steps. It is connector-driven: the **Connect** step is named after the source you choose (and is skipped entirely for Standalone). There is no AI setup step, because your instance comes preconfigured with the platform’s default AI model. 1. **Data source**: pick the system of record to mirror. Most instances start with Oracle NetSuite. The grid also shows the other business systems Turbobase can mirror — Stripe, HubSpot, Salesforce, Zoho CRM, QuickBooks, Xero, Bill.com, Ramp, Shopify, Zendesk, Intercom, Jira, Asana, Notion, GitHub, Workday, Dynamics 365 Business Central, GetSales.io, DocuSeal, PrintNode and WhatsApp Business — the knowledge sources it can read — Google Workspace, Fireflies.ai, Granola, FeedHive and Azure Blob Storage — and **Standalone** (no connector), which is always selectable. Every tile is listed on [Add sources](/console/add-sources/). Which connector tiles you can select depends on what’s enabled for your instance; a tile that isn’t enabled for you yet shows an early-access hint with a sales contact. 2. **Connect {Source}**: named for your pick, for example **Connect NetSuite**. Authorize the source so Turbobase can read from it (details below). Standalone skips this step. 3. **Initial admin**: set the initial admin user / password for your Turbobase app. These are the credentials your team will use at `.turbobase.app`, not your console sign-in. 4. **Finalize**: Turbobase runs the initial sync, restarts your instance’s services, and marks setup complete. The **Set-up** button disappears from the instance’s row in the instances list, and its slug becomes the link that opens your app. The wizard is resumable. Each step is idempotent and the server re-reads the next step on every load, so if you navigate away and come back you pick up where you left off. ## Connecting NetSuite [Section titled “Connecting NetSuite”](#connecting-netsuite) NetSuite runs inline in the wizard’s Connect step. It is two things at once: an OAuth 2.0 consent, and a one-time deploy of the Turbobase managed bundle into your NetSuite account. The bundle is what gives you real-time sync out of NetSuite rather than polling alone; see [NetSuite real-time sync](/extending/netsuite-realtime-sync/) for what it installs. Your NetSuite account does not need every optional feature turned on. If a feature the bundle’s integration role can use is off — Multiple Subsidiaries on a single-company account is the usual one — the deploy leaves out only the access that needs it and finishes, and the deploy log says what was left out. If you turn the feature on later, the next time your bundle is updated it includes that access again. [NetSuite OAuth 2.0 setup](/netsuite-oauth-setup/) is the click-by-click reference for issuing the credentials, if you want to see the steps the console is running on your behalf or need to do them by hand. ## Advanced settings: customizations only [Section titled “Advanced settings: customizations only”](#advanced-settings-customizations-only) The NetSuite connect card carries an **Advanced settings** panel, collapsed by default, holding one choice: **Data sync**. It is on the NetSuite card only, because it is the only source where turning record mirroring off leaves something behind to run on. Leave it on (the default) and Turbobase mirrors your records so apps, search and write-back can use them. That is what the product does, and it is what almost every instance wants. Turn it off and your instance runs on **customizations only**: Turbobase still discovers your fields, scripts and workflows, still keeps your schema in step, and still runs the [Analyzer](/tenant/analyzer/), but it never copies your record data. This is for the customer who specifically does not want their records held in Turbobase. On every other source, mirroring records is the whole connector, so there is no such choice to make — connecting one starts the mirror. The choice is **not symmetrical**. You can turn data sync on later from inside your instance; once on, it cannot be turned off again. The wizard warns you before you leave it off for that reason. ## Connecting additional sources [Section titled “Connecting additional sources”](#connecting-additional-sources) Turbobase runs on NetSuite and can bring other systems into the same canonical layer, either as your first source instead of NetSuite or added later alongside it. They all use the same wizard step and the same connector contract. Most connectors authorize over OAuth. You never paste long-lived secrets that Turbobase stores and reads back: the connect step either redirects you to the provider’s own consent screen or, for a few connectors, collects connect-time credentials that are written one-way into your instance’s secret store. The flow at a high level: 1. You click **Connect {Source}** in the wizard. 2. For shared-app OAuth connectors (Asana, Notion, Jira, QuickBooks, and others), Turbobase mints the provider’s authorize URL and redirects your browser to the provider. You pick the workspace / account / company on the provider’s own consent screen, then approve. 3. The provider redirects back to the console, which completes the OAuth exchange and stores the resulting access on your instance, write-only, never read back into the console. 4. The console polls the connection to success and advances the wizard. Connector specifics: * **NetSuite**: OAuth 2.0 consent plus a one-time managed bundle deploy into your NetSuite account. Runs inline in the wizard’s Connect step. * **Stripe / Bill.com / Fireflies.ai / FeedHive / Granola / GetSales.io / DocuSeal / PrintNode**: collect credentials in the wizard. Bill.com also prompts for an MFA code; FeedHive takes the API key from **Settings → Account → API Key** and reads by default, and Turbobase posts on your behalf only once publishing is switched on for your instance; Granola takes a workspace API key and registers its own webhook endpoint for you; GetSales.io needs your own `.getsales.io` host alongside the key; DocuSeal and PrintNode take an API token, and write back only once that is switched on for your instance. * **WhatsApp Business**: you sign in to Meta in a popup and pick the WhatsApp Business account to connect — there is nothing to paste. Your customers’ conversations are then mirrored onto your instance beside everything else it knows about those people, and replies and approved templates can be sent back out through your instance’s API. **Meta charges the per-message fees to your own WhatsApp Business account, on the payment method you hold with them — those charges do not come through Turbobase.** Sending is switched on per instance and starts off. See [Add sources](/console/add-sources/) for the rules on what may be sent and when. * **Azure Blob Storage**: collects a Microsoft Entra directory id, application id and client secret, plus the storage account and the one container to mirror. Two more choices sit on the same form, and neither is defaulted for you: **how the files are kept** — copied into your instance, or left in Azure and fetched each time one is opened — and whether Turbobase may **add files** to that container, which starts off and needs us to switch it on. Turning writing on requires the account to have blob versioning or blob soft delete turned on, checked when you connect. See [Add sources](/console/add-sources/) for what each choice means for you. * **HubSpot / Shopify / Zendesk / Salesforce**: OAuth with your own provider app. You supply the connection details on the connect form, then approve on the provider’s consent screen. * **Zoho CRM**: OAuth with Turbobase’s own app — there is nothing to register. Choose **production** or **sandbox** on the connect form, then approve on Zoho’s consent screen. Your organisation’s data centre is detected from that redirect, so a Zoho organisation in any region connects the same way. A CRM Plus subscription connects through this same tile: the sales data in that bundle lives behind the Zoho CRM API. * **Asana / Notion / Jira / GitHub / Google Workspace / QuickBooks / Xero / Intercom / Ramp / Workday / Dynamics 365 Business Central**: a provider consent redirect that runs on the dedicated connect-source route (`/instances//connect-source/`); the wizard links you there and back. Most need no input at all — you approve on the provider’s own screen and pick the organisation, repository or drive there; a few collect a little config first (Business Central asks for your Entra tenant id and the BC environment). * **Standalone**: no source. Your instance starts as a blank, native Turbobase database you populate yourself. Records from every connected source land on the same canonical schema, so a `customer` from Stripe sits in the same table as a `customer` from NetSuite, and [Atlas](/tenant/atlas/) consolidates the two when they are the same real-world entity. ## After it connects [Section titled “After it connects”](#after-it-connects) When Finalize completes, your instance is set up and its slug in the instances list links straight to it. Move on to [Open your app](/getting-started/open-your-tenant/) to sign in to the live app. Need to connect *another* source later, after setup is done? See [Add sources](/console/add-sources/). # Glossary > Plain-language definitions of the Turbobase words you'll meet as a customer — what each thing is and when you'd reach for it. A single reference for the words you’ll meet using Turbobase, in plain language: what each thing is and when you’d reach for it. For how the named pieces fit together, see [Key concepts](/concepts/). | Term | Definition | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **instance** | Your own provisioned Turbobase — your own database plus your own running app, created just for your organization. Nobody else shares it. | | **Turbobase app** | The application that runs in your instance — the one your team opens at `.turbobase.app`: your data, Pilot chat, knowledge base, and the Apps surface. “Your Turbobase app” on first mention; “the app” after that. | | **console** | The customer site at [`console.turbobase.io`](https://console.turbobase.io) where you sign in, create and manage instances, connect sources, and see your billing. | | **organization** | The billing and membership entity. Your organization owns your instances and is what invoices bill to. “Org” is fine as a short form. | | **account** | Your personal sign-in identity and profile — who *you* are when you sign in. Distinct from your organization, which is the shared billing entity your account belongs to. | | **source** | A connectable upstream system you sync with Turbobase, for example Oracle NetSuite, QuickBooks, or Shopify. The default word for the system itself. | | **connector** | The pluggable link mechanism between your instance and one source. Say “source” when you mean the upstream system; say “connector” when you mean the link itself — its contract, its auth, its sync behavior. | | **canonicalizer** | The part of a connector that turns a record shaped the way its source shapes it into the columns you actually query. It is why a customer from NetSuite and a customer from QuickBooks land in the same table under the same column names. | | **sink** | Your canonical mirror — the one database schema every connected source flows into, and the thing your apps, your queries and Pilot all read. | | **sync** | Keeping your canonical mirror current with a connected source. Changes in the source flow to your instance; changes you make can flow back too, once write-back is enabled for that source. | | **initial sync** | The first historical load from a newly connected source into your instance. It runs in the background after you connect; from then on, realtime sync keeps things current. | | **write-back** | The other half of sync: a change you make in Turbobase pushed back to the source it came from. It ships off and is enabled per source, so an instance starts read-only upstream. | | **sync event** | One record’s trip across, in either direction — the source into your instance, or your instance back to the source. | | **delta cursor** | The bookmark sync keeps for each kind of record: how far through a source’s changes your instance has got. It is what makes a re-sync take seconds instead of re-reading the whole history. | | **originating system** | Which side made the most recent change to a row — the source, or Turbobase. It is what stops a change syncing in and then bouncing straight back out again. | | **source health** | The view that tells you whether each connected source is syncing, and what failed if it isn’t. | | **sync failure** | A record that didn’t make it across. The Mechanic works these; you see them on source health. | | **custom field** | A field from your source (or one you add) promoted into a real, queryable column rather than something buried in the raw payload. | | **custom table** | A table you create in your instance for data that doesn’t come from any connected source. | | **OAuth connect** | The one-time authorization step that securely links a source to your instance. You grant access once; Turbobase keeps the connection live from there. | | **Pilot** | Pilot, your AI assistant — the agent you talk to in the app. Answers questions over your data, builds apps from plain-English prompts, and runs scheduled tasks. Also reachable over connected channels. | | **Mechanic** | The sync-healing agent. Watches sync failures, fixes the patterns it recognizes within the autonomy you’ve granted it, and escalates the rest with a recommendation. | | **Conductor** | The behind-the-scenes coordinator: routes events to the right agent, fires notifications, and keeps scheduled work moving. You rarely address it directly. | | **Concierge** | The onboarding agent — the click-path for connecting your particular source, and the check that your credentials actually work. | | **learning mode** | The opening phase of a new instance, while Turbobase learns your rules from real sync rejections. A “Learning” indicator shows in the app until the instance settles. | | **skill** | A capability bundle that teaches an agent one thing well — how to read a particular kind of record, or how a house rule of yours applies. Some ship with Turbobase; you can write and publish your own. | | **playbook** | A long-form operating manual your agents consult: how your business actually handles a situation, written out. Rebuilt nightly from what your instance has learned, so it stays true as you change. | | **permission tier** | How far an agent may act on its own — apply the fix, ask you for one tap first, or escalate it to a person. You set one for each kind of sync failure the Mechanic meets, across your whole instance; every kind starts on one-tap approval, and guardrails then tighten individual operations further. | | **Analyzer** | The customization-analysis surface for a connected source. Inventories source-side customizations — scripts, custom fields, workflows, roles, forms — and surfaces risk and improvement proposals. | | **risk family** | How the Analyzer groups what it finds so you can triage by the kind of exposure: inventory, GL posting, fulfillment, data integrity, and security. | | **Studio** | The chat-first canvas where you and Pilot shape a **proposal** into the **solutions** that realize it. You talk in the middle, your inputs sit on the left, and the draft takes shape on the right. | | **Development** | The admin area where changes to your instance are proposed, designed, shipped, and watched — Proposals, Solutions, Releases, App Management, and Processes behind one tab strip. | | **proposal** | A drafted change to your instance, with the evidence behind it. You shape it in Studio, then **mark it complete** to freeze it — after that, what was approved stays approved. | | **solution** | A concrete deliverable minted from a completed proposal — the thing that actually realizes the change. One proposal can mint several. | | **solution kind** | What flavour a solution is: a hook, a customization, a conduit, config, documentation, an app, a data manipulation, a skill, or native (something your instance already does out of the box). | | **data manipulation** | The solution kind for fixing data: a bounded, previewed, repeatable data change you apply as a one-off job rather than a deploy. | | **release** | A tracked deploy. Every release is gated, audited, watched against a pre-release health baseline, and reversible. | | **Inputs** | The drawer where raw material for proposals collects — notes you drop in, uploaded documents, query results, and signals the platform raises itself. Triage it, then start a proposal from what matters. | | **signal** | Something the platform noticed and thinks is worth reacting to — a risk finding, or a regression spotted after a release — raised into Inputs. | | **Standards** | Your house rules: notes and documents recording how your business wants things built. They ground every proposal, so drafts respect them automatically. | | **template** | A starting point for a proposal — an app, a piece of documentation, or a source change — that seeds the brief so you refine rather than start from blank. | | **epic / label** | Grouping for proposals. An epic collects related work; labels tag it. Both are just ways to find things again later. | | **task** | A job your instance runs to a timetable rather than when you ask — a question put to Pilot, a saved view delivered somewhere, or a system job. Ask Pilot to schedule, pause or stop one, or manage them at `/admin/development/scheduled-tasks`. | | **system job** | Scheduled work Turbobase runs for you rather than something you set up: the regular re-sync, the check for fields added in your source, and the like. It appears alongside your own tasks. | | **knowledge base** | Your instance’s library of documents and facts. Your team can search it directly, and it grounds Pilot’s answers. | | **app** | An internal app built on your data from a plain-English brief and served inside your instance. Your team opens the apps shared with them from the **Apps launcher** at `/apps`. | | **channel** | A notification surface (Slack, …) where your instance delivers alerts, confirmation requests, and scheduled-task results — and where you can talk to Pilot. | | **hook** | A rule that runs on a write *before* it’s pushed back to a connected source — to validate the change (and reject it if it’s invalid) or to rewrite it into the shape the source expects. Reach for a hook to enforce your own rules on outbound data. | | **provenance** | Where a hook came from: you wrote it, the Mechanic learned it from real sync rejections, or it arrived with a rule book promoted from another of your instances. Shown on every hook, so you always know what you are looking at. | | **conduit** | A bridge that automatically propagates a record from one connected system to another — for example, a customer created in QuickBooks shows up in NetSuite without anyone re-keying it. | | **extension** | What you author with the `@turbobase/extensions` SDK — your own hooks, conduits and processes — and deploy with the Turbobase CLI. | | **process** | A multi-record sequence an app asks the platform to run — raise a purchase order, wait for the source to post it, then raise the bill — performed on the server in order, with the source’s verdict awaited between steps and a rollback when a step fails. | | **guardrail** | A policy that decides whether a privileged operation is allowed to run at all: allow it, ask for confirmation first, or deny it. Guardrails are the complement to hooks — a hook shapes a write’s *content*, a guardrail gates whether the *operation* happens. | | **write boundary** | The one validated path every change into your canonical tables goes through, so hooks, guardrails and the audit log cannot be sidestepped. A write that tries to go around it is refused. | | **Atlas** | Cross-source identity resolution. When two sources both reference the same real-world customer, vendor, or contact, Atlas links them so you get one canonical record instead of duplicates. | | **identity field** | The normalized key Atlas matches on when it decides two records from two sources are the same real-world thing — an email address, a phone number, a tax ID. | | **audit log** | The append-only record of privileged operations, guardrail decisions, and sign-in events in your instance — a tamper-evident history of what happened and who did it. | | **access token** | A personal access token (PAT) you mint in the app to call your instance’s API from scripts, integrations, or the CLI. “PAT” for short. | | **MCP server** | The endpoint that lets an AI client (Claude Code, Cursor, …) read your instance — your catalog, knowledge base, and data — under exactly the permissions you already have. | | **plan** | Your pricing plan — the commitment level your organization is on. | | **billing** | Your organization’s usage and invoices: what you owe and why. | | **usage** | The metered activity — AI tokens, synced records — that rolls up into your billing. | | **sign in** | Authenticating to the console or to your Turbobase app. (“Sign-in,” hyphenated, is the noun for the event.) | | **identity provider (SSO)** | Your own sign-in system — Google Workspace, or any OIDC provider — wired to your instance so your team signs in with accounts they already have. | | **standalone** | An instance where Turbobase is your system of record, with no upstream source connected — you create and own the data directly. | | **marketplace** | Install a published hook or conduit into your instance with one command — or, if you’re an approved publisher, share your own for others to install. | # Open your app > Open your provisioned Turbobase app and sign in to start working with your data. Shipped Once the [setup wizard](/getting-started/connect-your-first-source/) finalizes, the instance’s row in the console’s instances list turns its slug into a link. Clicking that slug takes you to your Turbobase app at `.turbobase.app`, in a new tab. ## A separate sign-in [Section titled “A separate sign-in”](#a-separate-sign-in) Your Turbobase app is a different realm from the console, with its own credentials. The console sign-in (`console.turbobase.io`) is for managing your subscription — orgs, instances, billing. The app (`.turbobase.app`) is where your team does the actual work, and it has its own user store. You set the **first app admin** during the Admin step of the setup wizard. Those are the credentials you use here — *not* your console email/password. From inside the app, that admin invites the rest of your team and manages their permissions; see [Users and permissions](/tenant/users-and-permissions/) and [Auth levels](/tenant/auth-levels/). Note The console and the app live on different domains by design, so their sessions never cross. There’s no single sign-on between them in the current release — sign into each separately. ## What you see first [Section titled “What you see first”](#what-you-see-first) When you sign in, the app is already populated from the source you connected during setup, Oracle NetSuite for most instances. From the app you can: * Browse your mirrored data under **Data**, across the [data model](/tenant/data-model/) — the canonical tables your source maps into. * Chat with **[Pilot](/tenant/pilot/), your AI assistant** — grounded in your own data, once an administrator has enabled Pilot chat for your instance. That setting covers the app; asking Pilot from a connected [channel](/tenant/channels/) works either way. * Check [source health](/tenant/source-health/) to confirm your source is syncing. * Add [custom fields](/tenant/custom-fields/) and [custom tables](/tenant/custom-tables/) on top of the mirrored data. * Open the [knowledge base](/tenant/knowledge-base/) — your team’s living library of how the business runs. * Review the [audit log](/tenant/audit-log/) of changes. Administrators additionally get the [Development](/tenant/development/) pipeline — proposals, solutions, and tracked releases — and can build [apps](/build-apps/) on your data. Note If an administrator has shared an app with you but not the admin surface, your home is the **Apps launcher** at `/apps` — just the apps you’ve been given. ## Where to go from here [Section titled “Where to go from here”](#where-to-go-from-here) * Bring additional systems into the same canonical layer from the console: [Add sources](/console/add-sources/). * Keep an eye on consumption and your bill: [Billing and usage](/console/billing-and-usage/). * Go beyond the built-ins with [hooks](/extending/writing-hooks/) and [conduits](/extending/writing-conduits/). # Provision your instance > Sign up at the console and provision a dedicated Turbobase instance for your org. Shipped Provisioning is a console-only flow: you sign up, create an organization, create an instance, and watch it provision to **Ready**. Everything happens in the [console](/console/overview/) at `console.turbobase.io` — there is nothing to install. Signup is invite-gated. If you don’t have an invite yet, request one at `console.turbobase.io/early-access`; we’ll send you an invite link when we’re ready for you. ## 1. Sign up [Section titled “1. Sign up”](#1-sign-up) 1. Open your invite link, or go to `console.turbobase.io/signup`. 2. Enter your first and last name, email, and a password (at least 12 characters). The invite code travels with the link, so you usually don’t type it. 3. Verify your email when prompted, then you’re signed in to the console. The console is the **customer realm** of Turbobase — a sign-in distinct from your live Turbobase app. See [Open your app](/getting-started/open-your-tenant/) for why they’re separate. ## 2. Create an organization [Section titled “2. Create an organization”](#2-create-an-organization) An organization (org) is the billing and ownership boundary that owns your instances. On first sign-in the console walks you into creating one: 1. Go to **Orgs → New** (`/orgs/new`). 2. Enter the org name. The console auto-suggests a URL-safe **slug** from the name; you can edit it. 3. Provide a contact email, and optionally a website and a phone number. Your role is shown as **Admin** and can’t be changed — the first member of a new org is always its admin. 4. Submit. You’re now an admin of the org. One human can belong to multiple orgs and switch between them; see [Orgs and instances](/console/orgs-and-instances/). ## 3. Create an instance [Section titled “3. Create an instance”](#3-create-an-instance) An instance is your own Turbobase — its own database and app at `.turbobase.app`. 1. Go to **Instances → New** (`/instances/new`). The form is scoped to your **active org** (set by the org switcher in the nav). 2. Enter the instance name; the console auto-derives an instance **slug** you can edit. This slug becomes your subdomain, so choose it carefully. 3. The region is shown read-only: every instance runs in our **US East (Ashburn)** region today. The plan picker stays visible so you can see the tiers, but it’s locked while pricing is finalized: every new instance is created on the entry plan, **Ignition**. 4. Submit. The console redirects you to `/instances//provisioning`, where you watch the status live. Note Creating an instance requires **Manage instances** on the org. If you can’t see the form, ask an org admin to tick it for you. A newly self-signed-up org also needs staff approval before it can provision — if your org isn’t enabled yet, the page adds a **You’re on the waitlist** banner above the form with a sales address to write to. The form still works: your instance keeps its slug and plan and waits with a **New (no access)** badge until the org is enabled. ## 4. Watch it provision to Ready [Section titled “4. Watch it provision to Ready”](#4-watch-it-provision-to-ready) Provisioning runs through a small state machine. The console shows each stage as a friendly status label; you don’t have to do anything but wait (it takes a couple of minutes): | What you see | What’s happening | | ----------------------------------------- | ------------------------------------------------- | | Provisioning… | The instance record is created and work begins | | Provisioning… (database) | Your dedicated Postgres schema is built | | Provisioning… (services) | The instance’s app and sync services are deployed | | Provisioning… (waiting for health checks) | We wait for everything to report healthy | | **Ready** | Your instance is live | If something goes wrong the status flips to **Failed** — reach out to support. (You may also see **New (no access)** or **Pending** while the org waits on approval, as above.) The full status vocabulary, including the upgrade and pause states, is documented in [Orgs and instances](/console/orgs-and-instances/). ## Next step [Section titled “Next step”](#next-step) A Ready instance has no data yet. Continue to [Connect your first source](/getting-started/connect-your-first-source/) to start the setup wizard and connect Oracle NetSuite. # What is Turbobase? > What Turbobase is, who it is for, and what you get when you provision an instance. Shipped Turbobase is an ERP operations and development platform for Oracle NetSuite. Connect your instance and Turbobase mirrors it into a live dedicated Postgres database. This queryable copy of that data allows an operator to diagnose, automate, and build without the traditional performance overhead of NetSuite. You never install or run anything. Turbobase is a managed service: you sign up at the console, create an instance, and we provision the database, the sync engine, and your Turbobase app for you on dedicated infrastructure. ## Who it is for [Section titled “Who it is for”](#who-it-is-for) This guide is written for the **operator**, the person at your organization who signs up, provisions an instance, connects NetSuite, and hands the live app to their team. One operator gets the reach of a whole team of specialists: the platform builds, automates, diagnoses, and manages the instance alongside you. You work entirely through the [console](/console/overview/) at `console.turbobase.io`. ## What you get [Section titled “What you get”](#what-you-get) When you provision an instance you get: * **A dedicated instance** at `.turbobase.app`, with its own Postgres database, never shared with another customer’s data. * **Live NetSuite sync.** Turbobase mirrors your NetSuite records into your instance’s database and keeps it current. Write-back to the source is gated per source and turned on when you’re ready for it. * **[Pilot](/tenant/pilot/), your AI assistant.** Chat grounded in your own data. * **Development in the app.** A proposal-and-solution pipeline where you and Pilot shape changes in [Studio](/tenant/development/), then ship them as tracked releases. * **[Apps](/build-apps/).** Internal apps built on your data from a plain-English brief and served inside your instance. * **The [Analyzer](/tenant/analyzer/).** An inventory of your NetSuite customizations (scripts, custom fields, workflows, roles, forms) with the risk and improvement proposals that come out of it. * **A [knowledge base](/tenant/knowledge-base/).** A living library of how your business runs that your team searches and that grounds Pilot’s answers. * **Self-service management.** Orgs, instances, sources, billing, and usage, all from the console. * **Extensibility.** Hooks, conduits, and a marketplace for going beyond the built-in behaviour (see [Extending Turbobase](/extending/writing-hooks/)). ## How it is managed [Section titled “How it is managed”](#how-it-is-managed) Two surfaces matter to you as a customer: | Surface | Where | What you do there | | ---------------------- | --------------------------- | --------------------------------------------------------------------------------------------- | | **The console** | `console.turbobase.io` | Sign up, create orgs, provision and manage instances, connect sources, view billing and usage | | **Your Turbobase app** | `.turbobase.app` | Your team’s day-to-day work with the live data | The console and the app are separate sign-ins with separate credentials. See [Open your app](/getting-started/open-your-tenant/). ## Connecting additional sources [Section titled “Connecting additional sources”](#connecting-additional-sources) Turbobase runs on NetSuite and can bring other systems into the same canonical layer. A second source lands in the same Postgres, on the same canonical schema, under the same row-level security, so one query spans NetSuite and everything else instead of a round-trip through each vendor’s API. * **Stripe is live** alongside NetSuite, on the same connector contract: webhook-led ingest, canonical projection, and write-back. * **A broader set is registered** against that 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, WhatsApp Business, and the knowledge sources Google Workspace, Fireflies.ai, Granola, FeedHive and Azure Blob Storage. Each is enabled per instance, and [Add sources](/console/add-sources/) says what each one brings in. * **[Atlas](/tenant/atlas/) consolidates identity across sources**, so the same real-world customer seen in NetSuite and in Stripe resolves to one canonical entity rather than two rows. * **[Conduits](/extending/writing-conduits/)** pull from a system that has no connector at all and land the result on the same canonical tables. * **More than one connection of the same source** is supported: two NetSuite accounts, two QuickBooks companies, Stripe sub-accounts. Each is its own connection, with its mirrored rows kept apart so records sharing an internal id are never merged. **Standalone instances** (no upstream source at all) follow the same contract, with Turbobase itself as the system of record. The schema, RLS, hooks, guardrails, audit log, channels, and app surface are unchanged. [Add sources](/console/add-sources/) covers the flow for connecting a source to an instance that is already running. ## Next step [Section titled “Next step”](#next-step) Ready to get an instance running? Start with [Provision your instance](/getting-started/provision-your-instance/). # 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](#connecting-additional-sources) below. > New to the vocabulary? The [Glossary](/getting-started/glossary/) defines every word, and it is the one place each is defined. [Key concepts](/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](/getting-started/what-is-turbobase/) is the front door. ## How the pieces fit together [Section titled “How the pieces fit together”](#how-the-pieces-fit-together) ```plaintext 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 │ └──────────────────────────────────────────────────────┘ ``` ## What happens when a record changes [Section titled “What happens when a record changes”](#what-happens-when-a-record-changes) **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 `.delete` rule is separate from `.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. ## Auth at a glance [Section titled “Auth at a glance”](#auth-at-a-glance) | 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. ### How permission edits propagate [Section titled “How permission edits propagate”](#how-permission-edits-propagate) 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. ## Connecting additional sources [Section titled “Connecting additional sources”](#connecting-additional-sources) 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](/tenant/channels/), not a source. **Cross-source identity.** When the same real-world entity appears in two sources, [Atlas](/tenant/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](/extending/writing-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](#how-permission-edits-propagate) above for the `NULL` rule that makes this work. ### The connector contract [Section titled “The connector contract”](#the-connector-contract) 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 if you need a source that isn’t live yet. ## Cross-cutting subsystems [Section titled “Cross-cutting subsystems”](#cross-cutting-subsystems) Every connector sits inside the same set of generic subsystems. Each has a one-pager in [Key concepts](/concepts/); the diagram below shows where they sit on the write path. ```plaintext 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. # Build apps on Turbobase > Build a static React SPA served same-origin at your Turbobase instance. Auth, reads, realtime, and writes through one canonical contract. Turbobase is your backend. Once your instance is connected to a source (Oracle NetSuite for most instances, plus Stripe and the other registered connectors), every synced record is available in Postgres with row-level security, a Realtime publication, and a write-back queue. This page is the full reference for building an app on an instance. You build a static React SPA, deploy it into your instance, and it’s served **same-origin** at `https://.turbobase.app/a/`, so it inherits the instance’s auth and talks to every contract below over relative paths. Prefer a guided start? In your Turbobase app, the template gallery (**Development › Proposals**, the Templates icon in the header) turns a template into a proposal: you refine the brief conversationally, the AI generates the app source in Studio, and `turbobase apps deploy` builds and deploys it. **Development › App Management** (`/admin/development/apps`) is where deployed apps are managed — create, share, deploy a version, roll back. ## What you have to work with [Section titled “What you have to work with”](#what-you-have-to-work-with) * **Postgres reads** through PostgREST at `${SUPABASE_URL}/rest/v1/`, scoped to the `turbobase` schema. The same RLS policies that gate your Turbobase app gate the apps you build. A first paint that fans out many reads can multiplex them into **one** request via `POST /v1/batch` (the client’s `batch()` helper) so the request count never trips the rate limiter — see [Batch the cold-load fan-out](#batch-the-cold-load-fan-out). * **Realtime subscriptions** through the Supabase Realtime publication. Every synced table is already in `supabase_realtime`. * **Auth** is inherited from the instance. Your app is served same-origin at `https://.turbobase.app/a/`, so it rides the instance’s session cookies automatically. If the visitor isn’t signed in, the serving route 302s to `/login` (the instance’s own hosted sign-in page) before your SPA loads — the app has no sign-in logic. The RS256 session JWT carries the user’s role, subsidiary scope, and capability set, and RLS enforces all three on every read — your app never checks them itself. * **Writes** through the Turbobase write boundary (`POST /v1/data/:recordType`), which validates the record synchronously and queues the sync; transform-shape writes (e.g. sales order → item fulfillment) go through `POST /v1/sync/events/from-chat`. * **Discovery** through `GET /v1/catalog/records` — live introspection of which records this instance syncs, what tables back them, and what columns each has. * **Typed clients** via the Supabase CLI’s TypeScript codegen against the `turbobase` schema — see [Typed clients](#typed-clients) below. ## Authenticate [Section titled “Authenticate”](#authenticate) Your app never handles a password and ships no sign-in page. It’s served **same-origin** with the instance at `https://.turbobase.app/a/`, so it inherits the instance’s session cookies automatically. If a visitor hits your app without a session, the serving route 302s them to `/login` (the instance’s own hosted sign-in page, Zitadel) before your SPA ever loads, then bounces them back once they’re signed in. There is no callback route, no code exchange, and no cross-origin request — by the time your code runs, the request already carries a valid session. Two cookies matter to your app: * `turbobase_session` — the HttpOnly, Secure, SameSite RS256 session JWT. The serving route and the write boundary read it; your browser code cannot (and doesn’t need to). * `tb_jwt` — a non-HttpOnly mirror of the same JWT, readable from `document.cookie`. supabase-js reads it for the `apikey` position, the `Authorization: Bearer ` header, and `client.realtime.setAuth(...)`, so PostgREST reads and Realtime run under the user’s RLS context. The JWT lasts as long as the instance’s session length — one day unless the administrator changed it, and settable per role up to thirty days — and it slides: once a session is more than half spent, the next authenticated request re-mints it, so a continuously-active user never runs out. You don’t need to refresh manually. The supabase-js wiring (reading `tb_jwt`, arming Realtime) is in the [auth wiring doc](/build-apps/auth/). **Local development.** On `localhost` your app runs on its own Vite dev server (`APP_URL`, e.g. `http://localhost:3000`) with a `server.proxy` that forwards `/v1`, `/rest`, `/auth`, and the Realtime WebSocket to the instance origin — so `localhost:3000` behaves same-origin. Sign in on the instance origin first so the browser holds the `tb_jwt` cookie the proxy forwards. The JWT carries `app_role` (`'admin' | 'standard'`), `permissions[]`, `ns_subsidiary_id`, and similar. **You read it indirectly through RLS** — there is no client-side JWT inspection step in the happy path. ## Read with PostgREST [Section titled “Read with PostgREST”](#read-with-postgrest) ```ts import { createClient } from '@supabase/supabase-js'; const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { db: { schema: 'turbobase' } }); const { data, error } = await supabase .from('order') .select('external_id, tran_id, total, status, entity_name') .gt('total', 1000) .order('tran_date', { ascending: false }) .limit(50); ``` Three things this snippet relies on: * `order` is the table name, not the sort call — sales orders live in `turbobase."order"`. Table names are the catalog’s `table` value, not the record type: `salesOrder` reads from `order`, and `customer` / `vendor` / `employee` / `contact` all read from `entity`. Take the name from `GET /v1/catalog/records` rather than from the record type. * `Accept-Profile: turbobase` is set automatically by the `db: { schema: 'turbobase' }` client option. If you hand-roll `fetch`, you must set it yourself — PostgREST’s default schema is `public`, which is empty on a Turbobase instance. * RLS gates rows, not the anon key. The anon key only identifies which PostgREST instance to talk to. Permissions come from the session JWT in the cookie. A user without `LIST_VENDOR` gets zero rows from `/rest/v1/vendor` even though the request itself returns `200 OK` with an empty body. Pagination: PostgREST honours `Range: 0-49` headers and emits `Content-Range: 0-49/1234` on responses. The supabase-js client wraps this via `.range(from, to)`. Filter syntax: PostgREST’s filter dialect (`?status=eq.open&total=gt.1000`). The full reference is at . ### Batch the cold-load fan-out [Section titled “Batch the cold-load fan-out”](#batch-the-cold-load-fan-out) When the first screen needs several reads at once — a dashboard querying every record type, or a header table plus the lookup tables you join to it in memory — issue them as **one** request instead of many parallel PostgREST calls. Even paced client-side, the request *count* of a cold-load fan-out eats your rate-limit budget (1000 requests per 10 seconds per client IP, shared across everything your instance serves — see [Keep concurrency low](/build-apps/reading-data/#keep-concurrency-low)), and once you’re refused users see a “some data is missing” gap. `POST /v1/batch` takes `{ queries: string[] }`, where each string is exactly the path-and-query you’d read normally (`order?select=external_id,total&limit=100`), and returns one result per query **in order**, each with its own `ok` / `status` — a `429` or `5xx` on one read never fails the others. It’s a single HTTP request rather than many, so it spends one request of that budget instead of fifty (up to 50 queries per call). The scaffolded client exposes it as `batch()`: ```ts const [orders, invoices] = await batch([ 'order?select=external_id,total&order=tran_date.desc&limit=100', 'invoice?select=external_id,total,status&limit=100', ]); if (orders.ok) renderOrders(orders.rows); // rows = the parsed result set if (!invoices.ok) note(`invoices unavailable (${invoices.status})`); ``` **It reads, and it is RLS-scoped exactly like a normal read — here is why it can’t do anything else.** Three things stop a write going through this door, and all three would have to be undone: every sub-query leaves as a **GET**; each one is a single resource name, so there’s no way to name a stored **procedure** (PostgREST exposes those under `rpc/`, which this endpoint refuses); and the session you send is the same one your user reads with, which **holds no write grant** of its own — the database would refuse a write even if the first two were removed. To change a record, use the write boundary ([Write](#write)) — the only path that runs your instance’s hooks, checks the caller’s capabilities and records the change. Reserve individual reads for on-demand / drill-in loads after first paint. Full reference: [Reading the mirror → `batch()`](/build-apps/reading-data/#multiplex-the-cold-load-fan-out-with-batch). ## Subscribe to Realtime [Section titled “Subscribe to Realtime”](#subscribe-to-realtime) ```ts supabase .channel('order') .on( 'postgres_changes', { event: '*', schema: 'turbobase', table: 'order' }, (payload) => { // payload.new on INSERT / UPDATE. (payload.old on DELETE — but see // "Deletes never arrive" below: a deleted record does not emit one.) console.log('change', payload.new); } ) .subscribe(); ``` You can filter further on a column (for example, `filter: 'entity_id=eq.cust-123'` to fire only for one customer’s orders). The same RLS policies apply — Realtime checks the session JWT and only fires for rows the user could have read. **Subscribe to base tables, never to a view.** Postgres logical replication is driven off the write-ahead log, so it only ever fires for a physical table. A channel opened on a view subscribes without error and then streams nothing, forever — the failure is silent, which is why it usually ships. The entity sub-type read surfaces (`customer`, `vendor`, `employee`, `contact`) are views over the `entity` table: read them through PostgREST as usual, but subscribe to `entity` and narrow with `filter: 'entity_type=eq.customer'`. Match those rows on `external_id` (the sub-type views do not expose `pg_row_id`); match transaction rows on `pg_row_id`, which is their primary key — `external_id` is null on a row your app just created and stays null until the write-back worker commits it upstream. Base-table names are the catalog’s `table` value, not the record type: `salesOrder` reads and streams from `order`. Confirm the name against `GET /v1/catalog/records` rather than guessing from the record type — a channel opened on a table that does not exist is the same silent no-op as a channel on a view. Every synced base table is in the `supabase_realtime` publication out of the box. If you need realtime on a custom table that isn’t published yet, contact support and we’ll add it — you don’t run migrations against your instance yourself. ### Deletes never arrive, so reconcile instead [Section titled “Deletes never arrive, so reconcile instead”](#deletes-never-arrive-so-reconcile-instead) Danger **Do not write an `on DELETE → remove from the list` handler.** It can never fire for a deleted record. Nothing in Turbobase hard-deletes a mirrored row: a delete — whether your app asked for it via `DELETE /v1/data/…` or the source system reported one — is an `update … set deleted_at = now()`. Tombstones are soft on purpose, because a hard `DELETE` would cascade the line rows away and make a source-side un-delete unrecoverable. The tombstone is then hidden by a **restrictive** `SELECT` policy (`deleted_at is null`, on `authenticated` and `anon`) that every mirror table carries. Realtime authorises each frame by re-running RLS against the row it is about to send, so the post-update row fails that probe and the frame is dropped. Your subscriber gets **no `INSERT`, no `UPDATE`, and no `DELETE`** — the row simply stops existing, with no event to tell you. **On an instance with more than one system connected, a delete is routed by the record’s own system** — read off the record, not off the system the signed-in session belongs to. Two things follow for your code. A delete of a record belonging to a system whose upstream delete is not implemented yet comes back refused, naming that system, so handle the refusal rather than assuming a `DELETE` that returns 2xx is the only outcome. And `DELETE /v1/data/{recordType}?externalId=…` is refused `422 SOURCE_AMBIGUOUS` when that id exists in more than one connected system, or in two accounts of one system — a source’s own id is only unique within one connection. Pass `pgRowId` instead, which is on every row your reads return. (The flip side: that lookup is no longer narrowed to the connection your session signed in against, so an id that *is* unique across your connections now resolves the record it names rather than coming back `404`.) Two more refusals are worth handling: a record whose system has since been disconnected is refused rather than queued to another system, and one whose own system cannot be read at that moment is refused as retryable — which is not a statement that the record is gone. **A record type two of your systems spell the same way needs `sourceSystem`.** `account` is the one that does today: it means a GL account in NetSuite and a customer in Salesforce and in Zoho CRM, so with two of those connected the record type alone does not say which table a delete addresses, and the delete is refused `422 SOURCE_AMBIGUOUS` rather than applied to one of them. Pass `?sourceSystem=netsuite` to say which you mean. It is a qualifier, not a destination — it says which table to look in, the record’s own system still decides where the delete is sent, and a key that matches no row in the table you qualified comes back `404` rather than removing another system’s copy. If only one system is connected you will never see this refusal and need not send the parameter. **One published table is the exception: `entity_canonical`.** The combined identity list behind the `turbobase:identity_` record types is a consolidation rather than a mirror — it carries no `deleted_at`, and an Atlas merge or repair removes the row outright — so it *does* emit real `DELETE` frames. If you subscribe to it, write the handler. See below for what such a frame carries. **The supported pattern is a reconciliation sweep.** Any list that must drop deleted rows re-reads its own query on an interval (or on tab focus / user action) and replaces the list with what came back — the tombstoned row is absent from that read, because the same restrictive policy hides it. Realtime stays responsible for `INSERT` and `UPDATE`, which is what it actually delivers; the sweep is what removes rows. Thirty to sixty seconds is usually right for a dashboard: it is a cold-load-shaped read, so send it through [`POST /v1/batch`](/build-apps/reading-data/#multiplex-the-cold-load-fan-out-with-batch) rather than a fan-out of individual requests. ### Three more things about the frames you do get [Section titled “Three more things about the frames you do get”](#three-more-things-about-the-frames-you-do-get) **A real `DELETE` frame carries only the primary key.** Replica identity is `DEFAULT` on these tables, so Postgres writes only the key columns into the WAL for a delete, and `payload.old` has nothing else in it. A channel filtered on the primary key still matches and still fires. A channel filtered on any **other** column — `filter: 'entity_id=eq.cust-123'` — can never match a delete frame, because the column it filters on is not in the payload. (This is about genuine row deletions, which the mirror tables do not do. It bites on tables you have added yourself, and on `entity_canonical`, whose `payload.old` carries `id` and not `entity_type` — so drop the row by `id` rather than narrowing on the bucket.) **Line tables churn hard.** Every header sync deletes and re-inserts *all* of that header’s line rows rather than diffing them — so a channel on `order_line` sees a delete/insert storm on every sync of the parent, not a targeted update of the one line that changed. Subscribe to the header table and re-read the lines, rather than trying to reconstruct a line set from the stream. **A frame is the WHOLE row, `raw` included.** `raw` (the untouched source payload) is 93-99% of a mirror row’s bytes: an invoice row averages \~20 KB and reaches \~325 KB in the worst case whole, versus \~1.3 KB without `raw`. (Measured on the local development database on 2026-08-11, by summing `pg_column_size` per column across the mirror tables. Treat the figures as the shape of the problem, not as your instance’s numbers — run the same sum against your own data when the exact bytes matter.) Realtime sends every column and the pinned server version has no way to select a subset, so a wide channel on a busy table is a lot of traffic to the browser. Narrow the channel with `filter`, subscribe only to the tables a screen actually shows, and let the reconciliation sweep above — a normal PostgREST read, where you *can* `select=` just the columns you render — carry the bulk. ## Write [Section titled “Write”](#write) Two paths, depending on what the write does. **Records (create + update)** go through the Turbobase **write boundary**: `POST /v1/data/:recordType`. The boundary validates the record synchronously against the instance’s hooks (a missing or invalid field comes back as a `422` naming the field), writes it under your RLS context, and marks the row `write_status = 'pending'`. The write-back worker then pushes it to the source asynchronously and flips `write_status` to `synced` — or to `needs_input` / `blocked` if the source rejects it. Those two are one question, asked of the person looking at the record: **`needs_input`** means a value on this row releases the refusal, so the `record_write_state` row’s `message` says what to change and you offer an edit and a re-save; **`blocked`** means the fix is somewhere else — another document, a permission, a connection — so you show the message and stop. Read the `message` either way: `fields` names the column only when the connected system named one, and is empty on a refusal that is still the reader’s to correct. A third ending is neither: **`awaiting_approval`** means the instance’s guardrails require a person to approve the write before it is sent, so it has not been attempted and there is nothing on the row to fix — show the message and wait. ```ts await fetch('/v1/data/customerPayment', { // same-origin, relative path method: 'POST', credentials: 'same-origin', // the turbobase_session cookie rides automatically headers: { 'content-type': 'application/json' }, body: JSON.stringify({ idempotencyKey: crypto.randomUUID(), parent: { entity_id: 'cust-123', payment_amount: '500.00', tran_date: new Date().toISOString().slice(0, 10) } // lines: [...] // header+sublist records (salesOrder, invoice, …) // pgRowId, expectedSyncVersion // updates: address the row + optimistic concurrency }) }); ``` **The body carries six top-level keys and no others**: `parent` (the record’s own fields), `lines`, `pgRowId`, `externalId`, `idempotencyKey`, `expectedSyncVersion`. Anything else is refused `422 { error: 'validation_failed', rejection: { code: 'UNKNOWN_BODY_KEY', field, message } }`, naming the key it did not recognise, and nothing is written. This catches the one mistake that used to be silent: a body that put the record’s fields under `fields` (the RESTlet spelling) or `data` was read as an *empty* `parent`, so the boundary created a row of NULLs and answered `200`. An empty body is refused the same way (`EMPTY_BODY`). Custom fields go under `parent.raw`, not a top-level envelope. Responses: `200 { ok, row: { pg_row_id, external_id } }`; `422 { error: 'validation_failed', rejection: { code, field, message } }` (show inline against the field); `409 { error: 'conflict' }` (stale `expectedSyncVersion` — re-read and reapply); `503 { error: 'hook_unavailable', rejection: { code: 'HOOK_UNAVAILABLE', message } }` (send the same body again — see below); `403` (read-only mode / not permitted). The boundary authenticates via the same-origin `turbobase_session` cookie, which rides the request automatically — no Bearer token, no route handler, no proxy. **Reads stay on PostgREST; only writes go through the boundary.** **`503` means try again, not fix something.** Every write passes through your tenant’s safety checks first, and occasionally one of them does not finish in time. Nothing was wrong with the record and nothing was saved, so the one correct response is to send the same body again — the `Retry-After` header says how long to wait, in seconds. Do not show the person a validation error and do not ask them to change anything: there is nothing for them to change, and the message on the rejection is already written for them to read if you want to show it. Treat it exactly as you would any other temporary failure, and keep the retry idempotent by sending the same `idempotencyKey`. `DELETE /v1/data/:recordType` answers the same way for the same reason. **Two delete refusals are the opposite of a `503` — do not retry them.** `DELETE /v1/data/:recordType` answers `422 { error: 'validation_failed', rejection: { code, message } }` when the connected system was always going to refuse the delete, and it answers before anything is hidden, so the record is still there when you get one. Show the message; the answer will not change until somebody changes something. * **`DELETE_BLOCKED_BY_DEPENDENTS`** — other documents still point at the record: a payment or credit memo applied to the invoice, bills against the vendor. The message names each one by type and document number, so render it as the next action — clear those, then delete this one again. * **`SOURCE_BUNDLE_TOO_OLD`** — the connected account is running a version of the Turbobase integration package older than the one that allows this record type to be deleted. The message names both versions. Nobody using your app can fix this; an administrator updates the package in the connected account. Allow up to an hour after they do before the refusal clears, because the deployed version is re-read on a schedule rather than on each delete. **A `200` from the delete route still does not mean “and it will stay gone.”** It never did, and these two refusals narrow that gap rather than closing it: the commonest reasons a connected system refuses a delete are now caught on the request, but a dependency your instance does not hold a copy of is still only discovered when the delete reaches the source — and then the record comes back carrying the reason. Keep whatever handling you have for a record that reappears. ### Create one record in several connected systems [Section titled “Create one record in several connected systems”](#create-one-record-in-several-connected-systems) If your customer keeps the same company or person in more than one of the systems they have connected, you can create it in all of them with one write. **Name them on `parent.source_system`, as a list.** The first name is the system the record belongs to; the rest get a copy. ```ts await fetch('/v1/data/customer', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ parent: { source_system: ['netsuite', 'quickbooks'], // the record lives in netsuite; quickbooks gets a copy display_name: 'Acme Holdings' } }) }); // 200 { ok, row: { pg_row_id, external_id, sources: [ // { source_system: 'netsuite', external_id: null }, // { source_system: 'quickbooks', external_id: null } // ] } } ``` You get back **one record**, and Turbobase remembers which systems hold a copy — so a later edit through the same boundary is sent to every one of them on its own. You do not repeat the edit per system, and the copies cannot silently drift apart. Five things to build against: * **`sources` names what was accepted, not what landed.** The upstream writes are asynchronous, so every `external_id` in it is `null` when you get the response — there is nothing yet to report. `row.external_id` is the record’s own id in the system it belongs to, and it fills in the same way a single-system create’s does. * **`sources` appears only when you named more than one system.** A create naming one, or none, gets back exactly the two keys it always did. A one-element list means one system, not a degenerate fan-out. If you named several and `sources` is absent anyway, the record was created in the first-named system and no copy was recorded — the copies are the one part of the write that can fall away after the record is committed, so never read a missing `sources` as “they are on their way”. * **Send an `idempotencyKey` on a multi-system create, because it is the only thing that can recover the copies.** POST the same create again with the same key and the retry records the copies and answers with `sources` — it converges on the record you already have rather than making a second one. Without a key there is no call that recovers them: a plain re-POST creates a second record, and a list on an update is refused (below), so the record stays single-system until you add the copies yourself in each one. * **Every name has to be usable, or the whole write is refused.** A system that is not connected is `422 SOURCE_NOT_CONNECTED`, one with no write path for that record type is `422 SOURCE_NOT_WRITABLE`, and one that means a different record by that name is `422 SOURCE_AMBIGUOUS` — each naming the system you need to fix. A list carrying an entry that is not a system name at all is `422 SOURCE_INVALID`, rather than that entry being quietly dropped and the record created in fewer systems than you asked for. Nothing is written in any of those cases: a record that exists in some of the systems you asked for, with nothing saying which, would be worse than writing to each system yourself. * **Reading what each copy did.** The per-system standing lives on `turbobase.txn_source_link`, which is now readable by anyone who can read the record itself — so `txn_source_link?master_table=eq.entity&master_row_id=eq.` over PostgREST tells you each system’s `write_status` (`ok`, `pending`, `blocked`, `divergent`, `muted`) and, for a refused copy, the `last_error` the system gave. One copy being refused never costs you the others. **Name Turbobase itself** — `'standalone'` — when the master is to live here rather than in a vendor’s system: `source_system: ['standalone', 'netsuite']` keeps the record yours and pushes a copy out. Turbobase is never itself a write target, so nothing is sent to it. **On an update, `source_system` still names one system and is verified against the record** — a record cannot be moved between systems, or given a copy in another one, by editing it. A list there is `422 SOURCE_LIST_ON_UPDATE`. **Transform-shape writes** (item fulfillment from a sales order, invoice from an order, refund from a payment, …) go through `POST /v1/sync/events/from-chat`. These can’t round-trip through PostgREST because the source system rejects them as plain inserts — NetSuite specifically expects `N/record.transform(salesOrder → itemFulfillment)` to inherit lines and FKs. The endpoint accepts: ```ts await fetch('/v1/sync/events/from-chat', { // same-origin, relative path method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ recordType: 'itemFulfillment', // see allowlist below payload: { created_from_id: 'so-123' }, // string, the source-side id of the parent idempotencyKey: 'my-app-fulfill-so-123' // optional, dedups retries }) }); ``` The current allowlist is `itemFulfillment`, `invoice`, `customerPayment`, `customerDeposit`, `cashSale`, `creditMemo`, `estimate`, `salesOrder`, `returnAuthorization` and `vendorReturnAuthorization` — the last two raise a return against the document that caused it. Anything outside that set returns 403 — admins use `POST /v1/sync/events` to dispatch arbitrary records. This endpoint is gated exactly like the write boundary: the caller needs the record’s `write:
` capability (403 otherwise), and **every key in `payload` must be a real column of the record’s canonical table** — the only exception being `created_from_id` itself. An unrecognised key returns `422 { error: 'validation_failed', rejection: { code: 'UNKNOWN_COLUMN', field, message } }` and nothing is queued. Custom fields go under `raw`, as they do on `POST /v1/data/:recordType`; there is no `fields` / `sublists` envelope and no line array on this route. It carries one gate the write boundary has no equivalent of: **the parent named in `created_from_id` must be a record the signed-in user can read.** A transform builds the new record out of an existing one, so `write:order_fulfillment` alone is not enough — the same RLS that decides which sales orders that user sees on a grid decides which ones they may fulfil. A parent they cannot read returns `403 { error: 'forbidden' }` and queues nothing, and that answer is byte-identical to the one for a `created_from_id` that matches no record at all: the endpoint will not tell an app whether a record it cannot see exists. Show it as “that record isn’t available to you”, not “not found”. **Idempotency.** Both write paths accept an `idempotencyKey`. Retries that carry the same key dedupe and return the original row / `sync_event.id`, so you can retry without fear of double-writing. On `POST /v1/data/:recordType` the retry is answered with the row **the same caller** committed under that key — `{ ok: true, replayed: true }` carrying its `pg_row_id` and `external_id` — whether or not that caller can read the row back. So a role holding `write:
` without the matching read capability retries safely, and so does one whose row has since moved outside the dimensions it may browse. You choose the key, so a key another user committed is not an answer to your retry: that one keeps the original failure and tells you nothing about the row holding it. ## Discover the catalog [Section titled “Discover the catalog”](#discover-the-catalog) Two access modes: **1. Session cookie (humans + first-party apps).** Reads are RLS-scoped — a caller without `LIST_VENDOR` does not see `vendor_bill` in the response. ```bash curl --cookie 'turbobase_session=...' \ https://.turbobase.app/v1/catalog/records ``` **2. Access token (Claude Code, codegen tooling).** Mint a catalog token on the **Access tokens** page — your account menu, or `/settings/tokens` — choosing the AI / catalog type. Sign in with your Turbobase account to reach it: a token belongs to a Turbobase account, so the page is not offered to administrators who opened the app from inside NetSuite. The value is revealed once at mint and can be revoked there (it’s hashed at rest, like a password). A personal access token (PAT) with the `catalog:read` scope works too, but header-only — a PAT presented on the query string is rejected so it can’t leak into URL logs. Either token gets back the **full** catalog without permission filtering. Schema only — actual row data still flows through PostgREST RLS. ```bash # query param (browser-friendly; catalog-kind token only) curl 'https://.turbobase.app/v1/catalog/records?token=' # bearer header (curl / Claude / CI) curl -H 'Authorization: Bearer ' \ https://.turbobase.app/v1/catalog/records ``` Either mode returns: ```json { "records": [ { "recordType": "salesOrder", "source": "netsuite", "table": "order", "listOnly": false, "permkey": "TRAN_SALESORD", "readCapability": "read:order", "writeCapability": "write:order", "columns": [ { "name": "external_id", "pgType": "text", "nullable": false, "role": "identifier" }, { "name": "tran_id", "pgType": "text", "nullable": true, "role": "display" }, { "name": "entity_id", "pgType": "text", "nullable": true, "role": "identifier", "idFor": "entity_name" }, { "name": "entity_name", "pgType": "text", "nullable": true, "role": "display", "displayFor": "entity_id" }, ... ], "anatomyPath": "sources/netsuite/skills/anatomy/sales-order.md", "recipes": [] }, ... ] } ``` Columns carry a semantic layer beyond the bare Postgres facts, wherever your instance knows more: * `role` — what the column is for: `identifier`, `display`, `measure`, `timestamp` or `status`. Lead tables with `display` columns; use `identifier` columns for filters, joins and links. * `displayFor` / `idFor` — the canonical `_id` ↔ `_name` pairing. `entity_name` is the human-readable twin of `entity_id`; render the name, filter on the id. * `label` / `format` — authored display names (“Vendor”, “Bill #”) and render formats (`currency.native`, `date.relative`) where your instance has a display contract for the record. * `currencyKey` — on a money column formatted `currency.native`, the sibling column holding the ISO 4217 code (normally `currency_code`). See below. * `description` — the plain-English meaning of a custom field, when your instance has one (admin token required). Admin-added custom records also carry a record-level `description` — the plain-English account of what the record is for. ### Check `writeCapability` before you offer an edit [Section titled “Check writeCapability before you offer an edit”](#check-writecapability-before-you-offer-an-edit) `readCapability` and `writeCapability` name the capability each direction asks for on **your** instance. The read one is static; the write one is resolved live per request and is `null` whenever a Postgres-side edit of that record would be refused — either the record is inbound-only (nothing pushes it back upstream) or write-back is switched off for the whole source. Render your editing affordances off it rather than caching it: an instance granted write-back starts reporting a capability string on the very next call, with no re-deploy and no re-generated types. Older instances omit the field entirely — treat `undefined` as “unknown”, not as read-only. ### Money is in the document’s own currency [Section titled “Money is in the document’s own currency”](#money-is-in-the-documents-own-currency) `total`, `subtotal`, `tax_total`, `amount_paid`, `amount_remaining` and `balance` are the record’s **own face value**. The mirror copies the figure across exactly as the source system wrote it and never converts it to a reporting or base currency — so on a business that trades in more than one currency, two rows of the same table hold numbers in different currencies. Which one is in `currency_code`, an ISO 4217 code (`USD`, `GBP`, `EUR`) sitting on the row beside the money, so you read it without a join. Format a money column as `currency.native` and point `currencyKey` at it: ```json { "key": "total", "label": "Total", "format": "currency.native", "currencyKey": "currency_code" } ``` `currency.native` renders the amount in whatever currency the row records. When `currency_code` is NULL the currency is genuinely unknown for that row and the cell shows a bare number — do not fall back to a symbol, because `$1,234.50` on a sterling order is a wrong answer that reads like a confident one. `currency.usd` and `currency.eur` still exist and are unchanged, but they hard-code a symbol and should not be used on a mirrored column. Two traps worth knowing: * **`currency_id` is not a currency code.** It is the source’s own reference: a NetSuite internal id (`'2'`) on a NetSuite row, but the ISO code on a QuickBooks, Shopify or Dynamics one. `currency_name` is the source’s free-text name for the currency record, which an administrator can rename. Read `currency_code`. * **Do not sum money across mixed currencies without converting each figure first.** Group by `currency_code` and label each figure with its currency. A single total across mixed currencies is only meaningful once every amount has been converted at the rate in force on **its own** document date — see below. ### The exchange rate on a given date [Section titled “The exchange rate on a given date”](#the-exchange-rate-on-a-given-date) `turbobase.currency.exchange_rate` is the **current** rate on the currency lookup row. Do not convert a dated document with it: the answer changes after every sync, so a report built on it quietly rewrites its own history. **Read the document’s own `exchange_rate` column first.** Every transaction table carries one — see the list at the end of this section — holding the rate the source stamped on that document when it was posted. It is the rate the document was actually written at, so it beats any table lookup, and it never moves under the row afterwards. Fall through to the dated table below only when it is NULL, which means the rate is genuinely unknown. Dated rates are mirrored in `currency_rate`, one row per currency pair per `effective_date`. Read the rate **as of** the document’s own date — the latest one on or before it: ```plaintext /rest/v1/currency_rate?base_currency_code=eq.USD"e_currency_code=eq.GBP &effective_date=lte.2026-03-09&order=effective_date.desc&limit=1 ``` Three things about that query are not optional: * **`effective_date=lte.`.** Ordering and taking the first row without it answers with the newest rate held, for a document that may predate every one of them — a confident wrong answer. * **`order=effective_date.desc&limit=1`** takes the rate that was in force, not the nearest one. A document dated the 9th uses the rate effective the 1st, even if there is another effective the 10th. * **An empty result means no rate is held for that date.** It does not mean the rate is 1. Render the amount in its own currency rather than converting it. **If your tenant holds more than one connection to the same source, add `&connection_id=eq.`.** Each connection mirrors its own rate table, so two of them can hold a row for the same pair on the same `effective_date` — and `limit=1` with no tie-break then returns one of them arbitrarily. On a single-connection tenant (`connection_id` is `default` and there is nothing else to pick) the query above is complete as written. **Which way it multiplies:** an amount in `quote_currency_code` × `exchange_rate` = the amount in `base_currency_code`. One unit of the quote currency is worth `exchange_rate` units of the base currency. The two sides are named after NetSuite’s own fields — its *base currency* is the subsidiary’s and its *transaction currency* is the foreign one — which is the reverse of the FX-market sense of “base”, so read the direction rather than inferring it from the names. Either code is NULL when the source’s currency ref could not be resolved. Those rows never match a pair filter, so an as-of read for that pair returns nothing rather than a wrong factor. **Which records carry their own `exchange_rate`.** Every transaction table does: `order`, `invoice`, `cash_sale`, `credit_memo`, `estimate`, `vendor_bill`, `purchase_order` and `journal_entry`, alongside `expense_report`, `vendor_credit`, `vendor_prepayment` and `credit_card_charge` (which holds the card refunds too, as `record_kind = 'refund'`). That is the rate the source **applied** to that document, and it beats a table lookup whenever it is present — it can legitimately differ from the dated rate. Six of those tables also carry the portable ISO `currency_code` beside it — `order`, `invoice`, `credit_memo`, `estimate`, `purchase_order` and `vendor_bill`. The other six do not: on `cash_sale`, `journal_entry`, `expense_report`, `vendor_credit`, `vendor_prepayment` and `credit_card_charge` the currency is `currency_id` / `currency_name` (the source’s own reference, not an ISO code) for rows from an accounting system, so resolve the ISO key through `turbobase.currency` if you need one there; a card feed’s swipe on `credit_card_charge` carries `currency_code` directly. A table can be fed by more than one system, and not every one of them fills every column: `currency_id` and `currency_name` are the source’s own reference, written for rows mirrored from NetSuite, and a row from another source can leave them NULL. Treat a NULL as “this row does not say”, the same way you treat a NULL `currency_code` — render the bare number rather than assuming a currency. Per-record detail (`GET /v1/catalog/records/:type`) inlines the anatomy markdown and recipe JSONs so a single fetch is enough to render docs in a client. Same dual-auth modes apply. From a Claude Code session, the `record_anatomy` MCP tool wraps this endpoint — read it before building UI over a record. ## Ground an agent in the instance [Section titled “Ground an agent in the instance”](#ground-an-agent-in-the-instance) Everything the instance knows about itself is reachable in the same token realm: * `GET /v1/kb/search?q=…` and `GET /v1/kb/document/:slug` — the **published knowledge base**: how this business runs its processes. From Claude Code, the `search_documentation` / `read_document` MCP tools wrap these. For a custom record the KB is often the only spec that exists — search it before building over one. * `GET /v1/catalog/docs/search?q=…` and `GET /v1/catalog/docs/document/:slug` — **Turbobase’s own documentation**, the pages your instance’s release shipped with. Platform questions (“what does a hook receive”) go here rather than to the knowledge base above, which is what this business wrote about itself. MCP tools: `search_turbobase_docs` / `read_turbobase_doc`. Only the `catalog:read` scope your token already carries, and every answer names the release it came from. * `GET /v1/catalog/objects?q=…` and `GET /v1/catalog/objects/:kind/:externalId` — the **customization catalog**: scripts, custom records, fields, workflows, each with its captured definition and a generated plain-English explanation (admin token required). MCP tools: `search_catalog` / `explain_object`. * `GET /v1/catalog/grounding?records=a,b&query=…` — the assembled **grounding brief** (source primer, per-record anatomy, native-vs-custom, feature enablement, KB retrieval) as one markdown block; admin token required. The CLI writes it to a file for agent sessions: ```bash turbobase project grounding --records purchaseOrder,itemReceipt \ --query "inbound receiving" -o INSTANCE.md ``` Rule of thumb for generated UI: **render display columns, filter on identifiers.** The catalog marks each column’s role, its `_id` ↔ `_name` twin, and the label humans expect — an app that shows `entity_id` where `entity_name` exists is using the catalog wrong. `GET /v1/catalog/openapi.json` is a passthrough of PostgREST’s root OpenAPI document, scoped to the `turbobase` schema. Point `openapi-typescript` / `openapi-fetch` / similar codegen at it. Session-only — the token mode doesn’t apply because the OpenAPI proxy needs a JWT to scope the spec. ## Typed clients [Section titled “Typed clients”](#typed-clients) Generate a `Database` type for your client app with the Supabase CLI: ```bash supabase gen types typescript --project-id --schema turbobase > src/lib/turbobase.d.ts ``` Then point supabase-js at it: ```ts import type { Database } from './lib/turbobase'; import { createClient } from '@supabase/supabase-js'; const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { db: { schema: 'turbobase' } }); const { data } = await supabase.from('order').select('*'); // typed row ``` Installing the Supabase CLI is your app’s responsibility — there’s nothing Turbobase-specific to wrap. Re-run the command after a schema change to pick up new columns. ## One instance per customer [Section titled “One instance per customer”](#one-instance-per-customer) **One instance = one customer’s data.** The same Turbobase instance hosts at most one customer’s data. A product that wants per-customer isolation does it at the edge — route `customer-a.yourapp.com` to the `customer-a` instance and `customer-b.yourapp.com` to a different one. Don’t put a customer id in your queries; there isn’t one. A single instance can connect multiple sources (NetSuite + Stripe + QuickBooks side by side, each under its own `source_system` value), **and more than one connection to the same source** — two NetSuite accounts, two QuickBooks realms, two Salesforce orgs. Each connection is one row with its own `connection_id`, and a single-connection instance carries `default`. What that means for your queries: `connection_id` is on the mirror tables and is part of the natural key, so the same customer internal id in two NetSuite accounts is two rows rather than one merged one. On a single-connection instance you can ignore it. On a multi-connection one, filter on it anywhere two connections could hold a row you would otherwise read as one — the [dated exchange-rate read](#the-exchange-rate-on-a-given-date) above is the worked example. The one place it is deliberately absent is `entity_canonical`: consolidated identity is matched across connections on purpose, so the same real company seen in both accounts resolves to one canonical row. ## Deploy your app [Section titled “Deploy your app”](#deploy-your-app) Your app is a static bundle — no Node server, no SSR. Build it, then hand the `dist/` to your instance: ```bash npm run build # Vite → static dist/ with index.html ``` Deploy the `dist/` into your instance one of two ways (create the app first — **New app** under **Development › App Management** — if it doesn’t exist yet): * **In-product** — open **Development › App Management** (`/admin/development/apps`) in your Turbobase app, choose **Deploy a version**, and drag-drop a zip of `dist/`. * **CLI** — `turbobase apps deploy ./dist`. Either way the app is served at `https://.turbobase.app/a/` — same origin as the instance. The serving route applies the SPA fallback (every client route resolves to `index.html`) and the 302-to-`/login` guard for unauthenticated visitors. Turbobase hosts it for you; there’s no separate host to configure and nothing to self-host. ### Preview before you go live [Section titled “Preview before you go live”](#preview-before-you-go-live) `--preview` builds and uploads exactly as a deploy does, but serves the result at its **own URL** and leaves `/a/` untouched: ```sh turbobase apps deploy . --preview --label "new pick screen" # preview v7 → https://.turbobase.app/_p/pv_… (expires in 7 days; /a/ unchanged). ``` Send that URL to whoever asked for the change — it opens for anyone who can already open the app, so no new access is granted. Then `turbobase apps promote ` makes it live (the reviewed bytes ship as-is; nothing is rebuilt), or `turbobase apps preview discard` closes it. `turbobase apps preview list ` shows what is open, and `--json` makes that scriptable. `--replace-label ""` re-deploys onto the open preview carrying that label, opening one if there is none — so a pull request keeps a **single** preview URL for its whole life instead of minting a new one per commit. ### Deploy from GitHub [Section titled “Deploy from GitHub”](#deploy-from-github) `turbobase apps init` scaffolds `.github/workflows/turbobase-app-deploy.yml`. Set the repo variables `TURBOBASE_BASE_URL` and `TURBOBASE_APP_SLUG` plus the secret `TURBOBASE_PAT` (scope `extensions:deploy`), and: * **a pull request** gets a preview URL, posted as a single comment that is edited on every push, plus a plan of what merging would change in your hooks/conduits/sync config; * **a merge to your default branch** applies that config and then puts the app live; * **closing the pull request** discards its preview. Until `TURBOBASE_BASE_URL` is set every job skips rather than fails, so the workflow is not red on a brand-new repo. ### Preflight what a deploy would do [Section titled “Preflight what a deploy would do”](#preflight-what-a-deploy-would-do) ```sh turbobase apps plan . --app ``` Writes nothing, and exits non-zero on what otherwise surfaces as a failed deploy after review: a table field another app already declares at a different type, a source write a guardrail refuses (see below), and a declared role that can’t be created or whose scopes conflict (see [Roles](#roles-you-declare-an-admin-assigns)). `apps deploy` re-runs every check and enforces it — the preflight only moves the failure earlier. `--app` names the app the way the deploy does, and the role check needs it: it is how the plan tells your app’s earlier role declarations from another app’s. Without it the plan lists nothing a deploy would retire, and leaves out — with a notice — every scope an app has already declared on a role, which the deploy then decides and may refuse. The scaffolded workflow passes `--app "${{ vars.TURBOBASE_APP_SLUG }}"`; a workflow scaffolded before that runs the plan without it, so add it. For an app generated through a proposal, `turbobase apps deploy` covers the whole tail: it pulls the generated source (from **Studio → Generate app source**) when the directory is empty, builds it locally, and uploads the `dist/` through the same deploy path. Every deploy, rollback, enable, and disable lands as an entry under **Development › Releases**. ### Keeping the source on the instance [Section titled “Keeping the source on the instance”](#keeping-the-source-on-the-instance) Your instance can hold an app’s source, not just its built output. That’s what makes an app refinable later — from another machine, by a colleague, or by a coding agent. ```sh turbobase apps pull ./ # write the current source to disk # ... edit ... turbobase apps push ./ # record your edits as a new revision turbobase apps deploy ./ # build + go live ``` **A deploy from a source directory records source too.** Run `turbobase apps deploy ./` against a directory holding a `package.json` and it cuts the version and then records that directory as a new revision, through the same collector `push` uses. So the two-command loop above is a choice rather than a requirement: `push` is for recording WITHOUT going live, and `--no-source` on a deploy is for going live without recording — a CI preview loop, say, or a build machine whose tree is not the source of record. The version is cut first and the source recorded after it, so a collection that fails costs you the revision and never the deploy. The deploy prints `recorded N source files on the instance (rev R)`, or a line beginning `source not recorded:` naming what to do about it. **An app with no source on the instance cannot be changed from a user’s change request.** Whoever is using your app can file one from the floating **Suggest a change** button, and working it means editing the app’s source — which a built bundle cannot be turned back into. An app that was only ever deployed as a prebuilt bundle, or always with `--no-source`, therefore has no automatic path at all: **Development › App Management** says so on that app’s row and names the one command that fixes it, and the widget tells the person who filed the request that someone will make the change by hand. One `turbobase apps push ./` is the whole fix. One consequence worth knowing: the knowledge ingest *reads the newest recorded revision*. A `--no-source` deploy uploads your built bundle and no markdown, so if you edited `INSTANCE-LEARNINGS.md` or `TURBOBASE-FEEDBACK.md`, let the deploy record them or push first — otherwise the ingest runs against your previous revision and finds nothing. See [Connect Claude Code](/build-apps/claude-code/). Every recorded revision is appended — nothing is overwritten, and `apps pull` always serves the newest. Either path sends text files only: `node_modules/`, `dist/`, `build/`, `.git/` and `.claude/` are skipped, as is any file that isn’t text, so binary assets belong in your build pipeline rather than the source tree. Credential files are withheld by name and listed on stderr: `.env`, `.env.*` and `.mcp.json`, which holds a live access token. `.env.example` is a template with no secrets in it and travels as source. `turbobase apps list` shows where each app stands — whether it’s live, and which source revision the instance holds. A dash in the SOURCE column means the instance has the built app but not its source: it was uploaded as a prebuilt bundle, or every deploy used `--no-source`, so there is nothing to pull and nothing a change request can be applied to. An app you author yourself never has to involve a proposal at all — `turbobase apps create`, then push and deploy. See [Connect Claude Code](/build-apps/claude-code/) for driving that loop from a coding agent on your own machine. ## Declare the source writes your app needs [Section titled “Declare the source writes your app needs”](#declare-the-source-writes-your-app-needs) If your app writes back to a source system, say so in `public/turbobase.json`: ```json { "writes": [ { "source": "netsuite", "recordType": "salesOrder", "ops": ["write"] } ] } ``` `ops` is `write` and/or `delete`, defaulting to `["write"]`. They are checked separately, because permission to write a record never implies permission to delete one. **This is a request, not a grant.** The deploy evaluates each declaration against your instance’s [write guardrails](/tenant/guardrails/) and **refuses, naming the rule**, if one denies it: ```plaintext turbobase.json: this app declares 1 write a guardrail refuses: netsuite salesOrder.write — blocked by guardrail "ns-task-only" (rule). An admin must allow these writes (Compliance → Guardrails, or `turbobase guardrails create`), or the app must stop declaring them. An app cannot grant itself a write. Turbobase has staged 1 matching guardrail rule as PROPOSED — not enforced. An admin can review the evidence and activate it on Compliance → Guardrails, or dismiss it. ``` An app cannot add a guardrail — not from its manifest and not from its repo. Granting a write is an admin action, deliberately: a project that could author its own guardrail could author itself permission to write anything. **What the refused deploy leaves behind.** So your admin isn’t left translating a rejection message into a rule by hand, the refusal stages the matching guardrail for them: one rule per denied declaration, scoped to that source and that op (`salesOrder.write`, never `*.write`), landing on **Compliance → Guardrails** under **Proposed**. Staged is not granted — a proposed rule is inert, the deploy still fails, and the write stays blocked until an admin clicks **Activate**. Each proposal shows the evidence behind it: which app asked, which declared write, which rule refused it, and the deploy attempt it came from. An admin who doesn’t want it clicks **Dismiss**, and the same request is never proposed again — so re-deploying to nag them does nothing. Re-deploying while it is still pending is safe: proposals converge on one row per app + write rather than piling up. Declaring is worth doing. Without it, a blocked write is discovered one row at a time in production, by an end user, and reported in a `sync_failure` row the app author never reads. ## App manifest — name, description, icon [Section titled “App manifest — name, description, icon”](#app-manifest--name-description-icon) Declare how your app presents itself in a `turbobase.json` at the root of your build output. With Vite, put it in `public/turbobase.json` and it’s copied to the root for you. Every field is optional: ```json { "name": "Warehouse Management", "description": "Pick, pack, and ship against live NetSuite inventory.", "icon": "favicon.svg" } ``` * **`name`** — the display name shown in the launcher. It’s separate from your **slug**, which is fixed at create time and is the URL (`/a/wms`); the manifest changes the label, never the address. Max 60 characters. * **`description`** — one or two lines under the name in the launcher card. Max 200 characters. * **`icon`** — a path, relative to your build root, to the icon file (e.g. `favicon.svg`). It wins over the sniffed favicon below. The manifest is read at **deploy** time. `name` and `description` are applied to your app only when you declare them, so a deploy never wipes a value an admin set by hand. A malformed manifest (bad JSON, an over-length field, an `icon` that isn’t in the bundle) fails the deploy with a message telling you what to fix — it’s never silently ignored. `turbobase apps init` and AI-generated apps ship a starter `turbobase.json` and a matching `public/favicon.svg` for you, so a fresh app already has a name and an icon. ### Icon, without a manifest [Section titled “Icon, without a manifest”](#icon-without-a-manifest) You don’t need a manifest just for an icon. Ship a favicon and the launcher uses it: at deploy time Turbobase reads your entry document’s ``, falling back to a conventional `favicon.svg` / `favicon.png` / `favicon.ico` at the root of your build output. The icon is recorded per **version**, so rolling back restores that version’s icon with it. It’s served from your own bundle through the same access control as the app, so someone who can’t open your app can’t fetch its icon either. No icon is fine — the launcher draws a lettered tile from your app’s name, in a colour derived from its slug, so it stays the same colour every time and adding apps never re-colours the others. ### Turning off “Suggest a change” [Section titled “Turning off “Suggest a change””](#turning-off-suggest-a-change) Every app Turbobase serves carries a floating **Suggest a change** button, so whoever is using it can file a change request with a screenshot of the screen they were on. Some apps should not have it — a kiosk display, a shop-floor terminal, an app whose users are not the people who commission changes to it. Say so in the manifest: ```json { "suggestChanges": false } ``` * **Omit the key and the button stays.** Every app that says nothing behaves exactly as it does today; you only ever write this key to turn the button off. * **`true` or `false`, nothing else.** `"false"` in quotes, `0`, or anything that isn’t a boolean fails the deploy naming the key, rather than quietly picking a side. * **It also closes the endpoint.** A page someone left open before you deployed keeps its button until they reload, but the request it sends is refused — so turning it off means your users stop filing, not that they stop until the next refresh. Like the icon, the answer is recorded per **version**: a preview shows its own bundle’s answer while your live app is untouched, and rolling back restores the previous version’s answer with no redeploy and no manifest edit. It does not change who *may* file a change request — that is the `proposal:create` capability, which an admin grants across every app at once. This key is about one app. ## Roles: you declare, an admin assigns [Section titled “Roles: you declare, an admin assigns”](#roles-you-declare-an-admin-assigns) An app usually needs more than one kind of user — a warehouse clerk who ships orders, an auditor who only reads. Declare those roles in the same manifest: ```json { "roles": [ { "name": "Warehouse clerk", "description": "Picks and ships orders", "capabilities": ["read:order", "write:order_fulfillment", "read:cr_pick_list"] } ] } ``` Deploying creates each one as an ordinary role — the same thing an admin manages on **Compliance › Users › Roles** (`/admin/roles`), with nothing marking it as app-made. **A declared role has no holders until an admin assigns it.** Deploying grants nobody anything, including you. This is the same asymmetry as declared writes, one step further: an app may say what it needs and may not say who gets it. Tell your users the role exists and their admin assigns it on **Compliance › Users** (`/admin/users`). One thing you cannot declare: **`appRole`**. A manifest role is always a standard role — admin bypasses capability checks entirely, so it is assigned, never authored. Declaring it is a deploy error rather than an ignored key. **Capabilities only ever add.** A declared role that already exists gains the capabilities it is missing and loses none, so removing a capability from your manifest does not revoke it from anyone who holds that role — ask an admin to narrow it. **An admin’s narrowing is permanent:** a capability an admin removes from the role in **Users › Roles** is never added back by a deploy — not while your manifest keeps declaring it, and not after your app drops the role or is deleted and reinstalled — and neither is a `:*` wildcard that would grant it again. The deploy reports it as kept removed until an admin grants it back. A name that collides with a built-in (Admin, Data admin, Standard) fails the deploy: built-ins are immutable, and rename is the fix. A preview deploy reports what it *would* do without creating anything; promoting the preview applies it. `turbobase apps plan . --app ` shows the same verdicts and every warning, writes nothing, and exits non-zero if a role can’t be created or its scopes conflict — so a pull request catches it. Without `--app` it can’t tell your app’s earlier declarations from another app’s, so it shows no retirements and leaves any scope an app has already declared on a role undecided, with a notice — it never reports a conflict the deploy wouldn’t. With it, it also lists what `"roles": []` retires. ### Scopes: restricting a role’s rows, and granting it a Slack channel [Section titled “Scopes: restricting a role’s rows, and granting it a Slack channel”](#scopes-restricting-a-roles-rows-and-granting-it-a-slack-channel) A declared role may carry `scopes` — the same field an admin edits on **Users › Roles**. It holds two kinds of dimension with opposite defaults: ```json { "roles": [ { "name": "Receiver", "capabilities": ["read:item_receipt", "read:memory"], "scopes": { "location": ["{{user.location}}"], "content": ["slack:C0PUBLIC"] } } ] } ``` * **Restrictions take rows away.** `subsidiary`, `location`, `department`, `class` — or any lowercase name of letters, digits and `_` — narrows every table that carries that dimension to the listed values. Absent or empty means unrestricted. A whole-value `{{user.}}` token narrows each holder to their own value. A dimension no table on your instance carries restricts nothing, and the plan warns you so you don’t ship a role you believe is narrower than it is. * **`content` grants Slack history.** It lists the exact channels the role may read, as `slack:`. Absent or empty means nothing. At most 16 values; no wildcards and no `{{user.…}}` tokens. Slack is the only source whose content can be granted today, and direct messages can never be. **`content` works only with `read:memory`.** Someone who isn’t an admin reads a channel’s history — its `memory_document` rows, whose `source_system` is `slack` and whose `scope_ref` is the channel id — only when the role they are acting as holds `read:memory` *and* lists that channel in `content`. Either half alone reads nothing, and the deploy warns about a role with one and not the other. A granted channel is readable whether or not the person is a member of it — or has a Slack account at all — private channels included. Pilot is not affected. A person who holds several roles reads the channel only while acting as the role that grants it. A channel the role isn’t granted comes back as an empty list, like any other denied read — gate the screen on `hasCapability('read:memory')` and say which role to ask for. **`content` with `write:activity:message` also lets the role post into the channel.** A `POST /v1/data/channel_message` with `raw.channel` set to a granted channel is accepted whether or not the person is a member of the channel or a Slack user — private channels included — and goes out as your workspace’s Slack app, like every post. Send the channel *id*: a grant names an id, so a channel name matches nothing and is refused. `write:activity:message` posts only into the channels the role is granted — not into any other channel, public ones included — and does nothing else: no other kind of `activity` row, no edits, no deletes. Don’t reach for `write:activity` instead: that is every write on the shared `activity` table (other connectors’ tasks and posts, edits of any existing row, a post into every public channel), it does not post through a grant, and a role holding it posts only where its user can see the channel, as before. The post’s `author_user_id` is set to the person who posted it, whatever your payload says. The grant covers creating the message and nothing else: in a private channel they are not in, the person can’t edit or delete a message, and can’t read the channel’s `channel_message` rows — not even their own post (read the channel’s history through `read:memory`, above, and keep your own copy of what the app sent if you show it). A retried create whose `idempotencyKey` already landed is still replayed for them — the answer is the message they committed, and nothing is posted twice — even though they cannot read the channel’s messages otherwise. Direct messages can never be granted, so posting into one still needs membership. **Keep a customer’s channel id out of an app other accounts install.** A shared app ships its roles with `read:memory` and no `content`, and each account adds its own channel to `roles[].scopes.content` in its own deploy. Only an app built for exactly one account names that account’s channels directly. **What a redeploy does to scopes.** Unlike capabilities, scopes your app set can move — and only those: * **Leaving `scopes` out changes nothing**, and neither does leaving one dimension out. An older manifest can’t retract anything. * **`content`:** a redeploy adds the channels new since your last deploy and removes the ones you stopped declaring — only channels your app added, and not while another app on the same role still declares them. `"content": []` removes every channel your app added. A channel an admin added — one your app didn’t put there — is never touched, even if your manifest later declares the same channel: the deploy *adopts* a channel the role already has, and adopting isn’t owning. A channel an admin removed is never re-added by a deploy — not while your app keeps declaring it, and not after `"content": []`, dropping the role, or deleting and reinstalling the app; the deploy reports it as kept removed until an admin puts it back in **Users › Roles**. Removing the role from `roles`, or deleting the app, also removes the channels your app added to it. * **Restrictions:** a restriction is your app’s only when your deploy *wrote* it — on a role it created, or on one that was unrestricted on that dimension (which narrows what its current holders see). Your app can narrow, widen or clear (`[]`) a restriction it wrote, until an admin changes it — after that the admin’s value stays and the deploy tells you, and if the admin’s value is wider than yours, a deploy that adds capabilities to that role is refused, because they would reach rows your declaration keeps them from. Declaring the value a role already has *adopts* it: nothing changes, and your app can’t later clear it (`[]` leaves it) or change it (a different value is a conflict). Declaring a restriction your app never wrote on a role that already has a *different* one, or one another app declares differently on the same role, is a **conflict that fails the whole deploy**, capabilities included, because the capabilities without their restriction would widen the role. Set the value on **Users › Roles** (the next deploy adopts it) or give your app its own role. A role your app stops declaring keeps its restrictions exactly as they are. A channel removed from a role stops being readable on the holder’s very next request. Anything a role gains reaches its holders the next time they sign in. ### Capabilities, and gating your UI on them [Section titled “Capabilities, and gating your UI on them”](#capabilities-and-gating-your-ui-on-them) A capability is `:` — `read:order`, `write:invoice`, `delete:invoice` (a write allow does **not** imply delete), `read:cr_pick_list` for a custom table your app declared. Gate your screens on the same strings you declared, using the helpers in your generated `src/lib/turbobase.ts`: ```tsx import { hasCapability, write, TurbobasePermissionError } from './lib/turbobase'; {hasCapability('read:invoice') && Invoices} if (!hasCapability('read:invoice')) { return You don't have access to invoices. Ask an admin for the Auditor role.; } try { await write('itemFulfillment', { parent, lines }); } catch (e) { if (e instanceof TurbobasePermissionError) { setError("You don't have permission to ship orders — ask an admin for the Warehouse clerk role."); return; } throw e; } ``` **Check before you render — this is the part people miss.** A read the user isn’t allowed doesn’t fail; RLS filters the rows and you get an empty array. Without the check, someone who may not see invoices reads “No invoices yet” and reports missing data. A write is different: it returns 403, which the client throws as `TurbobasePermissionError` specifically so you can tell it apart from a validation failure and name the role to ask for. `hasCapability` is **UX, not security** — it reads the signed-in user’s own claims, so it’s exactly as trustworthy as their browser. RLS is the guarantee; hiding a screen is a courtesy to the user, not a boundary. ## Labels: group your app in the launcher [Section titled “Labels: group your app in the launcher”](#labels-group-your-app-in-the-launcher) An instance with fifteen apps gives everyone one flat grid at `/apps`. Labels narrow it: a coloured chip on your app’s card, and a filter pill above the grid. Declare them in the same manifest, and they arrive with the app instead of having to be re-typed on every instance you ship to: ```json { "labels": [ { "name": "Warehouse", "color": "amber" }, { "name": "Finance" } ] } ``` * **`name`** — what the chip says. Max 40 characters, up to 12 labels per app. Names are matched case-insensitively, so `Warehouse` and `warehouse` are one label. * **`color`** — one of `slate`, `violet`, `amber`, `emerald`, `rose`, `sky`, `indigo`, `fuchsia`. Omit it for a neutral slate chip. Anything else fails the deploy, naming the eight — a typo would otherwise ship green and render grey. **A label is navigation, never permission.** It changes nothing about who can open your app — that’s the audience, below. Someone who can’t open your app can’t see its labels either. **Your manifest owns your app’s labels, and only your app’s.** Declared labels are attached, and any label your app carries that the manifest no longer names is **removed from your app** — so deleting a line is how you take a chip off. An empty `"labels": []` clears them all; leaving the key out entirely means “no opinion”, and whatever your app carries is left alone. **The colour is only used when the label is new.** Label names are shared across the whole instance — one `Finance` row that your app, three other apps and the filter bar all read. So a name your instance doesn’t have yet is created at the colour you declared, and one it already has is used as it stands: your `"color": "amber"` will not repaint someone else’s chip, or overwrite a swatch an admin picked on **Development › App Management**. The deploy says so when they differ: ```plaintext labels added Finance violet — declared amber, the instance's colour is kept ``` Ask your admin if you want the instance’s colour changed — it’s one click on that page. A preview deploy reports what it *would* do and attaches nothing; promoting the preview applies it. `turbobase apps plan` shows which labels already exist and which would be created. ## Share and launch [Section titled “Share and launch”](#share-and-launch) Every hosted app has an **audience** — who may open it: only you (private), everyone in the instance, a role (admin or standard), or specific people. Admins and the app’s owner can always open it. Set the audience from **Development › App Management**; the row’s RLS policy enforces it server-side, so an unshared app is invisible to everyone else. End users open apps from the **Apps launcher** at `/apps` — a grid of every enabled app the signed-in user may open, each linking to its `/a/` URL. An app with no deployed version yet shows as a muted “Not deployed yet” card and isn’t openable. Standard (non-admin) users who have at least one app shared with them land on `/apps` as their home when they sign in — for them, your app *is* Turbobase. ## Generate with Claude Code [Section titled “Generate with Claude Code”](#generate-with-claude-code) The in-product template gallery (**Development › Proposals** → Templates) is the guided version of this — the proposal system generates the app for you. If you’d rather drive your own [Claude Code](https://docs.anthropic.com/claude-code) session, the shortest path from zero to a demoable frontend on your instance is to drop the prompt below into a fresh session. The agent scaffolds a Vite + React + TypeScript static SPA (Tailwind v4 + shadcn/ui) with a dashboard (top customers, recent orders, recent invoices) and a create-sales-order form — and **no sign-in page**: served same-origin at your instance, the serving route handles sign-in (302 to `/login`) before the SPA loads, so the app is wired only to PostgREST (reads) + Realtime + the same-origin write boundary (`POST /v1/data/:recordType`) contracts documented above. ### One-shot prompt [Section titled “One-shot prompt”](#one-shot-prompt) Open an empty directory, run `claude`, then paste: ```text Build a Vite + React + TypeScript static SPA + shadcn/ui demo app on top of Turbobase. Knobs (replace with your own from /admin/settings — these are Vite dev-proxy targets in local dev; in production the app is served same-origin and fetches relative paths): TURBOBASE_API_URL = http://localhost:4100 # proxy target for /v1, /auth SUPABASE_URL = http://localhost:54331 # proxy target for /rest, Realtime WS APP_URL = http://localhost:3000 # this app's own Vite dev server Same-origin, no auth code. The app is served same-origin with the instance, so it inherits the instance's session cookies. There is NO sign-in page, NO callback route, NO code exchange: an unauthenticated visitor is 302'd to the instance's /login by the serving route before the SPA loads. Build the app assuming a valid session is already present. Local dev — make localhost:3000 behave same-origin. Configure Vite server.proxy to forward /v1, /rest, /auth, and the Realtime WebSocket to the instance origin (TURBOBASE_API_URL / SUPABASE_URL). Sign in on the instance origin first so the browser holds the tb_jwt cookie the proxy forwards. Routing — react-router-dom (client-side; the serving route has an SPA fallback): / — dashboard: KPI strip + sparkline + 3 live cards (top 5 customers, last 10 orders, last 10 invoices) + inline "New order" Sheet that opens over the page. /orders/new — same form, standalone route (keep for deep-link / share); body: { idempotencyKey, parent: { entity_id, subsidiary_id, tran_date }, lines: [{ item_id, quantity, rate, amount }] } POSTed same-origin to /v1/data/salesOrder. Auth + supabase-js wiring (same-origin) const jwt = readCookie('tb_jwt'); // non-HttpOnly mirror cookie const sb = createClient(window.location.origin, jwt, { // same-origin; jwt is the apikey db: { schema: 'turbobase' }, global: { headers: { Authorization: 'Bearer ' + jwt, 'Accept-Profile': 'turbobase', 'Content-Profile': 'turbobase' }}, auth: { persistSession: false, autoRefreshToken: false } }); sb.realtime.setAuth(jwt); // arms WS for RLS supabase-js talks to PostgREST + Realtime SAME-ORIGIN (window.location.origin); in local dev the Vite proxy forwards /rest and the Realtime WS to SUPABASE_URL. Pass the session JWT (tb_jwt) as the apikey position — Realtime trusts the instance's signing key, so the JWT doubles as the apikey. (TENANT_ANON_KEY from /admin/settings is an optional dev fallback for the apikey slot.) Reads: PostgREST /rest/v1/
(same-origin) via the sb client above. Tables (the catalog's `table` value, NOT the record type): order, invoice, entity (customer is a read view over it — read either, but see Realtime below), item (or inventoryItem if catalog lists it), subsidiary, location. Realtime layer — this is the demo's wow moment. REST paints, stream updates. The dashboard MUST show both at once so a viewer sees data go live. - On mount, each card fetches its slice via PostgREST (initial paint). - Then open three Supabase Realtime channels — on BASE TABLES only, because logical replication never fires on a view: order, invoice, and entity narrowed with filter 'entity_type=eq.customer' (the customer read surface is a view over entity, so a channel on it streams nothing, silently). - On INSERT → prepend the row (trim list to its limit, 5 / 10 / 10). - On UPDATE → merge in place, matched on the row's identity column: pg_row_id for order / invoice (their primary key — external_id is null until the write-back worker commits the row upstream), external_id for entity rows (the sub-type views do not expose pg_row_id). - Do NOT write a DELETE handler. A deleted record is soft-tombstoned (deleted_at) and hidden by a restrictive RLS policy, so Realtime emits NO frame at all — not an UPDATE and not a DELETE. Instead, re-run each card's seed read every 45s (and on tab focus) through POST /v1/batch and replace the list with the result: the tombstoned row is simply absent. Realtime handles arrivals and edits; the sweep handles removals. - UI cues (make it obvious data is streaming): • A green pulsing "Live" pill in each card header. • New / updated rows briefly flash bg-emerald-500/10 (~1.5s) then settle. • Card footer shows "last event Xs ago" updating every second. import { createClient } from '@supabase/supabase-js' const sb = createClient(window.location.origin, jwt, { db: { schema: 'turbobase' } }) const CHANNELS = [ { table: 'order', filter: undefined }, { table: 'invoice', filter: undefined }, { table: 'entity', filter: 'entity_type=eq.customer' }, ] for (const { table, filter } of CHANNELS) { sb.channel(`dash:${table}`) .on('postgres_changes', { event: '*', schema: 'turbobase', table, ...(filter ? { filter } : {}) }, (p) => applyDelta(table, p)) .subscribe() } Same RLS gates Realtime — a user only receives events for rows they could have read via REST. No extra auth wiring. Design — make this look like a product, not a demo - FIRST, activate any high-quality design skill available in this Claude Code session (for example, `frontend-design`). If none is available, follow the directives below verbatim — they're the floor, not the ceiling. - Dashboard composition (top to bottom): 1. Header with title + a pulsing "REST + Realtime" eyebrow + primary CTA "+ New order" that opens an inline Sheet (NOT a route nav). 2. KPI strip: 4 tiles — Orders 30d, Revenue 30d, Open invoices, Overdue $. Each tile shows a tiny ▲/▼ % vs the prior 30d. 3. Sparkline: sales-order count by day for the last 14 days. Hand the SVG yourself (no chart dep needed). Size it per the design-system "Charts (hand-rolled SVG)" rule — responsive via a fixed viewBox + preserveAspectRatio, w-full, bounded height, coords in viewBox space (not scaled to pixel width). Hover → day + count. 4. The three live cards. Order rows show tran_id, entity_name, status pill (color-coded), money. Invoice rows show paid / remaining + a "N days overdue" rose badge when amount_remaining > 0 AND due_date < today. - Inline new-order Sheet: slides in from the right with the same fields as /orders/new. Submit → toast → close sheet → orders card flashes the new row in via Realtime. Never reload the page. - Visual rules: light mode only (no dark mode / theme toggle), white bg, single accent (violet/emerald), 4-pt spacing scale, generous whitespace, no boxy 1990s borders. Skeleton loaders. Designed empty states. - Status pill palette (orders): pending → zinc, approved → blue, pending_fulfillment → amber, billed → emerald, closed → zinc-muted, cancelled → rose. Invoices: open → amber, paid → emerald, overdue → rose. Full reference (auth, RLS, realtime, write paths, column lists): https://docs.turbobase.io/build-apps/ Stack: Vite + React + TypeScript static SPA (React 19 STABLE — do NOT pin an RC build, peer mismatches will block install), react-router-dom, Tailwind v4, shadcn/ui (New York, accent=violet, light mode only — no dark mode), react-hook-form + zod, sonner. TanStack Query optional — skip for this demo unless you need mutation orchestration; the Realtime layer already handles freshness. Build with `npm run build` → static dist/; deploy via Development › App Management → Deploy a version, or `turbobase apps deploy ./dist`. Polish bar: live-demo ready. If a reviewer sees a blank state, a stuck spinner, or a card without a Live pill within 3s of load, ship's not in. ``` ### Why this works [Section titled “Why this works”](#why-this-works) * **Auth.** The app is served same-origin at `https://.turbobase.app/a/`, so it inherits the instance’s session cookies — no sign-in page, no callback route, no code exchange. An unauthenticated visitor is 302’d to the instance’s `/login` by the serving route before the SPA loads. The instance sets two cookies: the HttpOnly `turbobase_session` (server-side auth + writes) and a non-HttpOnly `tb_jwt` mirror that supabase-js reads from `document.cookie`. Without the readable mirror, supabase-js in the browser would have no JWT and every PostgREST / Realtime call would go unauthenticated. * **Realtime apikey.** supabase-js runs same-origin (`window.location.origin`; in local dev the Vite proxy forwards `/rest` and the Realtime WS to `SUPABASE_URL`). Pass the session JWT (`tb_jwt`) as the `apikey` position to `createClient` — Realtime trusts the instance’s signing key, so the JWT doubles as the apikey (`TENANT_ANON_KEY` is an optional dev fallback). Always call `sb.realtime.setAuth(jwt)` after init so the channels carry the user’s role into RLS. * **Design.** The prompt asks Claude Code to activate a `frontend-design` skill if one is installed in the session; the explicit directives in the prompt (KPI strip, sparkline, inline Sheet for new orders, status pill palette, density rules) are the floor. Either path produces something demoable, not template-y. * **Reads.** Every PostgREST request needs `Authorization: Bearer ` + `Accept-Profile: turbobase`. The same RLS that gates the in-product app gates the demo — a logged-in user only sees what their role + subsidiary scope allow. * **Cold-load fan-out.** A dashboard that opens by querying every record type fires many reads at once; even paced, the request *count* can trip the per-request limiter. Collapse the first-paint reads into one `POST /v1/batch` call (the client’s `batch()` helper) — same query strings, results in order, partial-success per read. See [Batch the cold-load fan-out](#batch-the-cold-load-fan-out). * **REST + Realtime, side by side.** The dashboard’s job is to make the architecture visible: REST paints the first frame (top customers, last 10 orders, last 10 invoices), and three `postgres_changes` channels — on the base tables `order`, `invoice`, and `entity` filtered to `entity_type=eq.customer` — stream deltas into the same lists. A viewer doing a live demo sees a new sales order created in NetSuite (or via `/orders/new`) flash into the dashboard within \~1 s without a refresh — that’s the moment the product sells itself. Realtime checks the same session JWT, so RLS gates the stream identically to the REST read; no extra auth code. * **Writes.** `POST /v1/data/:recordType` is the canonical write path for every writable record — single-row (`customer`, `item`, `customerPayment`, `customerDeposit`, `timeBill`) and header+sublist (`salesOrder`, `invoice`, `itemFulfillment`, `cashSale`, `opportunity`, `estimate`, `creditMemo`, `expenseReport`). Send `{ parent, lines?, pgRowId?, idempotencyKey?, expectedSyncVersion? }`. The boundary runs the instance’s validation hooks synchronously (returns `422` with the offending field), commits parent + lines in one transaction under your RLS context, marks the row `write_status='pending'`, and the worker pushes to the source. Header-only fields like `entity_type`, `originating_system`, and `raw` default — you only send the user-facing fields. `source_system` defaults too on an instance with ONE system connected; with several, a create should name which one the record belongs to (`parent.source_system: 'zoho_crm'`), and naming a system that is not connected is refused `422 SOURCE_NOT_CONNECTED` rather than accepted and left with nowhere to sync. **Naming it is what decides the table.** A few record types mean a different record in different systems — `account` is a customer in Zoho CRM and Salesforce and a ledger account everywhere else — and for those, `parent.source_system` is what tells the boundary which one you mean, on a create and on an edit alike. Two refusals remain, and both are a question you can answer: with nothing named on such a record type you get `422 SOURCE_AMBIGUOUS` listing the connected systems to pick from, and naming a connected system that has no record of that kind is `400 record type "…" is not writable`. An UPDATE takes its system from the record itself, so you can edit records from every connected system in one session; `source_system` sent on an update is *verified* against the record and never written onto it — restating what you read back is fine, a different value is `422 SOURCE_MISMATCH`. On one of the record types above, where `source_system` is also what chose the table the row was looked for in, naming the wrong system can instead be `404 row not found or not permitted` — the row was looked for in that system’s table. Restate what you read back and it resolves. **A table your app authored belongs to the instance, and its rows stay here.** A custom table — one declared in your `public/turbobase.json` or created with `turbobase tables create` — is addressed either by its bare slug (`task`) or by the `customrecord_`-prefixed spelling of it (`customrecord_task`), and both name the same rows. Either way the create takes `source_system` from the table’s own definition, so it is `standalone` rather than whichever system your session signed in against, the row lands `write_status='synced'`, and it is never queued to a connected system — there is nothing upstream to send it to. Naming one anyway (`parent.source_system: 'netsuite'` on a table NetSuite does not have) is `422 SOURCE_MISMATCH` as the write is made, instead of an accepted write that fails against that system hours later. The same rule decides the other direction: a custom record a connected system owns (a NetSuite `customrecord_*`) is stamped with the system that has it whichever system your session is signed in against, and a prefixed name that no *active* table definition on this instance resolves is `400 record type "…" is not writable` rather than a write attempted against a table that is not there — “active” because a definition whose last reconcile failed keeps its table and is not advertised, which is the rule the bare slug has always had. A table whose owning system has since been disconnected is `422 SOURCE_NOT_CONNECTED` on the same door, rather than a row saved with nowhere to send it. * **Transforms.** For “convert estimate to sales order” / “create invoice from this SO” / “apply payment to this invoice” flows, the write goes through `POST /v1/sync/events/from-chat` with `{ recordType, payload: { created_from_id: , ... }, idempotencyKey }` instead, so NetSuite’s lifecycle verbs (line sourcing, GL inheritance) run. ### Variations [Section titled “Variations”](#variations) * **Different framework.** The contracts are the same; swap the stack line for any static-SPA toolchain — Vite + Svelte, Vite + Vue, SolidStart in SPA mode, or Astro with a client island. Keep it a static build (no SSR server) so it serves same-origin under `/a/`. The auth, read, and write calls don’t change. * **Different first record.** Replace `salesOrder` in the path with any record type the boundary accepts — header+sublist (`invoice`, `itemFulfillment`, `cashSale`, `opportunity`, `estimate`, `creditMemo`, `expenseReport`) or single-row (`customer`, `item`, `customerPayment`, `customerDeposit`, `timeBill`). Same `POST /v1/data/:recordType` shape for all. ### Run the generated app [Section titled “Run the generated app”](#run-the-generated-app) This is the canonical “what the agent does once the build is green” rule — every app template in the template gallery references it instead of repeating the steps, so the behaviour stays identical across templates. Once `npm run build` and `npm run lint` pass, **the agent starts the dev server itself** so the user lands on a running app, not a build log: 1. Start the dev server **in the background / non-blocking** from `${APP_DIR}` — e.g. `npm run dev` detached so it keeps running across turns and does not hold the agent’s session open. Do NOT run it in the foreground; a blocking `npm run dev` hangs the agent and burns context. 2. Report the local URL — the templates serve at `http://localhost:3000`. 3. **Smoke-test the live routes** (next section) before declaring done. 4. Report what was built and what wasn’t verified (which screens you scaffolded, what you couldn’t exercise without live data). If the dev server can’t start (port in use, missing dependency), report the exact error and the command to start it manually (`cd ${APP_DIR} && npm run dev`) rather than silently stopping. Stopping or restarting the dev server is cheap and reversible — restart it whenever the app’s environment changes (for example, editing the Vite `server.proxy` target or an env var; Vite reads its config and `.env` at boot and does NOT hot-reload those changes, so a restart is required to pick them up). #### Smoke-test the running app — build+lint green is not enough [Section titled “Smoke-test the running app — build+lint green is not enough”](#smoke-test-the-running-app--buildlint-green-is-not-enough) A build that compiles and a lint that passes prove the code is well-formed, not that it runs. The bugs that survive both are runtime-only: a guard that throws during a render, a PostgREST embed that 400s because an FK doesn’t exist on this instance, an env var read at boot that’s still a placeholder. None of those move the build/lint signal. Now that the dev server is up, **probe the real routes and treat a 500 or a broken happy-path as a failure to fix, not “done”:** 1. **There is no `/login` route.** The app has no sign-in page — `curl -si http://localhost:3000/login` 404s, and that’s correct. Don’t add one. 2. **Unauthenticated `/` renders the SPA shell, doesn’t crash.** In local dev the Vite server serves `index.html` for every route (SPA fallback), so `curl -si http://localhost:3000/` returns **200** with the app HTML — **NOT a 500** and **NOT a self-built sign-in form**. (In production the serving route 302s an unauthenticated visitor to `/login` before the SPA loads; the app itself has no sign-in logic and never renders a sign-in form.) A 500 on `/` means a component threw during render — fix it; don’t ship the 500. 3. **With a valid session, the dashboard resolves its identity row.** In local dev, sign in on the instance origin first so the browser holds the `tb_jwt` cookie the Vite proxy forwards, then load `/` → the dashboard renders **without** a “no record” empty state (e.g. expenses must NOT show *“Your sign-in email isn’t on any employee record”* for a user who has one). That empty state on a valid user usually means a required-path query 400’d — most often a PostgREST embed (`:(...)`) coupling the read to an FK that doesn’t exist on this instance. De-embed it (scalar select + a separate best-effort lookup) and re-test. If any probe fails, fix the cause and re-run the probe — a green build with a 500 on `/` is not a finished app. ## Where to go from here [Section titled “Where to go from here”](#where-to-go-from-here) * Read the [prompt skills page](skills/) when writing a Claude Code prompt against Turbobase — it captures the standard sections (knobs, playbook fetch, catalog discovery, write-path responses, realtime client-island, smoke-test ritual) that every app template uses, so a custom prompt can be a thin wrapper instead of 400 lines of boilerplate. * Start from a template in your Turbobase app — **Development › Proposals** → Templates. The template becomes a proposal, Studio generates the app source, and `turbobase apps deploy` builds and deploys it. * Call `GET /v1/catalog/records` to introspect every record this instance syncs. * Per-record reference for the NetSuite model lives at `sources/netsuite/skills/anatomy/.md`. The catalog exposes the path for each record so a client can render it inline. * To provision a new instance or connect another source, head to [`console.turbobase.io`](https://console.turbobase.io/). # App auth wiring > How a hosted Turbobase app authenticates — served same-origin under your instance, it inherits the session cookie and reads tb_jwt for supabase-js. Copy the one small browser client helper verbatim. This page is the single source of truth for how a hosted Turbobase app authenticates a user, and wires supabase-js so that PostgREST reads and Realtime channels carry the user’s role into RLS. Every app template links here instead of inlining the auth boilerplate per prompt. If you are writing a new template prompt, say *“Wire auth per `https://docs.turbobase.io/build-apps/auth/`”* and skip the inline copy. ## Your app is served same-origin — there is no sign-in page to build [Section titled “Your app is served same-origin — there is no sign-in page to build”](#your-app-is-served-same-origin--there-is-no-sign-in-page-to-build) A hosted app is a **static SPA** that Turbobase serves from your instance at `https://.turbobase.app/a/` — the **same origin** as the instance’s API, PostgREST, and Realtime. That single fact removes the entire auth surface a cross-origin app would have to carry (a sign-in page, an origin allowlist, a single-use sign-in code, a server-side redemption route, CORS). None of it exists here. Because the app is same-origin: * **It inherits the instance session.** When the browser loads `/a/`, it already carries the instance’s `turbobase_session` (HttpOnly) cookie. If the visitor is **not** signed in, the serving route **302s them to `/login`** on the instance *before your SPA ever loads*. So your app renders only for authenticated users — **it has no sign-in page, no sign-in form, no `/login` route, no guard/redirect logic of its own.** * **Reads and Realtime go straight to PostgREST**, same-origin, over supabase-js. * **Writes go straight to the write boundary** (`POST /v1/data/:recordType`), same-origin — the browser attaches the `turbobase_session` cookie automatically. No Bearer token, no proxy route handler. ```plaintext Browser → GET /a//… (served by the instance) ↓ no session cookie? 302 → /login (the instance's own sign-in page, same origin) user signs in on the instance ↓ session cookie is now set, first-party GET /a//… your SPA loads, already authenticated ↓ reads → GET /rest/v1/… (supabase-js, same-origin) writes → POST /v1/data/… (fetch, credentials:'same-origin') realtime → wss same-origin ``` The only cookie your code touches is **`tb_jwt`** — a non-HttpOnly mirror of the session JWT that the instance sets alongside `turbobase_session` at sign-in. supabase-js runs in the browser and can’t read HttpOnly cookies, so it reads `tb_jwt` for the `apikey`/`Authorization` value. Same JWT, readable cookie name. ## What an app can reach — read this before you install one [Section titled “What an app can reach — read this before you install one”](#what-an-app-can-reach--read-this-before-you-install-one) An app runs with the **full authority of the signed-in user**. It is served same-origin and reads that user’s own session JWT, so to PostgREST, Realtime and the write boundary it *is* that user. Concretely, an app you install can: * read **every table the user’s RLS allows** — not only the tables its manifest declares; * subscribe to **every table in the realtime publication**, on the same terms; * act with the user’s role. If the user is an admin, the app is an admin — an “admin-only” RLS policy is not a barrier between your data and app code. **The manifest is not an authorization boundary.** It describes what an app intends to use; it does not constrain what the app *can* reach at runtime. Treat installing an app as granting it that user’s access, and install only apps you would trust with the account. This holds for the manifest’s `roles` block too, and it is worth being explicit because the block *looks* like it grants something. Deploying a NEW role creates a role row — nothing more. **The role has no holders until an admin assigns it**, so an app cannot widen its own access, or anyone else’s, by declaring a role it needs. `appRole` is refused rather than accepted-and-ignored, because admin is assigned, never authored. Once an admin HAS assigned a declared role, a redeploy does change what its holders can reach, within limits an admin can see. It can add capabilities and never removes one — and never re-adds one an admin removed, nor a wildcard that would grant it again, until an admin grants it back. It can set the role’s `scopes` — narrow its rows with a restriction, or grant it a Slack channel’s history with `content` — and it can take back only what the same app put there: a channel an admin added stays, even if the app later declares the same channel (finding it already on the role, the deploy adopts it and does not own it); a channel an admin removed is never re-added by a deploy — not while the app keeps declaring it, and not after the app stopped declaring it, dropped the role, or was deleted and reinstalled — until an admin puts it back; a restriction the app wrote and an admin then changed keeps the admin’s value (and if that value is wider, any capabilities the deploy would add to the role are refused); and a restriction that would overwrite an admin’s or another app’s is refused, failing the deploy. The deploy reports every one of those changes, and so does `turbobase apps plan` when it is told which app it is planning (`--app `). The result is on **Compliance › Users › Roles** (`/admin/roles`) for an admin to read and undo. One of those grants reaches further than Slack itself would: a holder of a role whose `content` lists a channel reads that channel’s history even if they are not a member of it, or not a Slack user at all — private channels included. That is the point of it, and it is bounded tightly: exact channels only, never a wildcard and never a direct message; only together with `read:memory`; only while the user is acting as that role; and gone on their very next request once the channel is removed from the role. It reads history — and, when the role also holds `write:activity:message`, posts a new message into the channel, by its id; it never edits or deletes a message there, and never reads the channel’s `channel_message` rows. Pilot is unchanged. `write:activity:message` is deliberately not `write:activity`: the narrow capability posts only into channels the role is granted and does nothing else, where `write:activity` is every write on the shared `activity` table — other connectors’ tasks and posts, edits of existing rows, and a post into every public channel. The client-side `hasCapability()` helper is on the same footing as the manifest. It reads the session’s own claims to decide what to render, so it is exactly as trustworthy as the browser it runs in. Use it to explain a denial — a screen that says “ask your admin for the Auditor role” beats an empty table — never to protect data. Row-level security is what actually refuses the request, and it refuses it whether or not the app bothered to ask. This is why secret-bearing columns are revoked at the grant level rather than merely gated by an admin RLS policy: a policy that says “admins only” is satisfied by any script running in an admin’s browser tab. Columns such as `source_install.session_private_key` and `channel_config.bot_token` are therefore unreachable through `/rest/v1/*` by any session role, admin included. Narrowing this — an audience-scoped token with a manifest-derived table allowlist — is a deliberate future change, not a shipped guarantee. Until then, the rule above is the contract. ## The one file you copy — `lib/turbobase/client.ts` [Section titled “The one file you copy — lib/turbobase/client.ts”](#the-one-file-you-copy--libturbobaseclientts) This is the entire auth layer. There are **no route handlers** to paste — a same-origin SPA has no server of its own. ```ts import { createClient } from '@supabase/supabase-js'; // Same-origin in production: the instance's API + PostgREST + Realtime are all on // this origin. VITE_SUPABASE_URL is only for local dev (see "Local dev" below). const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || window.location.origin; function readCookie(name: string): string | null { if (typeof document === 'undefined') return null; const match = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]+)')); return match ? decodeURIComponent(match[1]) : null; } /** supabase-js client for reads + Realtime, authed by the instance session. */ export function browserSupabase() { // Prod: the readable tb_jwt cookie. Dev: an optional pasted token fallback. const jwt = readCookie('tb_jwt') ?? import.meta.env.VITE_TB_JWT ?? ''; const client = createClient(SUPABASE_URL, jwt, { db: { schema: 'turbobase' }, global: { headers: { Authorization: `Bearer ${jwt}`, 'Accept-Profile': 'turbobase', 'Content-Profile': 'turbobase', }, }, auth: { persistSession: false, autoRefreshToken: false }, }); if (jwt) client.realtime.setAuth(jwt); return client; } /** * Writes go through the canonical write boundary, same-origin. The HttpOnly * turbobase_session cookie rides automatically with `credentials:'same-origin'` * — no Bearer token, no proxy. Reads stay on PostgREST (above); only mutations * go through the boundary. */ export async function writeRecord(recordType: string, body: unknown): Promise { return fetch(`/v1/data/${recordType}`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); } ``` That’s it — no server-side route handlers and no route guard: a same-origin SPA has none of the auth scaffolding a cross-origin app needs. The serving route handles the not-signed-in case for you. ## Why the JWT goes in the `apikey` position [Section titled “Why the JWT goes in the apikey position”](#why-the-jwt-goes-in-the-apikey-position) `createClient(SUPABASE_URL, jwt, …)` — the second argument is normally the public anon key. We pass the session JWT instead. Why: * **The `Authorization: Bearer ` header is the real auth signal.** RLS evaluates the JWT in that header — never the `apikey` argument. Passing the JWT in both keeps the two consistent. * **The session JWT is signed by your instance**, so Realtime’s JWKS trusts it. (In local dev, the bundled anon key is signed with a `kid` the instance’s Realtime no longer trusts, which triggers WebSocket 403s — another reason to use the session JWT.) If you have a reason to pass the anon key instead, get it from `/admin/settings` and set `VITE_TENANT_ANON_KEY`, then swap the first `createClient` argument — the `Authorization` header still does the work. ## Local dev — a Vite proxy makes localhost same-origin [Section titled “Local dev — a Vite proxy makes localhost same-origin”](#local-dev--a-vite-proxy-makes-localhost-same-origin) While you build the app, it runs on its own dev server (`http://localhost:3000`), which is a **different origin** from the instance — so the same-origin cookie trick doesn’t apply directly. The fix is a Vite dev **proxy**: forward the instance paths through your dev server so the browser sees one origin. vite.config.ts ```ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; const TENANT = process.env.SUPABASE_URL || 'http://localhost:54331'; const API = process.env.TURBOBASE_API_URL || 'http://localhost:4100'; export default defineConfig({ plugins: [react()], server: { port: 3000, proxy: { '/rest': { target: TENANT, changeOrigin: true }, '/realtime': { target: TENANT, changeOrigin: true, ws: true }, '/v1': { target: API, changeOrigin: true }, '/auth': { target: API, changeOrigin: true }, '/login': { target: API, changeOrigin: true }, }, }, }); ``` Now open `http://localhost:3000`: an unauthenticated load proxies to the instance’s `/login`, you sign in, the cookies land on `localhost:3000`, and reads / writes / Realtime all work same-origin — exactly as they will in production. For a quick data smoke without signing in through the proxy, paste a `tb_jwt` from a signed-in browser session on the instance into `VITE_TB_JWT` (dev only) and the client helper above uses it as a fallback. ## Env vars [Section titled “Env vars”](#env-vars) | Var | Where | Value | | ---------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SUPABASE_URL` | dev only (`vite.config.ts`) | Instance PostgREST/Realtime origin for the dev proxy. Prod: unused — the app uses `window.location.origin`. | | `TURBOBASE_API_URL` | dev only (`vite.config.ts`) | Instance API origin (`/v1`, `/auth`) for the dev proxy. Prod: unused (same-origin). | | `VITE_TB_JWT` | dev only, optional | A pasted session JWT the client falls back to when the `tb_jwt` cookie is absent (local iteration without signing in through the proxy). Never set in production. | | `VITE_TENANT_ANON_KEY` | optional | Public anon key from `/admin/settings`, if you prefer it over the session JWT in the `apikey` slot. | There are **no server-only env vars** — a static SPA has no server. A secret an app would have needed (e.g. an AI key for receipt vision) is not held by the app at all; it calls a first-party instance endpoint instead (see the expenses template’s `POST /v1/apps/receipt-extract`). ## Realtime [Section titled “Realtime”](#realtime) Reads paint first, then Realtime streams deltas into the same state. `browserSupabase()` already calls `client.realtime.setAuth(jwt)`, so channels carry the user’s role into RLS: ```ts const sb = browserSupabase(); sb.channel('dash:invoice') .on('postgres_changes', { event: '*', schema: 'turbobase', table: 'invoice' }, (payload) => { // INSERT → prepend; UPDATE → merge by external_id / pg_row_id. // No DELETE handler: deletes are soft tombstones hidden by RLS, so no frame // is emitted at all — removals come from a reconciliation sweep instead. }) .subscribe(); ``` Deletes are the one thing this stream does **not** carry — see [Deletes never arrive](/build-apps/#deletes-never-arrive-so-reconcile-instead) for the mechanism and the sweep that replaces them. ## Refresh, expiry, and logout [Section titled “Refresh, expiry, and logout”](#refresh-expiry-and-logout) * **Expiry.** The session JWT follows the instance’s own session length — **1 day** by default, and an administrator may set it per instance or per role — sliding, so every authenticated request resets it. Your app does not refresh manually. If a read or write returns 401 (the session lapsed mid-visit), send the user back to the instance’s sign-in page: `window.location.href = '/login'`. The serving route takes it from there. * **A session that ENDED answers 403, not 401, and it is not the same thing.** A session can stop being valid long before its token expires: the person signed out in another tab, an administrator suspended them, or their role was changed. Turbobase refuses those at the database, so a read or write of your instance’s tables that used to work comes back **403** — through `/rest/v1`, through `POST /v1/data` and through the live data stream alike. Handle it the same way you handle a 401, and treat the pair as one case: ```ts if (res.status === 401 || res.status === 403) { // The session lapsed, ended or changed. Nothing to retry — re-authenticate. window.location.href = '/login'; return; } ``` Two things follow for your app. **Do not retry a 403** — it is a statement about the session, not a transient failure, and retrying it loops. **Do not treat it as “no data”** — the refusal is deliberately an error rather than an empty list, precisely so you can tell the two apart. * **The 403 says which kind it is, in `details`.** A 403 from `/rest/v1` carries a JSON body, not an empty one, and `details` is the field to read: `"turbobase:session_dead"` means the session itself has ended and signing in again fixes it. Any other 403 means this session may not touch that table or those rows, which signing in again will not change — so an app that tells the two apart can send one user to `/login` and show the other a permissions message instead of bouncing them in a loop. ```json { "code": "42501", "details": "turbobase:session_dead", "message": "this sign-in session has ended", "hint": "Sign in again to continue." } ``` `message` says which of the three causes it was — ended, suspended, or access changed — and is written to be shown to a person. Read `details` to branch, never `message`. * **A dead session can still answer 200, so never read a 200 as proof it is live.** The clearest case is a read with nothing to return: the check runs once per statement and only when the statement actually reaches rows, so a query over a table that is empty for you can come back `200 []` on a session that has ended. That one is not a way in — there is nothing there to read. Treat it as the general rule rather than the only exception: handle the 403 wherever it appears, and do not infer that a session is still good from a request that succeeded. * **On `POST /v1/batch` the 403 is on the SUB-RESULT, not on the response.** The batch is partial-success by design: the envelope answers `200` and each sub-query carries its own `{ ok, status, body }`, so a session that ends between one read and the next comes back as `200` with `results[i].ok === false` and `results[i].status === 403` — the same body, with the same `details`, one level down. Check each sub-result’s own status before you read its `body` as rows; an app that branches on the envelope alone will hand an error object to a screen as data, which is the “no data” mistake above wearing a 200. ```ts const { results } = await batch([...]); const dead = results.find((r) => r.status === 403 && r.body?.details === 'turbobase:session_dead'); if (dead) { window.location.href = '/login'; return; } ``` * **A live subscription stops delivering changes too, and silence is not a signal.** Realtime reads under the same session, so changes stop arriving once it ends. Whether your client is told, or simply goes quiet, is not something to build on — so a long-lived screen should re-read its data on reconnect rather than assume no events means nothing changed. * **A changed role recovers in one request to the instance — which is why the redirect above is the right move.** When an administrator edits the role a signed-in person holds, the instance re-mints their session cookie the next time the browser asks the *instance* for something: a page load, or a write through `POST /v1/data`. A `/rest/v1` read goes straight to PostgREST and refreshes nothing, so an app that only reads can sit on 403 indefinitely. Sending the user to `/login` is what breaks that: the navigation reaches the instance, the cookie is replaced with one carrying their new access, and somebody whose session is otherwise fine is not asked to sign in again. * **Sign-out.** Signing out is the instance’s concern — the app is embedded in it, and there is no app-owned session to clear. `/auth/logout` takes a **POST**, not a link: it clears `turbobase_session` and the `tb_jwt` twin and answers `{ ok: true, redirect: '/login' }`, so send the browser where it says. ```ts async function signOut() { const res = await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' }); const { redirect } = await res.json(); window.location.href = redirect ?? '/login'; } ``` ## What this page deliberately does NOT cover [Section titled “What this page deliberately does NOT cover”](#what-this-page-deliberately-does-not-cover) * Sign-up. Turbobase instances don’t self-serve user sign-ups — an admin invites a user (email + role) via `/admin/users`, and the row links to the person’s identity on their first sign-in (matched by verified email). * Password reset. Credentials live in the instance’s identity provider, which owns reset and email verification. * Hosting / deploy. Build your SPA to `dist/`, then deploy it from **Development › App Management → Deploy a version** (or `turbobase apps deploy ./dist`). See [`Build apps on Turbobase`](/build-apps/). # Connect Claude Code to your instance > Point Claude Code (or Cursor, or any MCP client) on your own machine at your Turbobase instance, so it knows your tables, your knowledge base, and your business processes — then build and ship an app with it. You can point Claude Code on your laptop at your Turbobase instance. Once connected it can read your record catalog, your published knowledge base, and the record reference docs for your connected sources — and it can create, refine, and ship a hosted app. This is the do-it-yourself path. If you’d rather describe the app in a chat and have Turbobase build it, use the template gallery in your app instead — **Development › Proposals**, the Templates icon in the header; see [Development](/tenant/development/). The two paths meet in the middle — an app started from a proposal can be pulled down and refined here, and an app authored here shows up in the same place. ## The two endpoints [Section titled “The two endpoints”](#the-two-endpoints) Your instance exposes **two** MCP servers. They do different jobs and authenticate differently, and you’ll want both. | | Data | Authoring | | --------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | Endpoint | `https://.turbobase.app/mcp` | `https://.turbobase.app/mcp/authoring` | | Sign-in | OAuth — a browser window, your normal sign-in | A personal access token | | Gives you | Your catalog, your knowledge base, record anatomy, live rows, Turbobase’s own documentation — plus customization search for an administrator | Create / pull / push apps, scaffold + deploy extensions, Turbobase’s own documentation, customization search | | Scoped to | **Your** permissions, row by row | Instance admin | The data endpoint is what makes the difference between an agent that guesses your schema and one that knows it. The authoring endpoint is what lets it ship. **Both endpoints can read these pages, so your session need not go out to the web for them.** `search_turbobase_docs` takes a phrase and returns ranked hits; `read_turbobase_doc` takes a hit’s slug and returns the whole page. The pages are the ones your instance’s own release shipped with, so what your assistant reads describes the version you are building against, and every answer names that version. They are deliberately separate from `search_documentation` / `read_document`, which read **your** knowledge base — your processes and policies. Platform questions (“what does a hook receive”) go to the first pair; business questions (“how do we handle returns”) to the second. Either pair can be used inside a `batch_read` on the data endpoint, so a search and two page reads are one call. If you prefer plain HTTP, the same corpus is at `GET /v1/catalog/docs/search?q=…` and `GET /v1/catalog/docs/document/:slug` on the `catalog:read` scope your token already carries. **Customization search is on both, and on the data endpoint it is administrator-only.** `search_catalog` and `explain_object` — “which script handles rebates?”, then that script in depth — answer over your whole connected account, so there is nothing to narrow person by person. On the data endpoint an administrator gets them with no token at all; anyone else is not offered them, and their assistant will say it has no tool for the job rather than refusing halfway through. On the authoring endpoint they come with the token, which is instance-admin by definition. ### Shipping over the authoring endpoint needs your explicit approval [Section titled “Shipping over the authoring endpoint needs your explicit approval”](#shipping-over-the-authoring-endpoint-needs-your-explicit-approval) Over the network, every tool that ships something asks for `approve: true` before it acts, and answers `approval_required` without it: `deploy`, `apps_push`, `tables_apply`, and — when your token carries `analyzer:run` / `releases:write` — every Analyzer tool that deploys a bundle, ships or rolls back a release, or toggles managed-script instrumentation. Claude Code shows you that answer and asks; say yes and it re-invokes the tool with the approval. Read-only tools never ask. The endpoint also takes one request per call: a JSON-RPC batch (an array body) is refused, which no MCP client sends in normal use. ## Setup [Section titled “Setup”](#setup) ### 1. Turn the MCP server on [Section titled “1. Turn the MCP server on”](#1-turn-the-mcp-server-on) An administrator enables it at **Settings › Integrations & AI access**. Until it’s on, both endpoints refuse every caller. That page also shows both URLs, filled in for your instance. ### 2. Mint a token [Section titled “2. Mint a token”](#2-mint-a-token) Sign in with your Turbobase account first: a token belongs to a Turbobase account, so this page is not offered to administrators who opened the app from inside NetSuite. On the **Access tokens** page — your account menu, or `/settings/tokens` — mint a personal access token with: * `extensions:deploy` — the whole hosted-app surface (create, push, pull, list) * `mcp:authoring` — the authoring endpoint itself * `catalog:read` — read the record catalog Copy it when shown; it’s stored hashed. Add `analyzer:run` and `releases:write` only if you also want the Analyzer and release tools. **`analyzer:run` authorizes source-system work**, including account capture and deploying a managed bundle into your connected Oracle NetSuite account. App work needs none of that. ### 3. Install the CLI and sign in [Section titled “3. Install the CLI and sign in”](#3-install-the-cli-and-sign-in) ```sh curl -fsSL https://console.turbobase.io/install-cli.sh | bash turbobase auth login --base-url https://.turbobase.app --token tb_pat_your_token_here ``` On Windows: `powershell -c "irm https://console.turbobase.io/install-cli.ps1 | iex"`. Both commands — and a `turbobase auth login` line with your instance URL already filled in — are in your instance under **Settings → Integrations & AI access**. Prefer npm? `npm install -g @turbobase/cli` works too. One instance, one login. Every command below uses it — you never pass a URL again. ### 4. Wire up Claude Code [Section titled “4. Wire up Claude Code”](#4-wire-up-claude-code) If you’re building an **app**, skip this step — `turbobase apps init` (below) lays the wiring down as part of the project. For a hook-authoring workspace, run it in an empty directory: ```sh turbobase project init ``` Either way you get a `.mcp.json` already filled in with your instance URL and token (both MCP servers), a `CLAUDE.md`, and a `.claude/` directory of skills. Start Claude Code in that directory. The first time it uses the data server it opens your instance’s sign-in page — there’s no client ID to paste. `.mcp.json` holds a live token. App projects gitignore it for you; in a hooks workspace, keep it out of version control yourself. ## Building an app [Section titled “Building an app”](#building-an-app) ### Start from scratch [Section titled “Start from scratch”](#start-from-scratch) ```sh turbobase apps init collections # scaffold the project + the Claude wiring cd collections turbobase apps create collections # register it on your instance claude ``` `apps init` lays down the same scaffold a proposal-generated app gets, and it is more than the SPA: * **The app** — Vite + React + TypeScript, Tailwind v4 and shadcn/ui (New York, neutral) per the [design system](/build-apps/design-system/), the auth-wired client, and a starter component that already does a row-level-security-scoped read, a write and a delete. * **The rest of the deployed state** — `hooks/` with a worked `before_save` example, `conduits/`, and `turbobase/sync.json` + `turbobase/schedules.json`. See [deploy, plan, promote](/extending/deploy-plan-promote/): your repo is the desired state, and merging applies it. * **The CI workflow** — `.github/workflows/turbobase-app-deploy.yml`: a preview URL per pull request (updated in place on every push), production on merge to your default branch. * **The Claude Code wiring** — `CLAUDE.md`, `.mcp.json` for both MCP servers, `.claude/` skills, and a session hook that refreshes your generated types, an instance grounding brief, and `ADOPT.md` (platform changes this project should act on). It also switches on a **Turbobase consultant** output style, written for ERP consultants, NetSuite administrators and business analysts: business outcome first, source-system vocabulary, and short answers unless you ask for detail. Pick a different style in `/config`, or delete the `outputStyle` line in `.claude/settings.json`, if you would rather have stock Claude Code. Nothing to hand-write, and nothing to remember to run second. `apps init` never overwrites an existing file, so re-running it in an older project is how you pick up additions. Its dev server proxies `/rest`, `/realtime`, `/v1`, `/auth` and `/login` to your instance, so `npm run dev` behaves same-origin: sign in on first load and reads, writes and Realtime all work exactly as they will in production. The app is served at `http://localhost:3000/a/collections/` — not `/`, which redirects there. Then ask Claude Code to build the thing. A useful prompt names the records, so it goes and looks them up: > Look at the customer and invoice record types in the catalog, read our knowledge base on the collections process, then rewrite src/App.tsx as a list of overdue invoices grouped by customer. Follow the Turbobase design system. When you like it: ```sh turbobase apps push collections # store the source on your instance turbobase apps deploy collections # build + go live ``` Your app is live at `https://.turbobase.app/a/collections`. ### Start from a proposal [Section titled “Start from a proposal”](#start-from-a-proposal) If the app was generated in Studio, pull it down and keep going: ```sh turbobase apps pull collections ./collections ``` Refine it with Claude Code, then push your changes back and deploy: ```sh turbobase apps push collections ./collections turbobase apps deploy collections ``` Pushing is what keeps the instance holding the source of record. Skip it and your changes exist only on that laptop — the next `apps pull`, from any machine, returns the version before your edits. ## Push and deploy are different things [Section titled “Push and deploy are different things”](#push-and-deploy-are-different-things) This trips people up, so it’s worth being explicit: * **`apps push`** records source. Nothing is built, nothing changes for your users. * **`apps deploy`** builds the app on your machine and deploys the result. This is what goes live. You can push a dozen times while iterating and deploy once. There is one place the difference bites beyond “where does my source live”, and it is easy to miss: **deploy triggers the knowledge ingest below, but push is what supplies the files it reads.** A deploy uploads your built bundle and no markdown at all. Skip the push and the ingest runs against your previous revision, finds nothing new, and reports nothing wrong. `apps push` sends text files only. It skips `node_modules/`, `dist/`, `build/`, `coverage/`, `.git/`, `.claude/`, `.turbobase/`, `.next/`, `.svelte-kit/`, `.vercel/`, `.cache/`, and anything that isn’t text — so images and fonts belong in your build pipeline or object storage, not the source tree. `.claude/` is skipped because a coding agent writes nested worktrees under it, each a second full copy of the project; `INSTANCE-LEARNINGS.md` and `TURBOBASE-FEEDBACK.md` live at the project root and are still pushed. It also never sends credential files: `.mcp.json` and `.env*` (except `.env.example`) are withheld, and it says so on stderr when it withholds one. Your token stays on your machine. ## Feed what you learned back into your knowledge base [Section titled “Feed what you learned back into your knowledge base”](#feed-what-you-learned-back-into-your-knowledge-base) Building against a live mirror teaches you things no page documents yet: the twin table nobody mentioned, the subsidiary number that turned out to be wrong, the rule that doesn’t actually hold. Write those down as you go, in a `## Suggested KB edits` section (something is missing) or a `## Knowledge-base corrections` section (something published is wrong) in any markdown file at your app root — `INSTANCE-LEARNINGS.md` is the conventional home. Your instance ingests those sections on the next `turbobase apps deploy` and then stops. A deploy never writes your knowledge base; it only queues what it found. Turning a finding into a change is a separate, explicit step. **Push before you deploy, or there is nothing new to ingest.** The deploy triggers the ingest; the ingest reads the source tree your last `turbobase apps push` recorded — not the files on your disk, and not the built bundle the deploy just uploaded. So the order is: ```sh turbobase apps push . # records INSTANCE-LEARNINGS.md and the rest turbobase apps deploy # goes live, and queues the ingest over that push ``` Get it backwards and nothing fails: the job runs, succeeds against the older revision, and writes nothing — after which `kb push` and `skills push` report an empty queue, which reads exactly like “it hasn’t finished yet”. `apps deploy` prints the source revision it queued the ingest against, and warns when there is none or when your local markdown is newer than it. Believe that line over the queue. Then, to turn findings into changes: ```sh turbobase kb push --dry-run # read the plan, write nothing turbobase kb push # same plan, then asks before writing ``` **The unit is a page, not a finding.** Everything you wrote about one page is one entry in the plan and one write: twelve findings about `REC-01` append once, and four findings asking for the same page nobody has written yet create that page once. So the plan reads “19 pages”, not “53 things”, and you can see which findings were merged before you confirm. The plan is printed before anything happens, and for each page it names the findings landing on it, the exact markdown it appends, and — loudest — whether confirming **withdraws that page from readers**. A correction whose verdict says a published claim is contradicted by live data pulls the page off the reader surface; the page’s text is not touched by that withdrawal, so publishing it again restores it exactly. Additive findings never withdraw anything. Confirming writes the pages: * **An existing page is appended to**, under a `## From app developers` heading. Nothing already on the page is rewritten — the only edit is an insertion, so a push cannot destroy a sentence an editor wrote. * **A new page lands unpublished**, filed under a “From app developers” page for an editor to fold into the tree. Either way nothing reaches readers until someone publishes a knowledge-base release, so a push is never a publication. `--dry-run` reviews without writing, `--diff` prints the exact markdown on the confirm path too, `--yes` skips the question for a scripted run, and `--app` / `--section` / `--limit` narrow what you act on. Three things worth knowing: * **It’s admin only.** Your token needs the same scope `apps push` uses, and your account has to be an administrator of the instance — writing the shared knowledge base is not a per-developer act. * **The plan is binding.** If someone else works through the queue while you’re reading, confirming fails with “the pending queue changed” and writes nothing. Re-run to see the current plan. You never confirm one plan and get another. * **A page someone is editing is skipped, not overwritten.** If a colleague has unpublished work on a page your push targets, that page is left alone and its findings stay queued — the plan tells you who, before you confirm. Re-run once they’ve published. ## What Claude Code can and can’t do [Section titled “What Claude Code can and can’t do”](#what-claude-code-can-and-cant-do) **It can:** read your catalog, search and read your published knowledge base, read the record anatomy docs for your connected sources, read live rows through your own permissions, and create, pull, push, and list apps. It can also scaffold, test, and deploy hooks, conduits, and processes. **It can’t:** * **Create a proposal or run Studio’s AI generation.** Those are in the app, by design — the metered generation step has no external API. Claude Code joins the loop at `apps pull`. * **Ship an app on its own.** Shipping builds a local directory and uploads the result, which a server-side MCP endpoint can’t reach. Claude Code runs `turbobase apps deploy` as a shell command instead. * **See anything your account can’t.** Data reads run through the same row-level security as the app. A colleague connecting their own client sees their own slice. * **Read unpublished knowledge base drafts.** Only the published release is served. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) **Every call returns 503.** The MCP server is off. An administrator turns it on at Settings › Integrations & AI access. **`insufficient_scope` on an authoring call.** The token is missing a scope — the error names which one. Mint a new token with the full set; scopes are fixed at mint time. **401 on the authoring endpoint with a token that works elsewhere.** The two endpoints are separate sign-ins. A data-endpoint OAuth token is rejected at `/mcp/authoring`, and a personal access token is rejected at `/mcp`. Check which URL the header is attached to. **429 on an authoring call, and your own token has barely been used.** Two budgets guard `/mcp/authoring`: 60 requests a minute from one address, which applies before the token is read, and 60 a minute per token after it. A refusal carrying `{"statusCode":429,"error":"Too Many Requests"}` is the per-address one — something else is calling the same instance, typically another agent, a CI runner or a second container. It need not share your outbound address: until an instance’s network is configured to pass the caller’s address through, that budget is shared by every client of the instance. One carrying `{"error":"rate_limited"}` is your token’s own. Wait out the minute; nothing is revoked. **`no source for this app`.** Nothing has been pushed or generated yet. `turbobase apps list` shows a dash in the SOURCE column for exactly these — push once and it fills in. **Claude Code doesn’t seem to know about a table.** Ask it to call `list_record_types` or read the `catalog:///` resource first. It reads the catalog on demand, not automatically. **Claude Code can’t find a *field*, and concludes the data isn’t there.** `list_record_types` describes record TYPES and never their columns, so a field not named after its record is invisible to it — a customer’s credit limit lives on `entity.credit_limit`, and nothing in the record list says so. Ask it to call `search_fields` with the words you’d use (“exchange rate”, “credit limit”); it returns the record type, table and column that hold it. ## Related [Section titled “Related”](#related) * [Building apps](/build-apps/) — the full app contract: reads, writes, realtime * [App auth wiring](/build-apps/auth/) — the one file you copy * [Design system](/build-apps/design-system/) — what apps here look like * [Knowledge base](/tenant/knowledge-base/) — where `turbobase kb push` pages land * [MCP server](/tenant/mcp-server/) — the data endpoint in detail * [Access tokens](/tenant/access-tokens/) — token kinds, scopes, revocation * [CLI reference](/cli/reference/) — every command # Code standards > The lint, format, dead-code and duplicate checks a Turbobase app starts with — what npm run check catches, how files are formatted for you, and how to add the standard to an app you already have. Every app `turbobase apps init` creates comes with **`@turbobase/code-standards`**, an open-source package (MIT, on npm) that checks your code the same way on every machine and formats it so nobody spends time on style. It is the same standard Turbobase’s own code is held to. ## What it catches [Section titled “What it catches”](#what-it-catches) The checks look for the mistakes that compile, build and still break a screen: * a React hook called after an early `return` — the screen works until the record it was waiting for arrives, then crashes; * a promise nobody waited for, so an error is lost and a write can finish after the screen moved on; * an object with the same key written twice, where the first value is silently replaced; * code nothing uses any more, and the same block copied in two places inside your app. Duplicates are counted inside your app only. Code that looks like another app’s is never a finding. ## The two commands [Section titled “The two commands”](#the-two-commands) | Command | What it does | | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `npm run check` | Reports every lint finding and every file not in the house format, and exits non-zero if there is one. Run it before you push. | | `npm run fix` | Applies every automatic fix and formats every file. | The house format is tabs, semicolons, single quotes and lines of up to 120 characters. **When Claude Code edits a file in your app, it is formatted straight away** by a hook in `.claude/settings.json`. The hook uses the copy of the package installed in your project, never a download, and it never blocks an edit: before your first `npm install` it simply does nothing. ## The files in your project [Section titled “The files in your project”](#the-files-in-your-project) | File | What it holds | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.oxlintrc.json` | The lint rules, extended from the package’s React profile. | | `oxfmt.config.ts` | The house format, re-exported from the package. | | `code-standards.json` | What counts as one unit for duplicates (your whole app) and which files are skipped. | | `code-standards.baseline.json` | The findings your app already had when it adopted the standard. A new app starts with an empty one. `npm run check` only fails on findings beyond it, and will not run without it. | | `knip.json` | Where the unused-code check starts looking: your app, plus your hooks, conduits and processes. | `src/types.ts` is skipped on purpose. `turbobase project types` writes it from your instance’s catalog and rewrites it every session, so a finding in it is not one you could fix. `src/lib/turbobase.ts`, the client your app talks to your instance through, is skipped for the same reason: it is ours, and an update replaces it. After the first `npm install`, run `npm run fix` once to put every file in the house format; from then on `npm run check` passes until something new needs fixing. ## Adding it to an app you already have [Section titled “Adding it to an app you already have”](#adding-it-to-an-app-you-already-have) The steps are in the [release log](/releases/), under **For app developers — adopt these**, and `turbobase project adopt` writes them into your project’s `ADOPT.md`, where Claude Code picks them up at the start of a session. In short: 1. `npm install -D @turbobase/code-standards`. 2. Add `.oxlintrc.json`, `oxfmt.config.ts`, `code-standards.json` and `knip.json` at the app’s root, as a new app has them, and remove `baseUrl` from your `tsconfig.json`. 3. Add `"check": "code-standards check"` and `"fix": "code-standards fix"` to `scripts` in `package.json`. If you already have a `check` script, keep it and give these two other names. 4. Run `npm run fix` once and commit the result on its own, so the formatting change is one commit you can skip past when reading history. 5. Run `npx code-standards baseline` and commit `code-standards.baseline.json`. It records what your app already has, so the check passes today and fails only on something new. 6. Run `npm run check` before every push. The baseline can only shrink: fix a finding, run `baseline` again, and commit the smaller file. # App design system > The shared visual language every Turbobase app template ships with. Enterprise-SaaS feel — tight, neutral, dense without being cramped. This page is the single source of truth for what apps built on Turbobase look like. Every app template links here instead of inlining the rules — so the order-management demo, the expense-management demo, and anything new you ship stay visually coherent without copy-pasting 80 lines of style guidance into each prompt. **`turbobase apps init` ships most of this stack.** A scaffolded project already has React 19, Tailwind v4 (as a Vite plugin — there is no `tailwind.config.js`; the theme lives in `src/styles.css`), shadcn/ui in New York style on the neutral base with `components.json` set up so `npx shadcn@latest add ` works immediately, six of its primitives already written out (button, card, badge, table, input, label), lucide-react, and self-hosted Inter. The four libraries named below that a starter app does not need are **not** installed: `react-hook-form`, `zod`, `sonner` and `date-fns`. Add them when you reach for them — the point of naming them here is that when you do need a form, a toast or a date, these are the ones to pick, so every app on the platform reaches for the same four. If you are writing a new template prompt, the prompt should read: *“Before writing any UI code, read `https://docs.turbobase.io/build-apps/design-system/` and apply it.”* Then the prompt only adds the **template-specific** rules (status pill palette for that domain, KPI tiles, etc.). This page covers how an app **looks**. It does not cover the rules that stop a screen showing the **wrong data** — rendering an id where a name exists, an empty list that is really a failed query, a sync timestamp presented as a business date. Those live in the `app-design` skill in your project (`.claude/skills/app-design/SKILL.md`) and in [Build apps](/build-apps/). ## North star [Section titled “North star”](#north-star) Looks like a real SaaS product on first paint, not a generated demo. The reference points are Linear, Notion, Stripe Dashboard — products where density and restraint do the heavy lifting and color is rationed. Avoid the AI-generated tells: gradient backgrounds, glassmorphism, oversized rounded corners, candy palettes, animated mesh blobs. ## Stack defaults [Section titled “Stack defaults”](#stack-defaults) * **Framework**: Vite + React + TypeScript (static SPA), React 19 stable (do not pin RC). * **Styling**: Tailwind v4. * **Components**: shadcn/ui, **New York** style, **neutral** base color. * **Forms**: react-hook-form + zod. * **Icons**: lucide-react. * **Toasts**: sonner. * **Dates**: date-fns. * **Font**: Inter, self-hosted via `@fontsource/inter` (or a `` to Google Fonts). Headings get `tracking-tight`. Do NOT add: Redux, tRPC, Prisma, NextAuth, a UI kit other than shadcn, chart libraries unless the template explicitly needs more than hand-rolled SVG. ### The one carve-out: Konsta, on a phone surface [Section titled “The one carve-out: Konsta, on a phone surface”](#the-one-carve-out-konsta-on-a-phone-surface) That list stands. The single exception is **Konsta**, and only when the app has a genuine phone-first surface: a handheld or scanner workflow, a field or warehouse task, or a prompt that explicitly asks for mobile. No trigger, no Konsta — an app that would merely *also* look fine on a phone does not qualify, and reaches the phone through the responsive floor below instead. Everything else is shadcn, and shadcn only. **The phone surface is a different product, not the desktop reflowed.** That single instruction separates a good implementation from a bad one: different navigation (a tab bar, not a sidebar), different density (one task per screen, not a table of twelve columns), different targets. A desktop table squeezed into 390px is not a mobile surface. Four conditions. Each one ships silently broken on its own — a green build and a wrong screen: 1. **React 19 is required.** Konsta 5 declares no `react` peer dependency, so npm installs it against React 18 with no warning at all. Its components take `ref` as a plain prop — the React 19 API; there is not one `forwardRef` in the package — and React 18 strips `ref` out of props before the component ever sees it. Anything that MEASURES (a segmented control positioning its highlight, a popover finding its anchor) gets nothing back and degrades silently: `tsc --noEmit` is clean, `vite build` is green, the component is inert. 2. **Tailwind must scan the whole package** — `@source "../node_modules/konsta/**/*.js"` in `src/styles.css`. The **whole package**, not the entry. `konsta/react` resolves to `react/konsta-react.js`, which only re-exports; the class strings are built in `shared/classes/*.js`. Point Tailwind at the entry alone and it compiles enough to look nearly right while every inset, rounding and width class is silently missing. 3. **Override Konsta’s Material colour scale**, or it tints the rest of the app — it ships its own palette and left alone that palette wins. Map it onto the accent this template already picked. Pick ONE Konsta theme (`ios` or `material`) at build time; it is not a user-facing toggle and never a `prefers-color-scheme` branch. 4. **On that surface Konsta REPLACES shadcn.** The two do not sit side by side — one screen, one kit, or you get two type scales in one view and two components fighting over the same focus. `components.json` stays exactly as it is: the desktop and tablet surfaces are still shadcn. ## Color and contrast [Section titled “Color and contrast”](#color-and-contrast) * **Background**: white. Light mode only — no dark default, no gradients on the body. * **Surface**: cards are `bg-card` (which maps to `white`) with `border border-border`. * **Accent**: pick ONE per template — `violet`, `emerald`, `blue`, or `rose`. Use it for the primary CTA, focused inputs, active nav, and one or two key numbers per screen. Everything else stays neutral. * **Danger / overdue**: `rose`. **Success / paid**: `emerald`. **Pending / draft**: `amber`. **Closed / inactive**: `zinc`. **In progress / approved**: `blue`. * **Text hierarchy**: `text-foreground` for headings + primary numbers, `text-muted-foreground` for everything else. No more than two text colors per card. ## Density and rhythm [Section titled “Density and rhythm”](#density-and-rhythm) * **Spacing scale**: multiples of 4. Card padding `p-5` (sm) / `p-6` (md+). Gap between cards `gap-6`. Sections separated by `space-y-8`. * **Border radius**: `rounded-lg` for cards, `rounded-md` for buttons + inputs, `rounded-full` only for status pills and avatar circles. No `rounded-2xl` or `rounded-3xl`. * **Borders**: `border border-border` (subtle). Never `border-2`. Tables get `border-b border-border` between rows, no vertical borders. * **Shadows**: `shadow-sm` on cards. No `shadow-xl`, no glow. ## Typography [Section titled “Typography”](#typography) * **H1 (page title)**: `text-2xl font-semibold tracking-tight` (sm:text-3xl). One per page. * **H2 (section)**: `text-lg font-semibold`. * **Body**: `text-sm` for content, `text-xs` for metadata. * **KPI numbers**: `text-2xl font-semibold tabular-nums`. Currency lines use `tabular-nums` everywhere they appear in tables. * **Eyebrows**: tiny uppercase pills — `text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground`. Used sparingly above section titles. ## Component patterns [Section titled “Component patterns”](#component-patterns) ### Top bar [Section titled “Top bar”](#top-bar) `h-14`, `border-b border-border`, `bg-background`, brand on the left (the Turbobase wordmark + a tiny accent dot — `

Turbobase

` with ``), user menu on the right. No theme toggle (the app is light-mode only). No drop-shadow. No backdrop blur. ### Page header [Section titled “Page header”](#page-header) Page title + a one-line description below it (`text-sm text-muted-foreground`), primary CTA right-aligned. If the page has tabs, they sit immediately under the header in a `border-b border-border` rail, not a pill group. ### How much prose a screen carries [Section titled “How much prose a screen carries”](#how-much-prose-a-screen-carries) A short label or one clause. That is the budget, and it applies to every surface on the screen — the line under the page title, a section intro, a note beside a control. Anything longer goes behind an **info icon opening a modal**: a `variant="ghost"` icon button with lucide `Info` and an `aria-label`, opening a shadcn `Dialog`. The reader who wants the explanation gets all of it; the reader who does not gets their data. A multi-sentence paragraph under every heading, or a full-width card of explanation sitting below the table, is the tell of a screen that has not decided what it is for — it pushes the actual content below the fold and reads as filler even when it is accurate. `Dialog` is already sanctioned, and the modal rule in [Accessibility floor](#accessibility-floor) already applies to it: labeled close button, focus trapped. ### KPI strip [Section titled “KPI strip”](#kpi-strip) Four tiles across at `lg`, stacks below. Each tile: tiny eyebrow → big number (`tabular-nums`) → a delta line showing `▲ 12% vs last 30d` in `text-xs`. Up = `emerald-500`, down = `rose-500`, flat = `muted-foreground`. No sparklines inside the KPI — those go in a dedicated card. ### Tables [Section titled “Tables”](#tables) shadcn `
`. Header row `text-xs uppercase tracking-wider text-muted-foreground`. Body rows `hover:bg-muted/40`. No zebra striping. Money columns right-aligned, `tabular-nums`. First column is the human-readable identifier (tran\_id, expense report number, customer name), last column is the action (status pill or kebab menu). Designed empty state: lucide icon + one line of copy + (where applicable) a primary CTA. ### Money & currency [Section titled “Money & currency”](#money--currency) Always show the **ISO 4217 currency code** (`AUD`, `USD`, `INR`), never a source-system display name or symbol-name. This matters because the canonical `*_currency_name` denorm on a row carries whatever the source labels the currency (Oracle NetSuite, for instance, stores `name` as a free-text label, so you’ll see `"AUS"`, `"US Dollar"`, `"Canadian Dollar"` in the same column). **Do not render `currency_name`**: it is inconsistent and not a code. The reliable code lives on the `turbobase.currency` lookup, whose `symbol` column is the ISO 4217 code: ```plaintext currency.external_id → matches the row's `currency_id` currency.symbol → ISO 4217 code (AUD, USD, INR …) ← display THIS currency.name → source label (may be "AUS") ← do NOT display ``` So you resolve the code by mapping the row’s `currency_id` to a `currency` row’s `symbol`. **Default to a scalar select + a separate lookup — do NOT embed `currency` into a query that has to succeed.** The tempting one-liner is a PostgREST embed (`?select=...,currency_id,currency:currency_id(symbol)`). It looks clean, but it silently couples the query to a *declared FK relationship* `
.currency_id → currency` that **may not exist on a given instance**. When the FK is absent, PostgREST doesn’t return a null embed — it **400s the entire request**, so a decorative currency symbol takes down the whole read (the employee fetch returns nothing, the dashboard shows “no record”). An embed on a must-succeed query is a latent 400. The robust pattern, which never blocks the primary read: ```plaintext # 1. Primary read — scalar columns ONLY, including currency_id: ?select=...,total,currency_id # 2. Resolve the symbol once, best-effort, in a SEPARATE lookup. # Either fetch the whole small list (~12 rows) and map client-side … GET /rest/v1/currency?select=external_id,symbol&is_inactive=eq.false # … or look up the one code you need: GET /rest/v1/currency?external_id=eq.&select=symbol&limit=1 ``` Build a `currency_id → symbol` map from step 2 and render `map[row.currency_id] ?? row.currency_id` (fall back to the raw id, never to `currency_name`). If step 2 fails or returns nothing, the amount still renders — you’ve just lost the prettier code, not the row. Format the amount with `Intl.NumberFormat(undefined, { style: 'currency', currency: symbol })` — passing the ISO code makes `Intl` place the correct symbol and decimals automatically. > **Embed only if you’ve confirmed the FK exists on the live instance.** `currency:currency_id(symbol)` is fine in a query whose failure is acceptable (a purely decorative read you’ve verified against the live catalog/relationships), but it must never sit on a required-path query — the employee/identity fetch, the dashboard’s hero read, anything whose `null` shows the user a broken screen. See the integration-assumptions rule in [`Build apps on Turbobase`](/build-apps/). > > This is about *guessing* at an FK on a lookup column. It does **not** apply to a header’s line table (`item_receipt_line(…)`), which is a real foreign key present on every instance, or to a relationship your app declared — both are safe on a required-path query. See [Reading the mirror](/build-apps/reading-data/). ### Charts (hand-rolled SVG) [Section titled “Charts (hand-rolled SVG)”](#charts-hand-rolled-svg) No chart library — hand-roll small dashboard charts (a 6-month bar trend, a 14-day sparkline) as a single inline ``. To size correctly at any viewport width: * **Render responsively via `viewBox`, not pixel coordinates.** Give the `` a fixed coordinate space — `viewBox="0 0 720 240"` (pick an aspect that suits the card) plus `preserveAspectRatio="xMidYMid meet"` — and let it fill the card width with `class="w-full"` (`width: 100%`). Compute every bar, gridline, and label position in the **viewBox coordinate space** (the `720×240` units), never from the rendered pixel width. The chart then lays out identically whether the card is 480px or 1200px wide. * **Bound the height.** Cap the chart area with a fixed Tailwind height on the SVG’s wrapper (e.g. `h-56` / `h-64`, ≈ 224–256px) so the plot doesn’t balloon or scale its height with width on wide desktops. Height is fixed; only width is fluid. * **Cap the card.** On very wide screens, constrain the chart card (e.g. `max-w-*` on the inner SVG wrapper, or let the card sit in the dashboard grid) so the plot area doesn’t grow unbounded. * Bars/columns use the accent at a muted opacity (`fill-current text-{accent}-500/80`); gridlines and axis labels are `text-muted-foreground`. Hover shows the data point’s label + value (a `` element per bar is the zero-dependency tooltip). ### Forms [Section titled “Forms”](#forms) * Stacked layout. Two columns only when the screen is `lg+` AND fields are short (date pickers, dropdowns). * Label above input, helper text below in `text-xs text-muted-foreground`. Errors replace helper text in `text-rose-500`. * Required fields get a `*` in the label, not a “(required)” suffix. * Submit button anchored to a sticky right-rail summary card on multi-line forms (line items, expense lines) so the user can submit without scrolling. * Inline dynamic field arrays (lines): each row is a single `flex items-end gap-3`, trash button on the right is a ghost icon button. “Add line” is a `variant="ghost"` button with a `Plus` icon, **left-aligned** under the last row. ### Status pills [Section titled “Status pills”](#status-pills) Always a `rounded-full px-2 py-0.5 text-xs font-medium` with a tinted background. These are the light-mode colors (the app is light-mode only): | State | Background | Text | | ------------------------ | ------------------- | ------------------ | | draft / pending | `bg-amber-500/10` | `text-amber-600` | | submitted / in review | `bg-blue-500/10` | `text-blue-600` | | approved / paid / synced | `bg-emerald-500/10` | `text-emerald-600` | | rejected / overdue | `bg-rose-500/10` | `text-rose-600` | | closed / inactive | `bg-zinc-500/10` | `text-zinc-600` | Templates can extend the palette, but the shape and sizing don’t change. ### Empty states [Section titled “Empty states”](#empty-states) Every list, table, and dashboard card has a designed empty state — never a blank box. The pattern is centered lucide icon (24px, `text-muted-foreground`), one line of copy (“No expenses yet — submit your first report”), and an optional primary CTA. Use it on first paint too: an empty dashboard for a new instance should feel intentional, not broken. ### Loading [Section titled “Loading”](#loading) shadcn `` shaped like the eventual content. KPI tile skeletons match the tile height. Table skeletons render 3–5 rows. Never use a generic centered spinner on the page body — only inside button labels and inline indicators. ### Errors [Section titled “Errors”](#errors) * Field-level errors: inline, `text-rose-500 text-xs`. * Mutation errors: sonner toast with a “Retry” action where it makes sense. * Page-load errors: a centered card with the message and a “Reload” button. Never throw a raw stack trace at the user. ### Light mode only [Section titled “Light mode only”](#light-mode-only) Generated apps render in **light mode, always**. No dark default, no `next-themes`, no theme toggle, no `class="dark"` on ``, no `prefers-color-scheme` branch, no theme cookie. Don’t ship the dark palette at all — keep the shadcn neutral light defaults. ## Animations [Section titled “Animations”](#animations) * Card mounts: none. Page transitions: none. * Realtime row flash: a single `bg-emerald-500/10` background for \~1.5s, then `transition-colors` settles. That’s the one visual flourish that earns its keep. * Hover transitions on buttons and links: `transition-colors duration-150`. * Toasts slide in from the top-right corner (sonner default). Position: `top-right`, NOT bottom-center. ## Accessibility floor [Section titled “Accessibility floor”](#accessibility-floor) * Every interactive element has a visible focus ring (shadcn defaults are good — don’t override). * Color is never the only signal: status pills also carry text, deltas also carry `▲` / `▼`. * **Every screen is usable at 390px, 834px and 1280px, with no horizontal scroll at any of them.** Phone, tablet, desktop — check all three before calling a screen done, forms included. A layout verified only at desktop width has not been checked on a phone at all; the three widths are the floor, not three designs. * **Never ship a `hidden md:block` / `md:hidden` layout pair.** Both trees render into the DOM: every input in the “hidden” one stays tabbable, its effects keep running, and two components fight over the same focus and the same ids. Reflow the ONE tree with grid/flex and responsive utilities. If a surface genuinely needs a different structure per breakpoint, it is a different product (see the Konsta carve-out) and it gets its own route, not a display toggle. * Modal / Sheet always has a labeled close button and traps focus. ## What NOT to do [Section titled “What NOT to do”](#what-not-to-do) * No hero gradients, no decorative animated backgrounds. * No emoji in production UI copy. * No “Powered by Claude” / “Built with AI” / similar badges. * No marketing landing page on `/`. The first authenticated route is the dashboard, and the unauthenticated `/` redirects to the instance’s hosted sign-in page (there is no in-app `/login` page). * No `:root { --radius: 1rem; }` style overrides — keep the shadcn neutral defaults so the templates look like the same product. ## Per-template rules go in the prompt [Section titled “Per-template rules go in the prompt”](#per-template-rules-go-in-the-prompt) Things the prompt SHOULD declare (because they vary by template): * Which accent color out of the four. * Status pill states specific to the record (orders have `pending_fulfillment`, expenses have `submitted`, etc.). * KPI tile contents and the time window. * The single hero card the dashboard leads with. * **Whether the app has a phone surface at all**, and which workflow lives on it. This is the door the [Konsta carve-out](#the-one-carve-out-konsta-on-a-phone-surface) comes through: a template whose prompt does not name that surface is shadcn-only, and adding Konsta to it is the same mistake as adding Redux. # Embedding a signing page > Mount a DocuSeal signing page inside your app from a mirrored submitter row — the signing URL is a typed column, the widget is DocuSeal's own, and the failure modes are silent. A customer who has been sent a document to sign should be able to sign it in the app they already have open, rather than in a separate tab. Your instance mirrors every signature request and every party on it, so the signing page is something you mount from an ordinary row read. **There is no Turbobase embed endpoint and no client helper for this.** The signing URL is a column on a table you already read, and the widget is the e-signature provider’s own. That is deliberate: a wrapper would be one more thing between you and a page that already works, and one more thing to go stale when the provider changes it. ## The three tables [Section titled “The three tables”](#the-three-tables) Connecting DocuSeal mirrors three tables into your instance: | Table | One row is | Direction | | ------------------ | ------------------------------------------------------------ | ---------------------------- | | `esign_template` | the reusable document a request is built from | read-only | | `esign_submission` | one signature request | read + create, never an edit | | `esign_submitter` | **one party asked to sign** — this is the one you embed from | read + edit, never a create | They are named for the record CLASS rather than for DocuSeal, so an app written against them keeps working if your instance later mirrors a different e-signature provider. The Direction column is what **your app** may do, which is not the same as what DocuSeal allows. A submission is **create-only** because a request that has gone out cannot be changed — see [Sending a request from your app](#sending-a-request-from-your-app) for what a write that addresses an existing one answers, and what to do instead. A submitter is the mirror image: a party comes into being as part of the request that asks them to sign, so there is no create — but an existing one can be edited, which is how you correct a request that has already gone out. ## Reading the signing URL [Section titled “Reading the signing URL”](#reading-the-signing-url) `esign_submitter.signing_url` is a **typed column**, not a key inside `raw`. That matters: a `raw` key is refreshed only by the periodic poll, while a promoted column is kept current by the webhook leg too. Read it like any other column. ```ts import { turbobase } from '$lib/turbobase'; const { data, error } = await turbobase .from('esign_submitter') .select('external_id, name, email, role, status, signing_url') .eq('submission_external_id', submissionId) .order('role'); ``` Reading these rows needs the `read:esign_submitter` capability, which the built-in **Standard** role carries. A user without it gets an empty array rather than an error — see [Reading the mirror](./reading-data) for why that shape is worth checking for explicitly. A signing URL is a capability Anyone who holds the URL can act as that party. Treat it exactly as you would a password-reset link: never log it, never put it in a query string of your own, and never render one party’s URL on a page another party can see. Render only the URL belonging to the person who is signed in. ## Mounting the widget [Section titled “Mounting the widget”](#mounting-the-widget) Load DocuSeal’s embed script and point it at the URL: ```svelte ``` Nothing about this call goes through your instance. The browser talks to DocuSeal directly; your instance’s only part is having told you the URL. ## What breaks, and what it looks like [Section titled “What breaks, and what it looks like”](#what-breaks-and-what-it-looks-like) Every failure here is quiet — the page renders and the form does not. * **The request is already signed.** `status` is `completed` and the URL loads a read-only receipt rather than a form. Check `status` before mounting and show the completed state yourself; a signed document presented as a form reads as a bug in your app. * **The request has expired.** `esign_submission.expires_at` is in the past. The URL still resolves and the form refuses to submit. Check the parent row. * **The request was archived.** `esign_submission.status` is `archived`. Same shape: the URL resolves and nothing can be done with it. * **`signing_url` is null.** The provider did not return one for this party. `signing_slug` is still set, and the next poll usually repairs the URL — do **not** build a URL from the slug yourself. A hand-built URL that is wrong renders a blank frame with nothing in the console, which is strictly worse than showing nothing. * **The row is stale.** A submitter row is refreshed by the webhook when somebody views, starts, completes or declines. If your instance’s DocuSeal webhook has no shared secret configured, those deliveries are refused and the rows only move on the periodic poll. Your instance’s Health tab for DocuSeal says so in as many words. ## Sending a request from your app [Section titled “Sending a request from your app”](#sending-a-request-from-your-app) Creating an `esign_submission` row sends a real signature request, by email, under your company’s name, to whoever you name. That write is held shut by a per-instance switch that ships **off** — ask your operator to enable it before building against it, and expect the write to be refused with `BIDIRECTIONAL_DISABLED` until they do. Two shapes are worth knowing before you build: * A submission carries `template_external_id` and the parties, and **both are required on a create** — a request with neither a document nor anybody to sign it is refused on the call, before anything is saved or queued. It cannot be **edited** once created: a write that addresses an existing one — by `pgRowId` or by `externalId` — is refused `422 APPEND_ONLY` before anything is written, so your copy never stops matching the document being signed. * **To correct a request that has already gone out, change the party.** A submitter can be edited (`name`, `email`, `phone`, `role`) through `POST /v1/data/esignSubmitter`, addressed by `pgRowId` or `externalId`. It cannot be created on its own — a party comes into being as part of the request that asks them to sign — so a create there is refused `422 EDIT_ONLY`. * **When the document itself is wrong, send a new request.** That is an ordinary create and is unchanged. Archive the old one in DocuSeal. The create goes to the write boundary, `POST /v1/data/esignSubmission`, like every other record write: ```ts await fetch('/v1/data/esignSubmission', { // same-origin, relative path method: 'POST', credentials: 'same-origin', // the turbobase_session cookie rides automatically headers: { 'content-type': 'application/json' }, body: JSON.stringify({ idempotencyKey: crypto.randomUUID(), parent: { template_external_id: '7001', // required — which document to send template_name: 'Mutual NDA', // optional, for your own lists raw: { submitters: [ // the people who have to sign { email: 'first@customer.example', name: 'First Party', role: 'First Party' }, { email: 'second@customer.example', name: 'Second Party', role: 'Second Party' } ] } } }) }); ``` **The signers go under `raw`, not at the top level.** `esign_submission` has no `submitters` column — the parties are rows on `esign_submitter`, which DocuSeal creates for you — so a top-level `submitters` key is refused with `422 UNKNOWN_COLUMN` naming the field. Each party takes `email`, `name`, `phone`, `role` and `source_external_ref` (your own id for them, which comes back on the mirrored submitter row); anything else is dropped. **An incomplete create is refused on the call, not afterwards.** Omit the template or leave the party list empty and you get `422` with `error.code = 'MISSING_REQUIRED_FIELD'` and `error.field` naming `template_external_id` or `raw.submitters`; no row is written and nothing is queued, so there is nothing to clean up. The same two paths are published on `required_inputs` for this record type — read them rather than hard-coding the list, and you cannot be refused for something nobody told you about. You get back `200 { ok, row: { pg_row_id, external_id } }` with `external_id` **null** — DocuSeal mints the request’s id, and your instance sends it in the background. Watch the row you were handed: `write_status` moves `pending` → `synced` and `external_id` fills in when the request has gone out, and the per-party `signing_url` values appear on `esign_submitter` shortly after. Any `write_status` other than `pending` or `synced` means the request has not gone out, and the three say different things about what happens next: * **`needs_input`** — yes. The refusal is released by the record itself, so the `record_write_state` row’s `message` always says what to change. Its `fields` array names the column **when DocuSeal named one**, and is empty when it did not — so render your correct-and-save prompt from the `message` and treat `fields` as the extra that focuses it, never as the test for whether to show it. A `template_external_id` your DocuSeal account no longer has names the column, and carries the id you sent in `requested`; a request DocuSeal turned down over its own content — a party it will not accept, a value its rules forbid — names no column and carries DocuSeal’s own reason in the `message` instead. `requested` is optional either way: it is absent when the column was simply left empty. * **`blocked`** — no. Either the refusal is not released by anything on the record — a credential DocuSeal rejected, a permission the connected account lacks — or DocuSeal could not be reached at all. The `message` is the generic hold and `fields` is empty. Show it and stop; your team is already notified. * **`awaiting_approval`** — nothing is wrong, and nobody has to do anything on this screen. A signature request leaves the company’s account and cannot be unsent, so an instance can be configured to hold one for an operator to approve before it goes. The request is held, not refused: once it is approved the row carries on to `synced` by itself, and the person who saved it needs no second save. Render it as waiting rather than as a failure, and do not offer a correct-and-save prompt — there is nothing on the row to correct. If nobody answers inside the window the row ends `blocked` with `reason_code` saying the hold expired, and sending it again is a fresh request. Sending the request needs the `write:esign_submission` capability, which is **not** in the built-in **Standard** role: a signature request leaves your company’s account under your name and cannot be unsent, so an admin grants it per user. Reading these rows needs only `read:esign_submission`, which every standard user has. # Reading the mirror > How to read the mirrored data efficiently — embeds across linked records, keyset pagination, and the query shapes that silently return nothing. Your instance’s tables are a **mirror** of the source system, not a copy of its UI. Most of the surprises below share one shape: the query succeeds, the page renders, and the data is absent or subtly wrong. Nothing errors. This page is the list of things worth checking before you trust a result. ## Reading rows always runs as a person [Section titled “Reading rows always runs as a person”](#reading-rows-always-runs-as-a-person) **Rows are never read by PostgREST under a token.** A personal access token reaches *schema* that way — `/v1/catalog`, types generation, the authoring MCP — but PostgREST rejects it outright, because a PAT is an opaque bearer rather than a JWT: ```json {"code":"PGRST301","message":"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 1)"} ``` Until 1.3.x a PAT carrying a `data:read` scope could be traded for a session at `POST /auth/pat-exchange`. Both are gone and neither is coming back. The session it returned was the token owner’s *ordinary* session — write included, for an admin owner — so a long-lived token in a project file was standing write access to production data. Four ways to read rows, each running under a real person’s identity and RLS: **Inside a hosted app**, you need nothing: the app is served same-origin and rides the visitor’s session cookie. `select()` through `src/lib/turbobase.ts` and the signed-in user’s row policies apply. **From a native app you build** — iOS, Android or desktop — sign the person in through the instance’s own OAuth server and read `/rest/v1` with the token it returns. That token *is* their session, so the same row policies fire as in a hosted app; it lasts eight hours and renews quietly, for thirty days from the sign-in, while their approval stands. On day thirty-one the person signs in again. See [building a native app](/tenant/auth-levels/#building-a-native-app). **From your own editor**, connect the [data MCP](/tenant/mcp-server/) as yourself. You sign in once over OAuth and your client gets `list_records`, `get_record` and `search_fields` as tools — scoped to *you*, expiring with your session. Connect it from **Integrations** settings, and revoke it from the *Connected apps* tab of your account settings. **From a script or an unattended agent that is not an AI client** — a label printer, a warehouse script, an overnight integration — present a PAT carrying the [`rest:read` permission](/tenant/access-tokens/) as a bearer at `POST /v1/batch`. You send PostgREST query strings and get their rows back, so your script keeps the read grammar it would use at `/rest/v1`, and the request runs as the administrator who minted the token, under that person’s row-level permissions. `rest:write` beside it reaches `POST /v1/data/`, the upload route and `DELETE /v1/data/` — the same write boundary an app goes through, so your hooks, guardrails and `expectedSyncVersion` all apply. `/rest/v1` itself takes a browser session only and does not accept a token, which is why a script reads through the batch endpoint. **From an AI client without a person signed in**, present a PAT carrying the [`mcp:data` permission](/tenant/access-tokens/) as a bearer at the same data MCP endpoint. Reads run as the administrator who minted the token, under that person’s row-level permissions, and the session behind them is minted inside each request and never handed to your client. Neither credential is granted by default — an administrator ticks it deliberately in the mint dialog — and each read permission is refused the matching write on its own. A token carrying `mcp:data` **alone** cannot change anything, whatever the instance’s MCP write setting says; changing data through that endpoint takes `mcp:write` as well, *and* the instance’s MCP write setting on. Neither pair implies the other, so a token connected to the data MCP is not thereby allowed on the web interface. From an app itself, writes still go through `POST /v1/data` as a signed-in person. ## `permission denied` usually means “not signed in” [Section titled “permission denied usually means “not signed in””](#permission-denied-usually-means-not-signed-in) ```json {"code":"42501","message":"permission denied for table cr_acme_incoming"} ``` This reads as *your role cannot see this table*. Far more often it means **there was no session at all**: PostgREST fell back to the anonymous role, which is denied everything. Check the session before you go looking at grants. In local development this is almost always a dev-proxy gap — the sign-in page loads its assets from `/_app`, so if that path isn’t proxied the login form renders but never hydrates, and you stay signed out with no error. The scaffolded `vite.config.ts` proxies it; don’t remove that entry. ## Five shapes that return nothing [Section titled “Five shapes that return nothing”](#five-shapes-that-return-nothing) ### 1. A `jsonb` custom field is a bare id [Section titled “1. A jsonb custom field is a bare id”](#1-a-jsonb-custom-field-is-a-bare-id) Every mirrored custom field is reported as `jsonb`, which says nothing about what is inside. It is **the bare source internal id**: ```json "cf_ns_custrecord_vendor": 1074 ``` Not `{"value": 1074, "text": "FedEx"}`. There is no display text and — unlike standard columns such as `entity_id` / `entity_name` — **no display twin**. Resolve the id against its lookup table yourself. An app built on the wrapper assumption renders empty cells, which look exactly like an empty table. An empty custom field holds the JSON literal `null`, **not** SQL `NULL`. So `is.null` matches zero rows on these columns, and `lt.0` matches blanks as well as negatives (jsonb sorts `null` below every number). ### 2. The obvious lookup table may be the wrong one [Section titled “2. The obvious lookup table may be the wrong one”](#2-the-obvious-lookup-table-may-be-the-wrong-one) A List/Record field named after a custom record does not necessarily point at it — a carrier field can point at `entity` (vendors) while a `customrecord_acme_carrier` table also exists and holds something else entirely. Confirm the target by checking that real ids resolve to sensible rows, not by matching names. ### 3. Some tables mix kinds in one id space [Section titled “3. Some tables mix kinds in one id space”](#3-some-tables-mix-kinds-in-one-id-space) `entity` holds customers **and** vendors — the same id is a valid row of each. `order` holds every connector’s orders. `GET /v1/catalog/records` marks these with a `sharedTable` note naming the discriminator. Apply it: ```plaintext /rest/v1/entity?entity_type=eq.vendor&external_id=eq.1604 /rest/v1/order?source_system=eq.netsuite ``` Skip it and you will label a vendor with a customer’s name, and both queries will have succeeded. The note only names filters that genuinely narrow, so read it rather than assuming one exists. `discriminators` is empty where nothing separates the kinds at all — `tax_code` holds NetSuite’s `salesTaxItem` and `taxGroup`, so filtering by source excludes nothing — and there the `hint` tells you to match on `pg_row_id` and check the row you read. Where a filter narrows only partly, the hint names the record types it leaves behind. ### 4. Five timestamps are the mirror’s, and two of them are worth rendering [Section titled “4. Five timestamps are the mirror’s, and two of them are worth rendering”](#4-five-timestamps-are-the-mirrors-and-two-of-them-are-worth-rendering) `created_at`, `updated_at`, `synced_at`, `last_modified_pg` and `source_confirmed_at` are all on **Turbobase’s own clock**, not the source’s, and the catalog marks all five `provenance: "mirror"`. After a bulk sync `created_at`, `synced_at` and `last_modified_pg` are identical across the whole table, so ordering by one of those gives you sync batch order, not business order. `updated_at` is when the row last **changed** — stamped whenever a change arrives from the connected source or one of your own writes edits the record. It is the column to order a “recently touched” list by, and the one `updated_since` filters on. Nothing else moves it: a write-back completing, a freshness confirm, a source-id backfill and a platform maintenance backfill all leave it alone, so a sweep that re-reads a record and finds it unchanged does not make it look edited. Use `created_at` when you want import order — that is the one `updated_at` used to answer by accident. `source_confirmed_at` is the other one worth putting on a screen: it is when the source was last asked about **this row** and agreed with it, whether or not a value moved, so it answers “is this figure current” in the same request that returns the row. It is not `updated_at` — a row re-read thirty seconds ago and found identical has a four-hour-old `updated_at` and a thirty-second-old `source_confirmed_at`. `NULL` means never confirmed, not just-confirmed, so render it as “not confirmed yet” rather than as age zero. For real business dates use `source_modified` (`provenance: "source"`) or the source’s own date field inside `raw`. ### 5. A column can exist and be empty on every row [Section titled “5. A column can exist and be empty on every row”](#5-a-column-can-exist-and-be-empty-on-every-row) `currency_name` and `subsidiary_name` are frequently NULL while `currency_id` / `subsidiary_id` carry the data. A display contract that points at an unpopulated column renders every total unlabelled — worse than wrong when not everything is in one currency. **Check one real row before rendering a column**, not just the schema. ## Follow links with embeds, not a second read [Section titled “Follow links with embeds, not a second read”](#follow-links-with-embeds-not-a-second-read) One request can pull related rows in alongside the row they hang off. Two kinds of link are embeddable, and the first is worth far more than the second: 1. **A header and its lines.** A real foreign key. Works on every instance today, needs nothing declared, and is where nearly all the wasted bytes are. 2. **A `jsonb` custom-field link your instance has declared.** Resolved on the server over `raw->>''`. ### Read a transaction’s lines as an embed — never out of the header’s `raw` [Section titled “Read a transaction’s lines as an embed — never out of the header’s raw”](#read-a-transactions-lines-as-an-embed--never-out-of-the-headers-raw) Every line table carries a real foreign key to its header (`item_receipt_line.parent_pg_row_id` → `item_receipt.pg_row_id`), so PostgREST embeds the lines directly. Name the columns you actually render: ```plaintext /rest/v1/order?select=external_id,tran_date,order_line(line_index,line_order,item_id,quantity) ``` **Order the lines on `line_order`, not on `line_index`.** `line_index` is the line’s identity — the key that does not move when the document is re-read — and it is what you match on when you join one document’s lines to another’s. Treat it as opaque, and never as a position. For a source whose lines are separate records (Salesforce, HubSpot) it is a hash of the line’s own id and carries no order at all; for NetSuite it is the line’s own line id, which is not in display order either — a sales order whose lines are shown 1st, 2nd, 3rd can carry ids 8, 4 and 9. Sorting on `line_index` scrambles the document in both cases. `line_order` is the position a person sees, counting from 1, and twenty line tables carry it. It can be **null** — on a NetSuite line whose position could not be read from the document it was synced with, until that document next changes in NetSuite, and on a source that states no order of its own — so the portable form falls back: `order=line_order.asc.nullslast,line_index.asc`. Six of the fifteen header/line pairs need a **constraint hint** — the `!…` in the next example — because two foreign keys join them. The table below marks which; read that before you copy a line from here. ```plaintext /rest/v1/item_receipt?select=external_id,tran_date,item_receipt_line!item_receipt_line_parent_pg_row_id_fkey(line_number,item_id,quantity) ``` The shape this replaces is pulling lines out of the header’s `raw` — `select=raw` (or `select=lines:raw->item->items`) and then walking the array in the browser. **That is the most expensive query shape on the platform.** A header’s `raw` is the entire source document, line array included; we have measured single requests of that shape at **\~10 MB** on live instances, and a first paint doing it for three record types moves tens of megabytes and takes seconds. The embed above moves kilobytes, because no `raw` column crosses the wire at all. Header / line pairs that embed this way. **Nine embed bare; six need the hint** — copy the exact embed text from the right-hand column: | Header | Line embed | | ----------------------------- | -------------------------------------------------------------------------------------------- | | `order` | `order_line(…)` | | `order_fulfillment` | `order_fulfillment_line(…)` | | `invoice` | `invoice_line(…)` | | `cash_sale` | `cash_sale_line(…)` | | `estimate` | `estimate_line(…)` | | `opportunity` | `opportunity_line(…)` | | `credit_memo` | `credit_memo_line(…)` | | `purchase_order` | `purchase_order_line(…)` | | `vendor_bill` | `vendor_bill_line(…)` | | `item_receipt` | `item_receipt_line!item_receipt_line_parent_pg_row_id_fkey(…)` | | `return_authorization` | `return_authorization_line!return_authorization_line_parent_pg_row_id_fkey(…)` | | `vendor_return_authorization` | `vendor_return_authorization_line!vendor_return_authorization_line_parent_pg_row_id_fkey(…)` | | `journal_entry` | `journal_entry_line!journal_entry_line_parent_pg_row_id_fkey(…)` | | `check` | `check_line!check_line_parent_pg_row_id_fkey(…)` | | `transfer_order` | `transfer_order_line!transfer_order_line_parent_pg_row_id_fkey(…)` | The hint is always the line table’s own name plus `!_parent_pg_row_id_fkey`, so you can derive it rather than memorise the six. #### Why the hint, and what happens without it [Section titled “Why the hint, and what happens without it”](#why-the-hint-and-what-happens-without-it) Those six line tables carry **two** foreign keys to their header — the `parent_pg_row_id` one above, and an older one on the source id (`tran_external_id` / `tran_internal_id` / `je_internal_id`). PostgREST refuses to guess which to traverse and answers **`300` with `PGRST201`**, listing both candidates. The response is helpful, but it is an error: no rows come back. Caution Nothing rescues this at runtime. The client’s fallback for a declared relationship keys on `400`/`PGRST200`; a `300` is neither, so an unhinted embed on these six is a hard error in your app, not a slower-but-correct read. Get the embed text right at author time. **Hint the `parent_pg_row_id` constraint, not the source-id one.** Both resolve the ambiguity, but `parent_pg_row_id` is `NOT NULL` on every line while the source-id column is nullable — a record created in Turbobase and not yet synced to the source has no source id yet, so hinting the older constraint silently drops its lines. That failure looks like “the header has no lines”, which is far harder to spot than a `300`. You can filter, order and cap the embedded rows without touching the parent query, by prefixing the parameter with the embed name (one line, wrapped here for readability): ```plaintext /rest/v1/purchase_order ?select=external_id,purchase_order_line(line_index,line_order,item_id,quantity) &purchase_order_line.quantity=gt.0 &purchase_order_line.order=line_order.asc.nullslast,line_index.asc &purchase_order_line.limit=200 ``` Those parameters filter the *lines*, not the purchase orders — a PO with no matching lines still comes back, with an empty array. Selecting parents *by* what their lines contain is a different problem an embed can’t solve; ask for a view. If your app reads a `*_line` table separately and joins it back to the header on `tran_external_id` in memory, or reads lines out of `raw`, replace it with the embed. Nothing has to be declared and nothing has to be enabled. ### Embedding across a declared `jsonb` link [Section titled “Embedding across a declared jsonb link”](#embedding-across-a-declared-jsonb-link) Some records point at another *record* through a `jsonb` custom field — an item receipt carries the internal id of the purchase order it was created from in `raw->>'createdFrom'`. Where your instance has **declared** that link, embed it: ```plaintext /rest/v1/item_receipt?select=*,created_from_po(*) ``` `created_from_po(*)` pulls each receipt’s originating purchase order into the same response. It is: * **one round trip** — no second read of `purchase_order`, no in-memory join; * **resolved on the server** — the join runs in Postgres over `raw->>'createdFrom'`, not in the browser; * **RLS-scoped** — the embed is filtered by the same row policy as the base read, so it can never surface a row your session couldn’t already see on its own. The links declared on the item-receipt / purchase-order pair: | From | Embed | Pulls in | | ------------------- | ----------------------------- | ------------------------------------------------ | | `item_receipt` | `select=*,created_from_po(*)` | the purchase order the receipt was created from | | `item_receipt_line` | `select=*,source_po(*)` | the purchase order a receipt line traces back to | | `purchase_order` | `select=*,receipt_lines(*)` | the receipt lines booked against the PO | ### Relationships are declared, not discovered [Section titled “Relationships are declared, not discovered”](#relationships-are-declared-not-discovered) Your instance does **not** grow a resolver for every link field it can see. A source account has hundreds of them and a given app uses a handful, so a relationship exists because something asked for it: your app declares the ones it embeds in `public/turbobase.json`, or someone creates one in the instance UI. Declaring is what causes it to be materialized — discovery on its own does nothing. ```json { "name": "ops", "relationships": [ { "name": "inbound_shipment", "sourceTable": "item_receipt", "sourceJsonbKey": "custbody_acme_inbound_shipment", "targetTable": "cr_inbound_shipment" } ] } ``` The list your app can rely on is the list in your own manifest — plus the header→line embeds above, which are structural and always present. **A declared embed works from the moment you declare it.** Materializing the fast path (the resolver function and its index) is a background pass, but you never wait on it and never gate a screen on it: `select()` and `selectPage()` try the native embed first, and while it isn’t armed yet the instance resolves the same embed for you server-side and returns the same rows. Arming changes how fast the read answers, not what it answers — and your code is identical either way, because the fallback reuses the query string you already wrote. One constraint holds only during that window: the instance-resolved path reproduces a straight `rel(columns)` embed exactly, and refuses what it can’t reproduce — nested embeds, `!inner`, spread embeds, embed-scoped filters — with a `501` naming the alias instead of quietly returning different rows. Keep a newly declared embed simple and add nested embeds or `!inner` once it’s armed. (Header→line embeds are never affected; they’re native from the start.) **Spread embeds (`...rel(col)`) are the exception — don’t use them at all.** They aren’t merely unsupported while unarmed: on PostgREST 12 they’re rejected outright, and on 14 the request succeeds but the spread column comes back under its own name alongside the parent’s column of the same name, so the parent’s value is the one you lose. That’s a silent wrong-value bug, so it’s the one shape to avoid armed or not. Select the embedded column normally and read it from the array. ### A declared relationship’s embed is always an array [Section titled “A declared relationship’s embed is always an array”](#a-declared-relationships-embed-is-always-an-array) Even when the link is to-one. `created_from_po` comes back as a one-element array, not an object — `row.created_from_po[0]?.tran_id`, never `row.created_from_po.tran_id`. The second form is `undefined` on every row and renders as an empty column rather than an error, so it survives review easily. This differs from a plain foreign-key embed, where PostgREST follows the cardinality of the FK: a header’s lines come back as an array (`row.item_receipt_line`), but a to-one FK embed like `currency:currency_id(symbol)` comes back as an object or `null`. Declared relationships are uniform because they resolve through a set-returning function, so index into them and you’re right in both directions. ### A missing embed means “not synced yet”, not “no link” [Section titled “A missing embed means “not synced yet”, not “no link””](#a-missing-embed-means-not-synced-yet-not-no-link) Embeds over a `jsonb` link are **eventually consistent**. The resolver matches the link id (`raw->>'createdFrom'`) against the target’s `external_id`. If the target row hasn’t synced yet — a receipt landed before its purchase order — the embed comes back empty (`created_from_po: []`) even though the link id is present on the row. That is a timing gap, **not** a signal that the relationship doesn’t exist. To tell the two apart, read the raw link id itself (`select=external_id,createdFrom:raw->>createdFrom`): if it’s populated but the embed is empty, the target simply hasn’t landed yet and the embed will fill in on its next sync. Don’t render an empty embed as “orphaned”, and don’t write a migration/repair on the strength of one. Header→line embeds don’t have this failure mode — a header and its lines are written by the same sync, so the FK is never dangling. ### A custom record’s children are not lines [Section titled “A custom record’s children are not lines”](#a-custom-records-children-are-not-lines) The third kind of parent/child, and the one that looks most like the first while behaving like the second. In Oracle NetSuite, a **native transaction** owns a real sublist: its lines have no identity of their own, they arrive in the same save as the header, and they get the real foreign key that the whole first half of this section relies on. A **custom record** has no sublist. What you see rendered as one on its form is a **second custom record type** whose rows each carry a List/Record field pointing back at the parent. Each child is a first-class record with its own internal id, its own permissions, and its own place in the mirror. NetSuite draws them under the parent as a convenience; nothing else about them is line-like. So in your instance they are two independent tables with **no foreign key between them**. The link is the promoted column that the parent field was projected into, holding the parent’s `external_id`: ```plaintext turbobase.cr_acme_inbound_shipment -- parent external_id = '162' turbobase.cr_acme_inbound_shipment_item -- child cf_ns_custrecord_acme_isi_parent = '162' ← the link ``` Three consequences: * **There is no `parent_pg_row_id`.** Reaching for it is the single most common mistake here; that column belongs to native `
_line` tables only. * **A bare embed does not work**, because PostgREST embeds traverse foreign keys and there is no constraint to traverse. Read the children with a filter on the link column — the child’s own table, filtered by the parent’s `external_id`: ```plaintext cr_?=eq. ``` To get a parent and its children in one request instead of two, the pair has to be **declared** as a computed relationship — the same mechanism as the `jsonb` links above, and subject to the same eventual-consistency caveat. Ask your Turbobase administrator to declare it; it is not discovered automatically. * **The link is an id, not a row.** Like every other custom-field reference it is a bare external id (see “A `jsonb` custom field is a bare id”), so joining it to the parent means matching it against `external_id` — never against `pg_row_id`. Writing follows the same shape, in the other direction: you cannot POST `lines` to a custom record (it is a `422 LINES_NOT_SUPPORTED`). Create the parent, read its `external_id` off the response, then POST each child to **its own record type** with the link column set to that id. ## Querying efficiently [Section titled “Querying efficiently”](#querying-efficiently) ### Project keys out of `raw`, never select it whole [Section titled “Project keys out of raw, never select it whole”](#project-keys-out-of-raw-never-select-it-whole) `raw` is the entire source document. Selecting it across a wide table can produce a response large enough to fail at the edge (502). Pull out the one key you need: ```plaintext /rest/v1/vendor_bill?select=external_id,total,inbound:raw->custbody_acme_inbound_shipment ``` `->` keeps the JSON type, `->>` gives text. Deep paths use `#>`/`#>>`: `raw#>'{inventory,items}'`. If the key you’re reaching into `raw` for is the **line array**, stop — embed the line table instead (see *Follow links with embeds* above). `raw->item->items` is the single most expensive read shape on the platform, and the line table holds the same rows in typed columns. ### One bad column loses the whole table [Section titled “One bad column loses the whole table”](#one-bad-column-loses-the-whole-table) PostgREST rejects an unknown column with `42703` and **fails the entire request** — one wrong name in a `select` returns nothing for every row. Record types are not uniform: `memo`, `total`, `currency_name`, `created_from_id` and even `pg_row_id` are absent from some transaction tables. Check each table’s real column list (`GET /v1/catalog/records/:type`) rather than assuming a shared transaction shape, and assert your selected columns against it in a test if you query several tables the same way. ### Page large reads with `selectPage()`, don’t drain [Section titled “Page large reads with selectPage(), don’t drain”](#page-large-reads-with-selectpage-dont-drain) PostgREST caps an unbounded read at **1000 rows**. A `select()` that comes back at *exactly* 1000 rows was almost certainly cut short — a partial page that looks identical to a complete one. The generated client makes that loud: an unbounded `select()` that hits the cap logs a `TurbobaseTruncationError` telling you to paginate. (A `select()` you gave an explicit `limit` opted into a bounded read and stays quiet.) To read a full result set, page it with `selectPage()` — a keyset seek, not `offset` (which slows on deep pages and can skip or repeat rows under concurrent writes): ```ts import { selectPage } from './lib/turbobase'; let { rows, nextCursor } = await selectPage('item_receipt?select=*', { pageSize: 100 }); render(rows); while (nextCursor) { ({ rows, nextCursor } = await selectPage('item_receipt?select=*', { pageSize: 100, cursor: nextCursor })); render(rows); } ``` Pass the SAME path+query you’d give `select()`; `selectPage()` supplies the order, limit, and seek. `nextCursor` is `null` on the last page. The cursor is opaque and pinned to the exact query that minted it — replaying it against a changed filter/select throws `TurbobaseCursorError` rather than silently mixing two result sets. #### A seek key must be NOT NULL and UNIQUE [Section titled “A seek key must be NOT NULL and UNIQUE”](#a-seek-key-must-be-not-null-and-unique) `pg_row_id` is the default and is right for almost every table. Read-only master tables that have no `pg_row_id` need `{ sortKey: 'external_id' }` — and **only** those. Get this wrong and nothing errors. A keyset seek walks `=gt.`, so a shared boundary value skips every row that shares it, and a NULL sorts to one end and is never passed. The list simply comes back short. **Don’t hard-code the column — read it from your generated types.** `turbobase project types` emits a `SEEK_KEYS` map derived from the live schema: ```ts import { SEEK_KEYS } from './types'; const { rows } = await selectPage('vendor_credit?select=*', { sortKey: SEEK_KEYS.vendor_credit, }); ``` That matters because the right answer changes. Five transaction headers (`vendor_credit`, `credit_card_charge`, `credit_card_refund` — now a `record_kind = 'refund'` row on `credit_card_charge` — `inventory_adjustment`, `inventory_status_change`) had no `pg_row_id`, so `external_id` was the documented fallback — then they gained one, and their `external_id` became nullable and unique only per connection in the same release. Every app carrying that override started dropping rows, on a column that still existed. Reading `SEEK_KEYS` means regenerating types is the fix; hard-coding means there isn’t one. If you do hard-code it, `selectPage()` now throws when it can see you’re wrong — when the rows coming back carry a `pg_row_id` and you passed something else, and when the seek key is null on the last row of a page that has more rows. A `SEEK_KEYS` entry may be a **tuple**, and it is passed exactly the same way. A line table is keyed `(parent_pg_row_id, line_index)` — `line_number` on a few of them — and has no correct single column: every line of one transaction shares `parent_pg_row_id`, and the line’s identity is unique only *within* a parent (and `line_order`, the position, is not in the key at all). Pass both, in key order, and `selectPage()` seeks the pair with a row-wise comparison, so a page boundary that falls mid-parent neither drops nor repeats a row: ```ts const { rows, nextCursor } = await selectPage('vendor_bill_line?select=*', { sortKey: ['parent_pg_row_id', 'line_index'], pageSize: 100, }); ``` Add `{ descending: true }` to walk any key — single column or tuple — backwards. The direction is pinned into the cursor, so one can’t be replayed against the other. A table absent from `SEEK_KEYS` has no keyset-safe key at all; page it by filtering to a bounded subset instead. **Cursors do not survive a client upgrade.** A cursor now carries every column of the seek key, and one minted by an older client is refused with a `TurbobaseCursorError` rather than reinterpreted — reading it as a one-column tuple would silently drop rows. If you persist a cursor to storage or a URL, re-read from the first page after upgrading. Embeds page fine: `pageSize` counts **parent** rows, and each parent carries its embedded rows whole, so `selectPage('order?select=*,order_line(*)')` pages orders and brings every order’s lines with it. ### Keep concurrency low [Section titled “Keep concurrency low”](#keep-concurrency-low) Your instance is metered in **two** places, and they count differently. The one that governs ordinary reads is the edge rule below; a handful of endpoints carry their own budget inside the instance as well, and those are covered at the end of this section. #### The edge rule, in front of everything [Section titled “The edge rule, in front of everything”](#the-edge-rule-in-front-of-everything) One rule covers everything your instance serves, applied before a request reaches it: **1000 requests per 10 seconds, per client IP.** Three things about how it counts: * **It’s one budget for the whole instance.** Every request that reaches your instance shares it: `/rest/v1` reads, `/v1/*` calls, the Realtime connection, and any page or asset served fresh rather than from cache. * **Only requests that actually reach your instance count.** Assets answered from the edge cache don’t, so a page reload is usually far cheaper than its request count in devtools suggests. * **It’s per client IP** — one browser tab’s fan-out, not your app’s total traffic. Users behind one shared NAT egress share a budget. Every `/rest/v1` response restates the budget so you can pace against it instead of hard-coding a number: ```plaintext X-RateLimit-Limit: 1000 X-RateLimit-Window: 10 X-RateLimit-Scope: instance-subdomain ``` `X-RateLimit-Limit` is requests, `X-RateLimit-Window` is the seconds they’re counted over, and `X-RateLimit-Scope` is the reminder that the count is shared across everything your instance serves. These are **advisory**: they describe the budget, they aren’t a live meter. On this edge budget there is deliberately no `X-RateLimit-Remaining`, `X-RateLimit-Reset` or `Retry-After`. The counter lives at the edge, not in the response path, so any number in those headers would be a guess — and a guessed budget is worse than none. Pace against the limit and back off when you’re refused. The in-instance budgets at the end of this section are the opposite — their counter *is* in the response path, so they report all three. **What a refusal looks like.** Exceed the budget and the edge — not your instance — answers: ```plaintext HTTP/1.1 429 Too Many Requests ``` Two consequences worth coding for: * **It isn’t JSON.** Nothing in the body matches the `{"code","message","hint"}` error shape your reads normally return, so `res.json()` throws on it. Check `res.status === 429` before parsing. If a JSON parser suddenly chokes on markup, this is what happened. * **It’s a flat 10-second block.** Treat it as exactly that rather than reading a retry hint out of the response: wait ten seconds before the next attempt, and don’t retry in a tight loop, which only re-arms the block. So: keep a small number of requests in flight (a handful, briefly spaced), back off for 10s on a `429`, and load what the first screen needs while fetching the rest on demand. This matters most on dashboards that open by querying every record type at once. Reads your instance makes for you while server-rendering a page don’t count — they never leave the instance. The budget is about requests arriving from a browser or an external client. #### Four endpoints carry their own budget as well [Section titled “Four endpoints carry their own budget as well”](#four-endpoints-carry-their-own-budget-as-well) The edge rule is not the only limit. A few endpoints do far more work per request than a read does, so they carry their own counter *inside* the instance, on top of the edge rule — exceed either and you’re refused: * `/mcp`, the [MCP server](/tenant/mcp-server/) an external client or agent talks to. Its allowance is published on that page; read it there rather than fitting a delay by trial and error. * `/v1/batch`, the multiplexed read described in the next section. * The auth endpoints (`/auth/callback` and the token exchanges), where every attempt costs real crypto and database work, so the budget is deliberately small. * The webhook ingress your source systems post to. **Unlike the edge budget, these report themselves.** The counter is in the response path, so every response from one of these endpoints carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`, and a refusal adds `Retry-After` in whole seconds — a live meter rather than the advisory figures above. Pace against those numbers, and on a `429` wait exactly what `Retry-After` says: retrying sooner neither extends the window nor shortens it. A refusal here comes from your instance rather than the edge, so it arrives in the format that endpoint normally speaks — JSON for `/v1/batch` and the auth routes, and for `/mcp` a JSON-RPC error whose `error.data` repeats the limit, what’s left and the seconds to wait as numbers. These budgets are counted **per instance process**, not per client, so on a busy instance you can be refused before your own request count reaches the published figure. #### A request also has a deadline of its own [Section titled “A request also has a deadline of its own”](#a-request-also-has-a-deadline-of-its-own) Separately from how MANY requests you may send, there is a limit on how long one may take to arrive. Your instance gives a request **120 seconds** to finish arriving, and answers a `503` carrying `statusCode`, `error`, `message` and `Retry-After` when it does not — rather than closing the socket with nothing on it, which is what a client sees as a request that vanished. That deadline sits a whole minute above the front door’s own 60-second patience with one request, deliberately: the front door is always the first of the two to give up, so a large `apps push` that is still making progress is never the thing this cuts. If you do meet it, the cause is a stalled connection rather than the size of what you sent — send it again. An unexpected failure answers the same shape: a 5xx whose body carries a `correlationId`. The cause is in your instance’s log under that id; quote it when you ask an administrator. Nothing about the inside of the instance is in the body, so there is nothing to parse out of the message. ### Multiplex the cold-load fan-out with `batch()` [Section titled “Multiplex the cold-load fan-out with batch()”](#multiplex-the-cold-load-fan-out-with-batch) When the first paint genuinely needs several reads — a dashboard that queries every record type, or a header table plus the small lookup tables (`currency`, `location`, `subsidiary`) you map ids against — don’t fire them as many parallel `select()` calls. Issue them as **one** request with `batch()`: ```ts import { batch } from './lib/turbobase'; const [orders, invoices, customers] = await batch([ 'order?select=external_id,total,entity_id&order=tran_date.desc&limit=100', 'invoice?select=external_id,total,status&limit=100', 'entity?select=external_id,display_name&entity_type=eq.customer&limit=500', ]); if (orders.ok) renderOrders(orders.rows); // rows is the parsed result set if (!invoices.ok) note(`invoices unavailable (${invoices.status})`); ``` Each element is the same path-and-query string you’d pass `select()`. Results come back in the **same order**, and each carries its own `ok` / `status`, so a `429` or `5xx` on one sub-query does **not** fail the others — the other result sets still render. Because it’s a single HTTP request rather than 50, it spends one request of your rate-limit budget instead of fifty: a \~50-read cold load collapses into one or two `batch()` calls (up to 50 queries each). `batch()` is **reads only** (writes still go through `write()`), it’s RLS-scoped exactly like `select()` (same session, same rows), and the server bounds its own fan-out to PostgREST so a big batch can’t overload the instance. Reserve individual `select()` for on-demand / drill-in reads after first paint. Each result is `{ ok, status, rows?, error?, count?, contentRange?, truncated?, notRun? }` — `rows` holds the parsed set on success, `error` the message on failure. Under the hood it’s a single `POST /v1/batch` with a `{ queries: (string | { query, count })[] }` body; if you aren’t using the generated client, call that endpoint directly with the same session cookie and read `results[i].body` / `results[i].count` from the response. The batch itself returns `200` whenever your session is valid — a `401` means you’re not signed in, a `400` means a malformed body, a sub-query whose path isn’t a bare resource name (the error names the offending index), or too many exact counts (below). The cap is 50 queries per call. **Check each result’s own `ok` / `status` before you read its rows.** The envelope stays `200` for anything that goes wrong with an individual sub-query, which includes a `403` for a session that ended mid-batch — the person signed out elsewhere, was suspended, or had their role changed. Reading `rows` without looking at `status` hands a refusal to a screen as an empty set. See [App auth wiring](/build-apps/auth/) for how to tell that `403` from a permissions one. `batch()` is for cold fan-out across *unrelated* record types, and for the small lookup tables (`entity_id` → `entity`) you map ids against. It is **not** the answer to a header and its lines, or to a declared link — embed those instead (see *Follow links with embeds* above) and the server resolves the join in one hop. Reaching for `batch()` first is how a two-query page becomes a fifty-query page. #### One page per sub-query — 1000 rows, and it says when it cut [Section titled “One page per sub-query — 1000 rows, and it says when it cut”](#one-page-per-sub-query--1000-rows-and-it-says-when-it-cut) Each sub-query returns at most **1000 rows**. Ask for more — `limit=5000`, or no `limit` at all — and the instance serves the first 1000 and sets `truncated` on that result: ```ts const [people] = await batch(['entity?select=external_id,email&entity_type=eq.customer']); if (people.truncated) { // `rows` is a PAGE, not the set. Page on, or narrow the query. const [more] = await batch([ 'entity?select=external_id,email&entity_type=eq.customer&limit=1000&offset=1000', ]); } ``` Three things to know: * **`truncated` is absent, not `false`, when the page is whole.** So the check is `if (r.truncated)`. A sub-query whose own `limit` is 1000 or less can never carry it — if you asked for 25 rows and got 25, that is your page and nothing was cut. * **Page with `offset`, or narrow the read.** If you need to know how far, ask that sub-query for a total (`{ query, count: 'exact' }`, below) — the cap applies to the page, never to the count, so `count` is still the whole filtered set. * **A cut page is one sub-query’s problem.** Its siblings in the same batch are unaffected, exactly as with a `429` or a `5xx` on one of them. Why the ceiling exists: a sub-query with no `limit` used to stream the whole filtered set, and on a busy instance one screen doing that put every other read behind it in the queue for the database connections your instance has. If a surface genuinely needs tens of thousands of rows, page them with `selectPage()` rather than asking for them in one shot — a browser cannot render them at once either. #### A wide batch has a deadline, and says which sub-queries never ran [Section titled “A wide batch has a deadline, and says which sub-queries never ran”](#a-wide-batch-has-a-deadline-and-says-which-sub-queries-never-ran) At most six sub-queries read at once across your whole instance, so a batch of 50 slow reads spends most of its time queueing. The **whole call** therefore has a wall-clock deadline of its own, set below the front door’s patience with one request: past it, the instance stops starting sub-queries and answers with what it has. A sub-query that never got its turn comes back `ok: false`, `status: 504` — the same as a read that timed out — plus `notRun`: ```ts const results = await batch(queries); // Ask again for just the slots that did not run, and merge them in. const missed = results.flatMap((r, i) => (r.notRun ? [i] : [])); if (missed.length) { const rest = await batch(missed.map((i) => queries[i]!)); for (const [j, i] of missed.entries()) results[i] = rest[j]!; } render(results.filter((r) => r.ok)); ``` Three things to know: * **`notRun` is absent, not `false`, on every other result.** So the check is `if (r.notRun)`, exactly as with `truncated`. A batch that finishes inside the deadline carries it nowhere, and nothing about such a call changed. * **Ask again for the slots that did not run, not for the whole batch.** Re-sending all of it re-runs the sub-queries that already came back and re-enters the same queue that ran out of time. * **Meeting it regularly means the batch is too wide, or the reads are too slow.** Split the first paint into two narrower calls, replace `select=*` with the columns you render, and make sure each sub-query’s filter is one the instance can serve from an index. Why it exists: without it a wide fan-out could run for minutes, and the front door gave up first — so the request failed with no body at all, which told you neither what came back nor which sub-queries to ask for again. #### Ask for a total on the one query that needs it [Section titled “Ask for a total on the one query that needs it”](#ask-for-a-total-on-the-one-query-that-needs-it) A batch result’s `count` is the number of rows matching your filter **ignoring `limit`/`offset`** — the denominator in “1–25 of 1,340”. It is opt-in per sub-query, because it isn’t free: ```ts const [page, currencies] = await batch([ { query: 'order?select=external_id,total&order=tran_date.desc&limit=25', count: 'exact' }, 'currency?select=external_id,symbol', ]); if (page.ok) { render(page.rows); showPager(page.count); // 1340 — the whole filtered set, not this page } // currencies.count is null: it never asked for one, and never paid for one. ``` Three things to know: * **A counted sub-query comes back `status: 206`, and that is success.** PostgREST answers `206 Partial Content` when the page it returned is shorter than the total it computed. Branch on `ok`, never on `status === 200` — a `=== 200` check turns every counted page into a phantom failure. * **`count` is `null` unless you asked.** Without `count: 'exact'` the server sends no count header at all and PostgREST reports the total as `*` (`contentRange: "0-24/*"`). `rows.length` is the page, never the total. * **Only a few per request.** An exact count makes the database scan your whole filtered set rather than the page, and nothing cancels it once it starts — so the instance caps how many sub-queries in one request may ask for one (`BATCH_READ_MAX_EXACT_COUNTS`, 4 by default) and answers `400` for the whole request over the cap. `batch()` chunks count-aware, so it never sends a request over the cap however many counted queries you pass — but each is still a full scan, and the extra requests go out one after another. Posting to `/v1/batch` yourself, keep it to four. Count the one table you’re paging; leave the lookups as plain strings. If an estimate is good enough — a rough “about 40k orders” — don’t ask for a count at all: page with `selectPage()` and show what you have. The exact count is for a pager that has to be right. ## `include_total` is an estimate [Section titled “include\_total is an estimate”](#include_total-is-an-estimate) The MCP `list_records` tool’s `include_total` returns PostgREST’s `count=planned` — a **planner estimate** from table statistics, not a count. On a recently-synced table it can be off by a factor of two or more. The response labels it (`estimated_total_note`). Never quote it to a user as a total; page through with `next_cursor` when the exact number matters. ## A file’s bytes come from one route, never from a column [Section titled “A file’s bytes come from one route, never from a column”](#a-files-bytes-come-from-one-route-never-from-a-column) A mirrored file — a blob in a connected Azure Blob Storage container, a file on a mirrored repository branch — has a row in `source_file` carrying its path, size, digest, mime type and modification time. **The row never carries the bytes**, on any source, and it never will. Ask for them: ```plaintext GET /v1/data/file//content ``` Several things about that route are worth knowing before you build on it. **Let the shared client build the URL.** `fileUrl(row)` in `src/lib/turbobase.ts` takes the mirrored-file row and returns exactly the path above, encoded and with the connection on it. Build it by hand and the part you will leave out is the connection — which works on an instance with one container connected and stops working on one with two. ```ts import { fileUrl } from './lib/turbobase'; {row.path} Download ``` **URL-encode the external id.** It carries slashes — `:/` for Azure Blob Storage — so `encodeURIComponent` it or the path segments split. `fileUrl` does this for you. **Name the connection, and store this URL rather than the source’s.** The route takes `?connection=` — the `connection_id` on the row — and that is what makes the URL address the FILE rather than whichever container the viewer’s own session defaults to. Omit it and the caller’s own connection is assumed, which is right on a single-container instance and wrong as soon as a second one is connected. Because the URL carries no host and no version marker, it is the string to save on any record that points at a file: it keeps resolving after the file is replaced, and it opens for every user who may read the file. **An image, a video or a PDF renders in place; everything else downloads.** The response is `content-disposition: inline` for raster images, `video/*` and `audio/*` containers a browser plays, and `application/pdf`. Every other type — including HTML, SVG, XML, JavaScript and a file whose type the connector never recorded — is an `attachment` with `nosniff`. The list is an allowlist, so a type nobody has catalogued downloads rather than rendering. Add `?download=1` to force the attachment answer for any type. **Seek with `Range`, and revisit with `If-None-Match`.** A request carrying `Range: bytes=…` answers `206` with `content-range` and `accept-ranges: bytes`, and the window is fetched from the source as a window — so setting a `
`) .on( 'postgres_changes', { event: '*', schema: 'turbobase', table: '
', filter: '=eq.' }, (payload) => applyDelta(payload), ) .subscribe(); ``` `applyDelta` rules — match every row by its identity column (`external_id` for entity sub-types, `pg_row_id` for transactions — read the rule off the catalog, see [Discover the catalog](#discover-the-catalog)): * `INSERT` → prepend (trim if the list has a cap). Flash `bg-emerald-500/10` for \~1.5s. * `UPDATE` → merge in place. Flash `bg-amber-400/10` for \~1.5s. If the row is currently being edited by this user (`saving: true`), QUEUE the delta and apply once the save resolves — otherwise you race-condition over the user’s typing. * `DELETE` → **do not write this handler.** It cannot fire for a deleted record. A delete is an `update … set deleted_at = now()`, and a restrictive RLS policy hides the tombstoned row from the Realtime authorisation probe, so the subscriber receives no frame of any kind — the row just stops appearing. See [Deletes never arrive](/build-apps/#deletes-never-arrive-so-reconcile-instead). Removals come from a **reconciliation sweep**: re-run the island’s seed read on an interval (30-60s) and on tab focus, and replace the list with the result — the tombstoned row is absent from it. Realtime carries arrivals and edits; the sweep carries removals. Then **derive every visible surface from the in-memory list** — KPI tiles, charts, recent-rows tables, anything that depends on the data. Do NOT issue a separate aggregate query per tile. The reconciliation sweep above is the *only* repeated read: one re-run of the island’s seed query on a 30-60s timer (and on tab focus), batched, replacing the list wholesale. Everything else redraws from state. **Live indicator.** Reflect channel state in a small “Live” pill in the dashboard header: emerald “Live” once `SUBSCRIBED`, muted “Connecting…” before, amber “Reconnecting…” on `CHANNEL_ERROR` / `TIMED_OUT` (supabase-js auto-reconnects). Tear the channel down on unmount. **RLS still applies.** A user only receives events for rows they could read via REST. The `Authorization: Bearer ` + `setAuth(jwt)` step from the auth doc arms the WS for RLS — no extra wiring. See [build-apps#subscribe-to-realtime](../#subscribe-to-realtime) for the full reference. ## Smoke-test ritual [Section titled “Smoke-test ritual”](#smoke-test-ritual) Build + lint green is **not** the stop condition. The bugs that survive both are runtime-only — a guard that throws during a render, an embed that 400s because an FK is missing on this instance, an env var read at boot that’s still a placeholder. The agent must run the dev server itself and probe the live routes before declaring done. The canonical ritual lives at [build-apps#run-the-generated-app](../#run-the-generated-app). The short version every prompt repeats: 1. After `npm run build` and `npm run lint` pass, start `npm run dev` from `${APP_DIR}` **in the background / non-blocking**. Do NOT run it in the foreground — a blocking dev server hangs the session and burns context. 2. Report the local URL (`http://localhost:3000` for local dev; the URL the deployed dev-server prints otherwise). 3. **Probe the live routes:** * There is **no `/login` route** — the app has no sign-in page. Don’t probe for one. * Unauthenticated `/` **renders the SPA shell, doesn’t crash.** In local dev the Vite server serves `index.html` for every route (SPA fallback), so `curl -si http://localhost:3000/` returns **200** with the app HTML — **NOT a 500** and **NOT a self-built sign-in form**. (In production the serving route 302s an unauthenticated visitor to `/login` before the SPA loads; the app itself never renders a sign-in form.) A 500 here means a component threw during render — fix it, don’t ship it. * With a valid session (in local dev, sign in on the instance origin first so the browser holds the `tb_jwt` cookie the Vite proxy forwards), load `/` → the dashboard resolves its identity row without a “no record” empty state. That empty state on a valid user usually means a required-path query 400’d — most often an embed coupling the read to a missing FK. De-embed (scalar select + separate best-effort lookup) and re-test. 4. **Verify the dashboard is actually live, not refresh-on-reload.** The `Live` pill turns emerald (`SUBSCRIBED`), and a row inserted/updated in a second tab (or via the app’s own write surface) appears with **no reload**. A dashboard that only updates on full reload is a bug — it almost always means the component seeds the initial read but never opens the Realtime channel. 5. Treat a 500 or a spurious empty state as a **failure to fix**, not a finished app. Report what was built and what wasn’t verified. Restarting the dev server is cheap and expected when the app’s environment changes — Vite reads its config and `.env` at boot and does NOT hot-reload changes to the dev-proxy target or an env var, so those need a restart. ## Decisions are locked [Section titled “Decisions are locked”](#decisions-are-locked) Every prompt opens with: > *Do not ask clarifying questions. Every decision is locked below. If you genuinely cannot proceed, stop and report what’s blocking; do not branch on uncertainty by asking the user to confirm.* This works because: * Every knob the agent could reasonably want is in the Knobs block, filled from your Turbobase app’s settings pages (see [Knobs](#knobs)). * Every defaulted choice is in a “Decisions already made — do NOT ask” table at the bottom of the prompt. Auth endpoint, anon key source, idempotency, category source, status palette, write path, write-state-in-UI policy — whatever the template uses, name the decision *and* the source of the value. * Speculative `if catalog has X / else Y` paths are the #1 source of asking — Claude surfaces them as “OK?” decisions. Don’t write them. State the one canonical path. If a record type or column might not exist on an older instance, the failure mode is “stop and tell the user”, not a fallback path. **Separate product decisions (locked) from integration assumptions (verify live).** “Every decision is locked” must NOT discourage the agent from verifying environment-dependent integration details: * **Locked product decisions** (state concretely, “do NOT ask”): accent color, status-pill palette, which fields a form shows, page layout, the write path, idempotency, category/tax-code source filters, whether to surface sync state. These don’t depend on the live instance’s schema relationships. * **Integration assumptions** (state as *intent + a robust default*, never as a pasted schema-coupled query): FK existence / whether a relationship can be embedded, exact column names on a view, whether a lookup table is populated. Write *“resolve the employee’s ISO 4217 currency code”* + the robust default (scalar select + a separate best-effort lookup), not a pasted `currency:currency_id(symbol)` embed the agent can’t verify without running the app. ## Standard non-goals [Section titled “Standard non-goals”](#standard-non-goals) Every template inherits these. Append per-template non-goals after them. * **No backend** / no rewriting Turbobase. The instance is the backend. * **No shipping `CATALOG_TOKEN` in the bundle.** `CATALOG_TOKEN` is a build-time discovery step the *agent* runs (a `curl` against `/v1/catalog/records`) to learn the schema — it never appears in the shipped static bundle. At runtime the app talks to the boundary **same-origin with the session cookie**; there is no server-side secret to expose because a static SPA has no server. (A genuinely secret template key — a raw `ANTHROPIC_API_KEY` — likewise can’t live in a static bundle; route that feature through an instance-side endpoint.) * **No design choices that contradict the [design-system doc](../design-system/).** * **No analytics, marketing pages, sign-up flow.** This is an authenticated in-instance app, not a landing page. * **No hard-coded record ids.** Read from the catalog or query at runtime. * **No direct canonical writes** through PostgREST. The writer-role cutover denies them — use the write boundary. * **No update without `expectedSyncVersion`** on optimistic-concurrency surfaces (grids, inline editors). Silent overwrites are how real data gets clobbered. ## Writing a prompt from scratch [Section titled “Writing a prompt from scratch”](#writing-a-prompt-from-scratch) If you are scaffolding a new template or a one-off custom app (instance-specific, an internal tool), the minimum viable prompt is roughly: ```text Build a Vite + React + TypeScript (static SPA) + shadcn/ui on top of Turbobase. [Standard "do not ask" opener — see Decisions are locked.] Knobs: TURBOBASE_API_URL = ... SUPABASE_URL = ... APP_URL = http://localhost:3000 CATALOG_TOKEN = ... TENANT_ANON_KEY = ... APP_DIR = ./ ACCENT = Step 0 — Read the shared playbook FIRST: https://docs.turbobase.io/build-apps/skills/ — this page https://docs.turbobase.io/build-apps/design-system/ https://docs.turbobase.io/build-apps/auth/ https://docs.turbobase.io/build-apps/ Step 1 — Discover the live catalog for: . Goal: . Write path: POST {TURBOBASE_API_URL}/v1/data/ per the Write path section of the skills doc. [Realtime needs, if the app is "live".] Decisions already made — do NOT ask:
Deliverables: per the Smoke-test ritual section of the skills doc. Non-goals: . ``` The agent fetches this page along with the design-system and auth docs, applies the standard sections it describes, and only needs your app-specific intent on top. The whole prompt fits in roughly 80–120 lines instead of 200–400. ## Adding to the standard skeleton [Section titled “Adding to the standard skeleton”](#adding-to-the-standard-skeleton) When you discover a pattern that’s repeated across templates and isn’t yet here, add it. The rule is the same as for [the design-system doc](../design-system/): one canonical place, and every template inherits the change without prompt edits. Before adding, check whether the pattern already lives in [build-apps](../), [auth wiring](../auth/), or [design system](../design-system/). If yes, link to it from here. If no, add the section here and link to it from the per-template prompts. # Changelog > What changed in Turbobase, newest first — one entry per release, written for the people using it. What changed in Turbobase, newest first. Every release that changes something you can see gets an entry here on the day it ships. Two neighbouring pages answer different questions, and it is worth knowing which you want: * **[Releases and adopt notes](/releases/)** — for people *building apps* on Turbobase: the one-line-per-change list an existing app project has to act on, and what `turbobase project adopt` writes into your `ADOPT.md`. * **This page** — for people *using* Turbobase: what is new in the product. []() ## v1.50.0 — 3 November 2026 [Section titled “v1.50.0 — 3 November 2026”](#v1500--3-november-2026) The “Open in Turbobase” button now appears when you create a new NetSuite record. You can access new records in Turbobase immediately after creation. []() ## v1.49.0 — 2 November 2026 [Section titled “v1.49.0 — 2 November 2026”](#v1490--2-november-2026) This release lets you resume process runs that stop waiting for source operations, and improves sync data consistency. * **Resume paused process runs** — process runs that stopped while waiting for source operations to complete can now be resumed to finish execution. * **References survive partial sync updates** — when a source sends an update that omits a reference field, the previously synced reference is now preserved instead of being cleared. []() ## v1.48.0 — 2 November 2026 [Section titled “v1.48.0 — 2 November 2026”](#v1480--2-november-2026) This release improves process execution and makes NetSuite line item syncing more consistent. * **Long-running processes no longer time out** — process sessions refresh automatically throughout execution, so multi-step workflows stay active to completion. * **Records created in processes are kept** — when you create a record with a reference that’s still syncing, the record is created and the link resolves later instead of being lost. * **Address updates in processes complete correctly** — updating addresses in a process now awaits properly instead of potentially continuing before the update finishes. * **NetSuite fulfillment and receipt lines sync consistently** — lines now canonicalize the same way regardless of which sync path retrieves them, so your mirror always shows the correct data. * **Session authentication is more stable** — sessions refresh before being marked stale, preventing spurious authentication failures. []() ## v1.47.0 — 1 November 2026 [Section titled “v1.47.0 — 1 November 2026”](#v1470--1-november-2026) This release fixes line item ordering for NetSuite syncs and improves sync freshness reporting. * **NetSuite line items sync in the correct order** — order lines, invoice lines, and other line items now preserve their original sequence. * **Sync freshness reporting is more accurate** — unchanged records are no longer marked as late arrivals, giving you more reliable sync metrics. []() ## v1.46.0 — 1 November 2026 [Section titled “v1.46.0 — 1 November 2026”](#v1460--1-november-2026) This release adds WhatsApp Business and expands Slack and Salesforce capabilities, improves conduit automation, and shows you when synced fields arrive late. * **Connect WhatsApp Business accounts** — WhatsApp Business conversations now sync bidirectionally; you can send and receive replies through the API. * **Share files through Slack** — agents can now upload files (screenshots, PDFs, CSVs) directly into Slack conversations as part of automated workflows. * **File FeedHive posts under labels** — posts can now be filed under workspace labels when created or updated. * **Sync Salesforce custom fields and objects** — custom fields and custom objects in your Salesforce org now sync the same way standard fields and objects do. * **Automate more with conduits** — conduits can now fire when records are saved on the instance, classify incoming data as they arrive, delete records, and target processes for multi-record workflows. * **See when fields arrive late** — sync freshness data now shows which fields arrived late from your connected systems, why they were delayed (realtime sync missed, rollup unrefreshed, or stale), and realtime drift on recently synced records. []() ## v1.45.0 — 28 October 2026 [Section titled “v1.45.0 — 28 October 2026”](#v1450--28-october-2026) This release improves sign-in reliability for users with extensive roles and prevents sync conflicts when local edits and incoming changes occur simultaneously. * **Sign in with many roles** — users with extensive role and capability assignments now sign in successfully with improved session handling. * **Concurrent edits and syncs no longer conflict** — when you edit a record and the connected system sends an update simultaneously, both changes complete successfully. []() ## v1.44.3 — 26 October 2026 [Section titled “v1.44.3 — 26 October 2026”](#v1443--26-october-2026) This release makes sync more reliable with multi-subsidiary setups and accounts missing certain record types. * **Records referencing unsynced subsidiaries now sync** — records that reference a subsidiary you haven’t connected now sync successfully. * **Sync completes when record types aren’t available** — if your NetSuite account doesn’t have certain record types, sync now skips them instead of failing. []() ## v1.44.1 — 26 October 2026 [Section titled “v1.44.1 — 26 October 2026”](#v1441--26-october-2026) NetSuite accounts without OneWorld can now connect. * **Non-OneWorld accounts connect successfully** — the NetSuite bundle now installs on accounts without multi-subsidiary (OneWorld) enabled. []() ## v1.44.0 — 25 October 2026 [Section titled “v1.44.0 — 25 October 2026”](#v1440--25-october-2026) The Turbobase widget in NetSuite now shows what each app does and lets you build new ones without leaving NetSuite. * **App icons and descriptions in the widget** — your available apps now display with their icons and full descriptions, so you can see at a glance what each app does. * **Build an app from NetSuite** — the widget includes a “Build the App” button, so you can start creating without navigating away from NetSuite. []() ## v1.43.0 — 21 October 2026 [Section titled “v1.43.0 — 21 October 2026”](#v1430--21-october-2026) External AI clients can now audit record changes, and NetSuite access is clearer. * **External AI clients can audit record changes** — Claude, Cursor, and other MCP clients can now query the audit trail to see what changed, when, and who triggered it. * **Debug your writes** — the system now records what each write requested and what the connected system confirmed, so you can diagnose why writes failed or synced partially. * **NetSuite app access is clearer** — a Turbobase icon opens your app list, error messages explain why apps failed to load, and the dashboard widget is now labeled “Turbobase”. * **Passkey sign-in preserves your location** — signing in with a passkey keeps you on the page you were viewing instead of sending you to the dashboard. []() ## v1.42.0 — 18 October 2026 [Section titled “v1.42.0 — 18 October 2026”](#v1420--18-october-2026) This release improves NetSuite custom record sync and gives administrators more control over catching records that realtime sync missed. * **Custom record auto-numbered names sync immediately** — auto-numbered fields on custom records now appear in your mirror right away. * **See your available apps in NetSuite** — the Turbobase Access widget now displays your accessible Turbobase apps. * **Clearer catch-up indicator** — the app now clearly shows when it’s catching up on records that realtime sync missed, rather than displaying a detailed list. * **Administrators control catch-up** — you can now manage which records are checked for missed syncs through the API, MCP, or CLI. []() ## v1.41.0 — 18 October 2026 [Section titled “v1.41.0 — 18 October 2026”](#v1410--18-october-2026) This release secures NetSuite with certificates, shows what realtime sync missed, enables process deletion, and consolidates identities across your systems. * **View records that realtime sync missed** — the app now shows records that synced after the realtime event fired, so you can catch backfilled changes realtime didn’t reach. * **NetSuite certificate authentication** — your connections now authenticate with a certificate that never expires, eliminating time-limited token maintenance. * **Delete records in a process** — a process step can now delete records, automating cleanup of obsolete or failed records. * **Know why a delete was refused** — when a delete is blocked, you see the specific reason before the request reaches the source system. * **Support case stage and assignee are readable** — case stage, priority, and assigned user now display as text instead of IDs. * **Consolidate people across systems** — if a person is a lead in one system and a contact in another, they’re now matched as one identity. []() ## v1.40.1 — 15 October 2026 [Section titled “v1.40.1 — 15 October 2026”](#v1401--15-october-2026) This release fixes record creation when an identity was previously deleted. * **Recreate records with the same identity** — when you delete a record and create a new one with the same identity, the create now succeeds instead of being blocked. []() ## v1.40.0 — 13 October 2026 [Section titled “v1.40.0 — 13 October 2026”](#v1400--13-october-2026) Processes are now fully automatable and reversible: invoke them from the CLI or scripts, roll back completed runs, and resolve merge conflicts when records exist in multiple systems. * **Roll back a process run** — you can roll back a completed process, deleting what it created in reverse order and restoring your system. * **Run processes from the CLI** — `turbobase processes run` invokes a process, and personal access tokens now authorize external scripts to trigger processes. * **Settle records in merge conflicts** — `turbobase data settle` links a Turbobase record to one your source system already created, resolving the conflict. * **Process history shows run details** — process runs now display the mode and source for each run, so you can see whether it was triggered manually, on a schedule, or automatically. * **Process execution is more reliable** — scheduled processes now validate and execute correctly, idempotent creates work on retries, and revisiting the same row re-runs correctly. []() ## v1.39.1 — 12 October 2026 [Section titled “v1.39.1 — 12 October 2026”](#v1391--12-october-2026) This release lets you delete more NetSuite record types, shows where Azure files are stored, and lets apps invoke your AI model. * **Delete NetSuite employees, subsidiaries, and GL accounts** — you can now delete these record types from Turbobase and sync the deletions back to NetSuite. * **Apps invoke the AI model** — a hosted app can now call your instance’s model and read the result. * **Azure files show their location** — when you write a file to Azure Blob Storage, the row displays where the file was stored. * **Processes page works reliably** — the processes list is now accessible instead of crashing. []() ## v1.39.0 — 12 October 2026 [Section titled “v1.39.0 — 12 October 2026”](#v1390--12-october-2026) This release expands process automation, adds GetSales custom field sync, and auto-enables new record types across your connected accounts. * **Process steps work on any writable record type** — a process can now create records on any type your connected systems support for writing. * **GetSales custom fields sync and write back** — custom fields on GetSales contacts now mirror to your database and sync changes back to GetSales. * **New record types auto-enable on all accounts** — when a new record type ships in a release, it automatically activates on every connected account unless you’ve customized the list. * **Write failures explain what went wrong** — when a source refuses a write without specifying a field, you now see a clear message that the write needs your attention. * **Processes moved to Development** — the Processes section is now under Development in the admin console. []() ## v1.37.0 — 9 October 2026 [Section titled “v1.37.0 — 9 October 2026”](#v1370--9-october-2026) REST API tokens now support separate read and write permissions, so you can grant external programs specific access without sharing full instance credentials. * **`rest:read` and `rest:write` permissions** — tokens can request separate read-only or read-write access to the REST API. A read token fetches data and downloads attachments. A write token also creates and edits records. * **Realtime hold releases are now accurate** — holds no longer get stuck and release at the correct time. []() ## v1.36.0 — 8 October 2026 [Section titled “v1.36.0 — 8 October 2026”](#v1360--8-october-2026) Your external print agents can now register printers directly, completing automated printing workflows without manual printer setup. * **Print agents register standalone printers** — an agent connected to your instance can now register and manage printers, adding them to the print job queue for fully automated printing. []() ## v1.35.0 — 8 October 2026 [Section titled “v1.35.0 — 8 October 2026”](#v1350--8-october-2026) External agents and processes can now claim and process print jobs from your instance, so you can automate printing workflows without manual steps. * **Print jobs work as a queue** — external agents and print adapters can claim jobs from the print\_job queue and mark them as processed, enabling end-to-end print automation. []() ## v1.34.2 — 8 October 2026 [Section titled “v1.34.2 — 8 October 2026”](#v1342--8-october-2026) Credentials entered through the console are now available to the worker when processing data syncs and writes. * **Console-written credentials reach the worker** — when you connect a source or update credentials through the admin console, those credentials are now properly available when the worker processes syncs and writes to the connected system. []() ## v1.34.1 — 7 October 2026 [Section titled “v1.34.1 — 7 October 2026”](#v1341--7-october-2026) Azure Blob Storage connections now store credentials correctly and work reliably. * **Azure credentials are properly saved** — when you connect an Azure Blob Storage account, the credentials are now stored in the tenant vault so file sync and uploads succeed. []() ## v1.34.0 — 7 October 2026 [Section titled “v1.34.0 — 7 October 2026”](#v1340--7-october-2026) This release adds PrintNode and Azure Blob Storage as new data sources, improves access controls for role-based security, and fixes stock accuracy on NetSuite accounts with bin tracking. * **Manage PrintNode printers and jobs** — sync your printer fleet and print jobs from PrintNode; apps can view printers and update job status. * **Read and upload to Azure Blob Storage** — connect an Azure storage container to sync file contents into your database and upload files back to storage. * **Control chat access by role** — administrators can now specify which roles are allowed to post in the chat interface. * **Role capability removals now persist** — removing a capability from a role stays removed after a deployment, no longer reverting to its previous state. * **NetSuite stock quantities are accurate per location** — stock now reads correctly on accounts with bin-level inventory tracking. * **Jira issues display project details** — project keys and references now project and display correctly on synced Jira issues. []() ## v1.33.0 — 6 October 2026 [Section titled “v1.33.0 — 6 October 2026”](#v1330--6-october-2026) This release makes inventory handling more reliable, improves live stock accuracy, and adds webhook delivery auditing. * **Stock receipts and fulfillments work correctly with bin-level tracking** — creating item receipts and fulfillments with bin-only inventory assignments now populate inventory detail reliably and no longer fail. * **Live stock shows accurate location and lot information** — locations, bins and lot codes now resolve correctly when reading real-time stock from NetSuite. * **Audit inbound webhook deliveries** — every webhook delivery to your instance is now recorded and accessible via MCP, so administrators can verify webhooks are arriving and diagnose delivery issues. * **`updated_at` reflects the actual last change time** — this field now shows when the record was last modified in the source, so historical data stays accurate as syncs occur. []() ## v1.32.0 — 5 October 2026 [Section titled “v1.32.0 — 5 October 2026”](#v1320--5-october-2026) This release improves data sync reliability across multiple systems, adds session management controls, and shows write status in real-time. * **Session duration is now configurable** — administrators can set how long team members stay signed in. * **Outbound writes show pending status** — records display “pending” while syncing to connected systems so you can see operations in progress. * **Jira data syncs without errors** — projects and boards now sync reliably. * **NetSuite custom fields sync immediately** — custom fields modified through NetSuite’s logic now reach your mirror right away, not hours later. * **Item receipt creation retries work reliably** — retrying a failed creation no longer throws database errors. []() ## v1.31.0 — 2 October 2026 [Section titled “v1.31.0 — 2 October 2026”](#v1310--2-october-2026) This release extends multi-system sync, reads stock in real-time from NetSuite, improves Jira webhook reliability, and makes page loads faster on busy instances. * **Create records across multiple systems at once** — an app can name several connected systems when creating a record, and that record is created in each. Edits to the canonical record now sync to every connected copy. * **Read live stock from NetSuite** — apps now fetch current stock levels from NetSuite at request time instead of reading a cached copy, so inventory changes are always fresh. * **GetSales contacts are now fully writable** — contacts can be labeled, companies can be created, and tags can be created by naming them. * **Jira webhooks renew automatically** — webhook subscriptions are renewed on each sync, and comments, work logs and links re-sync reliably. * **Records created in Turbobase join the identity graph** — when you create a record in Turbobase itself, it now enters the Atlas matching pool and can be linked to copies in connected systems. * **App pages load faster on busy instances** — sync event histories no longer block page rendering, and the database connection pool increased to handle more concurrent requests. * **Apps can disable the change-request widget** — a manifest setting lets you turn off the “suggest a change” launcher on your app. []() ## v1.30.1 — 30 September 2026 [Section titled “v1.30.1 — 30 September 2026”](#v1301--30-september-2026) This release fixes NetSuite support case synchronization. * **Support cases sync reliably** — support cases and their messages now sync bidirectionally between your instance and NetSuite. []() ## v1.30.0 — 30 September 2026 [Section titled “v1.30.0 — 30 September 2026”](#v1300--30-september-2026) This release adds file attachment capabilities to proposals and change suggestions, and extends MCP session duration. * **Attach evidence when suggesting changes** — you can now drag, drop, or paste screenshots and files directly into the suggest-change widget. * **Add files to proposals** — proposals now accept file attachments so you can include documentation and evidence while drafting. * **Extended AI client sessions** — external AI clients connected to your instance now stay authenticated for thirty days. []() ## v1.29.0 — 29 September 2026 [Section titled “v1.29.0 — 29 September 2026”](#v1290--29-september-2026) Administrators can now work through their change proposals from an external AI client without opening the Development tab. * **Manage proposals over MCP** — an administrator connected to your instance through Claude, Cursor, or another MCP client can read, comment on, revise, rename, sign off on, and delete proposals—the same actions as on the Proposals page, but from their client. []() ## v1.28.1 — 29 September 2026 [Section titled “v1.28.1 — 29 September 2026”](#v1281--29-september-2026) This release fixes NetSuite cash refund permissions for the built-in Standard role. * **Cash refunds work with the Standard role** — team members with the built-in Standard role can now create and edit cash refunds in NetSuite. []() ## v1.28.0 — 29 September 2026 [Section titled “v1.28.0 — 29 September 2026”](#v1280--29-september-2026) This release opens thirteen new write paths across your connected systems, adds NetSuite support cases and your team’s identity-matching decisions to the database, and redesigns the tenant app to match Turbobase’s design system. * **Answer NetSuite support cases** — support cases and their message threads now sync bidirectionally; apps can create replies and change case status. * **Write to Xero** — contacts, invoices, bills, credit notes, purchase orders, payments, items, journals, bank transactions, and three more record types are now editable from Turbobase. * **Pause or remove a data connection** — disconnect a source and purge its data from your database, or pause it temporarily while keeping the data. * **Create and edit GetSales.io contacts** — contacts and their activity feed now sync; apps can create new contacts in the lists you name. * **NetSuite cash refunds and employees** — cash refunds are now writable, and your employee records can be created and edited from Turbobase (NetSuite only). * **Call web services from apps** — an app can make HTTPS requests to external APIs on your behalf, with authentication and the request record logged. * **Save your identity match decisions** — when you correct a false merge or approve a match in the Atlas graph, your decision is remembered and survives the next sync. []() ## v1.27.1 — 25 September 2026 [Section titled “v1.27.1 — 25 September 2026”](#v1271--25-september-2026) GitHub record edits now sync both changes when different fields are edited concurrently in Turbobase and GitHub, instead of one version overwriting the other. * **Concurrent edits to different fields both sync** — when you edit one field of a GitHub record in Turbobase and someone else edits a different field in GitHub simultaneously, both changes now succeed. []() ## v1.27.0 — 25 September 2026 [Section titled “v1.27.0 — 25 September 2026”](#v1270--25-september-2026) Zoho CRM’s write capabilities now reach custom fields and custom modules, so you can sync changes to any field and record type you’ve defined in Zoho. * **Edit Zoho CRM custom fields** — custom fields in your Zoho org now sync changes back from Turbobase to Zoho. * **Write to Zoho CRM custom modules** — custom record modules you’ve defined in Zoho are now available for two-way sync. []() ## v1.26.0 — 24 September 2026 [Section titled “v1.26.0 — 24 September 2026”](#v1260--24-september-2026) This release adds an audit log for record changes and expands write-back to stock adjustments, Zoho CRM, and GetSales.io. * **See who edited each record** — the audit log shows every change, who made it, and when. * **Edit NetSuite stock adjustments** — stock adjustments can now be created and edited from Turbobase and synced back to NetSuite. * **Write to Zoho CRM Calls, Events, Tasks, Products and Quotes** — these five record types are now editable. * **Edit GetSales.io contacts and companies** — GetSales.io contacts, companies, and tags are now writable. []() ## v1.25.0 — 23 September 2026 [Section titled “v1.25.0 — 23 September 2026”](#v1250--23-september-2026) GitHub labels are now editable, extensions can be toggled on and off, and native mobile apps can sign in through your instance’s OAuth server. * **Edit GitHub labels** — you can now change a label’s name and color directly from Turbobase. * **Control extensions** — enable, disable, or remove extensions from the admin console or CLI. * **Mobile app sign-in** — native iOS and Android applications now authenticate through your Turbobase instance’s OAuth server. * **Retried writes complete successfully** — when a write to a connected system fails and is retried, it now succeeds instead of failing hard. * **Custom table permissions repair automatically** — if a permission is accidentally lost, Turbobase detects and restores it right away. []() ## v1.23.0 — 21 September 2026 [Section titled “v1.23.0 — 21 September 2026”](#v1230--21-september-2026) Members can personalize their profile, GitHub files are now available for sync, and write failures give clearer diagnostics when something goes wrong. * **Set your profile picture** — each team member can upload and set their own profile picture. * **GitHub repository files are now synced** — files from your connected GitHub repositories now mirror to your database, ready to search and act on. * **Write errors are now descriptive** — when a write to a connected system fails, the error message explains specifically what went wrong. * **Zoho CRM edits include company info** — when you edit a Zoho CRM contact’s company, that change now syncs back to Zoho correctly. []() ## v1.22.0 — 18 September 2026 [Section titled “v1.22.0 — 18 September 2026”](#v1220--18-september-2026) FeedHive is now a supported data source, and apps and processes are more reliable and controllable. * **Write to FeedHive** — sync records bidirectionally with FeedHive. * **Control your processes** — processes now support multiple execution modes, visible queues, and the ability to cancel work. * **Sync NetSuite inventory features** — tracked items and warehouses now mirror to your database. * **Identify failed writes** — error messages now clearly name which request was blocked. * **Deploy apps concurrently** — push multiple app versions at once without version conflicts. []() ## v1.20.0 — 16 September 2026 [Section titled “v1.20.0 — 16 September 2026”](#v1200--16-september-2026) Scripts and AI agents can now change data through your instance’s data connection, and the records management page is clearer when more than one system is connected. * **Let a script or agent write, not only read** — a separate permission on an access token reaches the write tools, and an instance setting decides whether it applies at all. Both have to be on, and a refusal tells you which one to fix. * **See what each record maps to** — the records management page groups every record by what it maps to, so which record you are looking at reads at a glance. * **Steadier GitHub and Zoho CRM writes** — pull request edits and Zoho account writes were reviewed end to end, including rows edited before they finish syncing. []() ## v1.19.0 — 15 September 2026 [Section titled “v1.19.0 — 15 September 2026”](#v1190--15-september-2026) This release opens more of your connected systems to writing, and brings CRM accounts onto a single surface. * **Reply to GitHub issues and pull requests** — an app can post a comment on an issue, pull request or discussion thread. * **Edit Salesforce cases and QuickBooks bills** — case edits, and bill, payment and item edits, now reach the connected system instead of stopping at your mirror. * **Save leads** — a Zoho CRM or Salesforce lead can be created and edited from an app, once an administrator turns on bidirectional sync for that system. * **Manage access tokens yourself** — list, create, rotate and delete your instance’s access tokens from Admin → Access tokens. * **GitHub issue history is searchable** — status changes, assignments, renames and label edits are mirrored as activity you can query. * **CRM accounts read as customers** — Zoho CRM and Salesforce accounts now appear as customers rather than companies. Any screen filtering those connections for companies needs updating. []() ## v1.18.1 — 13 September 2026 [Section titled “v1.18.1 — 13 September 2026”](#v1181--13-september-2026) This release fixes a startup issue that prevented your tenant API from initializing. * **Startup is now reliable** — the service boots successfully. []() ## v1.14.2 — 9 September 2026 [Section titled “v1.14.2 — 9 September 2026”](#v1142--9-september-2026) This release fixes three reliability issues affecting meeting sync, error diagnostics, and server startup. * **Meeting transcripts sync reliably** — meeting summaries and transcripts now sync consistently instead of occasionally missing the transcript. * **Failed requests are clearly identified** — when a write fails, your error records now show which specific request was blocked so you can quickly diagnose what went wrong. * **Startup handles transient network issues** — temporary DNS problems during initialization no longer cause the tenant API to abort; it will retry and complete startup. []() ## v1.14.1 — 8 September 2026 [Section titled “v1.14.1 — 8 September 2026”](#v1141--8-september-2026) This release fixes three bugs affecting sheet navigation, data sync control, and connecting new sources. * **Sheet content is now accessible** — tall forms and lists on sheet pages scroll into view instead of remaining unreachable. * **Data sync toggle works correctly** — a console checkbox that was inverted and turning off your sync is now fixed. * **GitHub and Zoho CRM connect successfully** — first-time connections for these sources now route to the correct setup page. []() ## v1.8.0 — 1 September 2026 [Section titled “v1.8.0 — 1 September 2026”](#v180--1-september-2026) Historical currency rates are now mirrored alongside your records, so a figure converted last quarter stays converted at last quarter’s rate. * **As-of currency conversion** — every exchange rate is kept with the date it was in force, one row per currency pair per day. A total computed from a dated rate no longer changes its own history the next time the rate moves, and a consolidated total across currencies is now something you can ask for. * **Blocked writes say why they were blocked** — when one of your own guardrail policies stops a write back to NetSuite, the record now reaches a final state carrying the reason instead of sitting on “pending” indefinitely. A screen waiting on that write stops waiting. * **Status fields say whose status they are** — a status value on a record now carries a note about which party asserted it and as of when, so a supplier’s “shipped” is not read as your own confirmation. # CLI overview > Get and authenticate the turbobase CLI — build hosted apps, author extensions, and drive your instance from the command line. Shipped The `turbobase` CLI is the command-line interface to a live instance. One binary builds and ships the [hosted apps](/build-apps/) your team uses, authors and deploys [extensions](/extending/writing-hooks/) — hooks, conduits and processes — promotes a reviewed configuration between environments, and generates the files an AI coding agent reads before it starts working. Every subcommand wraps the same library functions the authoring MCP server exposes, so the command line and the Claude Code surface never drift. > The CLI talks to a **running Turbobase instance** over its HTTP API with a personal access token. It is not a way to stand up Turbobase yourself — your instance is provisioned and operated as a managed service. ## Install [Section titled “Install”](#install) One command, on macOS and Linux: ```sh curl -fsSL https://console.turbobase.io/install-cli.sh | bash ``` …or on Windows: ```powershell powershell -c "irm https://console.turbobase.io/install-cli.ps1 | iex" ``` The installer downloads the current build, **verifies its checksum**, and puts `turbobase` on your PATH. Everything lands in `~/.turbobase` — no sudo, no package manager, nothing written outside your home directory. Re-run the same command any time to upgrade; it swaps the version in place. Node.js **22, or 24 and newer**, is the only requirement, and if your machine doesn’t have one the installer fetches a private copy into `~/.turbobase/runtime` rather than touching your system Node. ```sh turbobase --version ``` You can also find both commands in your instance under **Settings → Integrations & AI access**, alongside a `turbobase auth login` line with your instance URL already filled in. Other ways to install **npm**, if you’d rather manage it with your other global tools: ```sh npm install -g @turbobase/cli ``` ### Optional extras [Section titled “Optional extras”](#optional-extras) `turbobase ext test` and `turbobase ext replay` compile your extension and execute it in a real sandbox, which needs two native packages. They aren’t part of the base download — install them once, when you first need them: ```sh turbobase ext extras install turbobase ext extras status ``` Every other command works without them. Every command is discoverable from the binary itself. The top level is fifteen entries — thirteen nouns you drill into, plus two commands that stand on their own — so start there and open the one you want: ```sh turbobase --help # all fifteen turbobase ext --help # author, verify, ship, observe extensions turbobase ext new --help # hook | conduit | process ``` Each row links to that part of the [CLI reference](/cli/reference/), where every verb has its flags and an example. | Group | What lives there | | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | [`auth`](/cli/reference/#auth--sign-in-and-pick-which-instance-you-are-talking-to) | Sign in, sign out, and pick the active instance. | | [`project`](/cli/reference/#project--the-local-project-and-the-files-an-agent-session-reads) | Scaffold the project; generate the files an agent session reads. | | [`ext`](/cli/reference/#ext--author-verify-ship-and-observe-extensions) | Author, verify, ship, and observe extensions — plus the marketplace. | | [`apps`](/cli/reference/#apps--hosted-apps-served-from-your-instance) | Hosted apps served at `/a/` — scaffold, push, deploy, preview, promote. | | [`kb`](/cli/reference/#kb--write-findings-into-the-knowledge-base) | Write findings into the instance’s knowledge base. | | [`skills`](/cli/reference/#skills--write-standing-rules-into-the-instances-agents) | Write the standing rules your instance’s agents follow — push, publish, roll back. | | [`tables`](/cli/reference/#tables--custom-tables-of-your-own) | Custom tables of your own, for the data no connected system holds. | | [`guardrails`](/cli/reference/#guardrails--decide-which-write-backs-are-allowed-to-leave) | Decide which write-backs may leave your instance, and dry-run one before you do. | | [`relationships`](/cli/reference/#relationships--which-linked-record-pairs-re-read-each-other) | Which linked record pairs re-read each other when one side is saved, and switching either re-read on. | | [`data`](/cli/reference/#data--act-on-one-record) | Act on one record — today, link a row to the record your source already created. | | [`processes`](/cli/reference/#processes--what-runs-in-the-background) | What runs in the background: what is deployed, what it did, and starting or undoing a run. | | [`release`](/cli/reference/#release--ship-your-project-from-your-own-ci) | Ship your project from your own CI — deploy, status, rollback, list. | | [`sandbox`](/cli/reference/#sandbox--ephemeral-local-instances) | Ephemeral local instances to replay against. | | [`logs`](/cli/reference/#logs--every-log-under-one-noun) | Every log under one noun — guardrails, hooks, scheduled tasks, conduits and sync. | | [`mcp`](/cli/reference/#mcp--the-local-authoring-server) | A local stdio authoring server, for clients you point at a process rather than a URL. | ## Authenticate — `turbobase auth login` [Section titled “Authenticate — turbobase auth login”](#authenticate--turbobase-auth-login) The CLI authenticates as an **instance admin** and stores a personal access token (PAT, `tb_pat_…`). You point it at your instance’s base URL; one operator laptop can hold credentials for many instances at once. Mint a PAT in your Turbobase app on the **Access tokens** page — your account menu, or `/settings/tokens` — then hand it to the CLI. A token belongs to a Turbobase account, so that page is not offered to administrators who opened the app from inside NetSuite. This is the only sign-in path — there is no browser flow, so the same command works identically on a laptop, a headless box, and a CI runner: ```sh turbobase auth login --base-url https://acme.turbobase.app --token tb_pat_xxxxxxxx # signed in to https://acme.turbobase.app — now the active instance. ``` Useful flags: | Flag | Meaning | | -------------------- | --------------------------------------------------------------- | | `--base-url ` | **Required.** Your instance, e.g. `https://acme.turbobase.app`. | | `--token ` | **Required.** The PAT to store. | | `--no-verify` | Skip the post-sign-in connectivity/token probe. | After storing the token, `login` probes the instance: it checks that the base URL actually serves the `/v1` API and that the token authenticates, and prints a loud warning on a wrong host or a rejected token (stale, revoked, or for a different instance). The credential is stored either way; `--no-verify` skips the probe. In CI, read the token from a secret rather than a literal — `--token "$TURBOBASE_PAT"`. ## Where credentials live — `~/.turbobase/credentials` [Section titled “Where credentials live — \~/.turbobase/credentials”](#where-credentials-live--turbobasecredentials) `login` writes the PAT to `~/.turbobase/credentials`, a JSON file with an **active-instance pointer** plus a `tenants` map **keyed by base URL**: ```json { "active": "https://acme.turbobase.app", "tenants": { "https://acme.turbobase.app": { "token": "tb_pat_…", "updatedAt": "…" }, "https://beta.turbobase.app": { "token": "tb_pat_…", "updatedAt": "…" } } } ``` The PAT is the only secret in the file; it is written `0600` (owner-only) so a shared host doesn’t leak it. Every other command resolves the token from here: * With one stored instance, commands use it automatically. * With several, the last one you signed in to (or a `turbobase auth use` target) is the `active` default; pass `--base-url ` to reach a different one. If there’s no active default, an interactive terminal lets you arrow-pick one (and `turbobase auth use` with no argument always does); in CI the command dies with a clear “multiple instances stored” message instead. Override the directory for tests with `TURBOBASE_HOME`. ## Scaffold an extension project — `turbobase project init` [Section titled “Scaffold an extension project — turbobase project init”](#scaffold-an-extension-project--turbobase-project-init) `turbobase project init` scaffolds a Claude-Code extension project in the current directory — a `CLAUDE.md` authoring guide, a `.mcp.json` that points Claude Code at your instance’s MCP servers, a `.claude/` directory (skills for generate-types / record-learning / mock-test / deploy, plus a SessionStart hook that refreshes `types.ts` and the `settings.json` that wires it), `INSTANCE-LEARNINGS.md` and `TURBOBASE-FEEDBACK.md`, and empty `hooks/` and `samples/` directories. It never overwrites a file that already exists (each is reported `wrote` or `skipped`) — with one exception: an existing `.gitignore` is **merged**, not skipped, so the rule ignoring `.mcp.json` always lands. It runs signed out, too: with no stored credential it writes placeholders in `.mcp.json` and says so. ```sh turbobase project init # wrote CLAUDE.md # wrote .mcp.json # wrote .claude/settings.json # ... # wrote hooks/.gitkeep # wrote samples/.gitkeep # # init: N written, 0 skipped. ``` ## Author with Claude Code — `.mcp.json` and `turbobase mcp` [Section titled “Author with Claude Code — .mcp.json and turbobase mcp”](#author-with-claude-code--mcpjson-and-turbobase-mcp) `init` drops a `.mcp.json` so Claude Code can drive the same authoring operations the CLI exposes. It wires **two servers hosted on your instance**, over HTTP: | Server | URL | Auth | What it gives the agent | | --------------------- | -------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `turbobase-authoring` | `/mcp/authoring` | The PAT from your sign-in, written into the file | Scaffold a hook or conduit, generate types, dry-run against fixtures, deploy, tail logs, and the hosted-app verbs (list / create / pull / push). | | `turbobase` | `/mcp` | OAuth — your client opens a sign-in page on first use; no token is written | The data surface: catalog, record anatomy, knowledge base, and RLS-scoped reads. | Because the authoring tools and the CLI subcommands wrap the **same library functions**, what you can do conversationally in Claude Code is what you can do on the command line. Two deliberate exceptions: the MCP scaffold tools return the file’s path and contents rather than writing to disk, and there is no `apps_deploy` tool — deploying builds locally and uploads the result, which needs filesystem access the hosted server doesn’t have. Use `turbobase apps deploy` for that. > **`.mcp.json` holds a live access token.** The scaffold writes it `0600` and adds it to `.gitignore`. Keep it out of version control, and rotate the token if the file was ever pushed. `turbobase mcp` is a **separate** local stdio server, for clients you would rather point at a process than a URL: ```sh turbobase mcp --base-url https://acme.turbobase.app ``` ## Put a hosted app live — `turbobase apps deploy` [Section titled “Put a hosted app live — turbobase apps deploy”](#put-a-hosted-app-live--turbobase-apps-deploy) One command. It builds the directory, cuts a new version at `/a/`, and records that directory as the app’s newest **source revision** on your instance: ```sh turbobase apps deploy reorder-desk ./reorder-desk ``` Recording the source is not bookkeeping. It is what lets you pull the app onto another machine, and it is what lets a change request someone files from inside the running app be applied to it — a built bundle cannot be turned back into a project, so an app whose source only ever existed on one laptop cannot be changed that way at all. App Management states it per app, and the in-app widget tells the person who filed the request. The version is cut first and the source recorded after it, so a collection that fails costs you the revision and never the deploy. Read the line it prints: `recorded N source files on the instance (rev R)`, or one beginning `source not recorded:` that names the fix. Credential files are never uploaded — `.env`, `.env.*` and `.mcp.json`, which holds a live token — and are listed on stderr so a withheld file does not look like a missing one. Two flags are worth knowing: * `--no-source` cuts the version and uploads nothing from the working tree. Reach for it on a CI loop that re-deploys on every commit. * `turbobase apps push` is the other half: it records source and deploys nothing, for a tree you want stored before it is ready to go live. ## Watch a conduit — `turbobase logs` and `turbobase ext binding` [Section titled “Watch a conduit — turbobase logs and turbobase ext binding”](#watch-a-conduit--turbobase-logs-and-turbobase-ext-binding) A [conduit](/extending/writing-conduits/) produces two observable things at runtime: a **run ledger** (each attempt to carry a record across) and the **bindings** it created (the per-record link between the two sources). Two commands read them. `turbobase logs` is the one platform event stream, and two of its filters are about conduits: ```sh turbobase logs --conduit shop2ns:order --follow # one bridge's run ledger turbobase logs --subsystem conduits --since 2h # every bridge, recent history ``` `--conduit ` reads that one conduit’s run ledger — a line per attempt, with its state, the hop count, the id the target answered with (`to_ext=`) and the reason a failed run gave. `--subsystem conduits` stays on the ordinary event stream and narrows it to conduit events across every bridge, which is the one to reach for when you do not yet know which bridge is misbehaving. Both take `--follow` to poll, `--since <10m|2h|3d>` to bound the window, and `--json` for one row per line. `turbobase ext binding` lists and repairs the bindings themselves: ```sh turbobase ext binding --status divergent # every link out of step with its source turbobase ext binding --retry bnd_123 # re-enqueue that fan-out write ``` `--status` filters on the binding’s write status (`ok`, `pending`, `blocked`, `divergent`, `muted`); **divergent** is the one that wants attention, meaning the canonical record is valid but the target source refused the write. `--retry` re-enqueues a single fan-out and needs an admin PAT. Only divergent and blocked bindings can be retried — anything else returns 409. The same run ledger and counts are on the **Conduits** tab of `/data/extensions` in the app, with a 14-day runs-vs-failures chart over the top. ## Terminal output [Section titled “Terminal output”](#terminal-output) On an interactive terminal the CLI renders colour, a spinner for long-running calls, a live stage + percent bar for `--follow` jobs, aligned tables (`auth status`, `apps list`, `ext binding`), and boxed result cards for `ext deploy` / `ext promote`. Progress is treated as **chrome and written to stderr**; command *results* — ids, tables, JSON, `--reporter` artifacts, streamed job logs — stay on **stdout**, so `turbobase … | grep`, `> file`, and CI pipelines are never disturbed. Colour is disabled automatically when stdout isn’t a TTY, and can be forced off with `--no-color` (before the command) or the standard `NO_COLOR` environment variable; `--json` and `--reporter` output is always plain regardless. ## Next steps [Section titled “Next steps”](#next-steps) * [CLI reference](/cli/reference/) — every command, its flags, and an example. * [Build apps on Turbobase](/build-apps/) — the hosted-app workflow the `apps` group drives. * [Writing hooks](/extending/writing-hooks/) — the `defineHook` contract. * [Writing conduits](/extending/writing-conduits/) — cross-source bridges. * [Sandboxes and testing](/extending/sandboxes-and-testing/) — dry-run before you deploy. * [Deploy, plan, promote](/extending/deploy-plan-promote/) — config-as-code rollout. * [Marketplace](/extending/marketplace/) — install and publish extensions. # CLI reference > Full command reference for the turbobase CLI — every command, its flags, an example, and the response shape. Shipped Every `turbobase` command, its flags, an example invocation, and the shape of what it prints. The first column is the full command path; run any command with `--help` for the same surface from the binary itself. Commands are grouped under twelve nouns — `auth`, `project`, `ext`, `apps`, `guardrails`, `kb`, `skills`, `release`, `sandbox`, `tables`, `processes` and `data` — plus two standalone leaves, `logs` and `mcp`. Reach for `turbobase --help` when you know the area but not the verb. | Command | Description | Flags | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `auth login` | Authenticate as an instance admin and store a personal access token (PAT) in `~/.turbobase/credentials`. | `--base-url ` (required), `--token ` (required), `--no-verify` | | `auth status` | Print the stored instance(s) and which one is active (the default for every command). | *(none)* | | `auth use` | Switch the active instance when more than one credential is stored (omit the argument to arrow-pick from stored instances on a terminal). | `[base-url]` | | `auth logout` | Remove a stored instance credential (the active one, or `--base-url` to pick). | `--base-url `, `--all` | | `project init` | Scaffold a Claude-Code extension project in the current dir. | `--base-url ` | | `project types` | Generate `types.ts` from the signed-in instance’s catalog. | `--base-url `, `-o, --out `, `--records-only` | | `project grounding` | Write the instance’s grounding brief (source primer + record anatomy + KB retrieval) to a file for agent sessions. | `--base-url `, `--records `, `--query `, `-o, --out ` | | `project adopt` | Write the app-developer adopt notes — what changed in recent releases that this project should act on — to a file. | `--base-url `, `--since `, `-o, --out ` | | `project skills` | Rewrite the platform’s own skills in `.claude/skills` from this CLI, so what the platform learned since the project was created reaches its sessions. Never touches the project’s own skills, or a platform skill it edited. | `--dir ` | | `project samples` | Generate `samples/.json` fixtures from the live instance catalog. | `--base-url `, `--out `, `--event `, `--force` | | `project ci` | Write the reusable release-deploy GitHub Action workflow file (non-destructive; won’t clobber an existing file). | *(none)* | | `ext new hook` | Scaffold a hook file under `hooks/`. | `--record ` (required), `--event `, `--template `, `--field `, `--regex `, `--source `, `--id ` | | `ext new conduit` | Scaffold a conduit file under `conduits/` (cross-source bridge). | `--source ` (required), `--record ` (required), `--to ` (required), `--to-record `, `--event `, `--id ` | | `ext new process` | Scaffold a process file under `processes/` (an invoked multi-record sequence). | `--record ` (required), `--then `, `--on-failure ` (stop\|rollback), `--id ` | | `ext test` | Dry-run an extension against `samples/` fixtures (production sandbox parity). | ``, `--samples `, `--reporter ` (junit\|json), `--ci` | | `ext lint` | Statically lint a hook/conduit/process for semantic foot-guns (offline). | ``, `--ci`, `--reporter ` (junit\|json) | | `ext replay` | Dry-run a conduit through dispatch→transform→apply (offline against a fixture). | ``, `--fixture `, `--against `, `--trace`, `--reporter ` | | `ext deploy` | Deploy one extension (`POST /v1/extensions`) — it prints `new` for a new one and `replaced existing` when it replaced a copy already stored under the same id (the API’s `outcome` is `written` / `duplicate`), and your new code is live either way; the stored copy is keyed on the first `id: '…'` literal in the file, and a deploy whose first literal is not the module’s own `id` is refused with `extension_id_mismatch` and nothing stored — or this project’s WHOLE config with `--to` (`/v1/config/apply`). The desired state is the project tree — `hooks/`, `conduits/`, `turbobase/sync.json`, `turbobase/schedules.json` — falling back to a checked-in `turbobase/bundle.json` for a project that authors none. Adds and updates only: it does NOT remove what the project no longer declares, and prints what it declined to remove. Pass `--prune` to take those too. | `[file]`, `--base-url `, `--to `, `--prune` | | `ext plan` | Diff this project (`hooks/`, `conduits/`, `turbobase/`) against a target env — add, change AND destroy. Writes nothing. Run it on a pull request to see what merging would change. | `--from `, `--to `, `--base-url ` | | `ext promote` | Promote the bundle env→env behind a confirm-slug gate (fail-closed). | `--from ` (required), `--to ` (required), `--confirm ` (required), `--base-url `, `--prune` | | `ext export` | Pull the live instance into `turbobase/bundle.json`. This is the ADOPT path, not the authoring one: use it to bring an instance that predates repo-first config into a repo, then move its extensions into `hooks/`. | `--base-url `, `--out ` | | `ext binding` | List conduit source bindings (`--status divergent`) or re-enqueue one (`--retry `). | `--base-url `, `--status `, `--retry `, `--limit ` | | `ext proposals list` | List hook proposals and the evidence behind each (defaults to the pending queue). | `--base-url `, `--status `, `--provenance `, `--record-type `, `--source `, `--limit `, `--json` | | `ext proposals promote` | Arm hook proposals so the loader enforces them — by id, or by filter with `--auto`. | `[ids...]`, `--base-url `, `--auto`, `--apply`, `--provenance `, `--record-type `, `--source `, `--limit `, `--all`, `-y, --yes`, `--json` | | `ext proposals retract` | Send armed hooks back to proposed — inert immediately, source kept, re-promotable. | ``, `--base-url ` | | `ext proposals reject` | Decline proposals for good — the id becomes the denylist. Prefer `retract` if you may want it back. | ``, `--base-url `, `--reason ` | | `ext disable` | Take armed extensions out of service — inert on the next reload, definition kept. Works on any kind, including a deployed conduit. | ``, `--base-url `, `--json` | | `ext enable` | Put disabled extensions back into service. | ``, `--base-url `, `--json` | | `ext remove` | Delete extension definitions. Run history and what they already wrote are kept, and the same id deploys again — unlike `proposals reject`, which denylists it. | ``, `--base-url `, `--json` | | `ext install` | Install an extension from the marketplace into your instance’s extension store. | `` (name\|name\@version), `--base-url `, `--connection ` | | `ext publish` | Publish an authored extension to the public marketplace (publisher allowlist required). | ``, `--name ` (required), `--pkg-version ` (required), `--manifest ` | | `ext extras install` | Install the optional native packages `test` and `replay` need (esbuild, isolated-vm) into `$TURBOBASE_HOME` (needs npm). | *(none)* | | `ext extras status` | Report which optional native packages are installed. | *(none)* | | `apps init` | Scaffold a new app project locally — the same scaffold a proposal-generated app uses (Vite + React + TS, auth-wired client, tokenised `src/styles.css`, `base` resolved at build time, defaulting to `/a//`), the rest of the deployable surface (`hooks/` with a worked example, `conduits/`, `turbobase/sync.json`, `turbobase/schedules.json`), the Claude Code wiring (`CLAUDE.md`, `.mcp.json` for both MCP servers, `.claude/` skills, the `Turbobase consultant` output style, a SessionStart refresh of types/grounding/`ADOPT.md`, and a PostToolUse rule-placement check), the deploy workflow (`.github/workflows/turbobase-app-deploy.yml` — app projects only), and a dev proxy pointed at your instance. Writes files only, never overwrites; creates nothing on the instance. | ``, `[dir]`, `--title `, `--base-url `, `--no-claude`, `--force` | | `apps create` | Create a new hosted app (a bare app row) so a build can be shipped to it. | ``, `--name `, `--description `, `--base-url ` | | `apps list` | List the hosted apps on your instance, with whether each is live and its latest source revision. | `--base-url `, `--json` | | `apps push` | Record the local source tree as the app’s newest source revision, so the instance holds the source of record. Source only — does NOT build or deploy (`POST /v1/apps/:id/push`, scope `extensions:deploy`). Skips `node_modules/`, `dist/`, `build/`, `.git/`, `.claude/`, any non-text file, and credential files (`.mcp.json`, `.env*` except `.env.example`) — those are never uploaded. | ``, `[dir]`, `--base-url `, `--note `, `--proposal ` | | `apps deploy` | Build the app (if needed) and deploy it — cuts a new live version at `/a/`. Builds when the directory has a `package.json`, uploads as-is when it’s already a bundle, and pulls generated source into an empty directory. With `--preview`, serves the build at its own URL and leaves `/a/` untouched until you promote it; the preview URL opens for anyone who can already open the app, and expires after 7 days. `--preview` requires a build this CLI ran (a prebuilt bundle has its asset paths pinned to `/a//` and would render blank at a preview URL), so it is rejected with `--no-build`. The build runs on your machine — nothing is built server-side. With `--replace`, the build lands on an OPEN preview instead of opening a second one: the URL is unchanged, so whoever holds the link sees the new build. `--replace-label ` is the idempotent form for CI — it re-deploys onto the open preview carrying that label and opens one when there is none, so a pull request keeps a single preview URL for its whole life. Deploying also refuses when a guardrail denies a write the manifest declares, naming the rule. Two deploys of one app may run at the same moment — each takes its own version number and keeps its own copy of the files. A deploy from a directory holding a `package.json` ALSO records that directory as the app’s newest source revision, through the same collector `apps push` uses and with the same exclusions — after the version is cut, so a failed collection costs the revision and never the deploy. `--no-source` turns that off; nothing is recorded from a prebuilt bundle or from a directory this command pulled the source into. | ``, `[dir]`, `--base-url `, `--no-build`, `--no-install`, `--no-pull`, `--no-source`, `--entry `, `--create`, `--preview`, `--label `, `--replace [preview-id]`, `--replace-label `, `--proposal ` | | `apps plan` | Preflight this app’s `public/turbobase.json` against the instance: the `tables` block’s merge/conflict report, a guardrail verdict for every write the `writes` block declares, AND a per-role verdict (create / add-capabilities / update-scopes / no-op / error) for every role the `roles` block declares — including the `content` channels each role would gain or lose, the restrictions it would set, values an admin changed that the deploy will leave alone, and a warning for a role that holds `read:memory` without `content` (or the reverse) or restricts on a dimension no table carries. Writes NOTHING and grants nothing — in particular a planned role is not created, and a created role has no holders until an admin assigns it. Exits non-zero on a table conflict, a denied write, or a role that cannot be created or updated (a name colliding with a built-in, or a declared restriction that conflicts with a different one an admin or another app already set), so it works as a PR check. `apps deploy` re-runs every check and enforces it — a CI step can be skipped, a deploy cannot. **Pass `--app `** (or `--as app:`; two different slugs are refused): the role half needs the app’s slug to tell its own earlier `scopes` declarations from another app’s, exactly as the deploy does. Without it the plan never fails on that question: every scope dimension an app has already declared on a role is left out of the plan with a notice on that role (the deploy decides it, and may still refuse), every other dimension is planned exactly, and nothing the deploy would retire is listed — so a manifest whose roles carry `scopes` and no slug prints a warning naming the flag. With it the plan also lists what `"roles": []` retires. The role report prints a value the role already held as `adopted` (the admin’s, never removed by the app), and a verdict this CLI does not recognise as a change to upgrade for, never as up to date. | `[dir]`, `--base-url `, `--as `, `--app ` | | `apps promote` | Make a preview the live version at `/a/`. Promotes the exact reviewed bytes — nothing is rebuilt — and fires the effects a preview deliberately skipped (release ledger, relationship activation, proposal completion, knowledge ingest). | ``, ``, `--base-url ` | | `apps preview list` | List an app’s previews, newest first, with each one’s URL and expiry date. Expired previews are listed rather than hidden. `--json` emits the raw array (including `[]`) for scripting — the human format is prose and breaks any regex once a label contains two spaces. | ``, `--base-url `, `--json` | | `apps preview discard` | Close a preview. The URL stops resolving; the build stays in deploy history and can still be promoted. | ``, ``, `--base-url ` | | `apps pull` | Write an app’s source tree to disk — whether it was generated from a proposal or pushed by you (`GET /v1/apps/:id/pull`, scope `extensions:deploy`, read-only). Always returns the newest source revision. | `[app]` (omit with `--proposal`), `[dir]`, `--base-url `, `--force`, `--proposal ` | | `tables list` | List the custom tables on your instance — their fields, the physical `cf_sa_` column each field maps to, and which apps claim each table. | `--base-url `, `--json` | | `tables plan` | Preview what this project’s `public/turbobase.json` `tables` block would do. Writes NOTHING, and exits 1 with a conflict report when another claimant already declares one of your field names at a different type — so a CI step catches it before the deploy does. | `[dir]`, `--base-url `, `--as ` | | `tables apply` | Apply this project’s declared tables to the instance. Additive: an existing table gains the fields you declare and keeps the ones other claimants declared. | `[dir]`, `--base-url `, `--as ` | | `tables create` | Create a single table without authoring a manifest — the CLI twin of the Records page’s “New custom table”. Field spec is `name:type[:required]`, where type is `text`\|`number`\|`date`\|`bool`\|`select`. | ``, `-f, --field ` (required, repeatable), `--name `, `--restrict-to-owner`, `--base-url ` | | `tables drop` | Drop a table and every row in it. Irreversible, and fail-closed: `--confirm` must match the slug exactly. | ``, `--confirm ` (required), `--base-url ` | | `relationships list` | List the linked record pairs your instance has discovered: the line table, the field on the line that holds its parent, the parent table, and each of the two “re-read” behaviours as `on` — with when it was switched on — or `off`. The app only marks a record whose pair has one switched on, on Data → Records; this is where the list is. An empty list means no pair has been discovered yet, which happens when a source is reconciled. | `--source `, `--base-url `, `--json` | | `relationships refresh` | Switch ONE of a pair’s two re-reads on or off. The pair is one argument, `CHILD:KEY:PARENT`, exactly as `relationships list` prints the three names. Both halves are required and nothing is defaulted: `--children` re-reads the lines when their parent is saved, `--parent` re-reads the parent when one of its lines is saved. A pair your instance has not discovered is refused and the refusal names what IS declared on that line table — nothing is created here. Each switched-on pair costs one query to the connected system per parent save plus one re-read per line. | ``, `--children`, `--parent`, `--on`, `--off`, `--source `, `--base-url `, `--json` | | `guardrails posture` | Show or set the GLOBAL default posture. No `mode` prints the current one. `allow` = deny-list (unmatched writes allowed-with-audit, carve exceptions with `--decision deny` rules); `deny` = allow-list (unmatched writes denied, open records with `--decision allow` rules). Under deny-by-default, opening a record for writes does NOT open it for deletes — `.delete` is a separate allow. | `[mode]` (`allow`\|`deny`), `--base-url ` | | `guardrails list` | List the write guardrails on your instance, in evaluation order: what each one decides, its priority, the caller and row it narrows to, and whether it is active, proposed or inactive. These are the same rules the Compliance → Guardrails page shows. | `--base-url `, `--json` | | `guardrails create` | Create a rule gating PG→source writes that match it. `--decision` is `deny` (default), `allow`, `allow_with_audit`, or `require_confirm` — which holds the write for operator approval rather than refusing it. First match wins and lower `--priority` is evaluated first, so “open one record, block the rest” is an allow rule at a low priority plus a deny rule at a higher one. Guardrails sit UNDER the `sync.bidirectional.` entitlement and cannot open a write path your plan does not include. An unconditional deny matching every source AND every record needs `--confirm-freeze-all`. | ``, `--source `, `--record `, `--op `, `--target `, `--actor `, `--decision `, `--priority `, `--when `, `--reason `, `--disabled`, `--confirm-freeze-all`, `--base-url ` | | `guardrails edit` | Change a rule’s decision, priority, row predicate or reason. Widening an allowlist is a NEW `create`, not an edit — that way each opened record type stays its own named, individually revocable row. | ``, `--decision `, `--priority `, `--when `, `--reason `, `--base-url ` | | `guardrails activate` | Arm a parked or proposed rule. Rules the chat agent proposes land `proposed` and gate nothing until you activate them. | ``, `--base-url ` | | `guardrails deactivate` | Park a rule without deleting it — the reversible move mid-rollout. Writes it was gating fall through to the remaining rules immediately. | ``, `--base-url ` | | `guardrails delete` | Remove a rule. Prefer `deactivate` if you may want it back. | ``, `--base-url ` | | `guardrails test` | Dry run: what happens to a write to this (source, record) — allowed, held for approval, or denied, and by which rule? Runs the same evaluator the write path runs and writes nothing. Exits 1 when the write would not proceed, so CI can gate on it. `--phase` picks which of the four gates to simulate (defaults to `worker`, the last one that can still hold a write); pass `--fields` to decide rules whose `--when` reads a row column. To test a DELETE, pass `--op delete` — a delete is gated separately as `.delete`, and without it the dry run evaluates `.write`. Do not spell it by appending `.delete` to `--record`: that builds `.delete.write`, which matches no rule and reports a misleading deny (the response echoes the `op` it evaluated, so you can see this). | `--source ` (required), `--record ` (required), `--actor `, `--external-id `, `--fields `, `--op `, `--phase `, `--base-url `, `--json` | | `kb push` | Write the findings recorded in your standing markdown into your knowledge base, grouped so every finding about one page is one write. Re-reads your pushed markdown FIRST and prints what that found, so “nothing pending” is a statement about your file rather than about whatever a background job last saw. Prints the plan first — which page each group lands on, the exact markdown it appends, and which published pages a correction withdraws from readers — then asks. New pages land unpublished under a “From app developers” page; existing pages are appended to, never rewritten. Writes nothing until you confirm; admin only. | `--base-url `, `--app `, `--section
` (suggested\_kb\_edit\|kb\_correction), `--limit `, `--dry-run`, `--diff` / `--no-diff`, `--no-group`, `--yes` | | `skills push` | Write the hard rules in your standing markdown — the `## Agent rules` findings — as draft agent skills, **grouped by their `###` sub-heading** (the rules under one heading become one skill, named after it; `--no-group` writes one skill per rule). Re-reads your pushed markdown first, like `kb push`. `--reapply` also re-emits rules already applied, which is how a CORRECTED `###` grouping reaches the instance: re-filing a rule under a new heading is the same finding by identity, so it queues nothing on its own. The standing-rule twin of `kb push`: that writes what an agent can look up, this writes what every agent is told on every turn. Prints the plan and asks first. Rules land DRAFT — pushing is not publishing; admin only, on `extensions:deploy`. | `--base-url `, `--app `, `--limit `, `--dry-run`, `--diff` / `--no-diff`, `--no-group`, `--reapply`, `--yes` | | `skills list` | What agent skills exist and which are ACTUALLY injected — `draft`, `unreleased`, `live`. Worth reading before writing another rule. Needs `skills:publish`; admin only. | `--base-url `, `--status ` | | `skills publish` | Publish skills — **live by the time it returns**. Sets visibility and cuts a release scoped to these skills, so publishing one rule cannot ship a colleague’s half-reviewed one. Same one-step shape as publishing a knowledge-base page. Needs `skills:publish`; admin only. | ``, `--base-url `, `--yes` | | `skills unpublish` | The inverse of `publish` — retracts from every prompt immediately and keeps the text, its history and its author. The reversible counterpart to `delete`; prefer it when a rule is merely wrong for now. Needs `skills:publish`; admin only. | ``, `--base-url `, `--yes` | | `skills releases` | Publish/unpublish history, newest first — the rollback targets. Needs `skills:publish`; admin only. | `--base-url ` | | `skills rollback` | Re-point the current skill release at an earlier one. Restores CONTENT only: a skill an operator has since unpublished stays off. Needs `skills:publish`; admin only. | ``, `--base-url `, `--yes` | | `skills delete` | Delete a skill. Permanent, and it stops being injected immediately — no release needed. Prior releases keep their own copy of the text, so a rollback still works. Needs `skills:publish`; admin only. | ``, `--base-url `, `--yes` | | `kb list` | What knowledge-base pages exist and which are ACTUALLY readable — `draft`, `unreleased`, `live`. A page flipped to published but never released reaches nobody, and `visibility` alone does not tell you that. Needs `docs:publish`; admin only. | `--base-url `, `--status ` | | `kb publish` | Publish pages — **readable by the time it returns**. Sets visibility and cuts a release scoped to these pages, so publishing one cannot ship a colleague’s half-written one. Needs `docs:publish`; admin only. | ``, `--base-url `, `--yes` | | `kb unpublish` | The inverse of `publish` — drops the pages out of the reader snapshot immediately and keeps the text and its history. Needs `docs:publish`; admin only. | ``, `--base-url `, `--yes` | | `kb versions` | Knowledge-base release history, newest first — the rollback targets, with the current one marked. Needs `docs:publish`; admin only. | `--base-url ` | | `kb rollback` | Re-point the current knowledge-base release at an earlier one. Needs `docs:publish`; admin only. | ``, `--base-url `, `--yes` | | `kb delete` | Delete a page and its subtree, and stop serving it in the same call. SOFT — restorable from the knowledge-base trash, unlike `skills delete`. Needs `docs:publish`; admin only. | ``, `--base-url `, `--yes` | | `release deploy` | Enqueue a server-side release deploy. UPLOADS the SDF project’s `src/` from your checkout when present; otherwise a solution-only live-fetch deploy. `--target approved` requires `--approved-by`. | `--target ` (required, auto\|approved), `--solution `, `--project `, `--git-repo `, `--git-ref `, `--ref `, `--pr-url `, `--approved-by `, `--base-url `, `--follow`, `--interval-ms ` | | `release status` | Poll a release’s current deploy job. | ``, `--base-url `, `--follow`, `--interval-ms ` | | `release rollback` | Roll back a live release (LIFO-guarded; server-side reverse deploy). | ``, `--base-url ` | | `release list` | List releases, newest-first, with delivered titles + latest status. | `--base-url ` | | `release pull` | Write the project a **failed** release built into a directory: `owned/` (your solutions authored these — edit them) and `context/` (the rest of the bundle — leave alone). Includes the failure log. Read-only. | ``, `--dir ` (required), `--base-url ` | | `release push` | Ship the edited project as a **new** release delivering the same solution. Refuses if a `context/` file changed. `--target approved` requires `--approved-by`. | ``, `--dir ` (required), `--target ` (auto\|approved), `--approved-by `, `--base-url ` | | `sandbox create` | Stand up a fresh local sandbox seeded with a sampled+redacted slice of an instance. | `--from ` (required), `--ttl `, `--sample `, `--base-url ` | | `sandbox destroy` | Drop a sandbox database and remove it from the registry. | `` | | `sandbox list` | List active sandboxes with TTL/expiry; reaps any past their TTL. | *(none)* | | `processes list` | Every process deployed on the instance with its trigger (`manual`, or `scheduled` with the cron AND the timezone), whether it is switched on, and its last five runs. | `--base-url `, `--json` | | `processes runs` | One process’s whole run history, newest first — the read behind “when did this stop working”. | ``, `--base-url `, `--json` | | `processes run` | One run and its step ledger: each step’s disposition, what it wrote, and why it refused. | ``, `--base-url `, `--json` | | `processes invoke` | Start a run of a deployed process. The input is a JSON object, passed whole as the process input. Queued by default (prints the run id); `--sync` waits, bounded by the instance’s budget, and prints the outcome and each step. Needs an `extensions:deploy` token owned by an administrator. | ``, `--input `, `--input-file `, `--sync`, `--base-url `, `--json` | | `processes cancel` | Stop one run. A queued run never executes; a running one stops at its next step boundary and keeps what it already wrote. | ``, `--base-url ` | | `data settle` | Link a row to the record the connected system already created, after a `write_settle_conflict` (`POST /v1/data//settle`). The id comes from the row’s own write state, never from you; a row not in that state answers `409 NOT_SETTLE_CONFLICT`. | ``, ``, `--base-url `, `--json` | | `processes rollback` | Undo a finished run: delete, newest first, only the records its ledger shows it created — never one it updated. Source deletes are paced, so the run reads its progress until they are all sent. | ``, `--base-url ` | | `processes resume` | Carry on a run that stopped waiting on the connected system: the same run picks up at the step it stopped on, so nothing it already wrote is written again. Only a run that ended `committed` with work outstanding; anything else answers `409` naming the state, and a per-source-row process is invoked again instead. | ``, `--base-url ` | | `logs` | Read the platform event log — guardrails, hooks, scheduled tasks, conduits and sync in one stream. `--extension` / `--conduit` / `--process-run` absorb the old `ext logs` and follow a process run. | `--base-url `, `--subsystem `, `--severity `, `--code `, `--since `, `--correlation-id `, `--extension `, `--conduit `, `--process-run `, `--follow`, `--interval-ms `, `--limit `, `--json` | | `mcp` | Run the local stdio authoring MCP server, for clients pointed at a process rather than a URL. | `--base-url ` | *** ## When a command fails [Section titled “When a command fails”](#when-a-command-fails) Every command reports a failure as one line carrying what the instance said, and exits non-zero. Two of those lines are worth recognising on sight: * **`this instance is temporarily unavailable — it is upgrading or restarting. Retry shortly.`** Your instance is not answering — it is being upgraded, it is restarting, or it has been paused. Nothing is wrong with what you ran and nothing was written; retrying in a minute is the whole fix. Every command says it the same way, so a script can match on it. * **`the request did not reach the instance — a proxy or edge answered with an HTML page, so the server gave no detail.`** Something between you and your instance answered instead of it. Check the `--base-url` you passed and any proxy on the way out. **A refusal your instance wrote arrives whole.** When the instance refuses something — a deploy that would leave a recurring job running a process you removed, a module that does not compile — the reason is its own sentence, and the command prints the whole of it, so the half that says what to do about it is never cut off. Only a runaway reason stops short, at 2,000 characters: a build error can carry a compiler’s entire output, and `TURBOBASE_DEBUG=1` below prints even that in full. A body that did NOT come from your instance is still shortened to one line, which is where a proxy’s error page would otherwise land. Set **`TURBOBASE_DEBUG=1`** to put the full form back on any failure: the method, the path, the status, and the server’s response body untruncated — the form worth pasting into a bug report. `0`, `false` and empty are off. ```sh TURBOBASE_DEBUG=1 turbobase kb push --dry-run ``` ## `auth` — sign in, and pick which instance you are talking to [Section titled “auth — sign in, and pick which instance you are talking to”](#auth--sign-in-and-pick-which-instance-you-are-talking-to) Credentials live in `~/.turbobase/credentials`, keyed by base URL. One of them is the **active** instance: the default every other command uses when you omit `--base-url`. ### `auth login` [Section titled “auth login”](#auth-login) ```sh turbobase auth login --base-url https://acme.turbobase.app --token tb_pat_xxxx turbobase auth login --base-url https://acme.turbobase.app --token "$TURBOBASE_PAT" # CI ``` `--token` is required — there is no browser sign-in leg, so the same invocation works on a laptop, a headless box, and a CI runner. Stores the PAT keyed by base URL, makes that instance the active default, and prints `signed in to — now the active instance` (with a count of any other stored credentials). After storing the token the CLI probes the instance — it checks that the URL actually serves the `/v1` API and that the token authenticates — and warns loudly on a wrong host or a rejected token instead of silently claiming success; `--no-verify` skips the probe. See the [overview](/cli/overview/#authenticate--turbobase-auth-login) for the full sign-in model. ## `project` — the local project, and the files an agent session reads [Section titled “project — the local project, and the files an agent session reads”](#project--the-local-project-and-the-files-an-agent-session-reads) Everything under `project` writes into the current directory. `project init` scaffolds the project; `project types`, `project grounding` and `project adopt` generate the files a Claude Code session reads before it starts working, and `project skills` keeps the platform’s own skills in `.claude/skills` current — the scaffolded session hook runs all four every session. ### `project types` [Section titled “project types”](#project-types) ```sh turbobase project types --out types.ts turbobase project types --records-only # skip the OpenAPI half ``` Writes generated TypeScript types for the instance’s catalog and prints `wrote ( lines).`. `--records-only` emits only record-shape interfaces. The OpenAPI half (`/v1/catalog/openapi.json`) is session-gated and a PAT cannot authenticate it, so a headless run prints a note and degrades to records-only rather than failing the whole generation. The record shapes — the half apps generate against — come from `catalog:read` and are unaffected. Until 1.3.x the CLI worked around this by trading the PAT for a session at `/auth/pat-exchange`, which required a `data:read` scope. Both were removed: that session carried the owner’s write access, which is far too much to spend on fetching a schema document. ## `ext` — author, verify, ship, and observe extensions [Section titled “ext — author, verify, ship, and observe extensions”](#ext--author-verify-ship-and-observe-extensions) The extension lifecycle end to end: scaffold with `ext new`, verify offline with `ext test` / `ext lint` / `ext replay`, ship with `ext deploy` / `ext plan` / `ext promote`, then read what ran with `turbobase logs`. `ext install` and `ext publish` are the marketplace ends of the same object. ### `ext new hook` [Section titled “ext new hook”](#ext-new-hook) ```sh turbobase ext new hook --record salesOrder --event before_save \ --template require-field --field memo ``` Writes a hook file under `hooks/` and prints `wrote `. Refuses to overwrite an existing file. Templates: `require-field`, `enforce-format`, `blank`. See [Writing hooks](/extending/writing-hooks/). ### `ext new conduit` [Section titled “ext new conduit”](#ext-new-conduit) ```sh turbobase ext new conduit --source shopify --record order --to netsuite \ --event after_canonicalize_inbound ``` Writes a conduit file under `conduits/` (`2` bridge) and prints `wrote `. `--to-record` defaults to the trigger record type. See [Writing conduits](/extending/writing-conduits/). ### `ext new process` [Section titled “ext new process”](#ext-new-process) ```sh turbobase ext new process --record purchaseOrder --then vendorBill --on-failure rollback ``` Writes a process file under `processes/` and prints `wrote `. `--then` adds a second step that waits for the source’s verdict on the first and reads the id it minted; `--on-failure` defaults to `stop`. See [Writing processes](/extending/writing-processes/). ### `ext test` [Section titled “ext test”](#ext-test) ```sh turbobase ext test hooks/customer.before_save.ts turbobase ext test hooks/customer.before_save.ts --reporter junit > results.xml ``` Runs the extension against every fixture in `samples/` through the **same isolated-vm sandbox** production uses. Human output is one `PASS`/`FAIL` line per sample plus an `N/M sample(s) passed.` summary; exits non-zero on any failure. `--reporter junit|json` emits a machine-readable artifact for CI. See [Sandboxes and testing](/extending/sandboxes-and-testing/). ### `ext lint` [Section titled “ext lint”](#ext-lint) ```sh turbobase ext lint conduits/shop2ns.order.ts turbobase ext lint conduits/shop2ns.order.ts --ci ``` Offline static analysis of a hook, conduit or process for the semantic foot-guns the Zod schema can’t catch. No instance / DB / network. `--ci` exits non-zero when any warning fires. Each rule applies to the kinds it is actually about. The deploy-identity check — your module’s own `id` must be the first `id:` literal in the file — applies to all three. The wiring checks (weak `idempotencyKey`, missing `createdFrom`, extreme `cycleBudget`, a same-source “conduit”) describe a cross-source bridge, so they apply to a conduit only. ### `ext replay` [Section titled “ext replay”](#ext-replay) ```sh turbobase ext replay conduits/shop2ns.order.ts --fixture samples/order.json --trace ``` Drives a conduit through its lifecycle (dispatch guards → `from.when` → `transform` → apply-branch → bindings) in dry-run, against a JSON fixture (offline, default) or `--against sandbox` (online, requires a local DB). `--trace` emits the per-stage trace; `--reporter` emits one case per stage. ### `ext deploy` [Section titled “ext deploy”](#ext-deploy) ```sh turbobase ext deploy hooks/customer.before_save.ts # single file turbobase ext deploy --to prod # whole bundle ``` Single-file mode parses the extension and `POST`s it; prints `deployed → status= (new|replaced existing).`. The id is the first `id: '…'` literal in the file; when the module defines a different `id` the deploy is refused (`extension_id_mismatch`) and the CLI names both ids. Whole-bundle mode (`--to`) applies `turbobase/bundle.json` via `/v1/config/apply` and prints `applied bundle to : + ~ -`. A non-admin PAT gets a 403. See [Deploy, plan, promote](/extending/deploy-plan-promote/). ### `ext plan` [Section titled “ext plan”](#ext-plan) ```sh turbobase ext plan --to prod ``` Prints a terraform-style diff of the local bundle vs the target env’s live config — **no writes**: ```plaintext plan against prod: + hook ns:customer:require-name ~ conduit shop2ns:order 1 to add, 1 to change, 0 to destroy. ``` ### `ext promote` [Section titled “ext promote”](#ext-promote) ```sh turbobase ext promote --from sandbox --to prod --confirm acme ``` Promotes the bundle behind a **fail-closed** confirm-slug gate: if `--confirm` doesn’t match the target instance’s slug, nothing is POSTed. On success prints `promoted → (): + ~ -`. ### `ext export` [Section titled “ext export”](#ext-export) ```sh turbobase ext export ``` Snapshots the live instance’s deployable surfaces (hooks, conduits, sync fields/tables, schedules) into `turbobase/bundle.json` and prints `wrote turbobase/bundle.json ( lines).`. The bundle is your config-as-code source of truth that `plan` / `deploy --to` / `promote` ride. ### `ext binding` [Section titled “ext binding”](#ext-binding) ```sh turbobase ext binding --status divergent turbobase ext binding --retry bnd_123 # admin-only re-enqueue ``` Lists `txn_source_link` bindings (filter by `--status` = `ok|pending|blocked|divergent|muted`) or, with `--retry `, re-enqueues a divergent/blocked binding’s fan-out write. `--retry` requires an admin PAT (403 otherwise); a non-retryable binding returns 409. See [Writing conduits](/extending/writing-conduits/#inspecting-and-repairing-bindings). ### `ext install` [Section titled “ext install”](#ext-install) ```sh turbobase ext install acme-recipes@1.2.0 turbobase ext install acme-recipes --connection conn_123 # bind a connection ``` Fetches a published bundle from the marketplace and deploys it with `provenance:'marketplace'` (lands active). If the manifest declares a `{ type:'connection' }` input you must pass `--connection ` to bind one of your instance’s connections. Needs the registry credential — `TURBOBASE_REGISTRY_URL` + `TURBOBASE_REGISTRY_TOKEN`, **both**, from the same place. Ask Turbobase support to issue a registry token for your instance; it is separate from your PAT and from your instance’s internal credentials, and can be revoked on its own. The CLI will not pair one variable with a credential it found elsewhere in your environment, and the URL must be `https://` (loopback excepted) because the token travels to it as an `Authorization` header. See [Marketplace](/extending/marketplace/#the-registry-credential). ### `ext publish` [Section titled “ext publish”](#ext-publish) ```sh turbobase ext publish hooks/recipe.ts --name acme-recipes --pkg-version 1.0.0 \ --manifest manifest.json ``` Publishes an authored extension to the public marketplace. Your instance must be on the **publisher allowlist** (403 `not_a_publisher` otherwise); a duplicate version returns 409. A published bundle is **connection-agnostic** — the connection it touches is bound at install time, never baked in. Needs the same registry credential as `ext install` — see [Marketplace](/extending/marketplace/#the-registry-credential). ## `apps` — hosted apps served from your instance [Section titled “apps — hosted apps served from your instance”](#apps--hosted-apps-served-from-your-instance) A hosted app is a static single-page app that Turbobase serves from inside your instance at `/a/`. Because it is same-origin, it inherits the instance’s auth: no sign-in page, no callback, no CORS, no app-owned keys. The build runs on **your** machine — nothing is built server-side — and the CLI uploads the result. The whole group authorises on one scope, `extensions:deploy`, and additionally requires the caller to be an admin *at the time of the call*, so a demoted admin’s still-valid token cannot deploy. ### `apps create` [Section titled “apps create”](#apps-create) ```sh turbobase apps create reorder-desk --name "Reorder desk" ``` Registers the app so a build can be shipped to it. Required before the first deploy, unless you pass `apps deploy --create`. New apps are private until you share them. The slug **is** the URL and is fixed once created — nothing redirects the old one, so to change it, create a new app. ### `apps init` [Section titled “apps init”](#apps-init) ```sh turbobase apps init reorder-desk ./reorder-desk ``` Scaffolds the project locally — Vite + React + TypeScript, an auth-wired client, a starter read that respects row-level security, and a dev proxy so `localhost:3000/a//` behaves same-origin. Unless you pass `--no-claude` it also writes `CLAUDE.md`, a `0600` `.mcp.json`, and the `preview-app` / `ship-app` skills. It writes files only — nothing is created on the instance, and it works signed out (with placeholders). ### `apps push` [Section titled “apps push”](#apps-push) ```sh turbobase apps push reorder-desk ./reorder-desk --note "tighten the filters" ``` Records the local tree as a new source revision so the **instance** holds the source of record and the app can be pulled from another machine. Source only — it does not build and does not change what is live. Every push appends a revision; nothing is overwritten. `apps deploy` records source too, from the same directory and through the same collector, so this verb is for recording WITHOUT deploying: a tree you want stored before it is ready to go live, or a deploy you ran with `--no-source`. It is also what supplies the knowledge ingest for a `--no-source` deploy. The findings a deploy reads are the ones in the newest recorded revision, so push before deploying whenever you have edited `INSTANCE-LEARNINGS.md` or `TURBOBASE-FEEDBACK.md` and are not letting the deploy record them for you. ### `apps deploy` [Section titled “apps deploy”](#apps-deploy) ```sh turbobase apps deploy reorder-desk ./reorder-desk turbobase apps deploy reorder-desk ./reorder-desk --preview --label "new filters" turbobase apps deploy reorder-desk ./reorder-desk --replace ``` Builds and cuts a new live version at `/a/`. With `--preview` the build is served at its own URL and `/a/` is left untouched — see below. `--preview` opens a NEW preview URL every time, so the second build of the same change is a second link and whoever was sent the first one is still looking at the old bytes. `--replace` re-deploys onto an existing preview instead: the URL, and the label it carries, are unchanged. Pass the handle (`--replace `, from `apps preview list`) or omit it when the app has exactly one open preview — with several open, the CLI lists them and asks which. `--replace` implies `--preview`, keeps the reviewed-but-superseded build in deploy history, and resets the 7-day expiry, so it is also how you bring an expired preview link back. Two deploys of the same app may run at the same moment. Each takes its own version number and keeps its own copy of the files, so a CI job does not have to serialise them. Which one ends up at `/a/` is decided by whichever FINISHES last, not by whichever you started first — so when the order matters, still deploy one after the other. **A deploy from a source directory also records that directory as the app’s newest source revision**, through the same collector `apps push` uses. The version is cut first and the source recorded after it, so a collection that fails costs you the revision and never the deploy — it prints `recorded N source files on the instance (rev R)`, or a line beginning `source not recorded:` naming what to do. Credential files are withheld by name on stderr (`.env`, `.env.*` and `.mcp.json`; `.env.example` is a template and travels), and `node_modules/`, `dist/`, `build/`, `.git/` and `.claude/` are skipped, so what lands is the project rather than its output. Pass `--no-source` to cut the version and upload nothing from the working tree. Nothing is recorded from a directory with no `package.json` either — a prebuilt bundle has no source tree — nor from one this command pulled the source into, which is already the newest revision. An app with no source revision cannot be changed from a user’s in-app change request: there is no copy on the instance to edit, and a built bundle cannot be turned back into a project. App Management says so per app, and the widget tells the person who filed the request. A live deploy also queues the knowledge ingest, and prints which source revision it queued it against — it reads what the recorded source holds, not the bundle this command just built. It warns when the app has never been pushed, and when your local `INSTANCE-LEARNINGS.md` / `TURBOBASE-FEEDBACK.md` are newer than that revision. ### `apps preview list` / `apps preview discard` [Section titled “apps preview list / apps preview discard”](#apps-preview-list--apps-preview-discard) ```sh turbobase apps preview list reorder-desk turbobase apps preview discard reorder-desk ``` Previews expire after **7 days**; `list` shows them newest-first with each URL and expiry date. A preview URL grants no new access — it opens only for people who could already open the app. `discard` stops the URL resolving; the build stays in deploy history and can still be promoted. ### `apps promote` [Section titled “apps promote”](#apps-promote) ```sh turbobase apps promote reorder-desk ``` Makes a preview the live version. **Nothing is rebuilt** — the bundle’s asset URLs name the build rather than the page it is served from, so promotion is a pointer write and the reviewed bytes are the shipped bytes. Promotion also fires the effects a preview deliberately skips: applying the manifest’s name and description, the release ledger, relationship activation, proposal completion, and knowledge ingest. It also applies the manifest’s declared roles — the capabilities they gain and any `scopes` change — and prints what changed, so a Slack channel a role loses is in the promote output, not only in the plan. ### `apps pull` [Section titled “apps pull”](#apps-pull) ```sh turbobase apps pull reorder-desk ./reorder-desk turbobase apps pull --proposal ``` Writes the app’s newest source revision to disk — whether it was generated from a proposal or pushed by you. Read-only; it never deploys. With `--proposal` the app is derived from the proposal, and its request context is written alongside the source. ### `apps list` [Section titled “apps list”](#apps-list) ```sh turbobase apps list turbobase apps list --json ``` Every hosted app with whether it is live, its latest source revision, and its audience. A `—` in the source column means the app was deployed but never pushed, so there is nothing to pull. ## `kb` — write findings into the knowledge base [Section titled “kb — write findings into the knowledge base”](#kb--write-findings-into-the-knowledge-base) ### `kb push` [Section titled “kb push”](#kb-push) ```sh turbobase kb push --dry-run turbobase kb push ``` `turbobase apps push` uploads your standing markdown and parses it into a pending queue on the instance. `kb push` is the only thing that turns those findings into knowledge-base pages — recording what you learned and changing the company’s documentation are deliberately separate decisions. It re-reads your pushed markdown before planning and prints what that found: ```plaintext 2 file(s) scanned · 1 changed · 6 new finding(s) · 15 already known ``` Read that line before anything else. `0 file(s) scanned` means your markdown never reached the instance — you deployed without pushing, and `apps deploy` uploads the built bundle, which contains no `.md` files at all. `15 already known` means your edit landed and those findings were already dealt with; a finding’s identity is its normalized text, so reformatting, renumbering or re-heading one is deliberately the same finding. The unit is a **page, not a finding**: a dozen findings about one page become one appended section and one write, so you approve “19 pages”, never “53 things”. The plan is printed in full before anything is written, and the command asks. New pages land **unpublished** so a human can fold them into the taxonomy; existing pages are appended to, never rewritten; and a page whose unpublished work belongs to someone else is skipped rather than overwritten. `--dry-run` prints the plan and exits. `--yes` skips the prompt; without it, a non-interactive run refuses rather than assuming. If the queue moves while you are reading the plan, the write is rejected and nothing lands. Grouping is a judgement — two findings that name the same subject are assumed to be about the same page. When they are not, `--no-group` plans every finding on its own: the same pages are targeted, but N findings for one page become N sequential appends instead of one merged block. The plan also warns when two NEW pages look like the same subject written two ways; it never merges them for you. A **correction** whose page ref matches no page is skipped rather than turned into a new page — a correction needs a page to correct. The plan names the ref that failed, and those findings stay pending until you fix it. A skipped group is counted and shown as a skip throughout: it is not in the “N page(s)” tally, it gets no write-set under `--diff`, and it is not in the numbers the confirmation prompt quotes. If everything in scope is skipped there is nothing to confirm, so you are not asked. ## `skills` — write standing rules into the instance’s agents [Section titled “skills — write standing rules into the instance’s agents”](#skills--write-standing-rules-into-the-instances-agents) ### `skills push` [Section titled “skills push”](#skills-push) ```sh turbobase skills push --dry-run turbobase skills push ``` The other half of the same queue. An authoring session records two kinds of finding: the narrative that should be *findable* when someone asks the right question, and the hard rules that must apply on **every** turn whether or not anyone thinks to ask. `kb push` writes the first kind into the knowledge base; `skills push` writes the second as agent skills. Rules under one `###` sub-heading become ONE skill, named after that heading. The plan is printed in full before anything is written; `--dry-run` prints it and exits, `--yes` skips the prompt, and without it a non-interactive run refuses rather than assuming. Like `kb push`, it re-reads your pushed markdown first and prints what that found. #### `--reapply` — landing a corrected grouping [Section titled “--reapply — landing a corrected grouping”](#--reapply--landing-a-corrected-grouping) A finding’s identity is its normalized **text**, so re-filing a rule under a different `###` heading is deliberately the same finding: it queues nothing, and without `--reapply` there is nothing for a push to act on. That is the right default — the queue is a work list, not a history — but it means a grouping you fixed after the first push cannot reach the instance on its own. `--reapply` re-emits rules already applied, so the new grouping (and any body text you tightened) lands. It is safe to run more than once: the write is keyed on the skill’s slug, a finding already present in a rule is not appended twice, and a finding you **dismissed** stays dismissed — that status is the one a human set deliberately to mean no. Rules land **draft and disabled**, and there is deliberately no flag to change that. A PAT that can deploy an app is not thereby authorised to change what every agent on the instance is told. Publishing is a separate act on its own opt-in scope — `turbobase skills publish`, or the Skills page in the app. ### `kb list | publish | unpublish | versions | rollback | delete` [Section titled “kb list | publish | unpublish | versions | rollback | delete”](#kb-list--publish--unpublish--versions--rollback--delete) ```sh turbobase kb list turbobase kb publish month-end-close turbobase kb unpublish month-end-close turbobase kb versions turbobase kb rollback 12 turbobase kb delete month-end-close ``` The knowledge-base editor, from a terminal. All six need a PAT carrying **`docs:publish`** — a dedicated, opt-in scope that a token minted before this release will not have. `kb push` is unchanged and still runs on `extensions:deploy`: filing a draft and arming what every reader (and every AI answer) sees are different powers, so they carry different keys. `kb list` reports whether each page is **actually readable**, not just its visibility flag. A page flipped to published but never included in a release is approved text nobody can find, and it is the single most confusing state the knowledge base has. `kb publish` is one act: it sets visibility **and** cuts a release scoped to the pages you name, so the page is live when the command returns and publishing one page cannot ship a colleague’s half-written one. `kb unpublish` is the exact inverse and takes effect immediately. `kb delete` is **soft** — the page and its subtree drop out of the tree and out of every reader release, and stay restorable from the knowledge base’s trash. This differs from `skills delete`, which is permanent, and the asymmetry is deliberate: a skill’s text survives in every release that carried it, while a page is the only copy of prose a human wrote. ## `release` — ship your project from your own CI [Section titled “release — ship your project from your own CI”](#release--ship-your-project-from-your-own-ci) Your repo, your runner, your credential. `release deploy` uploads the bytes in your checkout; Turbobase clones nothing and holds no GitHub App key. The only credential is a PAT you mint with the `releases:write` scope. `turbobase project ci` scaffolds a GitHub Action that runs exactly this, and the CLI is the equal front door — neither is privileged over the other. ### `release deploy` [Section titled “release deploy”](#release-deploy) ```sh turbobase release deploy --target auto --project . --ref HEAD --follow ``` Uploads `<--project>/src/` as `{path → contents}` and enqueues the server-side deploy. `--target auto` applies immediately; `--target approved` fails closed with `403 approval_required` unless `--approved-by ` records an approval. Omitting an SDF `src/` requires `--solution ` (the live-fetch path). `--ref`, `--git-repo`, `--git-ref` and `--pr-url` are **provenance links only** — recorded against the release, never dereferenced. `--ref` is resolved locally with `git rev-parse`, so a branch, tag or short sha all normalise to a full one. They default from `$GITHUB_SHA` / `$GITHUB_REPOSITORY` / `$GITHUB_REF` / `$PR_URL`, so a CI run fills them in with no flags. Anything that can’t be derived is simply left null and never blocks the deploy. `--follow` polls to completion and exits non-zero if the deploy fails. ### `release status` [Section titled “release status”](#release-status) ```sh turbobase release status rel_abc123 --follow ``` Prints the release’s current stage and percent, or streams to completion with `--follow`. ### `release rollback` [Section titled “release rollback”](#release-rollback) ```sh turbobase release rollback rel_abc123 ``` Server-side reverse deploy, LIFO-guarded: a release that is not live, or that a newer release has advanced past, is refused with 409 rather than rolled back. ### `release list` [Section titled “release list”](#release-list) ```sh turbobase release list ``` Releases newest-first with delivered titles and latest status. ## `sandbox` — ephemeral local instances [Section titled “sandbox — ephemeral local instances”](#sandbox--ephemeral-local-instances) A sandbox is a throwaway database seeded with a sampled, redacted slice of a real instance — somewhere to run `ext replay --against sandbox` without going near production data. **`sandbox create` is not available yet**: it refuses and creates nothing. Seeding needs a per-column PII classification for the source instance’s tables, and redaction fails closed on any column it cannot prove is safe. `ext test` and `ext replay --fixture` are the offline paths that work today. ### `sandbox create` [Section titled “sandbox create”](#sandbox-create) ```sh turbobase sandbox create --from acme --ttl 72h --sample 2% ``` Stands up a fresh **local** authoring sandbox seeded with a sampled + redacted slice of `--from`, mounts the working tree’s `hooks/` + `conduits/`, and prints the sandbox name, source, URL, expiry, and mount counts. The sandbox process forces `TURBOBASE_SANDBOX=1` so every source resolves to a mock, so no code path reaches a real Oracle NetSuite or Stripe account. ### `sandbox list` [Section titled “sandbox list”](#sandbox-list) ```sh turbobase sandbox list ``` Lists active sandboxes (`name from=… expires=… url`) and opportunistically reaps any past their TTL. ### `sandbox destroy` [Section titled “sandbox destroy”](#sandbox-destroy) ```sh turbobase sandbox destroy acme-72h-abc ``` Drops the sandbox database and removes it from the registry; prints `destroyed sandbox (dropped ).`. See [Sandboxes and testing](/extending/sandboxes-and-testing/) for the two distinct senses of “sandbox” (the code sandbox vs. an account sandbox). ## `guardrails` — decide which write-backs are allowed to leave [Section titled “guardrails — decide which write-backs are allowed to leave”](#guardrails--decide-which-write-backs-are-allowed-to-leave) ```sh turbobase guardrails posture # the global default turbobase guardrails list # every rule, in evaluation order turbobase guardrails create "open invoices" --source netsuite --record invoice --decision allow --priority 10 turbobase guardrails test --source netsuite --record invoice # what would happen to this write turbobase guardrails deactivate "open invoices" # park it, reversibly ``` The same rules the **Compliance → Guardrails** page shows, from the command line. `posture` is the one global switch: `allow` makes the rules a deny-list, `deny` makes them an allow-list. Everything else is per rule — `create` a rule, `edit` its decision or priority, `activate` or `deactivate` it, `delete` it. `test` is the one to reach for before you change anything: it runs the same evaluator the write path runs, against a write you describe, and writes nothing. It exits 1 when the write would not proceed, so your own CI can gate on it. Guardrails sit **under** the per-source write entitlement and cannot open a write path your plan does not include. First match wins, lower priority is evaluated first, and a delete is gated separately from a write. ## `relationships` — which linked record pairs re-read each other [Section titled “relationships — which linked record pairs re-read each other”](#relationships--which-linked-record-pairs-re-read-each-other) ```sh turbobase relationships list # every pair, and which re-read is on turbobase relationships list --source netsuite # one connected system turbobase relationships refresh cr_delivery_line:custrecord_line_delivery:cr_delivery --children --on turbobase relationships refresh cr_delivery_line:custrecord_line_delivery:cr_delivery --parent --off ``` Some systems save a parent’s **lines** as part of the parent’s own save and raise no event on the lines, so the parent reaches your instance in seconds and its lines wait for the scheduled catch-up. The mirror image happens too: saving a line makes the system silently rewrite totals on the parent. Turbobase can close either gap by re-reading the other side, and these two verbs are how you switch that on. The app does not list them: a record on **Data → Records** carries a re-read mark when one of its pairs has a re-read switched on, and these verbs are where the three names, the time and the switch itself live. `list` prints one row per pair your instance has discovered: the line table, the field on the line that holds its parent, the parent table, and each of the two re-reads as `on` — with **when** it was switched on — or `off`. An empty list is a fact about discovery rather than about the behaviours: a pair appears once a source is reconciled. `refresh` takes the pair as one argument, `CHILD:KEY:PARENT`, exactly as `list` prints the three names. Then say which behaviour and which way — `--children` (re-read the lines when their parent is saved) or `--parent` (re-read the parent when one of its lines is saved), and `--on` or `--off`. Both halves are required: nothing is defaulted, because a default would switch a behaviour you did not name. A pair your instance has not discovered is refused and the refusal names what *is* declared on that line table — nothing is created here. Replaying a call that changes nothing says so, and leaves the original time alone. **Each switched-on pair has a running cost** — one query to the connected system per parent save, plus one re-read per line — so switch on the pairs that are actually arriving late (`turbobase logs`, and the **Late arrivals** page, say which) rather than all of them. Both verbs need a token with the `extensions:deploy` permission and an administrator behind it. ## `tables` — custom tables of your own [Section titled “tables — custom tables of your own”](#tables--custom-tables-of-your-own) ```sh turbobase tables list # the custom tables on your instance turbobase tables create expenses -f amount:number -f spent_on:date turbobase tables plan # what this project's manifest would do turbobase tables apply # apply it turbobase tables drop expenses --confirm expenses ``` Tables you add alongside your mirrored records, for the data your business keeps that no connected system holds. They are the CLI twin of the **New custom table** button on the Records page, and they behave the same way: a table is additive, so applying a manifest gives an existing table the fields you declare and leaves the fields other projects declared alone. `plan` writes nothing and exits 1 with a conflict report when another claimant already declares one of your field names at a different type — run it in CI and a clash is caught before the deploy. `drop` deletes the table and every row in it, and will not run unless `--confirm` matches the slug exactly. ## `logs` — every log, under one noun [Section titled “logs — every log, under one noun”](#logs--every-log-under-one-noun) ```sh turbobase logs --since 10m # everything, last 10 minutes turbobase logs --subsystem guardrails --severity warn turbobase logs --correlation-id 5f3c… # one write, across subsystems turbobase logs --follow # poll for new rows turbobase logs --extension ext_abc123 # was `ext logs ext_abc123` turbobase logs --conduit cnd_xyz --follow # was `ext logs cnd_xyz --follow` turbobase logs --process-run 1111… --follow # one process run, until it finishes ``` All log access under one noun. With no target flag this reads the unified platform event log (`GET /v1/events`) — guardrails, hooks, scheduled tasks, conduits and sync in one stream, one envelope, one correlation id. One line per row: timestamp, severity, outcome, event code, where it happened, the correlation id, the typed detail, and a `redacted=[…]` marker naming which redaction rule groups fired (never what they matched). `--severity` is a **threshold**, not an equality filter: `warn` includes `error`. `--since` takes a relative window (`10m`, `2h`, `3d`) or an ISO timestamp. `--follow` polls every `--interval-ms` (default 2000) from a cursor, so it never re-prints a row. `--extension ` reads that extension’s bounded log ring buffer and `--conduit ` reads that conduit’s `conduit_event` run ledger — the two modes the removed `ext logs` had. Each stays its own read rather than a filter, because each is the system of record for its subsystem and the platform log is derived from it. `--process-run ` is the third, and it is the one that ENDS. A conduit has no end, so `--follow` on one polls until you interrupt it; a process run reaches a terminal state, so following one prints each step as it lands and returns when the run finishes. Following a run is a flag here rather than a `processes logs` verb because all log access unifies under one noun. Rows are redacted at WRITE, so secrets, absolute paths, stack frames and SQL never reach the stored row for anyone — while your hook’s rejection messages, your guardrail reasons and your `custbody_*` / `cr_*` / `cf_*` ids survive verbatim. The log is sampled (high-volume events carry an `occurrences` count) and expires: 90 days for `error`, 30 for `warn`, 7 for `info`, 1 for `debug`. ## `processes` — what runs in the background [Section titled “processes — what runs in the background”](#processes--what-runs-in-the-background) ```sh turbobase processes list # what is deployed, and how it fires turbobase processes runs acme:reorder-suggestions # that process's whole history turbobase processes run 1111… # one run and its step ledger turbobase processes invoke acme:reorder-suggestions --input '{"warehouse":"east"}' # start one turbobase processes cancel 1111… # stop it turbobase processes rollback 1111… # undo what a finished run created ``` `processes list` is the answer to “is the nightly job actually scheduled?” — it prints each process’s trigger as `manual`, or as `scheduled` with **both** the cron expression and the timezone it is read in, because `0 2 * * *` is a different moment in `Europe/London` and in `America/New_York`. A process an administrator has switched off is marked `[off]`, and each row carries its last five runs with state, start and duration. `processes runs ` is the whole history rather than those five, and `processes run ` opens one run: every step, its disposition, the record it wrote and the refusal that stopped it. `processes invoke ` starts a run, exactly as an app’s call to `POST /v1/processes/:id` does. Pass the input with `--input ''` or `--input-file ` — a JSON object, sent as the whole input and checked against the schema the process declares; with neither, the input is `{}`. By default the run is queued and the command prints its id; add `--sync` to wait for it and print its state, anything still outstanding, and each step’s disposition. A synchronous wait is bounded by the instance’s budget: a run that outlasts it is **still running, not failed**, and the command prints its id and how to watch it. The run is the token owner’s — it writes as that person, and the token must carry `extensions:deploy` and belong to an administrator. `processes cancel ` stops a queued run before it executes, and stops a running one **at its next step boundary** — the step in flight finishes its write rather than being killed halfway through, and what already went upstream stays. A cancel is not a failure, so nothing is rolled back. `processes rollback ` undoes a run that has **finished** — committed, failed or cancelled. It deletes, newest first, only the records the run’s own ledger shows it created; a record it updated, a row an earlier run had already migrated, and a step declared `rollback: 'keep'` are left alone. A run still queued or running is refused: cancel it first. Rolling back a run that is already `rolled_back` does nothing. The deletes reach your connected system at the instance’s rollback pace rather than all at once, so a large run’s `reason` says its deletes are still being sent until they are; if the source refuses one, that record comes back and the run reads `failed` with the refusal named. To watch a run as it goes, use the log noun: `turbobase logs --process-run --follow`. ## `data` — act on one record [Section titled “data — act on one record”](#data--act-on-one-record) ```sh turbobase data settle nonInventorySaleItem 2b7c… # link the row to the record the source already created ``` `data settle` is the way out of a `write_settle_conflict`: the connected system **created** the record and handed back its id, and linking that id onto the row here failed, so the row reads as unsynced over a record that exists. Saving the row again would create a second record upstream; this links it instead. It reads the id from the row’s `record_write_state` (never from the command line), discards the inbound copy of the same record if one arrived meanwhile — only an untouched one; a copy someone edited is left alone and the command refuses with `409` — marks the row synced and resolves the failure. Running it again on a row it already linked answers the same success. It needs a token with the `rest:write` permission, and only links a row its owner could write in the app. ## `mcp` — the local authoring server [Section titled “mcp — the local authoring server”](#mcp--the-local-authoring-server) ```sh turbobase mcp --base-url https://acme.turbobase.app ``` Runs the local stdio authoring MCP server so Claude Code can drive the same library functions the CLI does. Not usually a command you type: it is the process `.mcp.json` spawns, launched on demand by the file `project init` generated. See the [overview](/cli/overview/#author-with-claude-code--mcpjson-and-turbobase-mcp). # Key concepts A single reference for the named pieces of Turbobase, the ERP operations and development platform for Oracle NetSuite. Read this once and the rest of the docs read straight through. > New here? Start with [Getting started](/getting-started/what-is-turbobase/) for the path to a live instance, then come back to this page for how the pieces fit together. Every word it uses is defined once, on the [Glossary](/getting-started/glossary/). If you only have ten minutes: * A **source connector** plugs a system of record (NetSuite, Stripe, and others) into your instance via a small, fixed contract. * The **sink** is your Postgres — every source flows into one canonical schema you own. * A **sync event** is one row’s trip in either direction; a **sync failure** is an event that didn’t land. * **Hooks** shape outbound writes (validate, rewrite); **guardrails** gate privileged operations (allow, audit, confirm, deny). * **Atlas** consolidates the same entity across multiple source systems. * **Tasks** are scheduled jobs — chat prompts, saved views, system maintenance — your instance runs on a cron. * **Channels** deliver human-in-the-loop notifications (Slack, …) for confirmations and alerts. * The **audit log** is one append-only table that records every privileged op and decision. The rest of the page expands each. *** ## Source connector [Section titled “Source connector”](#source-connector) A pluggable link between Turbobase and one external system. The contract is small enough to fit in a paragraph; the engine, queue, conflict resolver, named agents, hooks, guardrails, and chat UI never branch on which connector is attached. A connector implements five methods, give or take: | Method | Purpose | | ----------------------------- | --------------------------------------------------------------------- | | `verifyWebhookSignature(req)` | Confirm an inbound webhook came from the source (HMAC, OAuth, etc.). | | `parseEnvelope(body)` | Normalise the source’s webhook payload into a canonical envelope. | | `writeBack(event, patch)` | Push a canonical patch back to the source system. | | `listChanges(type, cursor?)` | Iterate records modified since a cursor (delta sync). | | `schemaIntrospect(account)` | Snapshot the source’s schema — custom fields, available record types. | Stateful connectors (NetSuite) add lifecycle hooks for governance, auth routing, and session-expired UX. Stateless connectors (Stripe) skip most of those — they’re webhook-led with a single-account API key. Both shapes ship today and serve as the reference for new connectors. Connectors that are not yet live ship through a managed onboarding flow rather than a public extension surface. Email if you need a source we don’t list. *** ## Third-party system integration [Section titled “Third-party system integration”](#third-party-system-integration) Two paths run in parallel for every connector: * **Source → your Postgres (inbound).** The source emits a webhook (or the engine pulls deltas); the envelope is verified and queued; the canonicalizer projects the source-shaped record onto your canonical schema. * **Your Postgres → source (outbound write-back).** An app write marked `originating_system = 'hybrid'` triggers the realtime listener; hooks fire; the connector pushes to the source. A **standalone instance** (`source_system='standalone'`) is the degenerate case: Turbobase is the system of record, webhook ingress and write-back both no-op. The schema, RLS, hooks, guardrails, audit log, channels, and chat UI are unchanged. *** ## Sink (the canonical Postgres mirror) [Section titled “Sink (the canonical Postgres mirror)”](#sink-the-canonical-postgres-mirror) The Postgres database every source writes into and every read serves from. “Sink” is the data-flow term; the rest of the docs call the same thing your **canonical mirror** — one concept, two vantage points. There’s exactly one per instance, and you own it. * Schema namespace: `turbobase` (never `public`). More than two hundred tables today, spanning customers, sales orders, invoicing, fulfillment, payments, items, vendor bills, purchase orders, journal entries, accounting periods, chart of accounts, projects, and the supporting lookups and line tables. * Every record carries `source_system`, `external_id`, `last_modified_date` (when the source last changed it), `last_modified_pg`, `originating_system`, `synced_at` and `sync_version` — enough metadata to reconstruct provenance and resolve conflicts. A record you can create in Turbobase also carries a `pg_row_id` of its own, so it exists before the source has given it an id. * Reads exit through PostgREST and Realtime; writes that need a round-trip to the source go through the write-back queue. * Row-level security scopes every PostgREST call to the calling user (`has_capability`, `scopes_match`). The anon key never bypasses RLS. *** ## Sync engine: events, failures, conflict resolution [Section titled “Sync engine: events, failures, conflict resolution”](#sync-engine-events-failures-conflict-resolution) The generic core that drives both directions. It doesn’t know what NetSuite or Stripe is — it knows about connectors, canonical patches, delta cursors, and queues. * **`sync_event`** — one row per record-trip. `direction` is `source_to_pg` or `pg_to_source`. State machine: `pending → in_flight → committed | failed`, with two sidings — `awaiting_confirmation` while a guardrail holds the write for someone to decide, and `quarantined`. * **`sync_failure`** — emitted when an event fails. Carries category, classification, the offending payload, and the connector’s error. The Mechanic agent subscribes to this table over Realtime. * **Delta cursor** — a per-record-type `lastModifiedDate` checkpoint. The cursor only advances on a clean run; a partial run safely re-scans the same window. * **Conflict resolver** — `last_modified_pg` against the last sync touch (`synced_at`) on one side, and the timestamp the source itself reports on the inbound record on the other, decide whether both sides changed in the same window. Latest write wins; on a tie the source does. Per-field ownership rules resolve which side wins on a per-column basis. *** ## Hooks [Section titled “Hooks”](#hooks) Hooks intercept write-backs before they leave for the source. Two phases: * **`validate`** returns validation errors. A non-empty list short-circuits — the event becomes a `sync_failure` with `category='hook_validation'`. * **`rewrite`** returns a new canonical patch. Composes left-to-right; the next hook sees the previous one’s output. Hook *kinds* are code; hook *rows* are admin-defined and instance-wide, selected by record type. RLS gates writes to admins only; a default guardrail also denies non-admin chat writes to the hook table. Shipped kinds include `require-field` (fail when a top-level canonical field is missing), source-specific arithmetic helpers (for example, NetSuite line `amount = quantity × rate` when missing), and `custom-validator` / `custom-rewrite` — admin-supplied JavaScript run in a sandboxed isolate with strict heap and wall-clock limits. Hooks also have **provenance** — `code-baseline` (**Code baseline**, shipped with Turbobase), `operator` (**CLI deploy**, what you deployed yourself), `mechanic` (**Mechanic**, learned from a real sync rejection) or `learning` (**Learning**, raised proactively) — which feeds the learning loop (see *Learning mode* below). It is the **Origin** column on the extensions page, and you can filter on it. *** ## Guardrails [Section titled “Guardrails”](#guardrails) A policy engine that gates **privileged operations** — anything that mutates source-side configuration (custom fields, role permissions, deployment surface) or pushes high-value writes (large dollar amounts, AI-driven mutations on sensitive fields). Where hooks shape the *content* of a write, guardrails decide whether the *operation itself* runs at all. Every privileged op declares a descriptor: ```ts { source: 'netsuite', op: 'customfield.delete', risk: 'destructive', target: 'customrecord_my_table.custrecord_signing_key', actor: 'agent:mechanic' } ``` The engine resolves one of four decisions: | Decision | Effect | | ------------------ | ------------------------------------------------------------------------------------ | | `allow` | Execute, no audit row. | | `allow_with_audit` | Execute, write an audit row (the default for most ops). | | `require_confirm` | Block, write a pending-confirmation row, fan out via channels, wait for an operator. | | `deny` | Refuse, write an audit row with `status='denied'`. | Policy is layered per source × per op-pattern × per risk × per actor. Defaults ship out of the box; admins layer instance-specific rules on top from the app. First match wins; the catch-all is the per-risk default. The headline use case is *“AI can’t update salesorder.amount, humans can”* — same op, different actor, different decision. *** ## Audit log [Section titled “Audit log”](#audit-log) One append-only table — `turbobase.audit_log` — captures every privileged op, every guardrail decision, every authentication event, and every user-management mutation. Written through a `SECURITY DEFINER` function so callers don’t need direct insert grants. Row shape (abbreviated): | column | what it carries | | --------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `actor` | `wizard:setup` / `cli:` / `agent:` / `user:` | | `action` | Verb. Connector ops use `connector..`; guardrail decisions use `guardrail.*`; auth events use `auth.*`. | | `target_table`, `target_id` | What was acted on. | | `before`, `after` | JSONB snapshots — deletes capture `before`, creates capture `after`, updates capture both. | | `correlation_id` | Confirmation token (when `require_confirm` fired) or caller-supplied id linking related ops. | | `context` | `{ decision, reason, risk, args, result }` — the full guardrail trace. | The `/audit` page in your Turbobase app gives operators a date-range form, quick-filter chips, and per-actor / per-target drill-down. *** ## Tasks (scheduled tasks) [Section titled “Tasks (scheduled tasks)”](#tasks-scheduled-tasks) A cron-like scheduler built into your instance. A worker polls `turbobase.scheduled_task` once a minute, picks rows whose `next_run_at` has elapsed, runs each task as the **owner** (per-run JWT — RLS, scopes, and capabilities all apply), records a `scheduled_task_run` plus a notification, and recomputes `next_run_at` from the task’s schedule. Three task body kinds: | `body.kind` | What it does | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `chat_prompt` | Invoke Pilot, your AI assistant, with a stored prompt — for example, *“Email me yesterday’s at-risk sales orders”*. | | `saved_view` | Re-run a pinned saved view and deliver the result. | | `system_job` | Built-in maintenance job (delta resync, schema-drift detection, playbook rebuild, learning rebuild). No per-user JWT — runs system-owned. | Delivery is either in-app (notification row) or out-of-band via a channel (Slack — see *Channels* below). Five consecutive failures auto-pause the task and notify the owner. The recurring sync jobs are exempt from that five-failure rule — the delta sync keeps its schedule through failure streaks (failures stay visible in the run history and on the sync-health report). The built-in jobs that use AI are exempt too when the only thing missing is an AI key: each keeps its schedule, does the part of its work that needs no AI, records in its run history that it produced no AI output, and starts using AI the moment you add a key. When a recurring platform job is failing because a source refused our sign-in — the scheduled sync itself included — the sync-health report says so on that job’s own row rather than counting it as one more failing job, and tells you to reconnect the source from **Data → Sources**, since a fresh sign-in is the only thing that mints a new credential. Those rows clear themselves on the first run that succeeds; there is nothing to dismiss. Nothing stays broken unnoticed, though: any system job still failing 24 hours after its streak began raises an escalation your operator is alerted on. The two document-memory sweeps (uploaded-document text extraction and embedding) additionally **stop** at that point rather than keep calling a dependency that is refusing them; the reason is recorded on the row, and they stay stopped until support resumes them, because platform jobs are not writable from the tenant surface. The sync jobs are deliberately not stopped — a source outage that ends should not leave your data sync switched off. Constraints plus RLS enforce the `(system=true ↔ owner=null)` split: any authenticated caller can read system rows, only admins or the service role can mutate them. *** ## Atlas (entity consolidation) [Section titled “Atlas (entity consolidation)”](#atlas-entity-consolidation) The multi-source identity-resolution layer. When two sources both emit a *customer* record for the same real-world organisation, Atlas decides whether to link them, create a new canonical entity, or surface the pair for human review. Pipeline (deterministic tier — what ships today): 1. Read identity fields off the canonical payload via the paths declared in the record type’s identity-field map. Seven dimensions can match: `email`, `phone`, `taxId`, `domain`, `gtin` (a product’s barcode), `externalKey` and, for a person, name-plus-employer. 2. Normalise each — lowercase email, E.164 phone, digits-only tax id, and a web address reduced to a bare host. A host that identifies nobody (a free mail provider, say) is dropped rather than matched on, so two unrelated customers who both use one never consolidate. 3. Query for exact matches on each populated key. 4. **Single match** → link. **Multiple matches** → review queue with both candidates. **No match** but at least one identity present → create a new canonical row. **Zero identities** → create new (no keys to dedup on; future embedding tier reconciles). Embedding and LLM tiers are wired but reserved for when dedicated workers ship. **Important:** Atlas consolidates the records that name a *thing* — customer, vendor, contact, employee, partner, and the items you sell (matched on barcode or SKU). Transaction-shaped records (invoice, payment, fulfillment) stay per-source — they’re ledger events, not identities. A future cross-source ledger *bridges* those transactions without collapsing them. *** ## Channels (notification delivery) [Section titled “Channels (notification delivery)”](#channels-notification-delivery) Pluggable fan-out for human-in-the-loop events: * `confirmation_requested` — a guardrail hit `require_confirm`; an operator needs to approve or deny within the configured window. * `confirmation_decided` — somebody approved or denied; the original requester and the other channels get the resolution. * Task failure notifications, scheduled-task delivery, schema-drift alerts. Slack is the channel your instance exposes today: connect it from **Settings → Slack** and it registers at boot. Every `require_confirm` decision ends in a Slack message with the approval link pre-baked. *** ## Agents (the four named cast) [Section titled “Agents (the four named cast)”](#agents-the-four-named-cast) Turbobase ships with four named agents. Each one has a specific job, a specific permission scope, and a specific surface where you talk to it. Future releases extend the cast under the same naming convention — recognisable English job titles. | Agent | Role | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Conductor** | The behind-the-scenes coordinator. Routes events to the right specialist, applies your permission rules, owns notification delivery and skill loading. Long-lived; supervises the others. | | **Mechanic** | Watches your sync, fixes what it can, asks for help on what it can’t. Subscribes to `sync_failure`; resolves known patterns (record locked, insufficient permission, field validation, governance limit, missing dependency) autonomously today, and is designed to grow into bundle drift, role permissions, and schema drift. | | **Pilot** | Drives Turbobase in plain English. Answers questions over the canonical schema, builds apps from natural-language prompts, runs scheduled tasks. The same agent runs in the app and on Slack. | | **Concierge** | Walks the operator through onboarding. Provides click-paths for the source you’re connecting, validates credentials, kicks off the initial sync. | **Why these four (and only these four)** — the cast is small on purpose. Memory, learning, and skills are *traits all four agents share*, not separate agents. Hooks are user-written code, not agents. Guardrails are policy, not agents. New agents added later will get their own English job-title names (Auditor, Watchdog, Archivist, …) so the cast stays legible to a non-technical reader. ### How the four agents handle multi-system sync [Section titled “How the four agents handle multi-system sync”](#how-the-four-agents-handle-multi-system-sync) Every agent works against the *canonical* layer, not source-specific code. That’s what makes a single cast scale across multiple sources instead of multiplying with each new connector. * **Conductor** is source-agnostic by construction. It dispatches events whose `source_system` is just a column. * **Mechanic** operates on failure patterns expressed in connector-neutral terms. “Record locked” covers a NetSuite record-lock today and a Stripe idempotency conflict tomorrow. “Governance limit” covers NetSuite governance windows and Salesforce daily-API caps. * **Pilot** queries the canonical schema over PostgREST plus RLS. It doesn’t know which connector wrote a given row. A question like “show me this customer’s billing across systems” naturally joins NetSuite, Stripe, and Salesforce data once the consolidation lands — no new “multi-source mode” in Pilot. * **Concierge** ships per-source onboarding steps, but the runtime is the same across all of them. Each new connector contributes one step — its credential validator plus introspection call. The same per-instance permission tiers and the same memory (“subsidiary 7 mandates a class on every line”; “this customer’s payment terms are NET 45”) apply across every source your instance has wired up. **You don’t get a per-connector agent farm; you get one cast that learns about your business and applies that knowledge wherever your data lives.** *** ## Permission tiers (AI agent + per-instance) [Section titled “Permission tiers (AI agent + per-instance)”](#permission-tiers-ai-agent--per-instance) How autonomously the Mechanic is allowed to act on a given failure pattern. Three tiers, configurable per instance per pattern: | Tier | Behaviour | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `one_tap` | The agent stages the fix and sends a one-tap approval to the configured channels. **This is the default for every failure kind** — a fresh instance applies nothing on its own. | | `auto_apply` | The agent applies the fix directly. You promote a pattern here once you trust it; your instance also offers to promote one after 20 resolutions it got right with high confidence. | | `escalate` | The agent posts a structured recommendation but does not act. | Tier choices are recorded in the audit log; they layer cleanly with guardrails — a `require_confirm` rule always wins over an `auto_apply` tier. *** ## Skills, playbooks, and learning mode [Section titled “Skills, playbooks, and learning mode”](#skills-playbooks-and-learning-mode) * **Skills** — agent capability bundles. Each bundle packages prompts, tools, and policies for a specific class of problem (for example, “record-locked retries”). Some ship with Turbobase; you can write and publish your own — see [Agent skills](/tenant/skills/). * **Playbooks** — longer operating manuals the agent consults when a skill isn’t pre-baked. Rebuilt nightly from the failure-resolution corpus. * **Learning mode** — a **phase** every instance starts in. While in `learning`, every recurring `sync_failure` cluster becomes a hook *candidate*; admins review on the extensions page and apply, edit, or dismiss. Two kinds of failure can land a hook on their own, above 0.85 confidence and as `provenance='mechanic'`: a locked record and a governance limit. Everything else always comes to you — a field the source rejected, a missing permission, a missing dependency and a refused sign-in are never auto-applied, because each needs a decision only you can make. The top bar shows a soft “Learning” indicator, with a three-item Steady checklist (7 days active, fewer than 2 novel failures in the last week, hooks reviewed). The offer to promote to **Steady** gates on your write error rate rather than on the checklist, so clearing all three does not by itself move you on. What one instance has learned moves to another: `turbobase ext export` pulls your hooks down into a project, and deploying that project into a second instance carries them over — so a sandbox’s learnings can promote to a fresh production instance. *** ## Proposals, solutions, and releases [Section titled “Proposals, solutions, and releases”](#proposals-solutions-and-releases) The pipeline for changing your instance. A **proposal** is a drafted, evidence-cited change; you shape it conversationally with the AI in **Studio**, the chat-first authoring canvas, then mark it complete to freeze it. Freezing mints **solutions** — the concrete deliverables that realize the proposal. Solution kinds are `hook`, `source_change` (shown as **Customization**), `conduit`, `config`, `native`, `documentation`, `app`, `data_manipulation` and `skill`. Solutions ship through **releases**: every deploy is tracked, audited, monitored against a pre-release baseline, and reversible. Full surface: [Development](/tenant/development/). *** ## The Analyzer [Section titled “The Analyzer”](#the-analyzer) The customization-analysis surface for a connected source. It inventories what an account has been customized into — custom fields, records, lists, scripts, workflows, roles, forms, and saved searches — and sorts the risk it carries into five families: inventory, GL posting, fulfillment, data integrity, and security. Findings don’t stay a static report; they enter the development pipeline as signals and proposals. NetSuite-specific today. Full surface: [Analyzer](/tenant/analyzer/). *** ## Apps [Section titled “Apps”](#apps) Internal apps built on your canonical data and served inside your instance at `/a/`, with an end-user launcher at `/apps`. An app is generated from a plain-English brief through the proposal pipeline — it is one of the solution kinds — and your instance keeps the app’s source of record so it can be refined later. Apps act as the signed-in user, so the same RLS applies to them as to everything else. Full surface: [Build apps](/build-apps/). *** ## Knowledge base [Section titled “Knowledge base”](#knowledge-base) Your instance’s living memory of how the business actually runs — a tree of markdown pages organised process-first, with drafts, immutable published releases, and comments. It is both somewhere your team reads and the grounding Pilot answers “how do we do X” from. Full surface: [Knowledge base](/tenant/knowledge-base/). *** ## Originating system [Section titled “Originating system”](#originating-system) A single column on every canonical row that records *who wrote this most recently* — `source` (the external system) or `hybrid` (Turbobase’s own write path). It’s the linchpin of the realtime listener: only `originating_system='hybrid'` writes enqueue a write-back, so source-driven inbound writes don’t bounce back as outbound writes and create a feedback loop. Enqueuing is itself gated per source: write-back ships **off**, so until it’s enabled for a given source the `hybrid` write lands in your canonical mirror without being pushed upstream. *** ## Glossary [Section titled “Glossary”](#glossary) Every word on this page, and every word you meet elsewhere in Turbobase, is defined once — on the [Glossary](/getting-started/glossary/). That page is the single place the vocabulary lives, so a word cannot mean one thing here and something else there. *** ## Where to read more [Section titled “Where to read more”](#where-to-read-more) * **Architecture diagram** — [`architecture`](/architecture/) * **Build apps on Turbobase** — [`build-apps`](/build-apps/) * **NetSuite onboarding** — [`netsuite-oauth-setup`](/netsuite-oauth-setup/) Questions the docs don’t answer? Email . # Add sources > Bring additional sources into an instance that is already running on Oracle NetSuite. Shipped Your first source is connected during the [setup wizard](/getting-started/connect-your-first-source/), and for most instances that source is Oracle NetSuite. This page covers adding *more* sources to an instance that’s already provisioned and set up. Additional sources are an extension of the same canonical layer, not a separate system: a new source writes the same canonical tables in the same Postgres, under the same row-level security, and [Atlas](/tenant/atlas/) consolidates entities that appear in more than one of them. ## The connect-source flow [Section titled “The connect-source flow”](#the-connect-source-flow) Connectors take one of two console routes, and which one a source uses is a property of the connector, not something you choose. **Shared-app OAuth connectors** — **Jira, GitHub, Google Workspace, Asana, Notion, QuickBooks, Intercom, Ramp, Business Central, Workday, Zoho CRM, and Xero** — connect on the console’s standalone connect-source page: ```text /instances//connect-source/ ``` **Every other source** — **NetSuite, Stripe, HubSpot, Shopify, Zendesk, Salesforce, Bill.com, Fireflies, GetSales, Granola, FeedHive, DocuSeal, PrintNode, WhatsApp Business, and Azure Blob Storage** — connects through the setup wizard’s connect step, re-entered on your running instance: ```text /instances//setup?source=&new=1 ``` That link renders the same connect form your first source used. You don’t normally type either address: the **Add connection** tiles in your app and the **Reconnect** button on a source both open the right one for you. **Connecting a source for the first time and adding a *second* connection of a source you already have are different links.** A first connect is the address above. An additional connection carries an extra flag — `?new=1` on the connect-source route, `&connection=new` on the wizard route — because the two actions must not be confused: see [More than one connection of the same source](#more-than-one-connection-of-the-same-source) below. The flow mirrors the first-source connect: 1. Open the connect page for the source on your instance. 2. For shared-app connectors, click **Connect {Source}** and Turbobase redirects you to the provider’s own consent screen, where you pick the workspace / account / company. 3. The provider redirects back; the console completes the OAuth exchange (write-only into your instance’s secret store) and polls the connection to success. A few connectors collect a little config first. For example, **Business Central** asks for your Entra tenant id and the BC environment before the OAuth redirect; the zero-input connectors render just the connect button. The NetSuite card alone carries the **Advanced settings → Data sync** choice described in [Connect your first source](/getting-started/connect-your-first-source/): leave it on to mirror records, turn it off to run NetSuite on customizations only. On every other connector, mirroring records is the whole connector, so connecting one starts the mirror and there is no switch to find. ## More than one connection of the same source [Section titled “More than one connection of the same source”](#more-than-one-connection-of-the-same-source) You can connect many *different* sources to the same instance. You can also hold **more than one connection of the same source**: two NetSuite accounts, two QuickBooks companies, Stripe sub-accounts. Each is its own connection, with its own mirrored rows kept apart so records with the same internal id in two accounts are never merged into one. Multiple connections of one source are enabled per instance: Turbobase staff turn it on for you, and until they have, a connected source’s tile offers no **Add a connection** affordance. Ask if you need it. Adding a second connection is deliberately a different action from **Reconnect**. Reconnect re-authorises the account you already have; adding a connection brings on a new one. Use the source’s Reconnect button to re-authorise, and **Add a connection** to add. Pointing an existing connection at a different account would silently mix two accounts’ data. ## Salesforce [Section titled “Salesforce”](#salesforce) Salesforce asks a little more of you than most sources, because the app your org signs in through is one **you** create. Read this before you start: the steps are short, but a missing setting shows up as a refused connection rather than as a prompt. **Which editions can connect.** Enterprise, Unlimited, Performance and Developer edition orgs have API access and connect as they are. **Professional and Group edition orgs do not have API access by default** — Salesforce sells it as an add-on, and until it is on your org, a connection attempt is refused and says so. If you are not sure which you have, Salesforce shows it under Setup → Company Information. **Create the app Salesforce now asks for.** In Setup, create a new **External Client App** with Distribution State **Local**, enable OAuth, and give it the callback URL the connect page shows you. Ask for the scopes the page lists (`api` and `refresh_token, offline_access`, plus the identity scope), leave PKCE and “require a secret” set to match what the page asks for, and under the app’s policies choose **All users may self-authorize**, a refresh-token policy of **valid until revoked**, and **relax IP restrictions**. Then copy the consumer key and secret into the connect page — Salesforce asks for a verification code before it shows them to you, and a brand-new app can take a few minutes to start working. If you already have a working **Connected App**, its key and secret still connect. You only need an External Client App because Salesforce stopped allowing new Connected Apps to be created; it has not stopped the ones that exist. **Production or sandbox, and your My Domain.** The connect page asks which you are connecting, and the choice is part of that connection rather than a setting on your instance — so you can hold a production org and a sandbox side by side as two connections. If your org uses a My Domain, give it on the same page (for example `acme.my.salesforce.com`); anything that is not a Salesforce host is refused there and then. **A sandbox needs its own app, every time it is refreshed.** Salesforce does not copy a Local External Client App into a new or refreshed sandbox. So create one in each sandbox you connect, and expect to create it again after every sandbox refresh — a sandbox connection that stops working just after a refresh is almost always this. **Connect as a user who can see what you want mirrored.** Salesforce’s own record-level sharing applies to the connection exactly as it applies to the person who made it, so a user who can only see their own opportunities mirrors only those. A dedicated integration user with **API Enabled** and **View All Data** is the usual choice. You are not stopped from connecting as a narrower user — the connection warns you instead, and your instance’s Health tab goes on naming anything the connection cannot read, so a half-mirrored table is never a mystery. **Your sign-in keeps itself alive.** Salesforce sessions expire after a couple of hours of quiet, and Salesforce withdraws a connection’s permission to sign in again if 30 days pass without it being used. Your instance renews both on its own and shows you the date it last did. If the permission really has been withdrawn — someone revoked the app, the connecting user was deactivated — the Health tab says so in those words and Reconnect is the repair. ## Xero [Section titled “Xero”](#xero) Xero connects as a shared-app OAuth grant on the connect-source page. Clicking **Connect Xero** sends you to Xero’s own consent screen, where you choose **the organisation** you want mirrored. Three things are worth knowing before you connect it: * **One connection holds one Xero organisation.** Your login may administer several; the one you pick on the consent screen is the one this instance mirrors, and choosing a different organisation on a later reconnect is refused rather than silently switched. Re-consenting to the *same* organisation is how you repair a connection Xero has expired. * **Turbobase only ever reads.** It never creates, edits, voids or deletes anything in your Xero organisation. The connection is one-way by design, not by configuration — there is no setting that changes it. * **Contacts, invoices, bills, credit notes, payments, purchase orders, bank transactions, manual journals, your chart of accounts, your items and your cost-tracking categories** are mirrored, and they land in the same tables as everything else you have connected. Your **tracking categories** come across twice over: each line names the option it is tagged with, and the options themselves become rows you can list and count — one row per option, with the category it belongs to on every one of them. An option you have archived in Xero is still there, marked inactive, so an older line that uses it still makes sense. Changes to contacts and invoices arrive within moments; everything else refreshes on a schedule, so allow up to four hours for a new payment or bank transaction to appear. ## GitHub [Section titled “GitHub”](#github) GitHub connects as a shared-app OAuth grant on the connect-source page. There is nothing to type: clicking **Connect GitHub** sends you to GitHub’s own install screen, where you pick the account or organisation and **which repositories** Turbobase may see. Three things are worth knowing before you connect it: * **The repositories you pick are the whole of what is mirrored.** Issues and their comments, pull requests and their reviews, labels, milestones, boards and cards, discussions, workflow runs and the files behind them come in for the repositories on that install screen, and for no others. Narrow or widen the selection on GitHub and the connection follows it. * **Changes arrive as they happen.** GitHub signs the calls it makes to your instance, so an issue opened or a run finished is in your database moments later rather than on a four-hour schedule. * **Turbobase reads until writing is switched on.** Opening an issue, editing one, moving its labels, replying on an issue, a pull request or a discussion, committing a file onto a branch behind a pull request, and starting a workflow that offers a manual run are all built — and all held shut until Turbobase staff turn writing on for your instance. Nothing is ever re-run, cancelled, approved or deleted on your behalf. ## Google Workspace [Section titled “Google Workspace”](#google-workspace) Google Workspace connects as a shared-app OAuth grant on the connect-source page, and **what it brings in is your shared drives**. There is nothing to type: you approve on Google’s own consent screen with an ordinary sign-in. A shared drive belongs to your organisation rather than to a person, so one connect reads the same documents whoever made it. **Gmail, personal My Drive and Calendar are not part of this connector** — reaching those would need a grant over your whole domain, and Turbobase does not ask for one. Once it is connected, the instance’s **Shared drives** screen in the console is where you say which drives to mirror, and it is not an optional setting: a connection with nothing picked reads nothing at all. The screen lists the shared drives the connect found and you tick the ones you want; a drive you have not ticked is never read, and un-ticking one stops it. Documents, spreadsheets, slides and the other files on a ticked drive land alongside your other knowledge sources, with their text read out so the assistant can answer from what they say. If a shared drive was created after you connected, it won’t be on the list yet — the list is what the connect saw. Reconnect the source and the new drive appears for you to tick. New and changed files arrive as they happen — Google calls out to your instance when a drive changes. The connection only ever reads: nothing Turbobase does creates, edits or deletes a file in your Google Workspace. ## PrintNode [Section titled “PrintNode”](#printnode) PrintNode connects with an **API key** rather than an OAuth redirect, and it is the shortest connect in the product: the key is the whole of what you type. Find it in PrintNode under your account’s API keys, then paste it into the PrintNode step of the wizard. Three things are worth knowing before you connect it: * **It is a printing connector, not a records one.** The printers you already have — and the computers they are attached to — are mirrored into your instance so that an automation can name one. Your app lists them on **Data → Synced records**; expand the `printer` row to find a printer’s id. * **Printing is the write, and it starts off.** A print job raised in Turbobase is sent out through PrintNode and paper comes out of your own printer. Because that cannot be undone, sending is a separate per-instance setting Turbobase staff enable on request. Until then the connection only reads. * **A job says how it ended.** PrintNode answers when it has accepted a job rather than when the page is printed, so Turbobase follows the job up and settles it on the row. A job still reading as sent has not printed yet. ## FeedHive [Section titled “FeedHive”](#feedhive) FeedHive connects with an **API key** rather than an OAuth redirect. Find it in FeedHive under **Settings → Account → API Key**, then paste it into the FeedHive step of the wizard. Three things are worth knowing before you connect it: * **Turbobase only reads until publishing is switched on.** Out of the box it never creates, schedules or edits a post in your FeedHive account. Publishing is a separate per-instance setting that Turbobase staff enable on request, so the connection stays one-way until you ask for it to be otherwise. Even with it on, Turbobase never deletes a post. * **New posts appear within about four hours, not instantly.** FeedHive can call out when a post publishes, but it offers no way to prove such a call really came from FeedHive — so Turbobase declines to accept them and refreshes on its own schedule instead. * **Your posts land alongside your other knowledge sources**, not in a table of their own, so the assistant can answer questions about what your company has said publicly, when, and on which channel. A post published to several social accounts at once is stored once, not once per account. ## DocuSeal [Section titled “DocuSeal”](#docuseal) DocuSeal connects with an **API token** rather than an OAuth redirect. Find it in DocuSeal under **Settings → API**, then paste it into the DocuSeal step of the wizard. Four things are worth knowing before you connect it: * **Say which DocuSeal cloud your account is on.** DocuSeal runs two — `docuseal.com` (US) and `docuseal.eu` (EU) — and a token issued in one is not valid in the other. The wizard defaults to the US cloud, which is what you get if you never chose. Picking the wrong one looks exactly like a bad token, so it is worth checking rather than guessing. * **A self-hosted DocuSeal cannot be connected.** Only the two published clouds are accepted. * **Turbobase reads by default, and only reads.** Your signature requests, the people they were sent to and the templates behind them are mirrored into your instance. Sending a request *from* Turbobase is a separate switch that starts off — ask us to turn it on once you have seen the connection working. Nothing leaves your DocuSeal account until you do. * **The webhook secret is optional, and skipping it costs freshness.** If you set one in DocuSeal (**Settings → Webhooks**, pointed at your instance), signatures show up in Turbobase as they happen. Without it, Turbobase refuses deliveries it cannot verify and refreshes on its own schedule instead. You can add it later. ## WhatsApp Business [Section titled “WhatsApp Business”](#whatsapp-business) WhatsApp Business connects by **signing in to Meta** in a popup and choosing the WhatsApp Business account to connect — there is nothing to find and paste. If your business has several accounts, connect each one; they stay separate on your instance, and a phone number in one is never confused with the same number in another. Five things are worth knowing before you connect it: * **Conversations become records, not a screen.** The person you are talking to is a contact, the thread is a conversation, and every message is an activity row beside everything else your instance knows about that person — so a customer’s WhatsApp history sits next to their orders and their invoices. Turbobase does not add an inbox or a chat view, now or later: reading conversations and replying are things an app built on your instance does, over its API. * **Who answers a number decides who can read it.** Each business number can be assigned to a person or a role through your instance’s API, and that assignment is what controls visibility: the person it names reads that number’s conversations, nobody else does, and a number nobody answers is readable by administrators only. Administrators read everything. The assignment is yours alone — it is never sent to Meta. * **Sending starts off, and WhatsApp decides what may be sent.** A reply raised in Turbobase goes out under your own verified business name and cannot be withdrawn, so sending is a separate per-instance setting Turbobase staff enable on request. Once it is on, two of WhatsApp’s own rules apply: you may send ordinary text only within **24 hours** of the customer’s last message, and outside that window only a **template WhatsApp has approved**. Both are checked before anything leaves your instance, so a refused send costs nothing and says what to send instead. * **Meta bills you for messages, not us.** Per-message charges go to your own WhatsApp Business account on the payment method you hold with Meta. They do not come through Turbobase and do not appear on your Turbobase invoice. * **History before the connection does not arrive.** WhatsApp publishes no way to read past messages, so your instance mirrors conversations from the moment the account is connected onward. The same is true of anything sent from the WhatsApp Business phone app on a number you also use there. ## Azure Blob Storage [Section titled “Azure Blob Storage”](#azure-blob-storage) Azure Blob Storage connects with a **Microsoft Entra application** rather than an OAuth redirect. In the Azure portal, register an application under **Microsoft Entra ID → App registrations**, give it a client secret, and grant it the **Storage Blob Data Reader** role on the container you want mirrored — or **Storage Blob Data Contributor** if you also want Turbobase to be able to add files. Then paste the directory (tenant) id, the application (client) id and the client secret into the Azure Blob Storage step of the wizard, along with your storage account name and the container. Five things are worth knowing before you connect it: * **One connection holds one container.** Not one storage account — one container inside it. If you keep documents in two containers, connect each as its own connection; nothing Turbobase does to one can reach the other. * **Choose how the files are kept, and the choice is yours to make.** **Copied** brings each file into your instance, so reading one never depends on Azure being reachable and never costs you egress again. **Left in Azure** keeps only a pointer, so you store nothing twice and pay egress each time somebody opens a file. The wizard asks and does not pick for you, because the right answer depends on your bill rather than on your data. * **Either way the assistant can answer from what your files say.** Text is read out of a file when it is mirrored, whichever mode you chose, so leaving the files in Azure saves you storage without costing you search. Your documents land alongside your other knowledge sources, not in a table of their own. * **Turbobase only reads until you ask for more.** Adding files is a separate per-instance setting that Turbobase staff enable on request, so the connection stays one-way until you ask. When it is on it covers **the whole connected container, at any folder depth** — there is no narrower scope to pick, so connect a container you are content for Turbobase to write into. Removing a file is built but not yet available by any route. * **Writing needs a container Azure can undo.** A write to blob storage is immediate and has no review step, so Turbobase refuses to write at all unless the account has **blob versioning** or **blob soft delete** turned on. It is checked when you connect, and again before anything would be overwritten. If neither is on, the connect is refused and tells you which to enable. New and changed files appear within about four hours rather than instantly: Azure can be made to call out when a blob changes, but that is a subscription you would create and pay for in your own Azure account, so Turbobase refreshes on its own schedule instead. ## Pausing a connection, and removing one [Section titled “Pausing a connection, and removing one”](#pausing-a-connection-and-removing-one) A connection’s life doesn’t end at connecting it. The instance’s **Connections** screen in the console lists every connection you hold, one row each, and carries three actions: **pause** one, which stops it syncing in both directions; **resume** it again; and **remove** it, which deletes the records it brought in. **+ Add a connection** is on the same screen, for a source that holds more than one. **Pausing is the reversible one, and it changes nothing you already have.** A paused connection stops being polled and its webhook deliveries are discarded, so no new records come in — and nothing goes the other way either: a change you make in Turbobase to a record that came from a paused connection is refused, with a message telling you the connection is paused, rather than being accepted and silently never sent. Everything already mirrored into your instance stays exactly as it is, and your apps, your reports and your queries carry on reading it. Resume it whenever you like and it picks up from where the source is then, and changes start flowing back again. Pause acts on **one connection**: if you hold two NetSuite accounts you can pause one and leave the other syncing. Your app shows a paused connection as **Paused** on its [Sources](/tenant/source-health/) row, so a connection that has gone quiet doesn’t read as a broken sync. **Pausing is not the same as turning data sync off.** The [data sync](/getting-started/connect-your-first-source/) choice is made once, per source, at connect time, and it can only be turned on — it decides whether a mirror exists at all, and app surfaces start depending on that mirror the moment it does. Pause is the other switch: per connection, and always reversible. **Removing a connection deletes the records it brought in, permanently.** There is no undo and no backup. Every mirrored row that came from that connection goes, along with the connection itself; a consolidated customer that only that connection knew about goes with it, and one that another connection also knows about stays. The console asks you to type the connection’s name before it will proceed, and offers pause as the non-destructive alternative in the same dialog. Re-connecting the same account afterwards starts a fresh, empty backfill — it does not bring the old rows back. Removing a connection does not revoke your other connections’ access: the stored credentials for a source are only cleared when its **last** connection is removed. ## Per-instance gating [Section titled “Per-instance gating”](#per-instance-gating) Adding a source to a live instance is enabled per instance and per connector: Turbobase staff turn a connector on for your instance. If a source isn’t enabled for you yet, the connect page surfaces a contact path instead of the connect button. Reach out if you need a connector turned on. ## After connecting [Section titled “After connecting”](#after-connecting) A newly connected source starts mirroring into your instance’s database. Watch it sync and confirm health from your app’s [source health](/tenant/source-health/) view, and browse the new data through your [data model](/tenant/data-model/). New sync volume shows up in [Billing and usage](/console/billing-and-usage/). ## Related [Section titled “Related”](#related) * [Connect your first source](/getting-started/connect-your-first-source/): the in-wizard first connect. * [Orgs and instances](/console/orgs-and-instances/): instance status and where the connect entry points live. # Billing and usage > Track usage and manage billing for your Turbobase instances from the console. Shipped The [console](/console/overview/) gives you usage tracking for each instance today, with self-service billing on its way for each [organization](/console/orgs-and-instances/). Billing is scoped to your **active org**: switch orgs in the nav to see another org’s billing. ## Usage [Section titled “Usage”](#usage) Usage is live today. It’s tracked per instance and rolled up per billing period; each instance has a **Usage** view (`/instances//usage`, linked from the instance’s row on the instances list) showing: * **Current period** — usage so far in the open billing period. * **Recent periods** — a history of prior periods. * **Lifetime** — cumulative totals. The metrics Turbobase meters are: * **Sync events** — the volume of records mirrored from your connected sources. * **AI tokens (in / out)** — input and output tokens consumed by Pilot, your AI assistant, and the other AI agents in your app. Inside the app the same usage is broken down by tier (Frontier, Chat, Fast and Other); what’s reported is token counts, not a dollar figure or the underlying AI provider. * **CPU hours**, **egress (GB)**, and **peak database size (GB)** — the infrastructure footprint of your instance. These roll up from the agents running inside each instance, so what you see reflects real consumption — not an estimate. ## Billing — coming soon [Section titled “Billing — coming soon”](#billing--coming-soon) The **Billing** tree (`/billing`) is in the console but sits behind a **“coming soon”** cover while the pricing model is finalized — you won’t be charged anything you haven’t seen in writing. If you need to talk pricing now, reach out to your Turbobase contact. When billing activates, the pages already under the cover open up: * **Billing details** (`/billing/details`) — your customer record (billing email and contact info). * **Payment methods** (`/billing/payment-methods`) — the cards on file; add or update a card. * **Invoices** (`/billing/invoices`) — your invoice history, with per-invoice detail and downloadable copies (and a CSV export of the list). Paid invoices also get a downloadable PDF receipt. * **Add-ons** (`/billing/addons`) — optional capabilities you can layer on top of your plan. Each add-on is priced as a flat monthly charge, metered on usage, or included up to a limit in your plan; it applies per instance or org-wide depending on the add-on, and some require Turbobase staff approval before they activate. Note Relatedly, the plan picker on the create-instance form renders the tiers but is locked while pricing is finalized — every new instance is created on the entry plan, **Ignition**. Usage metering runs as described above regardless. ## Who can see billing [Section titled “Who can see billing”](#who-can-see-billing) Billing is an org-level surface, gated by your console (customer-realm) membership of the org. It governs your subscription only; it never exposes the business data inside an instance, which is gated separately by the app’s own users. See [Console overview](/console/overview/) for the realm split. ## Related [Section titled “Related”](#related) * [Orgs and instances](/console/orgs-and-instances/) — how orgs own the instances you’re billed for. * [Add sources](/console/add-sources/) — connecting more sources drives sync-event usage. # Orgs and instances > How orgs own instances in the console and how to manage them. Shipped Two objects structure everything in the [console](/console/overview/): **organizations** own **instances**. ## Organizations [Section titled “Organizations”](#organizations) An organization (org) is the ownership and billing boundary. It owns instances, holds members, and is where invoices and payment methods live. * **Create** an org at `/orgs/new`. The console suggests a URL-safe slug from the name, and asks for a contact email; a website and a phone number are optional. Your own role is shown but not yours to pick — the first member of a new org is always its admin. * **Switch** orgs from the nav. Your **active org** scopes the create-instance form and the billing views. * **Settings** — an org admin can rename the org from its settings page. Any member can leave an org from the same page — except the last admin, who must promote another member to admin first so the org isn’t left unmanageable. A newly self-signed-up org starts disabled until Turbobase staff enable it. You can still create an instance in the meantime — it keeps your slug claim and parks with a **New (no access)** badge and an **Awaiting approval** note while you wait. Once the org is enabled the badge becomes **Pending**, and an instance admin gets a **Start Provisioning** button on the row — or **Launch instance** on the instance’s provisioning page, under “Your organization is approved”. ### Members and access [Section titled “Members and access”](#members-and-access) Manage who can do what from the org’s **Members & Access** page (admin-only): * **Org roles** — each member is an org **admin** or **viewer**. Admins manage members, invitations, and org settings; viewers are read-only (they can list the org’s instances and open usage, but can’t change anything). * **Per-instance roles** — separately from the org role, each member can hold a role on each instance: **admin** (manage the instance’s config and team, delete it), **developer** (work with the instance’s data, not its config), or **viewer** (read-only). The page lists one row per member; the **Instance access** cell summarises what they hold and expands to a line per instance, each with its own role picker — **No access**, viewer, developer or admin. * **Manage instances** — managing the org is separate from being allowed to spin up (or tear down) production. Creating or deleting an instance needs this org-level permission, a checkbox on the member’s row that an org admin ticks explicitly; being an org admin alone doesn’t confer it. * **Scoped invites** — when inviting a teammate, you pick their org role *and* a per-instance role for each existing instance (defaulting to no access), so the grants land the moment they accept. * **Guards** — the console blocks changes that would lock you out: you can’t suspend or remove the last active org admin, and you can’t untick **Manage instances** on yourself when you’re its last holder. Member and access changes are recorded in the org’s audit log, shown on the same page. ## Instances [Section titled “Instances”](#instances) An instance is one provisioned Turbobase environment — its own dedicated Postgres database and its own app at `.turbobase.app`. Each instance belongs to exactly one org. * **Create** at `/instances/new`, scoped to your active org. Pick a name; the slug becomes your subdomain. Instances are hosted in our US East (Ashburn) region — the single location on offer today, so there’s no region picker. The plan selector shows the tiers, but it’s locked while pricing is finalized: every new instance is created on the entry plan, **Ignition**. * **List** at `/instances`, grouped by org. Each row shows the instance’s live status and the one action that state allows while it’s still being set up: **Start Provisioning** (approved and waiting on you), **Resume** (provisioning already under way), **Set-up** (run the wizard) or **View failure**. * The instance’s slug is the link that opens your Turbobase app, in a new tab. It only becomes a link once setup is finalized; before that it’s plain text and the row points you back into the [setup wizard](/getting-started/connect-your-first-source/). * **Connections** on a finalized row opens that instance’s connections screen. Every system you’ve connected has a row there, and you can pause one — which stops it syncing in both directions until you resume it — or remove it, which permanently deletes the records it brought in. [Add sources](/console/add-sources/) covers both in full. * **Usage** and **Delete** are icon buttons rather than labelled ones — Delete is the trash glyph. Hover either one to see its name. * **Delete** is self-service but deliberately guarded: you must hold the **admin** role on that instance *and* **Manage instances** on the org, and you confirm by typing the instance’s slug exactly into the confirmation modal. Teardown then runs asynchronously — the instance shows **Deleting…** until it’s gone. ## Provisioning status [Section titled “Provisioning status”](#provisioning-status) Instance status moves through a small set of stages. The console renders each one as a friendly label and color (green = ready, amber = in-flight, red = needs attention, gray = neutral). The states you may see: | Console label | Meaning | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | New (no access) | Created against a not-yet-enabled org; parked with an **Awaiting approval** note until staff enable it | | Pending | Your org is enabled and the instance is yours to start — **Start Provisioning** on the row, or **Launch instance** on its provisioning page | | Provisioning… | Work has started | | Provisioning… (database) | Building your dedicated database schema | | Provisioning… (services) | Deploying the instance’s app and sync services | | Provisioning… (waiting for health checks) | Waiting for everything to report healthy | | **Ready** | Live; ready for setup / use | | Paused | Suspended | | Failed | Provisioning failed — contact support | | Upgrading… (with a database / services qualifier) | A version upgrade is in flight | | Upgrade failed | An upgrade didn’t complete — contact support | | Deleting… | Teardown in progress | | Deleted | Terminal — the instance is gone | The happy path runs Provisioning… → database → services → health checks → **Ready**. From Ready an instance can later move into an upgrade, into Paused, or into Deleting → Deleted. A failure during provisioning shows **Failed**; a failure during an upgrade shows **Upgrade failed**. Reaching **Ready** doesn’t mean setup is done — a Ready instance still needs the [setup wizard](/getting-started/connect-your-first-source/) run before its app opens. That’s why a Ready row keeps its **Set-up** button until the wizard is finalized, and only then does its slug become a link. ## Related [Section titled “Related”](#related) * [Provision your instance](/getting-started/provision-your-instance/) — the step-by-step create flow. * [Add sources](/console/add-sources/) — connect more systems to a live instance. * [Billing and usage](/console/billing-and-usage/) — what each org is consuming and owes. # Console overview > Tour of the user console: orgs, instances, billing, and adding sources. Shipped The **user console** at `console.turbobase.io` is your self-service home for everything around your Turbobase subscription. It’s where you sign up, create organizations, provision and manage instances, connect sources, and handle billing — without ever installing or running anything yourself. The console is distinct from your Turbobase app (`.turbobase.app`); see [Open your Turbobase app](/getting-started/open-your-tenant/) for the split. ## What the console offers [Section titled “What the console offers”](#what-the-console-offers) * **Organizations.** The billing and ownership boundary. Create orgs, manage members and their capabilities, switch between orgs. See [Orgs and instances](/console/orgs-and-instances/). * **Instances.** Provision new instances, watch them move through the provisioning states to **Ready**, and open the app once setup finalizes. See [Orgs and instances](/console/orgs-and-instances/). * **Sources.** Connect your system of record during the setup wizard (Oracle NetSuite for most instances), and add more sources to an already-provisioned instance later. See [Add sources](/console/add-sources/). * **Billing and usage.** See per-period usage (sync events, AI tokens, infrastructure footprint) per instance; billing self-service is coming soon. See [Billing and usage](/console/billing-and-usage/). * **Account.** Your console profile, sign-in, and security settings. ## The customer realm [Section titled “The customer realm”](#the-customer-realm) The console is the **customer** identity realm of Turbobase. It has its own sign-in, its own session, and its own user records — entirely separate from: * your **Turbobase app** users (`.turbobase.app`), who sign in with credentials you set during setup, and * Turbobase staff tooling, which you never see. A console account governs *which orgs and instances you can manage and pay for*. It never grants access to the business data inside an instance — that’s gated by the app’s own users and permissions. Sign in to the console to manage your subscription; sign in to the app to work with your data. ## Account security [Section titled “Account security”](#account-security) Your console account’s security settings live at `/account`: * **Change password** — update your password (you’ll confirm your current one). * **Passkeys** — enroll a passkey for phishing-resistant sign-in, and remove it later if needed. * **Authenticator app (TOTP)** — enroll a time-based one-time-password app as a second factor, and remove it later if needed. When a second factor is enrolled, sign-in adds an MFA challenge step — you’ll verify with your passkey or authenticator code after your password. Forgotten passwords are handled by the reset-password flow on the sign-in page. ## Getting started [Section titled “Getting started”](#getting-started) Signing up needs an early-access invite. If you don’t have one, request access at `console.turbobase.io/early-access` — tell us your company, where you are, and roughly how many people you are — and we’ll send an invite link when we’re ready for you. An invite link takes you straight to sign-up with the code already filled in. Once you’re in, start with [What is Turbobase?](/getting-started/what-is-turbobase/) and then [Provision your instance](/getting-started/provision-your-instance/). # Calling the model > Send a prompt to the AI model your instance is already configured with — from an app, from a program, or from inside a process step — and the limits that bound what it costs. Shipped Your instance already has an AI model configured and paid for. This page is how your own code asks it a question. There are two ways in, and they are the same model and the same allowance: * **`POST /v1/ai/complete`** — one prompt in, one answer out. Called from an app’s own screen under the session the person is already signed in with, or from a program with an access token. * **an `ai` step in a process** — the engine makes the call for you, mid-chain, and hands the answer to the next step. See [Writing processes](/extending/writing-processes/). A step’s own `build` function still **cannot** reach the model, and that is deliberate: an extension is pure — no secrets, no network — so the call is made by the engine around it rather than from inside the sandbox. ## `POST /v1/ai/complete` [Section titled “POST /v1/ai/complete”](#post-v1aicomplete) It is a **passthrough**. No skills, no knowledge-base grounding and no system prompt of ours is added to what you send — the prompt that reaches the model is the prompt you wrote. ```sh curl -X POST https://acme.turbobase.app/v1/ai/complete \ -H 'authorization: Bearer tb_pat_…' \ -H 'content-type: application/json' \ -d '{ "app": "reorder-helper", "prompt": "Summarise this purchase order in one sentence: …", "tier": "fast", "maxTokens": 500 }' ``` ```json { "text": "A 240-unit restock of three SKUs from Globex, due 14 October.", "model": "…", "tier": "fast", "usage": { "inputTokens": 212, "outputTokens": 19 } } ``` ### The fields [Section titled “The fields”](#the-fields) | Field | Required | What it does | | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `app` | **yes** | The slug of the app spending this call. The allowance is per app and the usage row is keyed on it, so a call that names no app is one nobody can attribute, bound or bill. It must be an app deployed on your instance; a preview’s slug spends its own app’s allowance. | | `prompt` | **yes** | What you are asking. Non-empty, and at most 100,000 characters. | | `tier` | no | Which class of model to use. Defaults to `fast`; your instance decides which tiers are allowed. | | `maxTokens` | no | The most the answer may be. Defaults to 1,024 and is capped by your instance’s per-call limit. | There is **no `model` field**, and sending one does nothing. A caller naming a raw model id picks the instance’s bill; the tier is the knob that keeps that decision the instance’s. ### From an app, use the client rather than the route [Section titled “From an app, use the client rather than the route”](#from-an-app-use-the-client-rather-than-the-route) `src/lib/turbobase.ts` — the file `turbobase apps init` writes into every app project — carries `askModel`, which fills in your app’s slug and turns a limit refusal into a typed error: ```ts import { askModel, TurbobaseAiLimitError } from './lib/turbobase'; try { const { text } = await askModel({ prompt: 'Summarise this order: …', tier: 'fast' }); } catch (err) { if (err instanceof TurbobaseAiLimitError) { // `err.retryAfterMs` is null when waiting would not help — a prompt over // the per-call ceiling, or a tier this instance does not allow. } } ``` An app project scaffolded before this release does not have it; re-copy the file, or call the route directly and send `app` yourself. ### Who may call it [Section titled “Who may call it”](#who-may-call-it) **From an app’s own screen**, the session cookie the person is already signed in with. Nothing extra to arrange — it is that app’s user asking, under the allowance the instance set for that app. **From a program**, a personal access token carrying **Ask this instance’s AI model** (`ai:invoke`). Tick it when you mint the token, on the **Access tokens** page — your account menu, or `/settings/tokens` directly. A token belongs to a Turbobase account, so that page is not offered to administrators who opened the app from inside NetSuite. That permission is **not** on any token by default, including every token that already exists — nothing gains the ability to spend your model budget by upgrading. It is revocable on its own, without touching what else the token does, and it reaches no rows: a token carrying it and nothing else can neither read nor change your data. A token without it is refused `403`, and the model is not called. ## The limits, and what they answer [Section titled “The limits, and what they answer”](#the-limits-and-what-they-answer) Every call bills model tokens against your instance, so four ceilings bound it. An administrator sets them; these are the shipped defaults: | Limit | Default | What it stops | | ------------------- | -------------- | ----------------------------------------------------------------- | | Tokens per minute | 60,000 | A loop spending a day’s allowance in a minute. | | Max tokens per call | 8,000 | One prompt taking most of a minute’s allowance. | | Tokens per day | 1,000,000 | A steady leak nobody notices. | | Allowed tiers | `fast`, `chat` | An app reaching for a model class this instance does not pay for. | These apply to **each app**. Every call must also fit one window the **whole instance** shares — four times the per-minute and per-day numbers above — so the total stays bounded however many apps are calling at once. The limits are checked **before** the model is asked, so a refused call costs nothing. A call that goes through spends what it actually spent, not what it estimated. ### The refusals [Section titled “The refusals”](#the-refusals) | Status | `error` | What happened | | ------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `429` | `ai_token_limit` | A per-minute or per-day ceiling. The body carries `retryAfterMs` — **when it clears** — and a `retry-after` header beside it, so you wait rather than poll. `scope` says whether it was your app’s own window (`app`) or the one every app shares (`instance`). | | `404` | `ai_app_not_found` | `app` is not an app deployed on this instance. | | `403` | `view_as_session` | The call came from a view-as session, which is read-only. | | `400` | `ai_tier_not_allowed` | You asked for a tier this instance does not allow. Waiting never clears this; ask an administrator. | | `503` | `ai_limits_unreadable` | The limits could not be read. **Fails closed** — the call is refused rather than let through unbounded. | | `503` | `llm_unavailable` | No model is configured on this instance. | | `503` | `ai_budget_paused` | The instance’s spend gate has paused model calls. The body carries `until`. | | `403` | `insufficient_scope` | The token does not carry `ai:invoke`. | `retryAfterMs` matters more than it looks: without it the only recovery from a rate limit is to poll, which spends the allowance you are waiting for. ## Declaring your app’s own, lower limits [Section titled “Declaring your app’s own, lower limits”](#declaring-your-apps-own-lower-limits) The instance’s limits bound the **bill**. Your app’s own limits bound **your app** — so that a runaway loop in one app exhausts its share rather than everybody’s. Declare them in `public/turbobase.json`: ```json { "name": "Reorder helper", "ai": { "tokensPerMinute": 5000, "maxTokensPerCall": 2000, "tokensPerDay": 50000, "tiers": ["fast"] } } ``` Every field is optional, and each is a **narrowing**: declare less than the instance allows and the lower number is what is enforced for your app. Declare **more** and `turbobase ext deploy` / `turbobase apps deploy` refuses the deploy, naming the field and both numbers: ```plaintext this app declares ai.tokensPerMinute of 90000, and this instance allows 60000. ``` The refusal is at deploy rather than at run time on purpose: a declaration above the instance’s limit means an author believes their app may spend more than it can, and the first symptom would otherwise be an unexpected refusal in code they thought they had sized. A deploy is the moment somebody is watching. ## What every call records [Section titled “What every call records”](#what-every-call-records) One `llm_usage` row per call, under `app:`, with the input and output token counts, the provider, the model and who asked. That is what the instance’s own spend reporting reads, and it is why `app` is required rather than inferred. A call made by a process’s `ai` step is recorded the same way, under `process:`, and also lands on that run’s ledger with its token counts — so a re-delivered run hands on the answer it already paid for instead of billing a second one. # Deploy, plan, promote > Ship extensions with config-as-code — export a bundle, plan the diff, deploy, and promote across environments behind a fail-closed gate. Shipped Once an extension passes [its tests](/extending/sandboxes-and-testing/), you ship it. Turbobase supports two deploy styles: a quick **single-file** deploy for one extension, and **config-as-code** for the whole instance — where **your repository is the desired state**, `plan` diffs it against an instance, and `deploy --to` applies it. `turbobase logs` closes the loop after a deploy. ## Single-file deploy [Section titled “Single-file deploy”](#single-file-deploy) The fast path — push one authored extension straight to the instance: ```sh turbobase ext deploy hooks/customer.before_save.ts # deployed ns:customer:require-name → status=active (created). ``` The CLI parses the file’s metadata (id / recordType / event / source / kind), `POST`s it to `/v1/extensions`, and prints the resulting id, status, and outcome. A non-admin personal access token (PAT) gets a `deploy forbidden (403)`. ## Config-as-code — your repository is the desired state [Section titled “Config-as-code — your repository is the desired state”](#config-as-code--your-repository-is-the-desired-state) For everything else, the unit of deploy is **your project tree**. These four inputs are the config-as-code source of truth for an instance’s deployable surfaces, and `plan`, `deploy --to` and `promote` all build from them: | In your repo | Becomes | | ------------------------------------ | --------------------------------------------------------------------- | | `hooks/**/*.ts` (`defineHook`) | hooks — including nested folders, which is what `ext new hook` writes | | `conduits/**/*.ts` (`defineConduit`) | conduits | | `turbobase/sync.json` | promoted-field ownership + mirrored tables | | `turbobase/schedules.json` | scheduled jobs | `turbobase apps init` scaffolds all four, so a project has them from the first commit. Nothing has to be generated first — a plan reads the branch you are on, which is what lets a pull request show what merging would change. ### Adopting an instance that already has config [Section titled “Adopting an instance that already has config”](#adopting-an-instance-that-already-has-config) If your instance was configured before the repo existed — hooks deployed one file at a time, a schedule set up in the console — pull it down first: ```sh turbobase ext export # wrote turbobase/bundle.json (142 lines). ``` `ext export` calls `GET /v1/config/export` (needs `extensions:deploy`; a PAT without it gets a clear 403). This is the **adopt** path, not the authoring one: move the extensions it lists into `hooks/` and `conduits/`, and the file stops being consulted. A project that authors nothing in-tree still falls back to `turbobase/bundle.json`, so nothing breaks while you migrate. ## `plan` — diff before you write [Section titled “plan — diff before you write”](#plan--diff-before-you-write) `plan` prints a terraform-style diff of **this project** against a target env’s **live** config. It writes **nothing** — run it on every pull request: ```sh turbobase ext plan --to prod # plan against prod: # + hook ns:customer:require-name # ~ conduit shop2ns:order # - hook custom:legacy:foo # # 1 to add, 1 to change, 1 to destroy. ``` `--from ` is advisory (it just labels which env the config was authored against); your project is always the desired state and `--to` selects the env whose live state you diff against. An empty plan prints `(no changes — up to date.)`. A `-` line means the instance has something your repo does not declare. Reading those **before** merging is the point of planning on a pull request — but note that `deploy` will not act on them unless you ask (see below). ## `deploy --to` — apply the whole bundle [Section titled “deploy --to — apply the whole bundle”](#deploy---to--apply-the-whole-bundle) To apply the bundle (idempotently) to an env: ```sh turbobase ext deploy --to sandbox # applied bundle to sandbox: +1 ~1 -1 (2 backfill(s) queued) ``` `deploy --to` builds the manifest from your project tree and `POST`s it to `/v1/config/apply`, reporting adds / changes and any backfills queued. `` is ignored when `--to` is given. As with single-file deploy, a non-admin PAT gets a 403. ### Removal is opt-in [Section titled “Removal is opt-in”](#removal-is-opt-in) **`deploy --to` adds and updates. It does not remove.** Deleting a hook from your repo does not delete it from the instance; the run prints what it declined to remove: ```sh turbobase ext deploy --to prod # applied bundle to prod: +1 ~1 -0 # 2 resource(s) exist on prod but are not declared by this project — NOT removed: # hook custom:legacy:foo # schedule nightly-legacy-export # Add them to this repo (`turbobase ext export` pulls them down), or re-run with --prune to remove them. ``` Pass `--prune` to take them: ```sh turbobase ext deploy --to prod --prune # applied bundle to prod: +1 ~1 -2 ``` The safe default exists because a repo does not always declare everything already running on an instance. Without it, the first `deploy --to` from a newly scaffolded project — which declares one example hook — would delete every other hook, conduit, promoted field, mirrored table and scheduled job on that instance. Once your repo IS the whole truth for your config, add `--prune` to your CI step. `promote` takes `--prune` too, with the same default. #### A prune never leaves a schedule running a process you removed [Section titled “A prune never leaves a schedule running a process you removed”](#a-prune-never-leaves-a-schedule-running-a-process-you-removed) One removal is refused rather than taken: dropping a **process** while a recurring job on the instance still runs it. The job cannot do anything useful once the process is gone — it fires on its schedule, fails every time, and switches itself off after five failures — so the deploy stops instead: ```sh turbobase ext deploy --to prod --prune # turbobase: bundle apply failed: POST /v1/config/apply → 400: invalid_bundle: # schedule 'rollup-tick' runs the process 'custom:process:nightly-rollup', which # this deploy would remove — the schedule would keep firing, fail every time and # switch itself off after five failures. Keep the process in the bundle, point # the schedule at another process in the same deploy, or drop the schedule in # the same deploy (delete it on the instance if it is not the bundle's) ``` That whole sentence reaches your terminal, ways out included — the refusal is the instance’s own words and the CLI no longer shortens one. Nothing is written when it refuses — not the additions earlier in the same plan. Three ways out, and the right one depends on what you meant: * **Retire both.** Delete the schedule from `turbobase/schedules.json` in the same commit as the process. One deploy, both gone — this is the supported way. * **Move the job.** Point the schedule at a different process in the same commit. The job is screened against the body it will have once the deploy lands, so repointing it at a process you keep — or one this same bundle brings — applies normally, and so does turning it into a chat-prompt or system job. * **Keep the process.** Leave it declared and the prune carries on with everything else. A job somebody created in the app rather than in your repo is not yours to drop from the bundle; delete it on the Scheduled tasks page and re-run the deploy. A process no job runs prunes exactly as before, and so does one whose job was already dead before this deploy: the refusal is about an orphan your deploy would *create*, never one it inherited. ## `promote` — env→env behind a fail-closed gate [Section titled “promote — env→env behind a fail-closed gate”](#promote--envenv-behind-a-fail-closed-gate) Promotion moves a vetted bundle from one env into another — typically `sandbox → prod`. It is guarded by a **fail-closed confirm-slug** check: ```sh turbobase ext promote --from sandbox --to prod --confirm acme # promoted sandbox → prod (acme): +3 ~2 -0 ``` If `--confirm` does **not** exactly match the target instance’s slug, the CLI rejects **before any network write** — nothing is POSTed: ```sh turbobase ext promote --from sandbox --to prod --confirm wrongslug # turbobase: promote refused (fail-closed): … ``` This is the customer promoting their **own** instance config across their own environments. It is unrelated to — and never touches — the control-plane’s production cloud deploys. ## Close the loop — `turbobase logs` [Section titled “Close the loop — turbobase logs”](#close-the-loop--turbobase-logs) After a deploy, watch the extension run: ```sh turbobase logs --subsystem hooks --since 10m # every hook outcome turbobase logs --extension ext_abc123 # this extension's log buffer turbobase logs --conduit shop2ns:order --follow # a CONDUIT's run ledger ``` With no target flag, `logs` reads the unified platform event log — every hook rejection and error, every guardrail verdict, every conduit run, in one stream with one correlation id. `--extension ` reads that extension’s bounded log ring buffer (`[level] ts message` per line) and `--conduit ` streams the `conduit_event` run ledger, polling every `--interval-ms` (default 2000). These two flags replace `turbobase ext logs`, which is removed. ## The full workflow [Section titled “The full workflow”](#the-full-workflow) ```sh turbobase ext export # 1. snapshot live config # edit hooks/ + conduits/, then: turbobase ext lint hooks/foo.ts # 2. lint offline turbobase ext test hooks/foo.ts # 3. dry-run in the sandbox turbobase ext plan --to sandbox # 4. preview the diff turbobase ext deploy --to sandbox # 5. apply to sandbox turbobase ext promote --from sandbox --to prod --confirm acme # 6. promote turbobase logs --extension ext_abc123 # 7. verify it runs ``` ## Next steps [Section titled “Next steps”](#next-steps) * [Sandboxes and testing](/extending/sandboxes-and-testing/) — the steps before deploy. * [Marketplace](/extending/marketplace/) — install / publish instead of authoring locally. * [CLI reference](/cli/reference/#ext-deploy) — every flag. # Marketplace > Install published extensions into your instance and publish your own to the Turbobase marketplace. Shipped Not every extension is authored from scratch. The **marketplace** lets you install a published hook or conduit into your instance with one command, and — if your instance is an approved publisher — publish your own for others to install. Installing and publishing are CLI-only today: there is no in-product marketplace to browse. You install an extension by name with `turbobase ext install`. ## Before you start [Section titled “Before you start”](#before-you-start) Both commands run against the instance you’re [signed in](/cli/overview/#authenticate--turbobase-auth-login) to — the install lands in that instance’s store. Publishing is gated separately: only approved publishers can push an extension to the registry. ### The registry credential [Section titled “The registry credential”](#the-registry-credential) The marketplace is the one part of the CLI that does **not** run on your stored PAT. It talks to the Turbobase registry rather than to your instance, so it takes its own credential — two environment variables, and you need both: ```sh export TURBOBASE_REGISTRY_URL=… # the registry endpoint, given to you with the token export TURBOBASE_REGISTRY_TOKEN=… # ask Turbobase support to issue one ``` **Ask Turbobase support to issue a registry token for your instance.** They give you both values together. The token is shown exactly once when it is created — store it in your secret manager, not in a repo. If you lose it, ask for a new one; issuing a replacement retires the old one. Three things worth knowing about it: * **It is not one of your instance’s internal credentials.** It authorises the marketplace routes and nothing else — it cannot read or write your instance’s data, and it is not the credential your instance uses internally. * **It can be revoked on its own.** If it leaks, ask support to revoke it. Your instance keeps running normally; nothing else is affected. * **Both variables must come from the same place.** The CLI will not pair a URL you set with a credential it found somewhere else in your environment, and `TURBOBASE_REGISTRY_URL` must be `https://` — the token travels to it as an `Authorization` header, so it is never sent over a plaintext connection. With either variable missing, both commands stop before making a request: ```plaintext turbobase: marketplace not configured — set TURBOBASE_REGISTRY_URL and TURBOBASE_REGISTRY_TOKEN. ``` ## `ext install` — install a published extension [Section titled “ext install — install a published extension”](#ext-install--install-a-published-extension) ```sh turbobase ext install acme-recipes # latest version turbobase ext install acme-recipes@1.2.0 # pinned version # installed acme-recipes@1.2.0 → ext_xyz (status=active, created). ``` The CLI fetches the published bundle from the marketplace, parses its metadata, and deploys it — an install lands **active**, with no proposal to approve. On the Extensions tab it then reads as **CLI deploy**, the same origin a hook you deployed yourself carries. A non-admin personal access token (PAT) gets an `install forbidden (403)`. ### Binding a connection at install [Section titled “Binding a connection at install”](#binding-a-connection-at-install) A published bundle is **connection-agnostic**: it never bakes in a specific connection. If the manifest declares a `{ type:'connection' }` input, you bind one of *your* instance’s connections at install time: ```sh turbobase ext install acme-recipes --connection conn_123 # installed acme-recipes@1.2.0 → ext_xyz (status=active, created) bound to connection conn_123. ``` Omit `--connection` for such an extension and the CLI tells you which source the extension wants and refuses until you bind one. The runtime then acts only on that connection. ## `ext publish` — publish to the marketplace [Section titled “ext publish — publish to the marketplace”](#ext-publish--publish-to-the-marketplace) ```sh turbobase ext publish hooks/recipe.ts \ --name acme-recipes \ --pkg-version 1.0.0 \ --manifest manifest.json # published acme-recipes@1.0.0 (kind=hook) to the marketplace. ``` The CLI derives the kind (hook vs conduit, exactly as `deploy` does), validates the optional manifest locally, and publishes. Flags: | Flag | Meaning | | ------------------------- | ------------------------------------------------------------------------------------------------- | | `--name ` | **Required.** Marketplace package name (lowercase, e.g. `acme-recipes`). | | `--pkg-version ` | **Required.** Semver-ish (e.g. `1.0.0`). Not `--version` — that is the CLI’s own version flag. | | `--manifest ` | Optional JSON manifest declaring `inputs[]` (including `{ type:'connection' }`) and capabilities. | ### The publisher allowlist [Section titled “The publisher allowlist”](#the-publisher-allowlist) Publishing is gated by an **admin-managed publisher allowlist**. An instance whose slug isn’t on the list gets: ```plaintext turbobase: publish forbidden (403) — this instance is not an approved publisher. Ask staff to add this slug to the publisher allowlist. ``` Re-publishing an existing `name@version` returns 409 (`bump the version`). ### The connection-agnostic contract [Section titled “The connection-agnostic contract”](#the-connection-agnostic-contract) A published bundle must **not** bake in a literal `connection_id` — that’s validated locally *before* the bundle hits the wire. Instead, a manifest declares a `{ type:'connection' }` input, and the installing instance binds its own connection at install time (above). This keeps a published extension portable across every instance that installs it. ## Install vs. author [Section titled “Install vs. author”](#install-vs-author) | | Install | Author | | ---------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------- | | Source | A published marketplace bundle | Your own `hooks/` / `conduits/` | | Command | `turbobase ext install` | [`turbobase ext deploy`](/extending/deploy-plan-promote/) | | Lands as | Active, straight away | Active, straight away | | Origin on the Extensions tab | **CLI deploy** — an install is not badged apart from a deploy | **CLI deploy**, or **Code baseline** for one your instance ships with | | Connection | Bound at install via `--connection` | Set in your own config | ## Next steps [Section titled “Next steps”](#next-steps) * [Writing hooks](/extending/writing-hooks/) / [Writing conduits](/extending/writing-conduits/) — author what you publish. * [Deploy, plan, promote](/extending/deploy-plan-promote/) — ship a self-authored extension instead. * [CLI reference](/cli/reference/#ext-install) — `ext install` / `ext publish` flags. # Migrating between systems > Move data one connected system has already mirrored into a second connected system — read it, reshape it, and have the target create it — with a process and the write boundary that already ship. Shipped A **migration** here is a shape, not a separate product: rows one connected system has already mirrored into your instance are read, reshaped, and written so that a *second* connected system creates them. A customer arriving with years of Xero history and a brand-new NetSuite account wants exactly that, and every piece it needs already ships — a [process](/extending/writing-processes/) to declare the work, `ctx.query` to read the mirror, and the write boundary to create the result and push it upstream. There is no bulk import endpoint and no migration screen. This page is the shape that works today, the three limits it runs into, and the records it cannot move at all — so you learn them here rather than against a customer’s data. ## The shape [Section titled “The shape”](#the-shape) One process, **one invocation per source row**, and the record naming the system it belongs to: ```ts import { defineProcess } from '@turbobase/extensions'; // The type parameter is what gives `ctx.input` its shape; the `input` schema // below is what the invoke route validates the body against. export default defineProcess<{ externalId: string }>({ id: 'acme:migrate-customer', input: { type: 'object', properties: { externalId: { type: 'string' } }, required: ['externalId'], }, steps: [ { id: 'customer', recordType: 'customer', build: async (ctx) => { // `ctx.query` takes a query STRING, so anything you interpolate into a // filter has to be encoded — an unescaped `&` or `,` in a value is read // as more query, and silently widens the read. const id = encodeURIComponent(ctx.input.externalId); const [row] = (await ctx.query( `entity?select=company_name,email,external_id&entity_type=eq.customer` + `&source_system=eq.xero&external_id=eq.${id}`, )) as { company_name: string; email: string | null; external_id: string }[]; if (!row) throw new Error(`nothing mirrored for ${ctx.input.externalId}`); return { parent: { company_name: row.company_name, email: row.email, // The record names the system it belongs to. source_system: 'netsuite', raw: { migrated_from: { source_system: 'xero', external_id: row.external_id } }, }, }; }, }, ], }); ``` Deploy it like any other extension, then ask for it once per row you are moving: ```sh turbobase ext deploy processes/migrate-customer.ts ``` ```ts // POST /v1/processes/:id — 202 { runId, processId, state: 'queued' } await fetch('/v1/processes/acme:migrate-customer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ externalId: 'XERO-C-1' }), }); ``` ## The record names its target, not your session [Section titled “The record names its target, not your session”](#the-record-names-its-target-not-your-session) On a **create**, `parent.source_system` names which connected system the new record belongs to, and it is the value the row is stamped with. That is what makes a migration possible at all from a session signed in against the system you are migrating *away from*: the write routes by the record’s own hint, never by the session’s claim. Invoked under a Xero session, the process above still creates a NetSuite record, and the write trail queues it for the NetSuite adapter. Two things to know about the field: * **Naming a system that is not connected is refused** — `422` with `SOURCE_NOT_CONNECTED` — rather than accepted and left with nowhere to sync. * **On an update it is verified, not written.** A record already belongs to a system; restating it is fine, a different value is `422 SOURCE_MISMATCH` — or, on a record type that means a different record in different systems (`account` is a customer in Zoho CRM and Salesforce and a ledger account everywhere else), `404 row not found or not permitted`, because on those the field is also what chose the table the row was looked for in. Either way you cannot hand an existing record to another system by relabelling it. A migration creates; it never re-points. * **On a custom table it is verified, not written — on a create too.** A table mirrored from one system, or authored on the instance, already says which system owns its rows, so a create addressed by its slug or by the `customrecord_`-prefixed spelling of it takes that answer and a disagreeing `source_system` is `422 SOURCE_MISMATCH`. Those tables are per-install and are not what a migration moves; the record types above are. On an instance with one system connected the field can be left off and defaults. Name it anyway in a migration — the whole point is that there are two. ## One invocation per source row [Section titled “One invocation per source row”](#one-invocation-per-source-row) A process declares its `steps` once and the list is **fixed** when the module is deployed — what varies is how many times the platform runs it. The plain shape below is one invocation per source row, driven by whatever is doing the migrating: your app under a signed-in session, a `{ kind: 'process' }` schedule, or a script outside the browser — `turbobase processes invoke`, or a call with a personal access token carrying `extensions:deploy` whose owner is an administrator; the run writes as that owner. See [Keeping a long run going](/extending/writing-processes/#keeping-a-long-run-going). Two declarations change that, and both are the right answer more often than fanning out from a caller: * **`eachRow`** makes the process a migration: one invocation runs the whole step list once per outstanding source row, a slice at a time, queues its own next slice, and each step reads its row from `ctx.row`. See [A migration recipe](/extending/writing-processes/#a-migration-recipe) for the create-then-mark-the-row shape, and [Moving many records](/extending/writing-processes/#moving-many-records). * **`forEach`** repeats a GROUP of steps once per element of an array on the invoked input — for a chain whose length the caller decides, up to the instance’s expansion cap. See [Repeating a group of steps](/extending/writing-processes/#repeating-a-group-of-steps). Three consequences worth planning for: * **Each run is its own unit.** One failing invocation does not stop the others. Each carries its own ledger — `GET /v1/processes/runs/:runId` — with its own steps and its own refusals. * **Idempotency is per run for a chain, and per SOURCE ROW for a migration.** A run redelivered to a worker resumes where it stopped and creates nothing twice. Re-*invoking* a plain chain with identical input mints a new run, and that run creates a **second** record — nothing knows the two invocations meant the same Xero customer, so guard it yourself: stamp provenance on the record, as the example above does, and look for it in `build` before returning a payload. A process that declares `eachRow` needs none of that: its write key is derived from the row’s own `key`, so re-offering a row is a no-op. * **It will not go faster than the source.** Writes of one record type reach a source strictly one at a time, in order, and outbound calls per source are gated. Ten thousand invocations enqueued at once queue. ## The three limits [Section titled “The three limits”](#the-three-limits) | What | Today | Constant | | ------------------------------------------------------------------------- | --------- | -------------------------------- | | Rows one `POST /v1/batch` sub-query returns (not `ctx.query` — see below) | 1,000 | `READ_MAX_ROWS` | | Bytes one `ctx.query` returns | 12 MB | `MAX_BATCH_RESPONSE_BYTES` | | How long a step waits for the source | 5 minutes | `DEFAULT_AWAIT_SOURCE_BUDGET_MS` | The last column is there so this page can be held to it: a test fails the build if a number here stops matching the platform, rather than leaving you to find out from a run. **`ctx.query` hands back the whole result, or throws.** Unlike a browser’s `POST /v1/batch` read, it has no row cap: a step reads in order to decide what to write, and a silently shortened answer would migrate the first thousand records and report success. What bounds it is the byte ceiling above — a read over it fails the step with the limit named. Read a page explicitly (`limit` and `offset`) when a page is what you want, or let `eachRow` take the slice for you, as the example above does. **`awaitSource` waits on a wall clock, and in bulk it will run out.** Declare it on a step only when a *later step in the same run* needs the id the source minted for an earlier one — the ordering it buys is paid for by holding the run open. Behind a queue of other writes of the same record type, five minutes is not a generous budget; it is the ordinary case. **A wait that runs out is not a failure**: nothing was refused, the write is on the trail, and the run ends `committed` with the steps it never reached in `outstanding` and its `reason` naming the step still waiting. The record that step created **is still there**, still on its way to the source. For a bulk migration, leave `awaitSource` off: let each record go, and watch [source health](/tenant/source-health/) for what the source said. ## What cannot be migrated at all [Section titled “What cannot be migrated at all”](#what-cannot-be-migrated-at-all) A step naming a record type that is **read-only** for the target system is refused at the write boundary before anything is created. The run ends `failed`, and the step’s ledger row carries `WRITE_REFUSED` with the message `record type "subsidiary" is not writable` — and no field, because the refusal is about the type rather than about anything in your payload. `subsidiary` on NetSuite is the live case: it is mirrored inbound and has no write-back path, and neither do NetSuite’s other setup lists such as `accountingPeriod` and `currency`, so a step that tries to create one is refused. The chart of accounts is **not** among them: `account` on NetSuite is writable, so accounts can be migrated like any other record. The refusal arrives at the first step rather than half-way through a run, which is the right failure — but reading a record type’s direction in the [data model](/tenant/data-model/) first is cheaper than discovering it. The same refusal covers a record type the target system does not have **at all**, not only one it holds read-only. Record types are not global: a message belongs to the systems that carry messages, and naming one in a step that targets a system without them is refused here with the same `WRITE_REFUSED` and the same sentence. Which record types a system has is what [the data model](/tenant/data-model/) lists per source. ## Nothing migrates until the target’s write-back is on [Section titled “Nothing migrates until the target’s write-back is on”](#nothing-migrates-until-the-targets-write-back-is-on) Write-back ships **off** for every source, so a new instance is read-only *towards* the system you are migrating into. While it is off, the first step of the first run is refused at the write boundary before anything is created: the run ends `failed`, the step’s ledger row carries `SYNC_BIDIRECTIONAL_DISABLED` with a message telling you to contact us, and its `pg_row_id` is null. Turn write-back on for the **target** system before you migrate anything — this is the first thing a migration runs into, not the last. The rollback reads the same entitlement. `onFailure: 'rollback'` deletes what the run created, newest first, through the delete boundary, and that boundary asks the same question the write asked. So if write-back is switched off *between* a run’s writes and its rollback, the undo cannot run: the run ends `failed` with a reason beginning `rollback incomplete`, and the records it created are still live. A timed-out `awaitSource` step reaches that same `rollback incomplete` ending by a different route, and that one needs no flag change. Its record is still pending on the trail with no verdict, so the rollback deliberately leaves it rather than tombstone a record the source may be about to create. ## Before you run one [Section titled “Before you run one”](#before-you-run-one) * Turn write-back on for the target system. With it off nothing is created at all — the first step is refused. * Read the direction of every record type your steps name. A read-only type is a wall, not a slow path. * Decide your re-run story. Nothing keys idempotency on the source row. * Leave `awaitSource` off unless a later step needs the id. * Run it for a handful of rows first and read each run back — `turbobase processes runs` or `GET /v1/processes/runs/:runId` — before you turn the volume up. There is no offline dry-run for a process: `ext test` and `ext replay` cover hooks and conduits, and [`turbobase sandbox`](/extending/sandboxes-and-testing/#turbobase-sandbox--ephemeral-local-authoring-instances) does not seed an instance yet. A few rows against the real target is the rehearsal that exists. ## Next steps [Section titled “Next steps”](#next-steps) * [Writing processes](/extending/writing-processes/) — the full `defineProcess` contract. * [Writing conduits](/extending/writing-conduits/) — bridge a record between sources as it arrives, rather than in bulk. * [Data model](/tenant/data-model/) — which record types are bidirectional, and what write-back is. * [Sandboxes and testing](/extending/sandboxes-and-testing/) — how extensions execute, and the offline dry-runs that exist for hooks and conduits. # Real-time sync from your own NetSuite scripts > How Turbobase picks up NetSuite changes in real time, when it can't, and the two-line notify call your SuiteScripts can use to close the gap. Shipped Turbobase keeps your Postgres mirror current in two ways: a **real-time push** from Oracle NetSuite the moment a record is saved, and a **periodic sweep** every four hours that catches anything the push missed. The push is what makes your data feel live; the sweep is a safety net, not the plan. The sweep reads what changed in bulk through SuiteQL rather than one record at a time, so it finishes in minutes on a busy account, and if a sweep or a first load is interrupted it saves its place and resumes automatically within a few minutes. This page is for NetSuite developers with their own SuiteScripts in the account. It covers what the push already handles (more than most people expect), the cases it genuinely cannot see, and the one-line call that closes them. ## What is already covered [Section titled “What is already covered”](#what-is-already-covered) When your Turbobase bundle is installed, it deploys a User Event script for every record type you sync. Those deployments are released for **every NetSuite execution context**, so a save reaches Turbobase in real time no matter what performed it: | How the record was saved | Real-time push? | | ------------------------------------------------------------------ | --------------- | | A user in the NetSuite UI | Yes | | Your Suitelet calling `record.save()` | Yes | | A scheduled or Map/Reduce script | Yes | | A workflow action | Yes | | A RESTlet or SOAP/REST web services call | Yes | | `record.submitFields()` / inline edit | Yes | | CSV import **with** “Run Server SuiteScript and Trigger Workflows” | Yes | Note **You probably do not need to change your scripts.** A Suitelet that creates transactions with `record.save()` already triggers the push. Adding a notify call there is harmless — the duplicate is discarded — but it is not what makes the record sync. ## What is not covered [Section titled “What is not covered”](#what-is-not-covered) Five cases. The first four are NetSuite not telling Turbobase anything; the fifth is a push that did arrive, carrying a value NetSuite had not written yet. 1. **CSV import with server scripts switched off.** The import assistant’s “Run Server SuiteScript and Trigger Workflows” option is per-import. With it off, no User Event runs for any of the imported rows. 2. **Parent records NetSuite recalculates behind the scenes.** Creating an item fulfillment moves its sales order to *Pending Billing*; creating a vendor bill moves its purchase order to *Fully Billed*. NetSuite rewrites the parent without saving it, so no User Event fires on the parent. *Turbobase handles the common ones for you* — eight standard pairs, found through the child’s **Created From** link — so for those the parent is re-read seconds after the child and there is nothing to do. It is still worth knowing why the parent’s own script never ran. **Outside those pairs, ask us to mark the relation.** Your own record types are never one of the eight, and a link that is not *Created From* — a custom body or custom record field pointing at the record NetSuite rewrites — is not the one they follow. Tell support which record makes NetSuite recalculate which, and we mark that relation on your instance; from then on the recalculated record is re-read seconds after the one that moved it, instead of waiting for the next four-hourly refresh. It is a setting on our side, not a script change on yours. 3. **Record types you have not enabled.** Only the record types listed on your Turbobase Configuration record push at all. 4. **Anything outside NetSuite** — a middleware job or partner system that writes through an integration you control. 5. **A document number that arrives as “To Be Generated”.** This one is not a missing push — the push arrived within seconds, and the number was still the placeholder. NetSuite assigns the real number when it commits the record, which is after the point it tells us about the save, so what we are handed is the form’s placeholder. *Your tenant now asks NetSuite for the committed value before it stores anything*, so the real number arrives on the first message. That covers the document number on a transaction, the separate transaction number on a check, the identifier NetSuite assigns a new customer, vendor, contact, partner, project or employee, and NetSuite’s own last-modified stamp. **This needs bundle TB 2.13.0 or later in your NetSuite account.** On an earlier bundle the placeholder stands until the next four-hourly refresh; there is nothing to change in your own scripts either way, and a record that was already stored with a placeholder is corrected the next time it changes or the refresh re-reads it. Check your bundle version under **Customization → SuiteBundler → Search & Install Bundles → List**, and ask support to upgrade it. A value NetSuite *computes* rather than stores — a running total, a balance — is not on the record at save time at all, so no read can reach it and it still waits for the refresh. **Your instance names the record types this is happening to.** Press **How to fix** on [Late arrivals](/tenant/source-health/) and, where the realtime path is plainly delivering but certain records keep coming in on the scheduled catch-up, the panel lists exactly which record types — so you can go to the scripts that save those and nothing else, rather than auditing every record type you have. Clicking one of the late rows shows which fields the catch-up moved, which tells a real edit apart from a change nothing on screen would show. ## The notify call [Section titled “The notify call”](#the-notify-call) For cases 1 and 4, tell Turbobase directly. Add the module to your script’s `define` list and call it after your write: ```javascript define(['N/record', '/SuiteScripts/tb/lib/tb_notify'], function (record, tb) { 'use strict'; function onRequest(context) { var soId = record.submitFields({ type: 'salesorder', id: context.request.parameters.id, values: { custbody_shipment_status: 'SHIPPED' } }); tb.notify('salesorder', soId); // ← the whole integration } return { onRequest: onRequest }; }); ``` If you already have the record loaded, pass it and skip the reload: ```javascript var rec = record.load({ type: 'salesorder', id: soId }); // ... your changes, then rec.save() ... tb.notify('salesorder', soId, { record: rec }); ``` **`tb.notify(recordType, id, options)`** | Argument | Meaning | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `recordType` | NetSuite record type id — `'salesorder'`, `'inventoryitem'`, `'customrecord_my_type'`. Use the SuiteScript spelling, the same string `record.type` returns. | | `id` | Internal id of the record. | | `options.record` | Optional loaded record, to avoid a reload. | Returns `true` when an update was sent, `false` when it was skipped. It never throws. Caution There is no `item` record type in NetSuite. Use the concrete type — `inventoryitem`, `noninventoryitem`, `serviceitem`, `kititem`, `assemblyitem`. ### What it guarantees [Section titled “What it guarantees”](#what-it-guarantees) * **It never breaks your script.** Delivery is fire-and-forget and every failure path is caught and logged. Your save cannot fail because Turbobase was unreachable. * **It never blocks.** No waiting on a round trip. * **It respects your configuration.** A record type you have switched off stays off — this call cannot re-enable it. * **It cannot loop.** Records Turbobase itself wrote are skipped, and a notify that duplicates a User Event on the same save collapses into a single update. ### Cost [Section titled “Cost”](#cost) About 20 governance units, or 30 if Turbobase has to load the record for you — against the 1,000 a Suitelet gets, or the 200 a User Event gets. ## Calling it from somewhere that can’t import a module [Section titled “Calling it from somewhere that can’t import a module”](#calling-it-from-somewhere-that-cant-import-a-module) Client scripts and workflow action scripts can use the **TB Notify** Suitelet instead — anything running inside NetSuite with the user’s session: ```javascript require(['N/https', 'N/url'], function (https, url) { https.post.promise({ url: url.resolveScript({ scriptId: 'customscript_tb_io_notify', deploymentId: 'customdeploy_tb_io_notify' }), body: JSON.stringify({ recordType: 'salesorder', id: soId }), headers: { 'Content-Type': 'application/json' } }); }); ``` It replies `{ "ok": true, "sent": true }`, or `{ "ok": true, "sent": false, "reason": "skipped" }` when the record type is not enabled or the change was Turbobase’s own write. The Suitelet requires a NetSuite **session** and runs under the caller’s role. It is not available without login, so it is **not reachable from outside NetSuite** — token-based (TBA) credentials authenticate RESTlets, not Suitelets. For an external system, have it call your own integration and use the library from a server-side script there, or ask us for a RESTlet endpoint. Note **Server-side scripts should import `lib/tb_notify` rather than call the Suitelet.** The library runs in your script’s own execution — no second HTTP round trip inside NetSuite, and no extra Suitelet governance. The Suitelet exists for callers that cannot take a file-cabinet dependency. ## Seeing which records are missing the push [Section titled “Seeing which records are missing the push”](#seeing-which-records-are-missing-the-push) Your instance’s **Late arrivals** tab (**/data/sync/late-arrivals**) lists the records the four-hourly sweep delivered first — so no real-time push arrived for them. That is the list of records hitting one of the four cases above, by record type and by whether the change was a create, an edit or a deletion, with each record type’s real-time rate beside the count. Start there rather than guessing which of your scripts needs the notify call: a record type sitting well below 100% is where it is worth adding. A record the push already delivered is re-read by the sweep, found unchanged, and does not appear. See [Source health](/tenant/source-health/) for the full description of the tab. ## Checking it worked [Section titled “Checking it worked”](#checking-it-worked) Your instance’s **Source health** page shows inbound webhook volume and any User Event errors NetSuite logged. If a notify call is not landing, turn on **Debug mode** there: NetSuite then logs every trigger and the reason for any skip (`not enabled`, `echo`, `no synced-field change`) to the script execution log, so you can see which gate a save is hitting. # Sandboxes and testing > Dry-run extensions in the isolated-vm code sandbox and stand up ephemeral local authoring instances before deploying. Shipped Before an extension ever reaches a live instance, you test it. Turbobase gives you two complementary tools: an **execution sandbox** that runs your extension’s code exactly the way production does, and **ephemeral local authoring instances** you can stand up from a sampled slice of real data. This page disambiguates the two senses of the word “sandbox” and walks the testing commands. ## Two senses of “sandbox” — don’t conflate them [Section titled “Two senses of “sandbox” — don’t conflate them”](#two-senses-of-sandbox--dont-conflate-them) | Term | What it is | | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Code sandbox** (this page) | The `isolated-vm` runtime that *executes* a hook or conduit — a fresh V8 heap, hard memory + CPU caps, **no IO, no secrets**. The same isolate runs an extension in production, under `turbobase ext test`, and under `turbobase ext replay`. | | **`turbobase sandbox …`** (this page) | An *ephemeral local authoring instance* — a throwaway local database seeded with a sampled, redacted slice of a live instance, with your `hooks/` + `conduits/` mounted, that reaps itself after a TTL. | | **An Oracle NetSuite (or other source) sandbox account** | A *provider* concept: your ERP’s non-production account. Unrelated to either of the above; you wire one up when [setting up a NetSuite connection](/netsuite-oauth-setup/). | The first two are Turbobase mechanisms; the third is your source provider’s. When this page says “sandbox” with no qualifier, it means the code sandbox. ## The code sandbox — how extensions execute [Section titled “The code sandbox — how extensions execute”](#the-code-sandbox--how-extensions-execute) Every extension is untrusted customer / AI-authored code, so the runtime never runs it as a live in-process closure. Instead: 1. **esbuild bundles** the extension’s TypeScript into one self-contained ESM module. An extension may import the SDK factory (`@turbobase/extensions` / `@/sync/hooks` / `@/sync/conduits` / `@/sync/processes`), which is resolved to a tiny inline identity stub, and a **relative sibling inside its own directory** (`./shared-rule.js`), which is folded into the bundle so the isolate still receives one module. Any **other** import — an npm package, a Node builtin, a path that escapes the extension’s directory — fails the bundle with a clear error (the “no shared-lib import” fence). 2. The bundle runs inside an **`isolated-vm` Isolate**: a fresh V8 heap with a **16 MB** memory cap and a **200 ms** per-call CPU/wall timeout, **no Node builtins, no IO, no module resolution**. The only host surface reachable from inside is the documented `HookContext` — `reject` / `patch` / `log` plus the read context — bridged across the isolate boundary. 3. A throw, an OOM, or a runaway loop surfaces as a thrown Error the executor handles exactly as it handled an in-process throw: **fail-open** by default, or, for a `failClosed` extension, **the operation is refused as a non-verdict** — `503 hook_unavailable`, retried rather than reported as a validation failure, because an extension the isolate had to kill reached no opinion about the record. Only `ctx.reject` produces a `422`. See [fail-open vs fail-closed](/extending/writing-hooks/#fail-open-vs-fail-closed). > The bridged context exposes **no `secrets`**. The isolate cannot reach `process.env`, the filesystem, or the network — there is nothing to leak even if an extension tried. Because `turbobase ext test` and `turbobase ext replay` reuse **the same bundle, the same isolate, and the same effect envelope**, a local dry-run and a production firing share one execution path. What passes locally behaves identically deployed. ## `turbobase ext test` — dry-run against samples [Section titled “turbobase ext test — dry-run against samples”](#turbobase-ext-test--dry-run-against-samples) > **First run on a fresh install?** `ext test` and `ext replay` compile and execute your extension, which needs two native packages the one-line installer leaves out. Fetch them once with `turbobase ext extras install` (`turbobase ext extras status` reports what you have). An `npm install -g @turbobase/cli` already has them. Every other command works without them. Run an extension against fixtures in `samples/`: ```sh turbobase ext test hooks/customer.before_save.ts # PASS customer-company # FAIL customer-individual-missing-last (rejected: MANDATORY_FIELD_MISSING) # # 1/2 sample(s) passed. ``` Each fixture is one case; the command exits non-zero on any failure (so it doubles as a CI gate). Generate fixtures from the live instance catalog with [`turbobase project samples`](/cli/reference/); point at a different directory with `--samples `. For machine-readable CI output: ```sh turbobase ext test hooks/customer.before_save.ts --reporter junit > results.xml turbobase ext test hooks/customer.before_save.ts --reporter json ``` ## `turbobase ext lint` — offline static analysis [Section titled “turbobase ext lint — offline static analysis”](#turbobase-ext-lint--offline-static-analysis) `lint` catches the semantic foot-guns the Zod schema can’t — a weak conduit `idempotencyKey`, a missing `createdFrom`, an extreme `cycleBudget`, a same-source “conduit.” No instance, no DB, no network: ```sh turbobase ext lint conduits/shop2ns.order.ts turbobase ext lint conduits/shop2ns.order.ts --ci # exit non-zero on any warning ``` It reads a hook and a process too, and each rule applies only to the kinds it is about: the four above describe a cross-source bridge, so they are raised on a conduit only, while the deploy-identity check is raised on all three. So `--ci` is safe to put in a deploy lane that ships every kind — a correct module of any of them exits 0. ## `turbobase ext replay` — drive a conduit’s lifecycle [Section titled “turbobase ext replay — drive a conduit’s lifecycle”](#turbobase-ext-replay--drive-a-conduits-lifecycle) `replay` is the conduit’s dry-run. It drives the conduit through dispatch guards → `from.when` → `transform` → apply-branch → bindings: ```sh turbobase ext replay conduits/shop2ns.order.ts --fixture samples/order.json --trace ``` * **`--fixture `** (offline, default) runs `when` + `transform` through the **same isolate** the engine’s untrusted path uses; the apply branch is the shared pure rule. No DB, no network. * **`--against sandbox`** (online) runs the fuller engine path against a running local sandbox (requires a local Postgres); it defers with a clear reason when no local DB is present. `--trace` emits the per-stage trace; `--reporter junit|json` emits one case per stage. ### Replaying a conduit that reads [Section titled “Replaying a conduit that reads”](#replaying-a-conduit-that-reads) A conduit that declares [`reads`](/extending/writing-conduits/#ctxquery--one-bounded-declared-read) looks something up while it runs, and offline there is nothing to look it up in. So the fixture answers the read: add a **`queryRows`** map, keyed by the PostgREST path-and-query string your transform passes to `ctx.query` — the whole string, exactly as you wrote it: ```json { "fromSource": "slack", "recordType": "channel_message", "externalId": "C09ABCDEF:1759000111.000200", "payload": { "scopeRef": "C09ABCDEF", "metadata": { "threadTs": "1759000000.000100" } }, "queryRows": { "channel_message?select=external_id,body&external_id=eq.C09ABCDEF%3A1759000000.000100&limit=1": [ { "external_id": "C09ABCDEF:1759000000.000100", "body": "acme/storefront#412 — thread opened" } ] } } ``` Three rules, and the second is the one that makes this workable: * **The key is compared verbatim.** Your transform usually composes its query out of the payload, so the key is whatever that composition produces — percent encoding and all. Nothing normalizes it for you. * **A key you did not supply answers `[]`**, and `--trace` names it. That is the loop: replay once with no `queryRows`, read the strings your conduit actually asked for off the trace, and paste them in. * **Rows are served only to a conduit that declared `reads`.** One that declared none has no `ctx.query` at all, offline and deployed alike, however much the fixture offers — so rows in a fixture do not buy a read the live conduit would not get. Replay reads that declaration off your file, so write `reads:` as a literal array on a line of its own, the way the reference conduit does. A `reads` assembled from a variable, or a whole definition squeezed onto one line, reads as *no reads declared* offline — you get `ctx.query is not a function` from a conduit that works once deployed. `--trace` prints one line per read, in the order the conduit made them — the `from.when` gate’s included, since a gate may read too: ```plaintext ctx.query (declared reads): channel_message?select=external_id,body&external_id=eq.C09ABCDEF%3A1759000000.000100&limit=1 → served, 1 row(s) contact?select=external_id,email&external_id=eq.USERX → not served by the fixture, 0 row(s) ``` That section prints whatever the conduit then does, which is what makes the first replay useful: a read your fixture has not answered yet often stops the conduit dead — the reference reading conduit refuses an empty root outright. The run ends in an error, the error is the trace’s last line, and the strings are still above it: ```plaintext ctx.query (declared reads): channel_message?select=external_id,body&external_id=eq.C09ABCDEF%3A1759000000.000100&limit=1 → not served by the fixture, 0 row(s) ERROR — slack2gh: no mirrored thread root for C09ABCDEF:1759000000.000100 ``` Without `--trace`, that run prints the one error line and nothing else. A served read does **not** mean the live read would be allowed: the declaration checks — an undeclared resource or column, a filter outside `reads[].by`, the per-run query budget — are the engine’s, and replay does not mirror them. Keep the fixture’s keys inside what `reads` declares, and read the refusal table in [Writing conduits](/extending/writing-conduits/#ctxquery--one-bounded-declared-read). ## `turbobase sandbox` — ephemeral local authoring instances [Section titled “turbobase sandbox — ephemeral local authoring instances”](#turbobase-sandbox--ephemeral-local-authoring-instances) When you want to author against something that feels like a real instance — real record shapes, real volume — without touching production, stand up a local sandbox seeded from a sampled, redacted slice of an instance: > **Not available yet.** `turbobase sandbox create` refuses and creates nothing. Seeding needs a per-column PII classification for the source instance’s tables, and redaction fails closed on any column it cannot prove is safe — so until that classification exists, a sandbox could only ever be empty or unsafe. Use [`ext test`](#turbobase-ext-test--dry-run-against-samples) and [`ext replay --fixture`](#turbobase-ext-replay--drive-a-conduits-lifecycle) above; both run offline and cover the authoring loop. The rest of this section describes the intended shape. ```sh turbobase sandbox create --from acme --ttl 72h --sample 2% # sandbox acme-72h-abc ready. # source: acme (sampled 2%, redacted) # url: http://acme-72h-abc.localhost:… # expires: 2026-06-11T… ``` `create` copies a sampled, **redacted** slice of `--from` and stamps a TTL. Crucially, the sandbox process forces a sandbox mode that resolves every source connector to a mock / fail-closed stub — **no code path in a sandbox reaches a real NetSuite or Stripe**. A sandbox is **local-only by construction**: `create`, `destroy` and the reaper all refuse any target that isn’t a localhost database. List and reap: ```sh turbobase sandbox list # active sandboxes; reaps any past their TTL turbobase sandbox destroy acme-72h-abc # drop the DB + remove from the registry ``` `list` and `destroy` both opportunistically reap expired sandboxes, so a forgotten sandbox doesn’t linger past its TTL. ## A typical loop [Section titled “A typical loop”](#a-typical-loop) 1. `turbobase ext new hook …` (or `new conduit`) — scaffold. 2. `turbobase project samples` — pull fixtures from the instance catalog. 3. `turbobase ext lint ` — catch foot-guns offline. 4. `turbobase ext test ` (or `replay` for conduits) — dry-run in the isolate. 5. `turbobase sandbox create --from ` — exercise against realistic data. 6. [`turbobase ext deploy`](/extending/deploy-plan-promote/) — ship it. ## Next steps [Section titled “Next steps”](#next-steps) * [Writing hooks](/extending/writing-hooks/) / [Writing conduits](/extending/writing-conduits/) — the authoring contracts. * [Deploy, plan, promote](/extending/deploy-plan-promote/) — the rollout workflow. * [NetSuite OAuth setup](/netsuite-oauth-setup/) — wiring a source provider’s sandbox account. # Writing conduits > Author conduits that bridge a canonical record from one connected source into another with the defineConduit contract. Shipped A **conduit** is the cross-source counterpart to a [hook](/extending/writing-hooks/). A hook transforms a record on **one** source’s write path; a conduit routes and transforms a canonical record from one source into **another**, with the canonical row as the interchange format: “a paid Shopify order should become an Oracle NetSuite sales order,” “a NetSuite item fulfillment should become a Shopify fulfillment.” It’s authored with the same SDK, validated by the same Zod machinery, run in the same isolated-vm sandbox, and stored in the same DB-native store — a conduit is “a hook that bridges sources,” not a separate subsystem. Both halves ship today: the authoring surface (the `defineConduit` SDK, the Zod schema, the registry entry, the CLI/MCP commands) and the **engine** that runs conduits — dispatch, binding, fan-out and conflict handling. The SDK is the same `@turbobase/extensions` npm package hooks use — see [Install the SDK](/extending/writing-hooks/#install-the-sdk) for local typechecking (`@turbobase/extensions/conduit` is the conduit-only subpath export). ## The `defineConduit` contract [Section titled “The defineConduit contract”](#the-defineconduit-contract) ```ts import { defineConduit } from '@turbobase/extensions'; export default defineConduit({ id: 'shop2ns:order', // '2:' on: 'after_canonicalize_inbound', // the cross-source lifecycle event from: { source: 'shopify', // origin source recordType: 'order', // canonical record type to match when: (ctx) => ctx.payload.financial_status === 'paid', // optional gate }, to: { source: 'netsuite', // target source the master fans out to recordType: 'salesOrder', // canonical record type to create }, mode: 'dual-bind', // default; or 'one-shot' idempotencyKey: (ctx) => `shop2ns:order:${ctx.externalId}`, cycleBudget: 4, // default 4 conflictStrategy: 'merge', // default 'merge' transform: (ctx) => ({ patch: { // canonical-in → canonical-patch-out (pure) memo: `from Shopify order ${ctx.externalId}`, }, createdFrom: { source: ctx.fromSource, recordType: ctx.recordType, externalId: ctx.externalId, }, }), }); ``` | Field | Required | Meaning | | ------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | yes | Stable id; collisions are loader errors. Convention `'2:'` (e.g. `shop2ns:order`). | | `on` | yes | The lifecycle event — `after_canonicalize_inbound`, `after_sync_success_outbound`, `after_save`, or `after_delete`. | | `from` | yes | `{ source, recordType, when? }` — which inbound record triggers it, with an optional payload gate. | | `to` | one form | `{ source, recordType }` — the target the master materializes into. One `transform` emits **one** target record type. | | `transform` | one form | Pure `ctx → { patch, createdFrom? }`. Returns a canonical patch + the lineage pointer. | | `sequence` | the other form | An ordered list of `{ to, transform, awaitSource? }` entries, materialized one after another on the same inbound record — see [several targets, in order](#several-targets-in-order). Declare **either** `to` + `transform` **or** `sequence`, never both. | | `mode` | no | `'dual-bind'` (default — keep both sides synced) or `'one-shot'` (materialize once, no ongoing binding). | | `idempotencyKey` | no | Dedups a replayed source webhook; anchors the `conduit_event` audit row. Convention `':'`. | | `cycleBudget` | no | Max-hop safety fuse on top of the loop guards. Default 4. | | `conflictStrategy` | no | What happens when a fanned-out write is rejected — see [conflict handling](#conflict-handling). Default `'merge'`. | | `timeoutMs` | no | Execution-budget override; default 200ms. | | `description` | no | Operator-friendly text in the UI. | | `systemReserved` | no | Managed-service guardrail instance admins can’t disable (mirrors hooks). | ## Several targets, in order [Section titled “Several targets, in order”](#several-targets-in-order) One record arriving in one system often has to become **several** records in another, and the order matters: the sales order before the deposit that applies to it, the invoice before the payment. Declare that as a `sequence` instead of a single `to` + `transform`: ```ts import { defineConduit } from '@turbobase/extensions'; export default defineConduit({ id: 'shop2ns:order-and-deposit', on: 'after_canonicalize_inbound', from: { source: 'shopify', recordType: 'order', when: (ctx) => ctx.payload.financial_status === 'paid' }, sequence: [ { to: { source: 'netsuite', recordType: 'salesOrder' }, awaitSource: true, // hold the next entry until NetSuite has numbered this one transform: (ctx) => ({ patch: { memo: `from Shopify order ${ctx.externalId}` }, createdFrom: { source: ctx.fromSource, recordType: ctx.recordType, externalId: ctx.externalId }, }), }, { to: { source: 'netsuite', recordType: 'customerDeposit' }, transform: (ctx) => ({ patch: { amount: ctx.payload.total_price, sales_order_id: ctx.materialized[0].externalId, // the id NetSuite minted for the entry above }, createdFrom: { source: ctx.fromSource, recordType: ctx.recordType, externalId: ctx.externalId }, }), }, ], }); ``` What the engine guarantees for a `sequence`: * **Declared order.** Entries run one after another, on the server, for one inbound record. Each entry’s `transform` sees `ctx.materialized` — the targets the run has already landed, in order, as `{ source, recordType, masterTable, masterRowId, externalId }`. It is empty for the first entry. * **The minted id, when you ask for it.** `awaitSource: true` on an entry holds the *next* entry until the target source has given its verdict on this one, so `ctx.materialized[i].externalId` carries the id the source minted. Without it the id is `null` — the write is still on its way. The wait is the same one a [process](/extending/writing-processes/) step uses, with the same five-minute budget, and it reads the same rule: a retrying write is not a refusal. It applies to a **different-type** target only — the one that mints a new record. An entry of the firing record’s own type merges onto the firing master, so there is no minted id to wait for, and `awaitSource` on it is refused at deploy. * **One ledger row per target.** The first entry’s `conduit_event` row carries the conduit’s idempotency key; later entries carry `#2`, `#3`, … so `turbobase logs --conduit ` shows every target’s disposition. All rows sit at the **same hop**: a declared fan-out is siblings on one hop, not a chain, and does not spend the cycle budget as though it were a loop. * **Each target is checked on its own.** Every `to` has to be [writable](#the-target-must-be-writable); a sequence whose second target is read-only is refused at deploy naming that pair. An entry that declares `awaitSource` against a record type with no write trail is refused too (`conduit_target_no_write_trail`), as is one that declares it on a target of the firing record’s own type (`conduit_target_await_same_type`). * **A failure stops the sequence and undoes nothing.** When an entry fails — the source refused it, no verdict arrived inside the budget, or its target could not be resolved — that entry’s row is `failed` with the reason, every later entry is `skipped` with `earlier target failed`, and a `sync_failure` is raised. What already materialized **stays**: a conduit has no compensating delete, and a half-materialized sequence is visible on the ledger, one row per target, rather than hidden. If you need the undo, that is a [process](/extending/writing-processes/) with `onFailure: 'rollback'`. * **Resumable.** A re-delivered run skips every entry whose row is already `committed` and continues from the first one that is not, so a retry never mints a second master for a target that already landed. The apply rule is unchanged per entry: a target of the **same** canonical type as the firing record binds it to the same master; a **different** type mints a new master chained to the origin by `createdFrom`. `turbobase ext replay` and `turbobase ext lint` understand both forms. ## The target must be writable [Section titled “The target must be writable”](#the-target-must-be-writable) `to` is the half the bridge **writes**. Turbobase materializes the canonical master and the existing write-back worker fans it out to `to.source` — so a target whose adapter declares no PG→source write path for that record type has nothing to fan out to, and every run of the conduit would fail after the master had already been flipped. That pair is therefore **refused at deploy time**, before the conduit is stored: ```sh turbobase ext deploy conduits/ns2slack.message.ts # 400 conduit_target_read_only # conduit target (slack, message) is declared read-only: the slack adapter has # no PG→source write path for message, so the bridge would flip the master row # and then lose every write to it ``` The refusal body carries `error: "conduit_target_read_only"`, `toSource`, `toRecordType` and that `reason`, so a script can branch on it. The same check runs when the assistant drafts a conduit — an unshippable target is refused before a module is written — and again at apply time, which catches a conduit deployed before this rule existed. Two targets are **not** refused, and the distinction matters if you are writing a bridge into custom data: * a pair the adapter declares **bidirectional** — the ordinary case; * a pair with **no static declaration at all**, which is what the per-install `cr_` / `cl_` custom-record mirrors are. Those are writable by construction and have nowhere to say so, so a conduit targeting one deploys normally. To check a pair before you author against it, read `GET /v1/catalog/records` (served to an MCP client as `catalog:///`) and look at `writeCapability`. A non-null value means the write path exists and this tenant may use it, so the pair is a valid `to`. A `null` is the one to read carefully: it covers both “the adapter declares this record read-only”, which is what the deploy refuses, and “write-back is not switched on for this tenant yet”, which is an entitlement and not a property of the conduit. ## Starting a process instead of writing a record [Section titled “Starting a process instead of writing a record”](#starting-a-process-instead-of-writing-a-record) A conduit’s `to` can name a [process](/extending/writing-processes/) rather than a record type: ```ts to: { source: 'turbobase', recordType: 'process', process: 'acme:reorder-suggestions' }, ``` The transform’s patch becomes the process **input** — so the transform is where you shape the firing record into whatever the process declares it takes — and the run is recorded on the conduit ledger like any other target. The process runs as a process always does: its steps go through the write boundary, and its run is watched at `GET /v1/processes/runs/:runId`. A process your instance has not got is refused at `turbobase ext deploy`, naming it. `turbobase` is a reserved source for the platform’s own records, not a connector. Use it only in the forms these docs describe. ## The four events [Section titled “The four events”](#the-four-events) ```ts // exported from @turbobase/extensions export const CONDUIT_EVENTS = [ 'after_canonicalize_inbound', 'after_sync_success_outbound', 'after_save', 'after_delete', ] as const; ``` The first two fire **after** a source record’s sync commits — the natural triggers for “source A landed, now drive source B”: * **`after_canonicalize_inbound`** — after an inbound source webhook is canonicalized and upserted into the PG mirror. The trigger for “a record arrived from source A; materialize it into source B.” * **`after_sync_success_outbound`** — after an outbound write-back commits to the source. The trigger for the next leg of a flow (NS `itemFulfillment` committed → push the fulfillment to Shopify). The other two fire on a record **here**: * **`after_save`** — after a record is saved on your instance and the save has committed, whoever saved it: your app through the write boundary, a process, the AI assistant, Studio, or another conduit. The trigger for “someone changed something here; drive source B.” A record *arriving* from a connected source is not a save in this sense — it keeps `after_canonicalize_inbound`, so a synced record never fires twice. * **`after_delete`** — after a record is **deleted** on your instance and the tombstone has committed. The trigger for “unpublish the counterpart”, “close the ticket”, “retract the announcement” — every flow that has to undo something the instance previously drove. It fires on the TRANSITION only, so a repeat delete of a record that is already gone (still a `200`, because a delete here is idempotent) does not fire a second time, and a delete your guardrails or a `before_delete` hook refused fires nothing at all. Conduits run **asynchronously** on all four — never inline on the ingest/write-back path, never inline on the save, and never inline on the delete. ### What an `after_delete` run is told [Section titled “What an after\_delete run is told”](#what-an-after_delete-run-is-told) `ctx.payload` is the record’s **last state** — the row as it stood before the tombstone, which is the one thing a deletion reaction cannot read back for itself: by the time your conduit runs the row is a tombstone. `ctx.externalId` addresses it the same way a save does, its external id where it has one and its surrogate key otherwise, and `ctx.savedBy` names the writer that made the delete on the same vocabulary as a save. `ctx.change` is **absent** on this event, as it is on the two inbound ones: a delete is neither an insert nor an edit and wrote no fields, so there is nothing for `fields` to name. ### What an `after_save` run is told [Section titled “What an after\_save run is told”](#what-an-after_save-run-is-told) Two extra context fields come with this event, and both are read by `from.when` as well as by `transform`: * **`ctx.change`** — `{ kind: 'insert' | 'update', fields: [...] }`. `fields` names the canonical columns the writer supplied, so `ctx.change.fields.includes('status')` is the gate for “run when the status changes” rather than something your body works out. * **`ctx.savedBy`** — `{ kind: 'app' | 'process' | 'agent' | 'studio' | 'conduit' | 'user', id? }`, carrying the conduit’s id when a conduit made the save. A conduit whose own write would fire it again refuses it here: ```ts from: { source: 'standalone', recordType: 'work_item', when: (ctx) => ctx.savedBy?.id !== 'tidy:work_item', }, ``` That refuses the save before the run starts. The cycle budget also stops a loop, but it stops it by tripping — after the conduit has already run. Both are **absent** on the two inbound events rather than guessed, so a conduit written before they existed reads exactly what it read before. ## Conduits are pure and credentials-free [Section titled “Conduits are pure and credentials-free”](#conduits-are-pure-and-credentials-free) A conduit body is a pure canonical→canonical transform. `ConduitContext` has **no `secrets`**: it reads a pre-resolved context and **returns** a canonical `patch` plus a target — it never calls a source API, never holds source credentials, never touches an env var or the secrets backend. The engine writes the canonical master row (`originating_system='hybrid'`) and the existing write-back worker fans it out to the bound source(s). The read context gives you `recordType`, `event`, `fromSource`, `externalId`, `payload`, `lines`, `tenantConfig`, the current `bindings` (on a master a conduit MINTED, that is the target source and nothing else — the system the record came from is not bound to it, and `created_from_id` is what names it), an `atlas` entity resolver (read-only, pre-resolved cross-source id mapping), a `canonicalStatus(...)` status-normalization helper, `log(...)`, and — when you declare it — `query(...)`. ### `ctx.query` — one bounded, declared read [Section titled “ctx.query — one bounded, declared read”](#ctxquery--one-bounded-declared-read) Some bridges cannot be written from the trigger’s payload alone. A reply on a Slack thread carries the thread’s root timestamp and nothing that names the GitHub issue it belongs to; resolving it is one keyed lookup. A conduit that cannot make it has to be a scheduled digest instead of a conversation. So a conduit may **declare** what it reads, and read exactly that: ```ts export default defineConduit({ id: 'slack2gh:comment', on: 'after_canonicalize_inbound', from: { source: 'slack', recordType: 'channel_message' }, to: { source: 'github', recordType: 'comment' }, // Declared up front, so it is reviewable in this file rather than // discovered at runtime. reads: [ { recordType: 'channel_message', columns: ['external_id', 'metadata'], by: ['external_id'] }, { recordType: 'channel', columns: ['external_id', 'name'], maxRows: 50 }, ], maxQueriesPerRun: 4, transform: async (ctx) => { const rootId = `${ctx.payload.scope_ref}:${ctx.payload.metadata.threadTs}`; const [root] = await ctx.query( `channel_message?select=external_id,metadata&external_id=eq.${rootId}&limit=1`, ); // …build the canonical patch from the root the thread describes. }, }); ``` `q` is one PostgREST path-and-query, the same shape `defineProcess`’s `ctx.query` takes. The supported subset is `select`, `limit`, `offset`, `order` and simple column filters — `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `is`, `in`. **What is refused, and it is always refused BY NAME:** | Refused | Because | | ---------------------------------------- | --------------------------------------------------------------------------------------------------- | | A resource not in `reads` | The declaration is the boundary. The message lists what you did declare. | | A column not in `reads[].columns` | Same, for the columns you select. | | A filter on a column not in `reads[].by` | Filtering is a separate power from reading — `by` is a second gate, not a restatement of `columns`. | | `select=*` and embeds | A declaration that promises nothing is not a declaration. | | An operator outside the list above | A silently dropped filter returns MORE rows — the failure an author never sees in a passing test. | | More than `maxQueriesPerRun` reads | The budget belongs to the RUN, so a `sequence` shares one across its entries. | **Two things the engine adds to every read**, because forgetting either returns wrong rows rather than an error: * the **type discriminator** on a polymorphic table — `channel_message` lives in the shared `activity` table, so a read without `activity_type` would also return calls and events; * the **connection scope** on a connection-scoped table, so a tenant holding two NetSuite connections cannot read the other one’s rows. You cannot opt out of either, and you do not have to remember them. **Why it is not scoped by RLS.** A process’s `ctx.query` runs as the invoking user. A conduit has no invoking user — it fires off a sync event — so there is no principal for RLS to scope, and running the read under a claimless role would deny every row. The declaration is the boundary instead. That is not a widening of trust: the engine already mints canonical master rows on a conduit’s behalf, so a read is strictly less power than the write it already has. **The transform is still pure**: read-only, no source API, no credential, no write. `ctx.query` cannot mutate anything. **Exercise it offline, before you deploy.** `turbobase ext replay --fixture` runs a declared read against rows the fixture supplies — add a `queryRows` map keyed by the query string your transform passes — so you can watch what the conduit asked for and what came back on your own machine. Run it once with no rows and `--trace` prints the strings your conduit actually composed, which is how you discover the key without deploying anything — including when the empty answer makes the conduit stop, where the error is the trace’s last line and the strings sit above it. See [Replaying a conduit that reads](/extending/sandboxes-and-testing/#replaying-a-conduit-that-reads). Replay serves the rows; it does not re-check the declaration, so the refusals above are still the deployed conduit’s first word on a read you got wrong. ## Idempotency and the cycle budget [Section titled “Idempotency and the cycle budget”](#idempotency-and-the-cycle-budget) Two distinct loop defenses: * **`idempotencyKey`** — dedups a replayed source webhook so the same inbound event doesn’t bridge twice; it anchors the `conduit_event` audit row. Make it derive deterministically from the trigger (`':'`), not from wall-clock or random data — a weak key is exactly what `turbobase ext lint` flags. **On `after_save` and `after_delete`, omit it.** A record saved here has no external id, so `ctx.externalId` is the record’s own surrogate key — the same string for the record’s create and for every later edit of it. A key built from it therefore dedups a record’s *second save into its first*, and the conduit fires once in that record’s lifetime and never again. Leave `idempotencyKey` off and each save gets its own key, which is what makes two saves two runs. If you do need your own key on one of these events, put something that moves in it — a field from `ctx.change`, say — and not `ctx.externalId` alone. `turbobase ext lint` warns `lifetime-idempotency-key` for exactly this, and says nothing about a key on the two inbound events, where `ctx.externalId` is the right one. * **`cycleBudget`** (default 4) — a hard per-conduit max-hop fuse on top of the opposite-direction + idempotency guards. When a record bounces more than `cycleBudget` times (A→B→A→…), the conduit trips. The hop a record sits at is read off the `conduit_event` ledger rather than carried on the job: neither sync seam knows a record’s chain position, so a budget that trusted them counted every leg as a fresh chain and could never trip. The one exception is a conduit’s OWN write, which carries its position with it, because at the moment that save is recorded the run that made it is not yet on the ledger to be read. Authoring two conduits that bridge the same pair in opposite directions is the classic way to create a cycle; the budget bounds the blast radius. Extreme values (0, or a very large number) are lint warnings. * **The opposite-direction guard** recognises the return leg two ways: by the id the echo arrives under, and — for the window between the conduit writing the master and the write-back confirming the target’s id — by the master itself. Both correlations rest on the write-back stamping the binding with the id the target source minted, which is what makes a two-way pair safe to author at all. ## Conflict handling [Section titled “Conflict handling”](#conflict-handling) `conflictStrategy` decides what happens when a fanned-out write to a bound source is **rejected**: | Strategy | Behaviour | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `merge` (default) | Keep the master value, mark the rejecting **binding** divergent, raise a sync failure (→ admin rollup + AI healing). The master is never reverted; divergence is a visible, healable per-binding state. | | `skip` | Drop the write, leave the binding untouched. | | `overwrite` | Force the master value onto every binding (last-writer-wins), no divergence tracking. | | `revert-on-reject` | Opt-in, audited: the master reverts when any bound source rejects. Never the default. | ## Scaffold, lint, and replay [Section titled “Scaffold, lint, and replay”](#scaffold-lint-and-replay) Scaffold a conduit with the CLI: ```sh turbobase ext new conduit --source shopify --record order --to netsuite \ --event after_canonicalize_inbound ``` Then iterate offline before deploying: ```sh turbobase ext lint conduits/shop2ns.order.ts # semantic foot-guns, offline turbobase ext replay conduits/shop2ns.order.ts --fixture samples/order.json --trace ``` `replay` drives the conduit through its full lifecycle — dispatch guards → `from.when` → `transform` → apply-branch → bindings — in dry-run against a JSON fixture (no DB, no network), and with `--trace` prints the per-stage trace. See [Sandboxes and testing](/extending/sandboxes-and-testing/). ## A conduit is one producer of bindings, not the only one [Section titled “A conduit is one producer of bindings, not the only one”](#a-conduit-is-one-producer-of-bindings-not-the-only-one) The fan-out a conduit relies on — one master row, one binding per source, `txn_source_link` — is not the conduit engine’s private machinery. **The write boundary produces bindings too**: an app that names several systems on `parent.source_system` when it creates a record gets one master and one binding per named system, with no conduit anywhere. The realtime listener fans a later edit out to every one of them exactly as it does for a conduit’s. Two consequences worth knowing while you author one: * **A master you fire on may already be bound**, by a create rather than by a bridge. `ctx.bindings` is where you see them — each entry is `{ source, externalId, role }`, so it tells you which systems hold a copy, which of those copies has landed upstream yet, and which one the record belongs to (`role: 'origin'`). It does not tell you what produced a binding, and a conduit should not branch on that: write your transform against the bindings that are there. * **Write a conduit when the two sides need a transform, a condition or an ordering** — a patch shaped per target, `from.when`, a sequence of targets with `awaitSource`. If all a customer wants is “this record exists in both of these systems and should stay in step”, the app can ask for that on the create and no conduit is needed. ## Inspecting and repairing bindings [Section titled “Inspecting and repairing bindings”](#inspecting-and-repairing-bindings) A conduit’s observable output at runtime is its **run ledger** and its **bindings**. Stream the ledger: ```sh turbobase logs --conduit shop2ns:order --follow ``` List `txn_source_link` bindings — filter to the ones that need attention, or re-enqueue a divergent write (an admin personal access token (PAT) is required): ```sh turbobase ext binding --status divergent turbobase ext binding --retry bnd_123 ``` A non-retryable binding returns 409; only divergent/blocked bindings can be retried. ## Watching your conduits [Section titled “Watching your conduits”](#watching-your-conduits) Every deployed conduit is listed on the **Conduits** tab of `/data/extensions`, whether or not it has run — a bridge you shipped an hour ago appears immediately, with a zero run count rather than not at all. Each row carries its run and failure counts and p95 commit latency over the last 14 days, and **View run trace** opens that conduit’s run ledger. Above the list, a runs-vs-failures chart plots the same window a day at a time, which is what answers “has this bridge been getting worse”. Picking a day on it narrows both the list and the trace drawer to that day’s runs. The Mechanic does **not** propose conduits unprompted the way it proposes hooks. Conduits are authored deliberately — by you with the SDK and CLI above, or by an assistant you ask, over the authoring MCP server below. ## Turning one off, and removing it [Section titled “Turning one off, and removing it”](#turning-one-off-and-removing-it) A deployed bridge is not permanent. Every armed row on the Conduits tab carries **Disable** and **Remove** beside View trace, and the same two acts are one command from a terminal: ```bash turbobase ext disable shop2ns:order # stop it, keep it turbobase ext enable shop2ns:order # start it again turbobase ext remove shop2ns:order # delete the definition ``` **Disable keeps the definition; Remove does not.** A disabled bridge stops being compiled on the next registry reload and carries no traffic, but its code, its route and its history are all still there — **Enable** puts it straight back, and its row reads `disabled` rather than `draft`, so it is never mistaken for a proposal waiting on a decision. Removing deletes the row instead; the bridge comes back only by deploying its definition again, under the same id. **Neither touches what the bridge already did.** Its run ledger, the bindings it created and every record it wrote into the target system stay exactly as they are. Turning a bridge off stops it writing — it does not undo what it wrote. A job already queued when you disable a bridge is not lost work in flight: it lands on the ledger as `skipped`, with `conduit not loaded` as the reason, and writes nothing. Both acts need an administrator, on either door — a browser session on the tab, or a PAT carrying `extensions:deploy` whose user is an admin at the moment of the call — and each writes one row to the [audit log](/tenant/audit-log/) naming who did it. A conduit your instance is operated with as a managed-service guardrail refuses both, and renders neither control. `ext remove` is not `ext proposals reject`. Rejecting a HOOK proposal is terminal: its id becomes the denylist entry that stops the Mechanic proposing the same rule again. A removed conduit id is simply gone, and redeploying it works. ## Asking the assistant to draft one [Section titled “Asking the assistant to draft one”](#asking-the-assistant-to-draft-one) `scaffold_conduit` hands back the right shape with the `transform` left empty. When you would rather describe the bridge than write it, `author_conduit` takes the sentence instead: > when a shopify order is paid, raise the matching NetSuite sales order Your tenant writes the module against the target record’s real columns, then checks it the same way a deploy would — it loads the result, validates it, and refuses an unwritable target **before** it drafts anything, with the same sentence [the deploy refusal](#the-target-must-be-writable) gives. What comes back is a **proposal, not a deployment**. It lands on the Conduits tab at `/data/extensions` with the checked source, and it carries no traffic until an administrator applies it — the same review step an [authored hook](/extending/writing-hooks/) passes through. Read the transform before you apply it: the bridge starts writing into the target system the moment you do. Drafting needs an AI provider configured for this instance and an administrator token; without one the tool answers that it is unavailable rather than producing an unchecked guess. Under the tool is `POST /v1/extensions/author-conduit`, which takes `{intent, from: {source, recordType}, to: {source, recordType}}` and answers `{status, intentId, id?, reason?}` — `written` staged a proposal, `rejected_by_validation` says why it could not. Like the rest of the `/v1/extensions` family it is the extension-authoring surface, reached with an admin [PAT](/tenant/access-tokens/) carrying `extensions:deploy`; it is not a route your apps call. ## Grounding over the authoring MCP [Section titled “Grounding over the authoring MCP”](#grounding-over-the-authoring-mcp) Authoring through an MCP client — Claude Code via [`turbobase mcp`](/cli/reference/#mcp), or the remote `/mcp/authoring` endpoint with a [PAT](/tenant/access-tokens/) carrying `mcp:authoring` — gets the same grounding hooks do, and conduits lean on it harder because a conduit must understand **both** ends of the bridge: * **Instructions** at `initialize` name your connected sources (the candidate `from`/`to` pairs), what’s already deployed, the loop — scaffold → `mock_test` until green → `deploy` with `approve: true` — and `author_conduit` as the alternative to starting from a blank template. * **Prompts** — connector skills for the connected sources via `prompts/list` / `prompts/get`: the record anatomy on each side of the transform. * **Resources** — `recipes:///` is the cross-source mapping aid: intent recipes (columns, stats, actions per record type + intent) filtered to your installed sources, so a `transform` knows which canonical fields carry the intent across. `catalog:///` and `types://all` give the canonical schema; the `samples:///` fixtures are what `mock_test` and `replay` run against. Only connector skills surface here; admin-authored skills for Pilot, your AI assistant in the app, never appear on the authoring server. ## Next steps [Section titled “Next steps”](#next-steps) * [Writing hooks](/extending/writing-hooks/) — the single-source counterpart. * [Sandboxes and testing](/extending/sandboxes-and-testing/) — `replay` + `lint` a conduit. * [Deploy, plan, promote](/extending/deploy-plan-promote/) — ship a conduit. # Writing hooks > Author hooks that validate and shape records on record lifecycle events with the defineHook contract. Shipped A **hook** is a TypeScript file that exports a `defineHook({...})` declaration. There are no “plugins” and no “customization rules” — one extension surface, one runtime that loads it. A hook intercepts a record on a single source’s write path (or a standalone record where Turbobase is the system of record) and either validates it, patches it, or logs against it. This page is the how-to: the `defineHook` contract, the lifecycle events, the execution model, and the proactive-learning **Mechanic** loop that proposes hooks for you. ## Install the SDK [Section titled “Install the SDK”](#install-the-sdk) The authoring SDK ships as the npm package **`@turbobase/extensions`**. Install it in your extensions working tree for local typechecking and editor completion: ```sh npm install --save-dev @turbobase/extensions ``` The root export carries the full public API: the `defineHook` / `defineConduit` factories and the generic `defineExtensionKind` machinery they build on, the publish-manifest validators (`ManifestSchema`, `validatePublishManifest`), and the `generateTypes` codegen library behind [`turbobase project types`](/cli/reference/#project-types). Subpath exports let you import one surface at a time: `@turbobase/extensions/hook`, `/conduit`, `/define`, and `/types-gen`. The package is pure and credentials-free, and installing it never touches your instance — at runtime the import resolves to the code sandbox’s inline stub, so your deployed bundle carries no npm dependency. ## The `defineHook` contract [Section titled “The defineHook contract”](#the-definehook-contract) `defineHook` is a pure identity factory that adds Zod validation and ergonomic types. The shape your default export must satisfy: ```ts import { defineHook } from '@turbobase/extensions'; export default defineHook({ id: 'custom:salesorder:require-subsidiary', // stable, unique; survives renames recordType: 'salesorder', // string | string[] | '*' event: 'before_save', // one of the 6 lifecycle events source: 'netsuite', // omit for cross-source / standalone when: (ctx) => ctx.tenantConfig.netsuite?.oneWorldEnabled === true, // optional gate failClosed: true, // optional; default fail-open description: 'subsidiary required on OneWorld accounts', run: (ctx) => { if (!ctx.payload.subsidiary) { ctx.reject({ code: 'MANDATORY_FIELD_MISSING', field: 'subsidiary', message: 'subsidiary required on OneWorld accounts', }); } }, }); ``` | Field | Required | Meaning | | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `id` | yes | Stable id. Collisions across files are loader errors. Convention: `::` for code-baseline, `custom:` or `mechanic::` otherwise. | | `recordType` | yes | The record type to bind to — a string, an array, or `'*'` for every record. | | `event` | yes | One of the 6 [lifecycle events](#the-six-lifecycle-events). | | `source` | no | Restrict to one source (`'netsuite'`); omit for cross-source; use `'standalone'` for standalone-only. | | `when` | no | Predicate gating execution before `run` fires; branch on `ctx.tenantConfig`, `ctx.direction`, etc. | | `run` | yes | The hook body. Receives `ctx`; calls `reject` / `patch` / `log`. | | `failClosed` | no | See [fail-open vs fail-closed](#fail-open-vs-fail-closed). Default fail-open. | | `timeoutMs` | no | Execution-budget override; default 200ms. | | `description` | no | Operator-friendly text surfaced in the UI. | | `systemReserved` | no | Managed-service guardrail instance admins can’t disable — see [system-reserved hooks](#system-reserved-hooks). Reserved for platform code. | Hooks are **pure and credentials-free**: `HookContext` has no `secrets`, and nothing in a hook reaches a token, env var, or the secrets backend. The runtime runs untrusted hooks inside an isolated-vm sandbox with no IO — see [Sandboxes and testing](/extending/sandboxes-and-testing/). ## The eight lifecycle events [Section titled “The eight lifecycle events”](#the-eight-lifecycle-events) ```ts // exported from @turbobase/extensions export const HOOK_EVENTS = [ 'before_read', 'before_save', 'after_save', 'before_delete', 'after_delete', 'before_sync', 'after_sync', 'on_sync_failure', ] as const; ``` Those eight are the events a hook can bind to. [Conduits](/extending/writing-conduits/) — “a hook that bridges sources” — bind to three of their own: `after_canonicalize_inbound`, `after_sync_success_outbound` and `after_save`. **The last name is in both lists and means two different things.** A hook’s `after_save` runs on the write path of the record being saved, synchronously with the save, for that one record type; a conduit’s fires after any save on the instance has COMMITTED, whoever made it, and its job is to drive another source. Use the hook to shape or validate the record, the conduit to react to it. `before_read`, `before_save`, `after_save`, `before_delete` and `after_delete` are **universal** (they fire for standalone records too); `before_sync`, `after_sync`, and `on_sync_failure` are **connector-only** (they fire only when a source connector is propagating the record). **Delete has its own pair, and that is deliberate.** A `before_save` rule never sees a delete, so a record whose writes are validated is not thereby protected from deletion — the rule that blocks deleting an order with a fulfillment has to bind to `before_delete`. Note the payload difference: a delete is addressed by key (`pg_row_id` / `external_id`), not by row body, because resolving the row is fused with the tombstone stamp in one transaction to close a read-then-stamp race. So `before_delete` can reject, but there is no row body to patch. | Event | When it fires | Typical use | Can `reject`? | Can `patch`? | | ----------------- | -------------------------------------------------------- | ------------------------------------------------------ | -------------- | ----------------- | | `before_read` | Before a record is read out | Redact / gate reads | yes | yes | | `before_save` | Before a write is committed | Validate, fill defaults | yes | yes | | `after_save` | After the write committed | Observe, derive side data | no (committed) | no-op | | `before_delete` | Before a delete is tombstoned (after the guardrail gate) | Block deletes that break referential or business rules | yes | no (key, not row) | | `after_delete` | After the tombstone stamped | Observe, cascade, annotate | no (committed) | no-op | | `before_sync` | Before an outbound write leaves for the source | Last-mile shaping for the source | yes | yes | | `after_sync` | After the source accepted the write | Observe, stamp lineage | no (committed) | no-op | | `on_sync_failure` | When the source rejected the write | Classify / annotate the failure | no | no | `ctx.direction` tells you which way the operation is flowing — `'outbound'` (propagating to the source), `'inbound'` (a source webhook applying to the canonical mirror), or `'standalone'` (no connected source). `ctx.origin` tells you what triggered it — `'user'`, `'agent'`, `'webhook'`, or `'system'`. ## The hook context — `reject`, `patch`, `log` [Section titled “The hook context — reject, patch, log”](#the-hook-context--reject-patch-log) `run(ctx)` receives a `HookContext`. The read surface includes `recordType`, `event`, `direction`, `origin`, `source`, `payload` (the working record — mutable via `patch`), `prior` (the previous payload on updates), `user`, `tenantConfig` (the source-feature snapshot), and `metadata` (source-specific scoping, e.g. NS `customFormId`). The three mutators: ```ts // Reject — short-circuits the pipeline. The rejection surfaces as a sync // failure with category='hook_validation' and the rejection code as classification. ctx.reject({ code: 'MANDATORY_FIELD_MISSING', field: 'subsidiary', message: '…' }); // Patch — mutate the working payload; subsequent hooks see the patched shape. // Multiple hooks patching the same field: last-writer-wins. ctx.patch({ memo: ctx.payload.memo ?? 'AUTO' }); // Log — structured logging, observable in hook_telemetry + the parse-error UI. ctx.log('warn', 'falling back to AUTO memo', { recordType: ctx.recordType }); ``` The first `reject` wins — the pipeline short-circuits, so a hook that rejects stops later hooks for that operation. ## Fail-open vs fail-closed [Section titled “Fail-open vs fail-closed”](#fail-open-vs-fail-closed) When a hook’s `run` throws an uncaught error, the default policy is **fail-open**: the runtime proceeds as if the hook didn’t run (the error is caught, isolated, and logged — one bad hook never wedges the queue). Set `failClosed: true` to stop the operation instead — appropriate for security-critical or contract-enforcing hooks where “couldn’t run” must mean “don’t proceed.” **A fail-closed hook that threw is not the same answer as `ctx.reject`.** It produced no verdict about the record, so the runtime does not report one. `ctx.reject` answers `422 { error: 'validation_failed' }` with your code, field and message; a throw — or an overrun of the `timeoutMs` budget — answers `503 { error: 'hook_unavailable' }` with a `Retry-After` header, and on the background sync queue it is retried on the job’s remaining attempts instead of failing the record permanently. Either way nothing is written. So a hook body that may fail for reasons of its own is honest about it: the caller is told to send the same record again, not to fix something. ## Where hooks live — the two trees [Section titled “Where hooks live — the two trees”](#where-hooks-live--the-two-trees) The loader scans hook files from two trees (it binds a hook by the `recordType` * `event` it **declares**, not by its path — the directory layout is purely organisational): 1. **`sources//hooks/**`** — code-baseline hooks shipped with the product. Reviewed first-party source (provenance `code-baseline`); these run in-process with IO. 2. **`custom/hooks/**`** — operator- and Mechanic-authored hooks, cross-source or standalone (provenance `operator` / `mechanic`). These are untrusted customer/AI-authored code: bundled and run in the isolated-vm sandbox. Per-source operator hooks may also live at `sources//custom/hooks/**` so they sit next to that source’s tree while still being operator-provenance. The `.proposed/` subtree under `custom/hooks/` is **skipped** — Mechanic proposals are inert until an operator confirms them. ## Worked example: code-baseline (Oracle NetSuite customer) [Section titled “Worked example: code-baseline (Oracle NetSuite customer)”](#worked-example-code-baseline-oracle-netsuite-customer) `sources/netsuite/hooks/customer/before_save.require-name.ts` enforces the NetSuite mandatory-name rule canonical-side, so a missing field surfaces at save time rather than after a wasted REST round-trip: ```ts export default defineHook({ id: 'ns:customer:require-name', recordType: 'customer', event: 'before_save', source: 'netsuite', description: 'Customer rows require either company_name (company) or first_name + last_name (individual). …', failClosed: true, run: (ctx) => { // `readIsPerson` / `readName` read the CANONICAL snake_case key first, then // the camelCase canonicalizer key, then `ctx.prior` — see below. const isPerson = readIsPerson(ctx); const companyName = readName(ctx, 'company_name', 'companyName'); const firstName = readName(ctx, 'first_name', 'firstName'); const lastName = readName(ctx, 'last_name', 'lastName'); // Individual customer: first + last both required. if (isPerson) { if (!firstName || !lastName) { ctx.reject({ code: 'MANDATORY_FIELD_MISSING', field: !firstName ? 'first_name' : 'last_name', message: 'Individual customers (is_person=true) require both first_name and last_name. …', }); } return; } // Company customer (default): company_name required. if (!companyName && !(firstName && lastName)) { ctx.reject({ code: 'MANDATORY_FIELD_MISSING', field: 'company_name', message: 'Customer requires company_name (or first_name + last_name …).', }); } }, }); ``` Note `failClosed: true` (a missing-name customer must never silently proceed), the `source: 'netsuite'` scoping, and that `run` only ever calls `ctx.reject` — it doesn’t reach out to NetSuite. ### Read the canonical (snake\_case) key first — and fall back to `ctx.prior` [Section titled “Read the canonical (snake\_case) key first — and fall back to ctx.prior”](#read-the-canonical-snake_case-key-first--and-fall-back-to-ctxprior) Two rules that a `before_save` hook gets wrong at the cost of rejecting every write, both learned the hard way (once on transactions, again on customer): **1. `before_save` payloads are snake\_case.** The event runs at the write boundary (`POST /v1/data/:recordType`), which hands hooks the canonical column names — `company_name`, not `companyName`. The camelCase spelling is the *canonicalizer’s* output shape and reaches a hook only on the inbound path of an adapter whose `trustInboundAdapter` is off. Read snake\_case first and treat camelCase as the fallback. A hook that reads only camelCase sees `undefined` forever, and with `failClosed: true` that is not a check that never fires — it is a check that rejects **everything**. **2. A partial PATCH does not restate fields it isn’t changing.** An edit that touches only `phone` carries no name key at all. Absent-because-unchanged and absent-because-never-set need opposite answers, so consult `ctx.prior` — the stored row as it was before this write — before rejecting. And key any classification flag (`is_person`) on **presence**, not truthiness: an explicit `false` is a real value, while a missing key must inherit from `prior` rather than defaulting. Neither mistake shows up in a test that drives the DB directly. `POST`ing through PostgREST runs no `before_save` hook at all, so a spec built that way passes while every real app write 422s. Cover the hook with a unit test that calls `hook.run(ctx)` directly on a snake\_case payload, asserting both the rejection and the pass — and, for a field like this one, the `prior`-inheriting case where the key is absent rather than falsy. ## Worked example — standalone custom table [Section titled “Worked example — standalone custom table”](#worked-example--standalone-custom-table) `custom/hooks/cr_example/before_save.required-fields.ts` is the template for a hook on a **customer-authored standalone table** (an instance where Turbobase is the system of record). The directory name `cr_example` is organisational; the binding is the declared `recordType`: ```ts export default defineHook({ // 'custom::' for customer-authored hooks. id: 'custom:example:required-fields', // The standalone table's slug. Change this when you copy the template; // '*' would bind to EVERY record — scope it to one table. recordType: 'example', event: 'before_save', // Standalone tables have no connected source, so don't pin `source`. description: 'Validation for the standalone "example" table — rejects a save when the required `title` field is empty. …', failClosed: true, run: (ctx) => { const title = asStr(ctx.payload.cf_sa_title); if (!title) { ctx.reject({ code: 'MANDATORY_FIELD_MISSING', field: 'cf_sa_title', message: 'The "title" field is required on the example table.', }); } }, }); ``` Copy this to `custom/hooks/cr_/`, set the `recordType` to your table’s slug, and write the validation body. Because the template’s slug (`example`) won’t match a real table, it’s inert until you copy and re-point it. ## Hot reload [Section titled “Hot reload”](#hot-reload) The runtime watches both hook trees with chokidar and reloads the registry on change — a new file, an edit, a rename, or an `.overrides.json` change — so a hook takes effect **without a redeploy**. Each file is esbuild-transpiled with mtime+content-hash caching; a single bad file is isolated (its parse error surfaces in the parse-errors UI) and never breaks the rest of the registry. To author against a live-feeling instance before deploying, use [`turbobase sandbox create`](/extending/sandboxes-and-testing/). ## Enabling and disabling hooks [Section titled “Enabling and disabling hooks”](#enabling-and-disabling-hooks) Operators toggle hooks from `/data/extensions`: * **Code-baseline hooks** (`sources//hooks/`) are disabled via `custom/hooks/.overrides.json` — the loader drops overridden ids. * **Operator hooks** under `custom/hooks/` are disabled by renaming the file to `*.ts.disabled` (the loader skips it). The watcher reloads on either change, so a toggle is immediate. ### System-reserved hooks [Section titled “System-reserved hooks”](#system-reserved-hooks) A hook marked `systemReserved: true` (e.g. `ns:bidirectional-readonly`) is a managed-service guardrail whose enforcement *is* the contract — the customer is the party being gated, so the hook isn’t theirs to remove. In **managed mode** (the standard control-plane-operated instance) the loader ignores any override, the UI shows a locked badge, and the disable action 403s; to change the capability, staff flip the gating feature flag. Reserve this flag for platform code only. ## The Mechanic loop Preview [Section titled “The Mechanic loop Preview”](#the-mechanic-loop-preview) The **Mechanic** is the proactive-learning loop: it watches clusters of sync failures that share a signature, proposes preventive hooks (written to `custom/hooks/.proposed/`), and surfaces them for an operator to accept. A proposal is inert until accepted (the loader skips `.proposed/`), at which point it loads through the identical path as any authored hook. The Mechanic loop is in **Preview**. ## Grounding over the authoring MCP [Section titled “Grounding over the authoring MCP”](#grounding-over-the-authoring-mcp) When you author through an MCP client — Claude Code via [`turbobase mcp`](/cli/reference/#mcp) over stdio, or any client pointed at the remote `/mcp/authoring` endpoint with a [personal access token (PAT)](/tenant/access-tokens/) carrying `mcp:authoring` — the server grounds the client before its first tool call: * **Instructions** at `initialize` name your connected sources, what’s already deployed, and the authoring loop: scaffold → `mock_test` until green → `deploy` with `approve: true`. * **Prompts** — the connector skills (record anatomy, domain playbooks) for the sources you have connected, via `prompts/list` / `prompts/get`. A skill for a source you don’t have never appears. * **Resources** — `catalog:///` and `types://all` for the schema a hook’s payload satisfies, `samples:///` for fixture rows, and `recipes:///` for the source-filtered intent recipes (which columns and actions matter for a record type + intent) — exactly the field knowledge a validating hook encodes. Only connector skills surface here; admin-authored skills for Pilot, your AI assistant in the app, never appear on the authoring server. ## Next steps [Section titled “Next steps”](#next-steps) * [Sandboxes and testing](/extending/sandboxes-and-testing/) — `turbobase ext test` / `lint` your hook before deploying. * [Deploy, plan, promote](/extending/deploy-plan-promote/) — ship a hook with `turbobase ext deploy`. * [Writing conduits](/extending/writing-conduits/) — the cross-source counterpart. * [CLI reference](/cli/reference/#ext-new-hook) — the `ext new hook` scaffolder. # Writing processes > Author processes — multi-record sequences an app invokes and the platform runs on the server, in order, with the source's verdict awaited and a rollback when a step fails — with the defineProcess contract. Shipped A **process** is the third extension kind, beside [hooks](/extending/writing-hooks/) and [conduits](/extending/writing-conduits/). A hook runs on one record’s write; a conduit fires when a record crosses a source boundary; a process is **asked for** by an app and runs a fixed sequence of record writes on the server: “raise a purchase order, wait until the source has posted it, then raise the bill against it.” The ordering, the waiting, the validation and the undo all happen on the server, survive a worker restart, and are reusable by every app on the instance — instead of living in one app’s browser code, where a closed tab or a locked phone loses the run mid-chain. It is authored with the same SDK, validated by the same Zod machinery, run in the same isolated-vm sandbox and stored in the same DB-native store as the other two kinds. The SDK is the same `@turbobase/extensions` package — see [Install the SDK](/extending/writing-hooks/#install-the-sdk) for local typechecking (`@turbobase/extensions/process` is the process-only subpath). ## The `defineProcess` contract [Section titled “The defineProcess contract”](#the-defineprocess-contract) ```ts import { defineProcess } from '@turbobase/extensions'; export default defineProcess({ id: 'acme:raise-po-and-bill', // the :id an app invokes input: { // JSON Schema — validated at invoke type: 'object', properties: { vendorId: { type: 'string' }, amount: { type: 'number' } }, required: ['vendorId', 'amount'], }, steps: [ { id: 'po', recordType: 'purchaseOrder', awaitSource: true, // hold the next step for the source's verdict build: (ctx) => ({ parent: { entity_id: ctx.input.vendorId } }), }, { id: 'bill', recordType: 'vendorBill', build: async (ctx) => { // a read, as the invoking user — exactly what POST /v1/batch does const [vendor] = await ctx.query(`entity?select=terms_id&entity_type=eq.vendor&external_id=eq.${ctx.input.vendorId}`); return { parent: { created_from_id: ctx.steps.po?.externalId, // the id the source minted for step one entity_id: ctx.input.vendorId, terms_id: vendor?.terms_id ?? null, }, }; }, }, ], onFailure: 'rollback', // undo what landed when a step fails }); ``` | Field | Required | Meaning | | --------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | yes | Stable id; collisions are loader errors. Convention `':'`. It is the `:id` in `POST /v1/processes/:id`. | | `input` | yes | A JSON-Schema **object** the invoke body is validated against. A body that fails it is refused with the field named, and no run is created. | | `steps` | yes | The ordered step list, at least one entry, ids unique. An entry is one **step**, or a **repeated group** of them — see [Repeating a group of steps](#repeating-a-group-of-steps). A step acts on **one** record of `recordType` through the boundary your app’s own writes cross: it creates one, [changes](#changing-a-record-instead-of-creating-one) one, or [removes](#removing-a-record) one. | | `steps[].awaitSource` | no | Hold the **next** step until the source has given its verdict on this one — `confirmed` with the minted id, or `failed` with the source’s own sentence. Declare it whenever a later step needs `ctx.steps..externalId`. | | `steps[].build` | yes, unless `delete` | Pure `ctx → { parent, lines? }` — the same payload `POST /v1/data/:recordType` takes — or `null` to decline this time. Nothing else is read from it: a `pgRowId` or `externalId` returned beside `parent` is ignored, and the step **creates**. To change an existing record, declare `update`. The engine performs the write; the step never does. A step that declares `delete` has nothing to build and must **not** carry one. | | `steps[].update` | no | Makes this step **change** a record instead of creating one. See [Changing a record](#changing-a-record-instead-of-creating-one). | | `steps[].delete` | no | Makes this step **remove** a record instead of writing one, and the step carries no `build`. See [Removing a record](#removing-a-record). | | `steps[].when` | no | `ctx → boolean`. `false` records the step `skipped` and the run carries on. See [A step that declines](#a-step-that-declines). | | `steps[].rollback` | no | `'undo'` (default) or `'keep'` — what a failure **at this step** does to what the run already created. See [Scoping the rollback](#scoping-the-rollback). A `delete` step is always `'keep'`. | | `steps[].onRefused` | no | **Delete steps only.** `'stop'` (default) or `'continue'` — whether a refused delete ends the run. See [What a refused delete does to the run](#what-a-refused-delete-does-to-the-run). Declaring it on any other step is refused at deploy time, because a refused write always ends the run. | | `eachRow` | no | Makes this a **migration**: the step list runs once per outstanding row, a slice at a time. See [Moving many records](#moving-many-records). | | `onFailure` | no | `'stop'` (default — what landed stays) or `'rollback'` (delete everything the run created, newest first). | | `description` | no | Operator-friendly text in the UI. | ## What a step can see [Section titled “What a step can see”](#what-a-step-can-see) `ctx` carries `input` (already validated), `runId`, `steps` — every earlier step’s `{ pgRowId, externalId, disposition }` keyed by step id — `row` (the source row this pass is migrating, for a process that declares `eachRow`), `item` and `index` (the element this iteration of a repeated group is for, and its position), `log(...)`, and **one** read, `ctx.query(q)`. The read takes a PostgREST path-and-query exactly like the client’s `select()` (`order?select=id,total&limit=100`) and runs **as the user who invoked the process**, carrying their session: a row their row-level security hides is absent from the result, and a process cannot widen its own read scope. There are no secrets in the context and no way to call a source system — a process is pure, like a conduit, and the engine does every write. **`ctx.query` cannot write, and three things stop it.** The request leaves as a GET; the path is one resource name, so no stored procedure can be named (PostgREST exposes those under `rpc/`, which the read refuses); and the session it carries is the invoking user’s, which holds no write grant of its own. A step changes a record by returning one from `build()` — the engine takes it through the write boundary, with the hooks, the guardrails and the audit trail that door runs. Reaching for a write through the read is not a shortcut past them; it is refused three times over. **A read works however long the run has been going.** The session `ctx.query` carries is renewed before it expires, so a step that reads an hour into a run — after a few `awaitSource` waits — reads as the first step did. It is still the invoking user’s session: if an administrator changes that user’s access while the run is going, the run’s later reads are refused rather than carried on under the old access. ## Changing a record instead of creating one [Section titled “Changing a record instead of creating one”](#changing-a-record-instead-of-creating-one) A step **creates** a record by default. Declare `update` and it changes one instead — which is what a linking pass needs, because the first half of a pair is created before the second half’s id exists: ```ts steps: [ { id: 'out', recordType: 'salesOrder', awaitSource: true, build: (ctx) => ({ parent: { … } }) }, { id: 'back', recordType: 'salesOrder', awaitSource: true, build: (ctx) => ({ parent: { … } }) }, { id: 'link', recordType: 'salesOrder', update: { step: 'out' }, // what an earlier step landed build: (ctx) => ({ parent: { paired_with_id: ctx.steps.back?.externalId } }), }, ] ``` Address the record one of three ways, and only `step` is a plain string — the other two are functions of the context, because a declaration cannot know the value until the run: | Key | Addresses | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `update.step` | What an earlier step of **this run** landed, by its id. Inside a repeated group that is the namespaced id, `'leg:2:doc'`. | | `update.pgRowId` | `(ctx) => string` — a row the run did not create, by its surrogate key. | | `update.externalId` | `(ctx) => string` — the same, by the id the source minted. | | `update.expectedSyncVersion` | `(ctx) => number` — optimistic concurrency. The row’s current version must match. | `build` is unchanged: it returns the payload, and only the fields it names are written. The write goes through the **same** boundary as a create, so your `before_save` hooks, guardrails, write RLS and the write trail all apply — and the refusal on the step’s ledger row is the boundary’s own. A row the invoking user cannot see is a `404`; a moved `expectedSyncVersion` is a `409`. An address that resolves to nothing — a `step` that was skipped or has not run, or a `pgRowId` function that returned `undefined` — **fails the step** with `STEP_ADDRESS_UNRESOLVED`, naming what it could not resolve. It is never allowed to fall through to a create: that would quietly make the record the update was meant to change, with nothing downstream able to tell. **The address lives on `update`, never in what `build` returns.** A step that returns `{ pgRowId, parent }` from `build` and declares no `update` is a **create** — the extra key is not read — and it inserts a new row every time it runs. `recordType` must be the record type of the row you address; the boundary looks the row up in that type’s table. **If an update step still creates rows, the code that ran is not the code you wrote.** Check the two things that cause it, in order: the instance is older than **v1.38.0** (where `update` first shipped — an older instance drops the key at deploy and the step creates), or an older deployed copy of the process is still the one being served — see [Deploying a new version](#deploying-a-new-version). ## Removing a record [Section titled “Removing a record”](#removing-a-record) Declare `delete` and the step **removes** a record instead of writing one. It carries no `build` — a delete has nothing to build — and it addresses records your run did not create, which is what a background cleanup needs: undoing a migration, clearing a batch an import got wrong. Your app declares the work once and the platform runs it, so it survives the page being closed. ```ts steps: [ { id: 'drop-invoice', recordType: 'invoice', delete: { externalId: (ctx) => ctx.input.invoiceId, // the id the source minted sourceSystem: 'netsuite', }, onRefused: 'continue', }, ] ``` Address the record one of two ways — the same two keys `DELETE /v1/data/:recordType` takes, and both functions of the context for the reason an `update` address is: | Key | Addresses | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `delete.pgRowId` | `(ctx) => string` — the row by its surrogate key. | | `delete.externalId` | `(ctx) => string` — the row by the id the source minted. | | `delete.sourceSystem` | Which system the record belongs to. A **qualifier**: it narrows which table the id is looked up in on an instance where one record type means different things under two connected systems. It never decides where the delete is sent — the row’s own system does that. | There is deliberately no `delete.step`. A step addresses a record the run did **not** create; a run that deleted its own work is a rollback, which is what `onFailure: 'rollback'` is for. **It is an ordinary delete.** The step crosses the same boundary your app’s own `DELETE /v1/data/:recordType` crosses, as the user who invoked the run — so write RLS, the write-back entitlement, the `.delete` guardrail and your `before_delete` and `after_delete` hooks all apply exactly as they do to a delete your app sends, and the refusal on the step’s ledger row is theirs. It is charged to your instance’s ordinary **delete** cap. **A delete cannot be undone**, so the step is always `rollback: 'keep'`: declaring `rollback: 'undo'` is refused at deploy time, and a rollback some other step causes passes over it. The records it removed stay removed. ### What a refused delete does to the run [Section titled “What a refused delete does to the run”](#what-a-refused-delete-does-to-the-run) `onRefused` decides, and it is the one choice this step asks you to make: | Value | The run | | ------------------ | ---------------------------------------------------------------------------------------- | | `'stop'` (default) | Ends at the refusal, as every other step does. The remaining steps are `skipped`. | | `'continue'` | Carries on to the next step and commits. The refusal is still on this step’s ledger row. | Reach for `'continue'` on a tier-ordered cleanup — transactions, then entities, then items. A record the connected system will not delete because another document still points at it is a record to **leave alone**, not a reason to abandon the remaining tiers. Either way the outcome of every record is on the run’s step ledger. Read it with `GET /v1/processes/runs/:runId`, which answers one entry per step in the shape [Watching a run](#watching-a-run) sets out: * `disposition` — `confirmed` for a record that is gone, `failed` for one that was refused, `skipped` for a step that was never attempted: its `when` declined, an earlier step failed, or the run paused on the delete cap with steps still to go. * `refusal` — `null`, or `{ code, field, message }`: the refusal’s own code and sentence, whichever party refused — your guardrail, a hook, or the connected system. * `pgRowId` and `externalId` — which record the step removed. **Read `refusal` on a `confirmed` entry too**, because there is one `confirmed` that does not mean the record is gone from the connected system — the held delete below. Its `refusal.code` is `SOURCE_AWAITING_CONFIRMATION`, and every other `confirmed` leaves `refusal` null. Those are the route’s names. The same rows are also readable through PostgREST as `process_run_step`, where the ledger’s own columns carry the same facts: `disposition`, `refusal_code`, `refusal_field`, `refusal_message`, `pg_row_id` and `external_id`. Reach for the table when you are already querying your data; reach for the route when you are watching one run. A delete the connected system refuses is **un-hidden again**: the record comes back and the step row explains why. An address that resolves to nothing fails the step with `STEP_ADDRESS_UNRESOLVED` and deletes nothing — it is never allowed to fall through. ### Pacing, and your delete cap [Section titled “Pacing, and your delete cap”](#pacing-and-your-delete-cap) Deletes are capped tighter than updates, because the cap is a circuit-breaker against a mass delete and a background run is exactly the thing that would trip one. A delete step **waits** for the rolling window to free and carries on in the same run, so you do not have to spread a cleanup thin to stay under it. A step still waiting when its budget runs out stops the run with the cap named on it, having deleted nothing at that step. That step and the ones after it are recorded `skipped`. Invoke the process again to carry on, or raise the cap in **Settings → Write guardrails**. ### A delete your instance holds for approval [Section titled “A delete your instance holds for approval”](#a-delete-your-instance-holds-for-approval) **This is the default, not an edge case.** Turbobase classifies a delete as destructive and asks an operator to approve it unless a guardrail rule on your instance says otherwise — so on a stock instance every delete a process makes is held, exactly as every delete your app sends is. A hold is **not a refusal**. The record is hidden in Turbobase straight away, the delete is queued, the run carries on to the next step and commits, and a person approves it on **Compliance → Guardrails** — the same path a delete your app sends takes. The step does not wait for that approval: nothing bounds how long a person takes. What tells you it happened is the step’s entry: | | `disposition` | `refusal` | | -------------------------------------------------- | ------------- | -------------------------------------- | | Removed, and the connected system has done it | `confirmed` | `null` | | Hidden here, waiting for an operator to approve it | `confirmed` | `code: 'SOURCE_AWAITING_CONFIRMATION'` | | Refused | `failed` | the refusing party’s own `code` | If your cleanup is meant to run unattended, write a rule for the record types it deletes — `allow_with_audit` on `.delete` keeps the audit trail and removes the hold. ## Repeating a group of steps [Section titled “Repeating a group of steps”](#repeating-a-group-of-steps) A step-list entry may carry nested `steps` instead of a `recordType` and a `build`. Declare `forEach` on it and the group runs once per element of an array read off the validated input: ```ts input: { type: 'object', properties: { legs: { type: 'array', items: { type: 'object' } } }, required: ['legs'], }, steps: [ { id: 'leg', forEach: { input: 'legs' }, // names a property of `input` steps: [ { id: 'doc', recordType: 'salesOrder', build: (ctx) => ({ parent: { memo: `leg ${ctx.index}`, entity_id: ctx.item.vendorId } }) }, { id: 'note', recordType: 'salesOrder', build: (ctx) => ({ parent: { created_from_id: ctx.steps[`leg:${ctx.index}:doc`]?.externalId } }) }, ], }, { id: 'close', recordType: 'salesOrder', update: { step: 'leg:0:doc' }, // address one iteration from outside the loop build: (ctx) => ({ parent: { memo: 'chain closed' } }), }, ] ``` * **`ctx.item` is the element and `ctx.index` its position**, from 0. * **Every iteration’s steps get their own ledger rows**, named `::` — `leg:0:doc`, `leg:0:note`, `leg:1:doc`, … That id is part of the contract you write against: it is how a later step reads one iteration’s outcome out of `ctx.steps`, and how it addresses one with `update: { step: … }`. * **`forEach.input` names a property rather than taking a function.** That is what lets the instance check the expansion against its cap before any of your code runs and before the first write. One run may repeat a group at most **100** times (`DEFAULT_PROCESS_FOR_EACH_MAX_ITEMS`). Over that, the run is refused with both numbers in its `reason` and **nothing is written** — a chain that wants hundreds of legs wants [`eachRow`](#moving-many-records), which the platform paces a slice at a time. ## A step that declines [Section titled “A step that declines”](#a-step-that-declines) Two ways, one outcome. `when(ctx)` says “not this time” before the payload is built; a `build` that returns `null` says it after: ```ts { id: 'rush', recordType: 'salesOrder', when: (ctx) => ctx.input.express === true, build: … }, { id: 'fee', recordType: 'salesOrder', build: (ctx) => (ctx.input.fee ? { parent: { … } } : null) }, ``` Either way the step is recorded **`skipped`** with no refusal on its row, the steps after it still run, and the run ends `committed`. Declining is not a refusal: a chain with an optional leg is a chain that succeeded. A `when` that **throws** is a different thing — an author whose condition is broken, not an author saying no — so it fails the step and stops the run rather than being read as a `false` that would silently drop a leg. ## Scoping the rollback [Section titled “Scoping the rollback”](#scoping-the-rollback) `onFailure` is the run’s switch; `steps[].rollback` narrows it to the step: ```ts onFailure: 'rollback', steps: [ { id: 'out', recordType: 'salesOrder', build: … }, // 'undo' — the default { id: 'back', recordType: 'salesOrder', build: … }, { id: 'stamp', recordType: 'salesOrder', rollback: 'keep', build: … }, // the closing stamp ] ``` **It is the failing step’s setting that decides whether the run rolls back at all.** A failure at `stamp` above undoes nothing and the run ends `failed`, so a closing stamp that could not be written does not destroy the chain it was closing. A failure at `out` or `back` rolls the run back exactly as it always has, and `stamp`’s own record is spared there too — `keep` is about this step’s record as well as about the run’s ending. Rolling a finished run back on request — `POST /v1/processes/runs/:runId/rollback`, see [Stopping work](#stopping-work) — is somebody asking rather than a failure, so the failing step’s `keep` does not stop it. Each step’s own `keep` still spares that step’s record. A record an **`update`** step merely changed is never deleted by a rollback and never put back: the run did not create it, the prior values are not the run’s to own, and restoring them would silently overwrite an edit somebody else made in between. The ledger is what says which steps changed what. `ctx.query` answers with the **whole** result set, or it throws. There is no row cap on it, and that is deliberate: a step reads in order to decide what to write, so a silently shortened answer would be a wrong write rather than a slow one. The `POST /v1/batch` row cap does **not** apply here. What does apply is a size ceiling — a single read whose response exceeds the per-read byte budget fails the step with the limit named, rather than coming back short. Ask for a page explicitly (`?limit=…&offset=…`) when a page is what you want, and prefer a filter to reading a table and narrowing it in `build`. Two **time** bounds apply as well. A read that has started is abandoned at the per-read ceiling. And reads take from a small pool of slots shared with the other runs on the same worker, so a read that waits too long for a slot is never sent at all. That second bound is the **worker’s own**, and it is several times longer than the one a request from an app gets: nobody is watching a background run, so it is given room to be slow, and the bound is there to end a run that is waiting on a queue that has stopped moving rather than to hurry one along. Either bound fails the step by name, with the bound that stopped it in the message, rather than waiting indefinitely — and a failed step ends the run (`failed`, or rolled back where the process asks for that), so the remedy is to invoke it again rather than to wait. One more reason to filter a read rather than drain a table: a step that reads everything holds a slot for as long as it takes, and the runs behind it are the ones that pay. ## Invoking a process [Section titled “Invoking a process”](#invoking-a-process) An app calls the process under its ordinary session — the same cookie its own writes use — with the input as the body. **The body *is* the input**, with no `{ input: … }` envelope around it: ```ts const res = await fetch('/v1/processes/acme:raise-po-and-bill', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ vendorId: '123', amount: 250 }), }); // 202 { runId, processId, state: 'queued' } ``` The response comes back **before the first step is written**. Nothing runs on the request: the run is queued, and a worker performs it. Refusals carry an envelope and never a `runId`: | Status | Body | Meaning | | ------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `401` | `{ error }` | No session. | | `404` | `{ error: 'process_not_found', id }` | No deployed process of that id. | | `400` | `{ error: 'invalid_input', rejection: { code, field, message } }` | The body fails the process’s `input` schema; `field` names what to fix. | | `400` | `{ error: 'invalid_mode', rejection }` | `?mode` was neither `sync` nor `async`. | | `409` | `{ error: 'process_disabled', id, reason }` | An administrator switched this process, or every process on the instance, off. See [Stopping work](#stopping-work). | | `202` | `{ runId, processId, state: 'queued' }` | Queued. | **Send the input bare.** A caller that posts `{ "input": { "batch": 7 } }` gets exactly that as `ctx.input` — `ctx.input.input.batch` — and `eachRow.outstanding` receives the same wrapped object. If one of your callers wraps the body and another does not, the process sees two shapes. An `input` schema with `additionalProperties: false` and the real properties `required` turns the wrapped shape into a `400` instead. **Who can invoke.** A signed-in session — an app, or a person in the browser — or a personal access token carrying `extensions:deploy` whose owner is an administrator, which is what `turbobase processes invoke` sends (see [From a terminal](#from-a-terminal)). A token’s run is its owner’s: it writes as that person, exactly as their browser session would. A token owned by anyone else is refused (`403 admin_required`). A process cannot start another process — a step has no network and no way to call the platform. For work that must carry on with nobody signed in, see [Keeping a long run going](#keeping-a-long-run-going). ### Waiting for the answer [Section titled “Waiting for the answer”](#waiting-for-the-answer) Add `?mode=sync` when the app wants the outcome rather than a ticket — a short chain a person is watching a spinner for: ```ts const res = await fetch('/v1/processes/acme:raise-po-and-bill?mode=sync', { … }); // 200 { runId, processId, state: 'committed', reason: null, outstanding: 0, steps: [ … ] } ``` The run itself is queued and performed by a worker exactly as an asynchronous one is — the same queue, the same engine, the same ledger. All that changes is that the request stays open until the run settles, and it is bounded: a run that outlasts the instance’s synchronous budget answers `202 { error: 'sync_budget_exceeded', runId, processId, state: 'queued', message }`. **That is not a failure and the run is not cancelled** — it is still going, which is why the answer carries its id. Watch it as below, or invoke it asynchronously in the first place. So in `?mode=sync` a `202` is the one answer that does not mean “finished”: **branch on `error`, not on the status.** A `200` carries the settled run, a `202` carries `error: 'sync_budget_exceeded'` and a run still going, and the `4xx` refusals above carry no `runId` at all. The two modes run in separate lanes with separate limits, so a long migration cannot crowd out the short interactive runs. ## Watching a run [Section titled “Watching a run”](#watching-a-run) Poll `GET /v1/processes/runs/:runId` under the same session. It answers the run — `state` is `queued` → `running` → `committed` | `failed` | `rolled_back` | `cancelled`, with a `reason` on any ending that needs one — and one entry per step: ```json { "run": { "runId": "…", "processId": "acme:raise-po-and-bill", "state": "committed", "reason": null, "outstanding": 0, "…": "…" }, "steps": [ { "sequence": 1, "stepId": "po", "recordType": "purchaseOrder", "pgRowId": "…", "externalId": "PO-1041", "disposition": "confirmed", "rowKey": "", "slice": 1, "refusal": null }, { "sequence": 2, "stepId": "bill", "recordType": "vendorBill", "pgRowId": "…", "externalId": null, "disposition": "confirmed", "rowKey": "", "slice": 1, "refusal": null } ] } ``` `rowKey` and `slice` matter for an `eachRow` migration: `rowKey` is the source row the entry belongs to, and `slice` is the slice of the run that recorded it. A row that a later slice offers again gets a second set of entries, one per visit. For a chain they are always `""` and `1`. A step’s `disposition` is one of `confirmed`, `failed`, `skipped` (it was never attempted — its `when` declined, an earlier step failed, or the run paused on the delete cap) or `rolled_back`. A step that was refused carries the refusing party’s **own** `code`, `field` and `message` — a `before_save` hook’s rejection, the write boundary’s, or the source’s sentence from the write trail — so a failed run explains itself the way a `422` from `POST /v1/data` does. The refusal stays on the row when a rollback later undoes that step’s record, so a `rolled_back` step can still say why. The same rows are readable through PostgREST as `process_run` and `process_run_step`, scoped by row-level security to the invoking user (an administrator sees every run). **`committed` does not always mean there is nothing left to do.** A run carries an `outstanding` count — the work it did not get to — and a slice of a migration ends `committed` with a non-zero one. You do **not** have to ask for the next slice: the run queues it itself, under the same run id, until nothing is outstanding or it reaches its slice cap. A run that stopped at the cap ends `committed` with a `reason` naming it, which is how “stopped early” and “finished” stay two different things to read. ## Watching the whole queue [Section titled “Watching the whole queue”](#watching-the-whole-queue) One run at a time is the wrong shape once a migration is thousands of records. `GET /v1/processes/queue` answers everything in one read, scoped exactly as the single-run read is: ```json { "runs": [ { "runId": "…", "processId": "acme:migrate-customers", "mode": "async", "state": "committed", "outstanding": 4, "failure": null }, { "runId": "…", "processId": "acme:migrate-customers", "mode": "async", "state": "failed", "failure": { "stepId": "customer", "code": "MANDATORY_FIELD_MISSING", "message": "company_name is required" } } ], "counts": { "queued": 12, "running": 2, "committed": 40, "failed": 1, "rolled_back": 0, "cancelled": 0 } } ``` Add `?mode=async` for the migration lane alone. Each failure travels **with** the run it belongs to, so nothing has to be looked up separately, and every bucket is reported even when it is empty — so “none cancelled” reads differently from “cancellation is not reported”. ## Stopping work [Section titled “Stopping work”](#stopping-work) ```ts await fetch('/v1/processes/acme:migrate-customers/cancel', { method: 'POST' }); // { cancelled: 387 } await fetch(`/v1/processes/runs/${runId}/cancel`, { method: 'POST' }); // { runId, state: 'cancelled' } await fetch(`/v1/processes/runs/${runId}/rollback`, { method: 'POST' }); // 202 { runId, state, queued: true } ``` The first stops every **queued** run of one process in a single call — a caller who queued four hundred slices is not asked for four hundred requests. The second stops one run, queued or running. A queued run never executes. A running one stops at its **next boundary** — the next step, and for a migration the next **row**, so a one-step migration stops after the row it is on rather than running to the end of its slice. The write in flight finishes rather than being killed halfway through, and **records already sent upstream stay** — even when the process declares `onFailure: 'rollback'`. A cancel is not a failure, and undoing records the source has already accepted is exactly the half-built chain rollback exists to prevent. A cancelled slice reports the rows it never started in `outstanding`, the same way one stopped by the budget does. Cancelling one slice cancels the run: a cancelled run queues no continuation. **To undo a run after it has stopped, roll it back.** The third call above deletes, newest first, the records the run’s ledger shows it created — the same records an `onFailure: 'rollback'` would have deleted, so a record an `update` step changed, a row an earlier run had already migrated and a step declared `rollback: 'keep'` are left alone. It works on a run that is `committed`, `failed` or `cancelled`; a run still `queued` or `running` answers `409` (cancel it first), and one already `rolled_back` answers `200` with `queued: false`. It is open to whoever invoked the run and to an administrator, and the deletes are made as the person who invoked it, through the same delete rules and hooks as any other delete. The work is queued rather than done inside the request, so the answer is `202`; watch the run until it reads `rolled_back` (or `failed`, with the records it could not remove named in its `reason`). **An administrator can also stop a process without touching its runs.** Switch one process off, or every process on the instance off, and no new run starts by schedule or by hand — an invoke answers `409` naming the switch — while a run already going stops at its next step boundary. A run also stops there when it passes the instance’s maximum runtime or maximum writes for one run. Every one of those endings is `committed` with a `reason` naming the limit or the switch, rather than `failed` — they are not faults, and counting them as failures would set off the alarms that watch for real ones. **Those two ceilings are the RUN’s, and they apply even where nobody has set one.** An instance that has never been told a number gets shipped defaults sized off the slice budget — the most work the slice caps already allow one run to do — so nothing that was going to finish is stopped by them, and a run that is stuck is bounded rather than endless. They count across the whole run, not per slice: a migration of a thousand slices has one runtime allowance and one write allowance between them, which is what “per run” means on an instance’s settings page. ## Carrying a stopped run on [Section titled “Carrying a stopped run on”](#carrying-a-stopped-run-on) ```ts await fetch(`/v1/processes/runs/${runId}/resume`, { method: 'POST' }); // 202 { runId, state, queued: true } ``` A run that stopped on an `awaitSource` wait is **resumed**, not started again. The same run goes back on the queue at the step it stopped on: every step its ledger already recorded is passed over, the one whose verdict never arrived is waited for again, and the rest of the chain runs. Nothing is written twice, because each step’s idempotency key comes from the run id and the run id does not change — which is exactly what invoking the process a second time cannot give you, since a new run’s keys are its own and its first steps create again. **A step that already said no is not asked again.** “Already recorded” covers a step whose `when` declined as well as one that wrote: the run reached it and decided, so the continuation leaves that decision alone even where the data the `when` read has changed in the meantime. Only the steps the run never reached are performed. If you want a declined step reconsidered against today’s data, invoke the process again rather than resuming the old run. **Only a run that stopped with work left.** `committed` with `outstanding` above zero is the one state a resume is for. A run still `queued` or `running` has not stopped, a `failed` or `rolled_back` one needs its refused step re-keyed rather than re-offered, a `cancelled` one was stopped by a person, and a `committed` run with nothing outstanding has finished — each answers `409` (`process_run_not_resumable`) naming the state on the body, with nothing queued. Two resumes in quick succession start one continuation; the second answers `409`. **A migration is invoked again instead.** An `eachRow` process keys each write by the source row, so a slice that stopped at its cap carries on by being invoked again — re-offering the same slice would be wrong rather than merely unnecessary. The resume answers `409 process_is_a_migration` and says so. It is open to whoever invoked the run and to an administrator, the continuation runs as the person who invoked it, and the work is queued rather than done inside the request — so the answer is `202`; watch the run until it reads `committed` with `outstanding: 0`. **The clock stays the run’s**: `startedAt` is not reset, so a resume cannot buy a run more than the instance’s maximum runtime, and a run already past it stops again at its first step boundary with the ceiling named in its `reason`. ## From a terminal [Section titled “From a terminal”](#from-a-terminal) ```sh turbobase processes list # what is deployed, and how it fires turbobase processes runs acme:migrate-customers # that process's whole history turbobase processes run 1111… # one run and its step ledger turbobase processes invoke acme:migrate-customers --input '{"batch":"2026-10"}' # start one, queued turbobase processes invoke acme:raise-po-and-bill --input-file po.json --sync # start one and wait turbobase processes cancel 1111… # stop it turbobase processes resume 1111… # carry on a run that stopped on a wait turbobase processes rollback 1111… # undo what a finished run created turbobase logs --process-run 1111… --follow # watch one run until it finishes ``` `processes invoke` sends the input — a JSON object, from `--input` or `--input-file` — as the whole request body, exactly as an app does. Queued, it prints the run id and the `logs --process-run` line to follow it; with `--sync` it prints the settled state, anything outstanding and each step, or — past the instance’s budget — that the run is still going and how to watch it. `processes list` is the read behind the admin **Processes** screen as well: every deployed process with its trigger — `manual`, or `scheduled` with **both** the cron expression and the timezone it is read in — whether it is switched on, and its last five runs. It is scoped by your own row-level security, so the processes are everyone’s and the runs are yours (an administrator sees every run). Following a run is a flag on the one log verb rather than a `processes logs` of its own, because all log access on this CLI unifies under `turbobase logs`. Unlike `--conduit`, it **ends**: a run reaches a terminal state, so `--follow` returns there instead of polling until you interrupt it. ## Moving many records [Section titled “Moving many records”](#moving-many-records) A process runs its step list once, which is the right shape for a three-record chain and the wrong one for moving tens of thousands of records. Declare `eachRow` and one invocation becomes a **slice** of that work: ```ts export default defineProcess({ id: 'acme:migrate-customers', input: { type: 'object' }, eachRow: { // Re-run at the start of EVERY slice. Write the predicate that excludes // what is already done, and rows an earlier slice finished simply are not // in the answer any more. outstanding: () => 'legacy_customer?select=id,company_name&migrated_at=is.null&order=id', key: 'id', }, steps: [ { id: 'customer', recordType: 'customer', build: (ctx) => ({ parent: { company_name: ctx.row!.company_name } }), }, ], }); ``` * **`outstanding`** returns one PostgREST query — the same shape `ctx.query` takes — read as the invoking user and row-level-security-scoped for free. * **`key`** names the column that identifies a row. That value becomes the write’s idempotency key, which is what makes re-offering a row a **no-op** rather than a second record upstream. Without it every retry of a slice would duplicate everything it had already moved. * **Each step reads its row from `ctx.row`**, not from `input`: `input` describes the whole migration, the row describes one record of it. One invocation takes rows off the front of that answer until the instance’s slice budget — a record count or a wall clock, whichever comes first — stops it. It finishes the step it is on, ends `committed` with the remainder in `outstanding`, **and queues the next slice itself**. One run id, one ledger, several deliveries: you invoke a migration once and it carries on until nothing is outstanding. A separate job each time rather than a loop, deliberately — a loop would hold its lane for the whole migration, which is the starvation the two-lane design exists to prevent. The chain is bounded by a per-run slice cap, so a migration that is larger than anyone expected stops and says so rather than running forever. Re-offering a row is still safe, which is why the `key` above matters: a create step offered the same row twice writes it once. An update step does not work that way. It runs again on every visit, so the row can move through states over several slices (see the contract below). ### The `eachRow` contract [Section titled “The eachRow contract”](#the-eachrow-contract) * **`outstanding(input)` receives the bare invoke body** — the same object as `ctx.input` — and returns one PostgREST query. * **It is read once per slice, not once per row.** The slice works through the rows that one read returned. A row that becomes outstanding while the slice is running is not in that answer; the next slice’s read picks it up. If the slice finished every row it read, nothing is outstanding and the run ends — a row added during that last slice waits for the next invocation or schedule. * **`outstanding` on the run is counted from that read**: the rows the slice read minus the rows it finished. * **Your predicate must exclude a row once it is done.** That is what makes a slice’s work shrink. A predicate that keeps returning finished rows makes every slice re-offer them, and the run keeps re-queuing until it reaches its slice cap. The usual shape is an update step at the end of the list that stamps the row out of the predicate. See the [migration recipe](#a-migration-recipe). * **Each time a later slice or a later run offers a row again, that is a new visit.** On that visit: * **Create steps run once per row `key`.** The step records `skipped` and hands `ctx.steps` the record that was created first, with its `pgRowId` and `externalId`, so later steps can still address it. * **Update steps run again on a later visit.** Their write goes through the boundary again, so a row can be stamped `sent` on one visit and `recorded` on a later one. * **A step that declined runs its `when` and `build` again.** That covers a `when` that returned false and a `build` that returned `null`. A step that declines while the row is waiting acts on the visit where it is ready. * **An AI step reuses its answer for the rest of the run.** Once the model has answered for a row, later slices of the same run hand that answer on as `skipped` and do not bill again. A new run asks the model again. * **A crash re-delivery is not a new visit.** If a worker dies mid-slice, the queue hands the same slice back. That delivery resumes after every step the slice already recorded, whether `confirmed` or `skipped`, and writes nothing twice. A row the outstanding read lists twice in one slice is also visited only once. * **Steps share state only through `ctx.steps` and `ctx.row`.** `ctx.steps` holds the outcomes of earlier steps **for the row being processed** and starts empty for the next row. There is no memory across rows or slices: anything a later row needs to know belongs in a table the steps write and `ctx.query` reads. * **A step that fails on one row ends the run** under `onFailure`, the same as in a chain. The rows after it are counted in `outstanding`. | Limit | Default | Set by | | ---------------------------- | ------------------------------------------- | --------------------------------------- | | Rows one slice works through | 500 (`DEFAULT_PROCESS_SLICE_MAX_ROWS`) | the instance (`PROCESS_SLICE_MAX_ROWS`) | | Wall clock one slice may run | 10 minutes (`DEFAULT_PROCESS_SLICE_MAX_MS`) | the instance (`PROCESS_SLICE_MAX_MS`) | | Slices one run may take | 1000 (`DEFAULT_PROCESS_MAX_SLICES`) | the instance (`processes.slice_cap`) | A slice checks both of its limits before **starting** a row, never in the middle of one. ### A migration recipe [Section titled “A migration recipe”](#a-migration-recipe) The common shape is a tracking table your app owns — one row per record to move, with a status — and a process that creates the target record for each pending row and then marks that same row done. Everything below needs an instance on **v1.38.0 or later**: `update` steps, `ctx.row` inside a deployed process, slices that queue themselves and `{ kind: 'process' }` schedules all first shipped there. ```ts import { defineProcess } from '@turbobase/extensions'; export default defineProcess({ // Keep this the FIRST `id:` in the file — see "Deploying a new version". id: 'acme:migrate-customers-tracked', input: { type: 'object', properties: { batch: { type: 'string' } }, additionalProperties: false }, eachRow: { // Select pg_row_id: the update step below addresses the row by it. outstanding: (input) => `migration_item?select=pg_row_id,legacy_id,company_name&status=eq.pending&batch=eq.${input.batch}&order=legacy_id`, key: 'legacy_id', }, steps: [ { id: 'create', recordType: 'customer', build: (ctx) => ({ parent: { company_name: ctx.row!.company_name } }), }, { id: 'mark', recordType: 'migration_item', // the record type of the row you change update: { pgRowId: (ctx) => ctx.row!.pg_row_id as string }, rollback: 'keep', build: (ctx) => ({ parent: { status: 'sent', target_pg_row_id: ctx.steps.create?.pgRowId ?? null }, }), }, ], }); ``` * `mark` **changes** the row this pass is on. It does not insert, because it declares `update`, and it takes the row out of `outstanding` because it moves `status` off `pending`. * `create` does not declare `awaitSource`, so `mark` records the new row’s `pgRowId` — set the moment the write lands — rather than waiting for the connected system to mint its id. In bulk, a wait for the source’s verdict runs out behind the queue of other writes; read the verdict later from the new row (`synced_at`, `external_id`) or the write trail. * `pg_row_id` is in the `select`. Leave it out and `ctx.row.pg_row_id` is `undefined`, and `mark` fails with `STEP_ADDRESS_UNRESOLVED`. * `rollback: 'keep'` on the stamp means a failure while stamping does not undo the record `create` already made upstream. * Invoke it once — `POST /v1/processes/acme:migrate-customers-tracked` with `{ "batch": "2026-10" }` — and it carries on by itself. ### Keeping a long run going [Section titled “Keeping a long run going”](#keeping-a-long-run-going) You do not need a browser tab open, and you do not need to re-invoke after each slice. 1. **An asynchronous run continues itself.** A slice that stops at its row or time limit with work outstanding queues the next slice, under the same run id, and the run stays `running`. It ends when nothing is outstanding, or at the slice cap. At the cap it ends `committed` with a `reason` saying to invoke it again. A cancel, an administrator’s off switch or a per-run ceiling stops it without continuing. 2. **A schedule invokes it with nobody signed in.** Add an entry to `turbobase/schedules.json` and deploy it with `turbobase ext deploy --to `: ```json [ { "name": "migrate-customers-nightly", "scheduleKind": "cron", "scheduleExpr": "0 2 * * *", "scheduleTz": "UTC", "body": { "kind": "process", "process": "acme:migrate-customers-tracked", "input": { "batch": "2026-10" } } } ] ``` * **Only an administrator can deploy one.** The deploy is refused if `process` is missing or names a process that is not deployed. Declaring the process and the schedule that runs it in the SAME deploy is fine — the check reads the processes the deploy will leave behind, not the ones already there. * **A process a schedule runs cannot be removed on its own.** Dropping the process while this entry survives is refused, because the schedule would go on firing against nothing and switch itself off after five failures. Delete both in the same deploy — that is the supported way to retire a process and its schedule — or point the schedule at another process in the same deploy, or keep the process declared. See [Deploy, plan, promote](/extending/deploy-plan-promote/#a-prune-never-leaves-a-schedule-running-a-process-you-removed). * **`name` may not begin with `system.`** — that prefix is the platform’s own recurring jobs, and a bundle schedule using it is refused. * **A schedule written by `ext deploy` runs as the instance’s administrator identity**, and the run’s ledger records the schedule, not a person. * **The schedule’s own run says `success` as soon as the process run is queued.** The process run’s outcome is on the run itself — read it with `turbobase processes runs `. * **A scheduled invoke is skipped, not failed,** while the process or the instance is switched off. * **`input` is checked against the process’s `input` schema on every run,** exactly as the invoke body is. Leave it out and the run gets `{}`. Input that is not a JSON object, or does not match the schema, fails that scheduled run with the field and the reason, and no process run is queued. Repeated failures pause the schedule like any other. A schedule is also how you restart a run that stopped at its slice cap, and it is safe to fire while an earlier run is still going: rows are keyed, so two runs that reach one row write it once. ## What the engine guarantees [Section titled “What the engine guarantees”](#what-the-engine-guarantees) * **Every write goes through the write boundary, as the invoking user.** Hooks, guardrails, row-level security and the write trail apply to a step exactly as they apply to a write the app makes itself. A payload a `before_save` hook rejects fails *that* step with the hook’s own code and field, and no later step is attempted. * **`awaitSource` waits for the source’s verdict, not for the first attempt.** A failed attempt is not terminal — the outbound worker retries — so the wait ends only on `synced` (the source said yes) or on a parked refusal (the source said no). The wait budget defaults to five minutes per step. * **A record the source created but could not be linked back is kept.** When the source accepts an `awaitSource` step’s create and the link from the new record back to its row fails (`write_settle_conflict`), the step ends `failed` with `refusal_code: 'SOURCE_APPLIED_UNLINKED'`, the source’s id on the step’s `externalId`, and the source’s own sentence. The run stops there, but no rollback deletes that row — the record exists upstream, and deleting the row would leave it with nothing pointing at it. Offer `POST /v1/data/:recordType/settle` with the step’s `pgRowId` to link it. * **A wait that runs out is not a failure.** Nothing was refused: the write is on the trail and the source has not answered either way. Behind the one-at-a- time ordering below, exceeding the budget is what an ordinary busy day looks like — so the run stops there with its record intact, ends `committed`, counts the steps it never reached in `outstanding`, and names the waiting step in its `reason`. Branch on that rather than on `failed` if you need to spot a slow source, and **carry it on with a resume** rather than starting over — see [Carrying a stopped run on](#carrying-a-stopped-run-on). * **A run survives a worker restart.** Every step carries an idempotency key and is recorded the moment its write lands, so a re-delivered run resumes at the first unfinished step and never creates a duplicate. For a chain that key is per run. For a migration it is per **source row**, which makes a create step a no-op when the same row is offered again, across runs as well as within one. Update steps and declined steps run again on that later visit; see [the `eachRow` contract](#the-eachrow-contract). * **Rollback is newest-first, through the delete boundary.** With `onFailure: 'rollback'`, every record the run created is deleted in reverse order — `before_delete` hooks see it, and a record that had already reached the source is deleted there too — and the run ends `rolled_back`. That includes a record the source refused after it landed locally. What a rollback never touches is a record the run did not create — a migration step skipped because an earlier run had already moved that source row — and a record the source created whose link back failed (`SOURCE_APPLIED_UNLINKED`, above). **A record whose own upstream create is still in flight IS deleted**: a step that did not await the source, or one whose wait ran out, is recorded as landed, and a rollback removes it here and queues its delete. If the create then reaches the source after the delete was decided, the source can keep a record nothing on the instance points at — so **resume** a run that stopped on a wait rather than rolling it back, and roll one back only when you want the work undone. * **A rollback has its own delete limit, and waits rather than failing.** Every delete that reaches a connected system is capped per source system. A delete your app or a person sends counts against the instance’s delete limit — by default **60 a minute, 1,000 an hour and 5,000 a day**, raised with `guardrails.max_deletes_per_min`, `guardrails.max_deletes_per_hour` and `guardrails.max_deletes_per_day` — and over it the delete is **refused** and the record stays. A run’s rollback is not counted there. Each of its deletes is checked against the run’s own ledger, and one that removes a record **that run created** counts against a separate rollback limit — by default **600 a minute, 10,000 an hour and 50,000 a day**, raised with `guardrails.max_rollback_deletes_per_min`, `guardrails.max_rollback_deletes_per_hour` and `guardrails.max_rollback_deletes_per_day`. Over that limit the delete **waits** for the window to free and is then sent; it is never refused for being over it. A run can never spend more of it than the number of records its ledger shows it created, and a delete of anything else — including a record the run only updated — is an ordinary delete under the first limit. * **`rolled_back` means removed here and on its way to the source.** A run reads `rolled_back` once every record it created has been removed from the instance and its delete accepted for sending. While deletes are still being sent, the run’s `reason` says how many. If the connected system then refuses one, that record comes back, its step returns to what it was, and the run reads `failed` with the refusal named — roll it back again once the cause is fixed. There is no bulk delete endpoint. * **Same-type writes reach the source one at a time, in order.** Two writes of one record type are never committed to the source together, so a source that numbers documents per type cannot hand one number to two of them. This holds for every writer on the instance, not only for processes. ## When the write boundary refuses a step [Section titled “When the write boundary refuses a step”](#when-the-write-boundary-refuses-a-step) A step’s refusal is the boundary’s own, with its `code`. Two are worth knowing before you write a NetSuite migration: * **`ATOMIC_WRITE_REQUIRED`** — a NetSuite transaction create (a credit memo, a vendor credit, a deposit, an invoice, …) arrived with no lines. Header and lines go to NetSuite in one write, so a create step’s `build` must return a non-empty `lines` array: `{ parent: { … }, lines: [{ … }] }`. A document transformed from another one — `parent.created_from_id` set — is the exception; NetSuite copies the lines from its source. * **`write_settle_conflict`** — the record **was** created in the connected system and handed back its id, and linking that id to the row here failed. Do **not** write the row again: a second create makes a second record upstream. The id is on `record_write_state.external_id` for that row, with `reason_code = 'write_settle_conflict'` — read it there rather than parsing it out of the message. You cannot set the row’s `external_id` by writing the row (it is a protected column); link it with the settle verb instead — `POST /v1/data//settle` with `{ "pgRowId": "…" }`, or `turbobase data settle `. It reads the id from the row’s write state (never from you), discards the inbound copy of the same record if one arrived meanwhile, marks the row synced and resolves the failure. It answers `409 NOT_SETTLE_CONFLICT` for a row that is not in this state. ## Scaffold, deploy, and share a rule [Section titled “Scaffold, deploy, and share a rule”](#scaffold-deploy-and-share-a-rule) ```sh turbobase ext new process --record purchaseOrder --then vendorBill --on-failure rollback turbobase ext lint processes/purchase-order-then-vendor-bill.ts turbobase ext deploy processes/purchase-order-then-vendor-bill.ts ``` `ext lint` is the offline check for a process — it reads the file and warns about the id-literal trap below, which is the only rule a process can trip. The four conduit rules it also carries (a weak `idempotencyKey`, a missing `createdFrom`, an extreme `cycleBudget`, a same-source “conduit”) describe a cross-source bridge and are not raised on a process, so `ext lint --ci` is safe to put in your deploy lane: a correct process exits 0. `ext test` is **not**: it invokes a hook’s `run()`, which a process does not export, so every sample comes back failed with `extension default export has no run() function`. A process is exercised by invoking it for a few rows and reading the run ledger. A deploy validates the module the way the loader will and refuses one whose `steps` are not a list, whose step has no `build`, or whose step names a record type the write boundary would not accept — with the field named, and nothing stored. ### Deploying a new version [Section titled “Deploying a new version”](#deploying-a-new-version) `ext deploy` answers `new` for a process the instance did not have, and **`replaced existing` for one it did** (the API’s `outcome` is `written` / `duplicate`). A replace is not a no-op: the new code is stored either way. The instance picks it up within about 30 seconds; a slice that is already running finishes on the version it started with, and the next slice uses the new one. **The stored copy is keyed on the first `id: '…'` literal in the file**, and the running instance serves the module by its own `id`. So make the first literal the process’s own id — not a constant, and with no step, comment or inlined helper carrying an `id:` literal above it. A deploy evaluates the module and refuses a file where the two disagree, with `extension_id_mismatch`, naming the literal it read and the id the module defines; nothing is stored, and a bundle apply (`ext deploy --to `) refuses the whole plan before writing anything. `turbobase ext lint` warns about the same file before you deploy it. The deploy line names the id it stored under and whether it replaced a copy — `deployed → status=active (replaced existing)`. A copy stored under the wrong name by an instance that predates the refusal is still there: both define the same process, only one is served — chosen by the stored names’ order, not by which is newer — so your new code may never run. `turbobase ext plan --to ` lists what is deployed that the project does not declare; a copy there under a step’s id or another stray name is one of these, and `turbobase ext remove ` takes it out. ### A preview runs no process from its branch [Section titled “A preview runs no process from its branch”](#a-preview-runs-no-process-from-its-branch) `turbobase apps deploy --preview` ships an app’s screens and nothing else — a process is installed only by `turbobase ext deploy`, and that is a step of your live deploy, not of a preview. So on a preview build a process the branch **adds** answers `404 { error: 'process_not_found' }`, and a process the branch **changes** runs the version already installed on the instance, not the branch’s. Have the screen say that a process action cannot run from a preview rather than show the 404, and plan for the first real run of a changed process to be the one after it is deployed. Extension files may now import a **relative sibling** inside their own directory — `import { requireMemo } from './shared-rule.js'` — so two hooks (or a hook and a process) share one rule instead of carrying two copies. `ext test`, `ext deploy` and `ext lint` fold the sibling in; an npm package, a Node builtin, or a path that escapes the extension’s directory is still refused. ## Next steps [Section titled “Next steps”](#next-steps) * [Migrating between systems](/extending/migrating-between-systems/) — the recipe for moving data one connected system mirrored into another, and where that shape stops. * [Writing hooks](/extending/writing-hooks/) — validate one record on its write. * [Writing conduits](/extending/writing-conduits/) — bridge a record between sources. * [Sandboxes and testing](/extending/sandboxes-and-testing/) — how extensions execute, and the offline checks that apply. * [Deploy, plan, promote](/extending/deploy-plan-promote/) — ship it. # NetSuite OAuth 2.0 setup > How to issue OAuth 2.0 credentials for a Turbobase instance to call NetSuite — the click-by-click reference behind the console onboarding flow. How to issue OAuth 2.0 credentials for a Turbobase instance to call NetSuite. The values produced here populate your instance’s NetSuite connection in the [console.turbobase.io](https://console.turbobase.io/) onboarding flow. > **Most sign-ups don’t read this end-to-end.** The console walks you through the OAuth 2.0 consent, bundle deploy, and config steps interactively. The click-paths below are the reference for operators who want to drive each NetSuite click by hand — for example, when re-running the flow against a refreshed sandbox. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * Admin access to the target NetSuite account (sandbox or prod). * A modern browser. The whole flow is clicks in the NetSuite UI. ## 1. Enable required features [Section titled “1. Enable required features”](#1-enable-required-features) *Setup → Company → Enable Features → SuiteCloud tab.* Check the boxes, then **Save**: * **OAuth 2.0** (under “Manage Authentication”) * **REST Web Services** (under “SuiteTalk (Web Services)”) * **Client SuiteScript** and **Server SuiteScript** (under “SuiteScript”) * **SuiteCloud Development Framework** (under “SuiteCloud Development”) — needed so Turbobase can deploy its bundle into your account. If any are already checked, leave them. If you can’t see this tab, your role is missing the “Enable Features” permission. ## 2. Create an Integration Record [Section titled “2. Create an Integration Record”](#2-create-an-integration-record) *Setup → Integration → Manage Integrations → New.* * **Name:** `Turbobase Sync` (any human label). * **State:** Enabled. * **Authentication tab:** * Check **OAuth 2.0**. * Under “Authorization Code Grant”, check **AUTHORIZATION CODE GRANT** and leave **PUBLIC CLIENT** unchecked. Turbobase uses a confidential client, so copy the client secret NetSuite surfaces alongside the client id. * Check **CLIENT CREDENTIALS (MACHINE TO MACHINE) GRANT**. This is the one box that lets your instance stop depending on the consent’s refresh token: within the hour after you connect, the instance maps its own certificate to this record through NetSuite’s certificate API and mints with it from then on — no reconnect cadence, and no visit to NetSuite’s M2M setup page. Nothing else about the record changes, and the box does not ask you for a certificate; the instance supplies its own. See “Certificate auth” below. * **Refresh Token Validity (in hours):** set this to **720** (NetSuite’s maximum, 30 days). It defaults to `168` — 7 days. Once certificate auth is active this only bounds how long the instance has to complete that switch after a connect; until then it is how often you would have to reconnect. See the note under step 5. * **Scope:** check `rest_webservices` and `restlets`. In the UI these are labelled **REST Web Services** and **RESTLETS**. * **Redirect URI:** `https://console.turbobase.io/ns/auth/callback`. * Uncheck **Token-Based Authentication** and the user-credentials option — pure OAuth 2.0 only. * Click **Save**. NetSuite shows the **Client ID** (sometimes labeled “Consumer Key” in older UI strings) and the **Client Secret** **once** at the top of the next page. Copy them now; the client secret is never shown again. You’ll paste them into the console in step 7. ## 3. The integration role [Section titled “3. The integration role”](#3-the-integration-role) *Already done for you.* The Turbobase bundle adds an integration role to your account when the console deploys it. The role carries the permissions for every record Turbobase syncs. From bundle 2.18.0 the deploy adds **two** roles, and you choose which one your user holds. You do not have to do anything to take the first: | Role | Shown in NetSuite as | What it grants | | ---------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `customrole_tb_io_integration` | TB Hybrid Integration | Everything Turbobase can do, including reading your script execution log and managing script deployments. | | `customrole_tb_io_integration_min` | TB Hybrid Integration (min) | The same record access, without the two permissions below. It carries one permission the default does not, so that a connection held under it can renew its own authentication. | `customrole_tb_io_integration` is the **default** — it is the role this guide assigns, and the one every account that installed before 2.18.0 already has. Nothing changes for you if you leave it alone. The narrower role, `customrole_tb_io_integration_min`, drops two permissions: **Manage Roles** and **SuiteScript**. Together those two let the role edit any role in your account and deploy server-side code into it, so if your security review will not accept a vendor credential that can do that, this is the role to assign instead. Everything you actually use Turbobase for still works: every record it syncs, in both directions, and every app built on it. What you **give up** by choosing it: * The **script execution log**. Turbobase can no longer read NetSuite’s own script execution log, so the diagnostics that quote it are unavailable — the log view in your instance, the “Recent UE webhook errors” health check (which reports a warning instead of a result), and the script detail the Customization Analyzer shows. The AI assistant cannot answer questions about why a script failed on your account. * **Script deployment changes.** Turbobase cannot enable real-time sync on a *custom* record type on its own; that needs a bundle redeploy from support instead. Real-time sync on standard records is unaffected. What you **keep**, and it is the reason this choice is safe to take: the narrower role also carries **Manage own OAuth 2.0 Client Credentials Certificates**, which the default role does not need because this guide has you consent as Administrator. NetSuite expires the credential from a sign-in on a fixed cadence — 30 days at most — and the way your instance stops depending on it is to map a certificate of its own, under the role that consented (see “Certificate auth” below). The permission is what makes that possible, so a connection consented under the narrower role renews itself exactly like one consented as Administrator. Without it the connection would stop when that credential expired — a week after consent at NetSuite’s default setting, 30 days at the maximum step 2 asks for — and the only way back would be to reconnect as an Administrator, undoing the switch. To switch an existing connection, assign the other role to your integration user (*Setup → Users/Roles → Manage Users*, then the user’s **Access** tab) and run the OAuth consent flow again from the Turbobase console, choosing that role at NetSuite’s role picker in step 5 instead of Administrator — the token stays bound to the role you consented as, so re-consenting is what moves it. Switching back works the same way, and no synced data is affected either way. For the OAuth consent step below, **consent as Administrator** — unless you have deliberately chosen the narrower role above, in which case consent as that. The bundle’s deploy needs Administrator-level permissions, and at the current version runtime tokens remain bound to whatever role you consented as. The integration role narrows runtime calls in a later step. ## 4. Pick a User [Section titled “4. Pick a User”](#4-pick-a-user) *Setup → Users/Roles → Manage Users.* Pick or create a user with the Administrator role assigned. This is the user you’ll authenticate as during the consent step. For prod, create a dedicated integration user so the runtime OAuth tokens stay bound to a stable account rather than someone’s personal sign-in. ## 5. Run the OAuth 2.0 authorization-code flow [Section titled “5. Run the OAuth 2.0 authorization-code flow”](#5-run-the-oauth-20-authorization-code-flow) The console drives this for you when you connect from the onboarding wizard. To run it manually: 1. Build the authorize URL: ```plaintext https://.app.netsuite.com/app/login/oauth2/authorize.nl ?response_type=code &client_id= &scope=rest_webservices+restlets &redirect_uri= &state= &code_challenge= &code_challenge_method=S256 ``` `` is the account-specific domain (`` — for example `1234567` or `3141592-sb1`). 2. Open the URL in a browser. Sign in as the user from step 4, pick the account from step 4’s user record, and **at the role picker, choose Administrator**. NetSuite redirects to your `redirect_uri` with `code` and `state` query parameters. 3. Exchange the code for tokens: ```bash curl -X POST https://.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'grant_type=authorization_code' \ -d 'code=' \ -d 'redirect_uri=' \ -d 'code_verifier=' \ -d 'client_id=' ``` Turbobase’s integration record is a confidential client, so drop `client_id` from the body and add `-u ':'` instead. NetSuite returns: ```json { "access_token": "...", "refresh_token": "...", "expires_in": 3600 } ``` Copy the **refresh token** — it gets pasted into the console in step 7. Access tokens are short-lived (60 min); the runtime mints fresh ones on demand. > **Heads-up — the refresh token expires on a fixed cadence, and the instance is designed to stop needing it.** Using it returns a fresh 60-minute access token but *the same* refresh token, so the deadline never moves. When it passes, every `/token` call answers `invalid_grant` and sync, sign-in and bundle operations stop together. Re-running this connect flow is the only thing that mints a new one — a retry, a redeploy or a restart cannot. Turbobase reports the exact expiry on the instance’s sync-health page and warns 72 hours ahead. > > With **CLIENT CREDENTIALS (MACHINE TO MACHINE) GRANT** ticked in step 2, this deadline matters only once: within the hour after connecting, the instance uses this consent’s token to map its own certificate to your integration record and switches to the certificate grant, which has no refresh token at all (see “Certificate auth” below). If the box is left unticked, the cadence below is what you live with. > > **How long you get is a setting on your integration record, not a NetSuite constant.** Two fields decide it: > > * **Refresh Token Validity (in hours)** — how long the token lives. Defaults to `168` (7 days); the maximum is `720` (30 days). Step 2 asks you to set 720. > * **Maximum Time For Token Rotation (in hours)** — whether NetSuite issues a *replacement* refresh token when you use one, which would move the deadline forward. > > NetSuite renders the rotation field **disabled for a confidential client**, so the chain cannot roll and simply lapses at the validity window. Ticking **Public Client** enables rotation — but measured against a live account, rotation is permitted only until *the original consent* plus that window, after which the chain stops with `invalid_request` even though the token’s own expiry is still weeks away. So a public client lands on the same 30-day ceiling while giving up client authentication. **30 days is the ceiling for this flow either way**, which is why step 2 keeps the confidential client and just maximises the validity. ## 6. Find the Account ID [Section titled “6. Find the Account ID”](#6-find-the-account-id) *Setup → Company → Company Information → Account ID.* For sandboxes the format is like `TSTDRV1234567` or `3141592_SB1`. Lowercase it for the console field: `tstdrv1234567`. The REST hostname derives from the account ID as `${id-with-underscores-replaced-by-hyphens-lowercased}.suitetalk.api.netsuite.com`. For `3141592_SB1` that’s `3141592-sb1.suitetalk.api.netsuite.com`. The console builds this automatically. ## 7. Hand the credentials to your instance [Section titled “7. Hand the credentials to your instance”](#7-hand-the-credentials-to-your-instance) When the OAuth callback lands on the console, it writes the values directly into your instance’s secret store — encrypted at rest, never written to a local file. If you ran the flow manually, paste these into your instance settings on [console.turbobase.io](https://console.turbobase.io/): * Account ID * Client ID * Client Secret * Refresh Token * Access Token (optional but recommended on first run) * Access Token expiry (ms-epoch) If a refresh token ever leaks: revoke it at *Setup → Users/Roles → Active Tokens* in NetSuite (the same screen lists OAuth 2.0 refresh tokens), then re-run step 5 and update the value in the console. The client secret is **required** — Turbobase’s integration record is a confidential client. ## Certificate auth — automatic after the first connect [Section titled “Certificate auth — automatic after the first connect”](#certificate-auth--automatic-after-the-first-connect) Everything above produces an authorization-code credential, and NetSuite caps that grant at 30 days from consent however the record is configured. The grant that has no such cap is **client credentials**: the instance holds a private key, NetSuite holds the matching certificate mapped to your integration record, and every access token is minted from a signed assertion. No refresh token exists to expire. You do not set this up. Once per hour the instance runs a job that: 1. checks whether it already holds a certificate NetSuite accepts, and if so does nothing until 30 days before that certificate expires; 2. otherwise generates a 4096-bit RSA key pair and a two-year self-signed certificate in-process, uploads the certificate through NetSuite’s certificate endpoint using the authorization-code token from your consent, mapped to the **same role and entity** that consented; 3. proves the new certificate by minting with it, and only then stores the material in the instance’s encrypted secret store. Every process picks the certificate up at its next start, and a process whose refresh token dies before then switches over on the spot. Thirty days before the certificate expires the same job uploads a successor using the certificate’s own token, so renewal needs no one either. The previous certificate stays mapped until NetSuite expires it. The only certificate the instance revokes on its own is one it has just uploaded in a run that then failed, so a failed run never leaves an unused certificate on your record. **If NetSuite stops accepting the certificate** — it was revoked, the box was unticked, or the employee it acts as was deactivated — the instance falls back to the consent’s refresh token at once, and the next hourly run maps a new certificate from it. If that token has also run out, the run reports BLOCKED and asks for a reconnect; the run after the reconnect maps the new certificate. **If NetSuite answers the hourly token request with a server error instead of refusing it**, the instance keeps the certificate and tries again next hour, because a brief NetSuite outage is not a reason to replace a working credential. After four hours of that it stops waiting: it treats the certificate as beyond saving and maps a replacement from the consent’s refresh token, exactly as above — so a connection stuck this way repairs itself within about five hours, with nothing for you to do. If there is no usable refresh token left either, the run reports BLOCKED and asks for a reconnect. **What you have to do:** tick **CLIENT CREDENTIALS (MACHINE TO MACHINE) GRANT** on the integration record (step 2) and consent as an **Administrator** (step 3). The upload needs the “Manage own OAuth 2.0 Client Credentials certificates” permission, which Administrator carries — and so, from bundle 2.18.0, does `customrole_tb_io_integration_min`, so a connection consented under the narrower role from § 3 renews itself the same way. **If the box was left unticked**, NetSuite refuses the mapping and the job reports **BLOCKED** in its run output with that exact remedy, then tries again next hour. Tick the box; nothing else needs redoing. The instance keeps working on the authorization-code credential meanwhile, with its 30-day cadence. **Where to see it:** the instance’s sync-health page reports the credential in force — a refresh token with its expiry, or a certificate with its `notAfter` — under the same `NetSuite credential` check, so the countdown gauge and alerts keep working without knowing which grant is live. When the hourly job cannot get a working certificate for your account — NetSuite refused the mapping, or the credential it maps from has itself been refused — a second red check, **NetSuite certificate auth**, appears beside it: it names the integration-record setting to change, carries the hourly job’s own words about which of the two NetSuite said, and disappears by itself on the first run that succeeds — there is nothing to dismiss. That is the row to look for whenever the credential countdown is going down instead of being renewed. In NetSuite, *Setup → Integration → Manage Authentication → OAuth 2.0 Client Credentials (M2M) Setup* lists the mapping. Revoking a certificate there does **not** by itself cut the instance off: while the consent’s refresh token is still valid, the instance falls back to it and maps a new certificate within the hour. To cut the instance off deliberately, untick **CLIENT CREDENTIALS (MACHINE TO MACHINE) GRANT** and revoke the certificate, or disable the integration record. ## 8. Smoke-test [Section titled “8. Smoke-test”](#8-smoke-test) Saving the credentials tests the connection and then starts the first sync in the background — the console doesn’t wait for it, so the wizard moves on as soon as the connection answers. Watch the first pull arrive under **Activity** (**/data/sync/history**) in your app, and read anything that went wrong on **Health** (**/data/sync/health**) or **/data/sync/failures** — see [sync health](/tenant/source-health/). The three errors you’re most likely to meet here: * `401 INVALID_LOGIN_ATTEMPT` on a RESTlet call means the OAuth bearer was minted under a role the script deployment doesn’t audience — almost always because the user picked a role other than the Turbobase integration role at OAuth consent time. Re-run step 5. * `invalid_grant` from `/token` almost always means the refresh token has passed its validity window — 7 days from consent at NetSuite’s default, 30 if you set the maximum. See the heads-up under step 5 for the two fields that set it. Check the sync-health page, which reports the exact expiry instant. Re-run the connect flow from the console to mint a new one; nothing else recovers it. * `403 Permission Violation` means the integration role is missing a record-level permission. Contact — a role refresh from the console fixes it. ## 9. Pin the Turbobase portlet on your NetSuite dashboard [Section titled “9. Pin the Turbobase portlet on your NetSuite dashboard”](#9-pin-the-turbobase-portlet-on-your-netsuite-dashboard) The Turbobase bundle ships a portlet that lists the Turbobase apps each person is allowed to open, and opens any of them already signed in. **NetSuite does not auto-pin portlets** — every user who wants it has to add it to their dashboard once: 1. Open NetSuite home (the dashboard you land on after signing in). 2. Click **Personalize** in the top-right. 3. Click **Add Content** in the sidebar that opens. 4. Find **Turbobase** in the list (search for “turbobase” if needed) and click it. 5. Drag the new portlet to the column you want it in. Click **Done**. If **Turbobase** isn’t in the Add Content list, the bundle install was likely partial — request a redeploy from the console or email . Once the portlet is pinned it shows the apps shared with whoever is looking at the dashboard — the same cards they see on the Apps page in Turbobase, with each app’s own icon and its full description, each opening that app in a new tab, already signed in. Everyone sees their own list and nobody sees an app they were not given. The Turbobase icon above the list opens Turbobase itself in a new tab, already signed in, and the **Build the App** button beside it opens the Apps page, where you can describe a new app at the top. The local sign-in at `/login` is the always-available fallback. Three things worth knowing: * **It needs bundle version 2.17.3 or later.** On an older bundle the portlet is called **Turbobase Access** and may show only an **Open Turbobase** button, show *Your apps could not be loaded* with HTTP status 406, show each app as two initials with no **Build the App** button, or show the cards with the label chips that were on them when your instance last upgraded, so a label added since then shows on the Apps page and not here. Request a redeploy from the console to pick it up. * **A newly shared app can take up to an hour to appear there**, because each person’s list is remembered for an hour so the dashboard is not slowed down. It opens from Turbobase itself straight away. * **If the list cannot be loaded** — your instance is briefly away, or signing in did not answer — the portlet shows an **Open Turbobase** button instead, which takes you to Turbobase already signed in. ## Next steps [Section titled “Next steps”](#next-steps) Whichever NetSuite environment you connect (sandbox or production), the 14-day learning window builds your instance’s rule book from real write rejections. * **Have a sandbox?** Start in the sandbox-first path — refresh the sandbox from production, learn safely for 14 days, then carry the rule book into a fresh production-pointing instance. * **No sandbox or smaller account?** Use the production-direct path. The same reactive loop runs against real traffic; watch the failures page in your app more attentively during the first week. Questions? Email . # Releases and adopt notes > What each Turbobase release changes for existing app projects — one actionable line per change, newest release first. Every Turbobase release that changes something an existing app project has to act on lists it below, newest first. Work the entries newer than the version your project last adopted: `turbobase project adopt` writes exactly those into your project’s `ADOPT.md`, and your own instance serves the same list at `GET /v1/catalog/adopt-notes`. A release with nothing for app developers is simply absent — silence is the signal that there is nothing to adopt. ## v1.50.0 [Section titled “v1.50.0”](#v1500) * **Handle a partial answer from `POST /v1/batch`: the whole call now has its own deadline.** Until now a batch either came back complete or the front door gave up on it and your request failed with nothing in the body. A large fan-out of slow reads now answers in time with whatever finished, so check each slot before you render it — a batch that met the deadline comes back partial rather than failing. * **Tell a sub-query that never ran from one that was tried: read `results[i].notRun`.** A slot the deadline refused answers the same `504` an aborted read answers, plus `notRun: true` — present only on a sub-query that was never sent, absent on every other slot, the way `truncated` already behaves. Its `body.error` says so in words if you log it. Nothing changes for a call that finishes inside the deadline: no slot gains a field. * **Ask again for the slots that did not run — not the whole batch.** Re-sending all of it re-runs the sub-queries that already came back and re-enters the same queue that ran out of time. Collect the indexes carrying `notRun`, send those queries on their own, and merge them into what you have. Splitting a wide first paint into two narrower batches is the better fix if you meet the deadline often. * **Give your app a page to open from a NetSuite create form, if you want one.** The “TB Open in Turbobase” button can now also appear while a user is creating a new record: the NetSuite admin sets “Turbobase path on new records” on the script deployment (e.g. `/a/orders/new`), and the button opens that path exactly as set, signed in as usual, with no `source`, `recordType` or `recordId` appended — the record has not been saved yet. Nothing changes for a deployment that leaves the parameter empty, and the VIEW button still appends all three. ## v1.49.0 [Section titled “v1.49.0”](#v1490) * **Offer `resumeProcessRun(runId)` instead of your own “finish what the run left” screens.** A process step that declares `awaitSource` waits a bounded time for the connected system’s verdict; past that budget the run ends `committed` with the steps it never reached counted in `outstanding`. Until now nothing could carry it on, and invoking the process again started a NEW run whose step keys were its own — so the records the first run made were created a second time. `resumeProcessRun` posts to `POST /v1/processes/runs/{runId}/resume`, which puts the SAME run back on the queue at the step it stopped on: the steps its ledger already recorded are passed over — a step whose `when` declined as well as one that wrote, because the run reached it and decided — the one whose verdict never arrived is waited for again, and the rest of the chain runs. Only the steps the run never reached are performed, so nothing is written twice and nothing the run chose not to do happens behind you. Answers `202 { runId, state: 'running', queued: true }` — watch the run until it reads `committed` with `outstanding: 0`, exactly as you do after an invoke. * **Branch on `409 process_run_not_resumable` rather than offering the button on every stopped run.** Only a run that ended `committed` with work outstanding can be resumed. A run that is `queued` or `running` has not stopped, a `failed` or `rolled_back` one needs its refused step re-keyed rather than re-offered, a `cancelled` one was stopped by a person, and a `committed` run with nothing outstanding has finished — each answers `409` naming the state on the body, with nothing queued. A process declared `eachRow` answers `409 process_is_a_migration`: its writes are keyed per source row, so invoke it again to carry one on. The resume is open to whoever invoked the run and to an administrator, and anyone else gets the same `404 process_run_not_found` the run read gives them. Two resumes in quick succession start one continuation — the second answers `409`. * **Run `turbobase processes resume ` when nobody has built a screen for it yet.** The same door from a terminal, on an `extensions:deploy` token owned by an administrator, beside `processes cancel` and `processes rollback`. * **Stop telling readers to roll a stopped run back and start over.** The guidance in *Author processes* said to roll back a run that stopped on a wait once that step’s record had reached the source; resuming it is now the answer, and a rollback is for undoing work you do not want. A record the connected system applied but could not link back is still kept by every rollback (`SOURCE_APPLIED_UNLINKED`), and a resume onto one of those now records it the same way — so a resumed run no longer deletes a record that exists upstream. * **Undo the shallow-schema workaround in a process `input`, if you added one.** A process (or hook, or conduit) deployed from an app is read into the platform by a walk that used to stop at six levels and drop everything below in silence. A JSON Schema spends five of those on its own nesting — `input` → `properties` → a field → `items` → `properties` — so every per-field schema inside an array’s items was lost while the `additionalProperties: false` beside them survived, and the process then refused every non-empty array before it ran (`targets.0 must NOT have additional properties`). The walk now goes 64 levels, so an input that hoisted its item schema into `definitions` and pointed at it with `$ref` purely to stay shallow can be written inline again. Nothing is required of you: a hoisted schema keeps working. * **Expect a deploy to be refused, by name, where it used to be accepted and then misbehave.** Past 64 levels of nesting, or more than 256 keys in one object or entries in one array, the deploy now fails with an error naming the path that hit the limit instead of registering a schema with the deep part cut off. A definition that was over either limit was already validating the wrong shape — accepting bodies it should refuse where a `type` union, a nested `items` or an `anyOf` member went missing, and refusing correct ones where a `properties` did. Flatten the shape, or declare the repeated part once in `definitions` and reference it with `$ref`. ## v1.48.0 [Section titled “v1.48.0”](#v1480) * **Read `line_number` on `order_fulfillment_line` and `item_receipt_line` as the line’s own NetSuite id, not as a position.** It always meant that on rows a REST read wrote. Rows written by the scheduled poll held a 1-based position and rows written in realtime a 0-based one, so the value changed whenever the record was re-read. Each row takes the id the next time its record syncs. Keep ordering lines by `line_number` if you do already: the order is stable, but it is not guaranteed to be the order NetSuite displays them in. Join to `system_note.line_index` on it as documented. * **Read `line_index`, not `line_number`, on `order_fulfillment_line` and `journal_entry_line` — only `item_receipt_line.line_number` is kept for good.** Two v1.46.0 texts disagreed: the adopt note said those two columns are dropped a release later, while the header of the `line_order` migration called all three `line_number` columns frozen. The adopt note is right; the migration header overstated it, and migrations are not edited once shipped, so this is the correction. Until the two are dropped, `project types` still names `line_number` as the paging key for them in `SEEK_KEYS`, because `line_index` arrived nullable — paging through `SEEK_KEYS` keeps working while the column exists. Order and display lines by `line_index`. * **Return a Promise from an update step’s `pgRowId`, `externalId` or `expectedSyncVersion` when the value needs a read.** A deployed process has always awaited them, but the `@turbobase/extensions` type said they were synchronous, so the natural code — read the row’s current `sync_version` through `ctx.query`, then hand it over — did not type-check, and authors passed values across from `when` by hand. The type now allows a Promise, and the engine awaits it on every path. The order is unchanged: `when`, then the address functions, then `build`. * **Read `SOURCE_APPLIED_UNLINKED` on a process step, and link the record instead of telling the user to clean it up by hand.** When an `awaitSource` step’s create was APPLIED by the connected system but linking the new record back to its row failed (`write_settle_conflict`), the step used to be recorded `SOURCE_REFUSED` — and a rollback then deleted the row, which had no `external_id`, so the record stayed in the connected system with nothing on your instance pointing at it. The step is now `failed` with `refusal_code: 'SOURCE_APPLIED_UNLINKED'`, the source’s id on the step’s `external_id` and the same message as before, and no rollback — automatic or `POST /v1/processes/runs/:runId/rollback` — deletes its row. The run still stops there. Offer `POST /v1/data/:recordType/settle` with the step’s `pg_row_id` to link it. Code that matched the message rather than the code keeps working; code that branched on `SOURCE_REFUSED` for this case should branch on the new code. * **Add `turbobase project skills` to `.claude/hooks/session-start-refresh-types.sh` once, so the platform’s skills stay current in this project.** The skills the scaffold writes that are platform knowledge — `write-a-process` and `business-rule-to-hook` — used to be written at `init` and never again, so nothing the platform learned afterwards reached your sessions. They now carry a `platform-maintained` line, and `turbobase project skills` rewrites them from the installed CLI. Paste this at the end of the hook, after the `project adopt` line: ```sh if turbobase project --help 2>&1 | grep -qE '^ +skills( |$)'; then turbobase project skills >/dev/null \ || echo "turbobase: \`turbobase project skills\` failed — the platform skills were NOT refreshed." >&2 fi ``` The first run replaces an older copy you never edited and saves it as `SKILL.md.bak`; a copy you DID edit is left alone and named, so move your notes to `INSTANCE-LEARNINGS.md` and delete it to take the platform’s. Your own skills are never read. New projects get the hook line from `init`. * **Stop probing the session before calling an `/api/account/*` route, and send a `turbobase:session_dead` user to `/login` as the docs already say.** A role edit or a role-changing deploy ends every signed-in session at the data plane with `turbobase:session_dead`, and the instance’s own pages used to answer that by deleting the session cookie — so the next console call, or `/login` itself, asked for a password. They now ask the instance to re-mint the session first: a person whose access changed keeps working with their new access, and only a session that was signed out, superseded or suspended is cleared. The documented behaviour of `/login` is now what happens. * **Refresh `.claude/skills/write-a-process/SKILL.md` from a new scaffold.** It gains six traps a production app met moving its writes onto processes: a chain that stops cannot be resumed and invoking again creates again; the invoke takes no idempotency key; `SOURCE_APPLIED_UNLINKED` and how to link its record; async update addresses; delete-only `onRefused` and bare scope refusals; and that a preview cannot run a new or changed process. Run `turbobase project init` in an empty directory and copy that one file over yours — never `--force` over your project, which overwrites everything. ## v1.47.0 [Section titled “v1.47.0”](#v1470) * **Keep the `line_index` fallback when you sort a NetSuite document’s lines by `line_order` — the column is not guaranteed to be filled by the next poll.** The v1.46.0 note said a document’s lines are rewritten with `line_order` the next time it is read, within four hours. That holds only for a document that changed: a re-read that finds nothing new writes nothing, so an unedited document kept `line_order` null. This release fills it once, on upgrade, for every stored NetSuite line whose position can be read exactly from the document it was synced with. A line it cannot place stays null until the document next changes in NetSuite, so keep ordering by `line_order` with nulls last, then `line_index`. PostgREST: `purchase_order_line.order=line_order.asc.nullslast,line_index.asc`. ## v1.46.0 [Section titled “v1.46.0”](#v1460) * **Declare `on: 'after_save'` on a conduit to react when a record is saved on your instance, whoever saved it.** Until now a conduit only woke up when a record arrived from, or landed in, another connected system — so a save made here, by your app through `POST /v1/data/:recordType`, by a process, by the in-product agent, by Studio or by another conduit, triggered nothing at all. It fires once per COMMITTED save, after the save commits, through the same dispatcher the two existing triggers use: the run shows up on `turbobase logs --conduit`, idempotency and the cycle budget apply unchanged, and a save that was refused or rolled back fires nothing. A record ARRIVING from a connected source is still `after_canonicalize_inbound`, so a synced record never fires twice. * **Leave `idempotencyKey` off an `after_save` conduit.** With no key declared, each banked save gets its own, which is what makes two saves of one record two runs. Declare one and it is used exactly as written — and a record saved here has no external id, so `ctx.externalId` is its own surrogate key and is the same string for the record’s create and every later edit. The conventional `':${ctx.externalId}'` therefore dedups the second save into the first and the conduit fires once in that record’s lifetime. `turbobase ext lint` asks for `ctx.externalId` in a declared key, which is right for the two inbound events and wrong for this one. If you need your own key here, put a field from `ctx.change` in it. * **Read `ctx.change` in an `after_save` conduit’s `from.when` instead of firing on every edit.** It is `{ kind: 'insert' | 'update', fields: [...] }`, and `fields` names the columns the writer actually supplied — so `ctx.change.fields.includes('status')` is a gate rather than something you work out in the body. Both the gate and the transform see it. It is ABSENT on the two existing triggers, so a conduit you already have reads exactly what it read before. * **Read `ctx.savedBy` to refuse your own writes.** It is `{ kind: 'app' | 'process' | 'agent' | 'studio' | 'conduit' | 'user', id? }`, with the conduit’s id when a conduit made the save. **A conduit’s own write is one of these saves** — the row it writes into the target fires `after_save` under the pair its `to` names, so a conduit that listens where it writes feeds itself. Gate on `ctx.savedBy.id !== ''`: that stops the loop before it runs, where the cycle budget stops it after. Absent on the two existing triggers. * **Point a conduit at `from: { source: 'turbobase', recordType: 'proposal' }` to react to a change request.** `turbobase` is a reserved source for the platform’s own records — it is not a connector and never will be, so do not use it as a `source_system` anywhere else. A change request fires on filing, on classification and on every status change, each run carrying `{ id, title, body, app_slug, filed_by, status }` and nothing else. The projection is fixed: a column added to the table later does not join it. * **Point a conduit’s `to` at `turbobase:proposal` to FILE a change request from a connector record** — a message in a Slack channel becoming a request, for instance. The target may create a DRAFT and nothing more: a patch that names `id`, `status` or any other lifecycle field is refused, and the ledger row says which key refused it. Where the request came from is recorded on the created request’s `solution_target`, and the filing itself reaches an `after_save` conduit as `savedBy: { kind: 'conduit', id }` — so a conduit that both files requests and reacts to them can tell its own from a person’s. * **Expect a conduit whose `to` names a system this instance does not have to be refused at deploy time.** `turbobase ext deploy` previously accepted a target under any unknown source — including a mistyped connector id and `turbobase:` — and the conduit then failed on its first run with `unresolved target`. It now 400s with `conduit_target_unknown`, naming the pair. A conduit already stored with such a target is refused at the apply layer with the same sentence instead of running. If you have one deployed against a typo, this is where you will find out. * **Declare `after_save` HOOKS on records your app writes and they now run.** `POST /v1/data/:recordType` ran `before_save` and then nothing once the save committed, so an `after_save` hook on a record type an app writes never saw the app’s own saves — only ones arriving from a source. It now runs post-commit, observe-only, in the shape `after_delete` already uses at the same boundary: it reports `direction: 'standalone' | 'outbound'` and `origin: 'user'`, it cannot refuse or revert the write, and a throw out of it does not fail the write. A hook you declared and assumed never fired for app writes does now. * **Declare `on: 'after_delete'` on a conduit to react when a record is deleted on your instance.** Until now a conduit could react to a record arriving, to a record landing in another system and to a record being saved — and to nothing at all when one went away, so “unpublish the counterpart”, “close the ticket” and “retract the announcement” were unbuildable. It fires once, after the delete commits, and `ctx.payload` is the record’s LAST STATE, which is the one thing a deletion reaction cannot read back for itself. A delete your guardrails or a `before_delete` hook refused fires nothing, and a repeat delete of a record that is already gone — still a `200`, because a delete here is idempotent — fires nothing either. `ctx.change` is absent on this trigger, as it is on the two inbound ones. * **Read `kinds` and `origin` on a `turbobase:proposal` conduit.** The change- request projection was `id, title, body, app_slug, filed_by, status`; it is now those six plus `kinds` — the solutioning kinds the request has been classified as, `[]` until somebody classifies it — and `origin`, which says how the request arrived. `origin.channel` is one of `widget`, `conduit` or the request’s own creator (`authored`, `request`, …); a widget request also carries `origin.route`, the screen it was filed from, and a conduit-filed one carries `origin.conduit_id`, `origin.from_source`, `origin.from_record_type` and `origin.from_external_id`. Keys that do not apply are absent rather than null, so test for the key. * **Gate on `ctx.change.fields.includes('kinds')` to react when a request is CLASSIFIED.** Classifying a request now fires `after_save` on `turbobase:proposal` with `kinds` named as what changed — before this, the only run a request ever produced was its filing, where `kinds` is empty by construction, so a conduit keyed on classification could not be written. A classification reports `kinds` and nothing else: it does not report `status`, so a conduit watching for a decision on a request no longer fires when somebody merely works out what the request is. * **Set `app_slug` and `filed_by` in a `create-draft` patch** to say which app a request a conduit files is about and who reported it. Both are fields the projection already published and neither could be set, so a conduit-filed request arrived with two of its fields permanently null. An `app_slug` your instance does not have is REFUSED, naming the slug, and nothing is created. `filed_by` is recorded as REPORTED by the conduit and never as a signed-in user, so a request’s `created_by` still only ever names somebody who authenticated. * **Address a Slack `channel_message` to a person by email** to send a direct message. `raw.channel` took a channel id, `#name`, a bare name or Slack’s own `<#C…|name>` markup; an email address now resolves against your workspace’s mirrored members and the message is delivered into that person’s direct-message conversation. An address no member carries is refused with the address named, and a Slack app installed before this shipped is refused with `im:write` named — re-install the Slack app from Settings → Slack to grant it. Everything about a channel post is unchanged. * **Name a process in a conduit’s `to` to start it:** `to: { source: 'turbobase', recordType: 'process', process: '' }`. The transform’s patch becomes the process input, so the transform is where you shape the firing record into whatever the process declares it takes, and the run is recorded on the conduit ledger like any other target. A process your instance has not got is refused at `turbobase ext deploy`, naming it. * **React to a release and to a published knowledge page.** Two new reserved records publish into the same `after_save` seam: `turbobase:app_release` (`id`, `app_slug`, `app_name`, `version`, `deployed_by`, `deployed_at`), which fires on a PRODUCTION deploy and not on a preview; and `turbobase:knowledge_page` (`id`, `slug`, `title`, `visibility`, `published_by`, `published_at`), which fires when a page is published and not when one is written, edited or taken back to draft. Both may only be reacted to — a conduit naming either in its `to` is refused at deploy. * **`turbobase ext lint` has new advice about an `after_save` or `after_delete` idempotency key, and it reverses what it used to say on those triggers.** A declared key that carries nothing about the save now warns `lifetime-idempotency-key`: on a save trigger `ctx.externalId` is the same string for the whole of a record’s life, so a key built on it alone dedupes every later save into the first and the conduit fires once per record and never again. Omit the key, or include something from `ctx.change`. The advice for `after_canonicalize_inbound` and `after_sync_success_outbound` is unchanged — there `ctx.externalId` is exactly the right key. * **A banked save that cannot be dispatched is now parked after five attempts and reported as a sync failure**, instead of being re-claimed every five minutes for ever. The failure names the record and the conduits that were waiting for it, and the row is kept rather than dropped, so an operator who fixes the cause can replay it. Nothing changes for a save that merely fails once and then works: it is still dispatched, exactly once. * **Turn on “Classify a change request when it arrives” (`solution.classify_on_arrival`) to have every new request classified as it is filed**, from any channel, instead of waiting for an admin to mark it ready for solutioning. Off by default, because it spends one planner model call per filed request. It records the kinds only: the request’s solutioning state does not move and no draft solution is seeded. * **Classifying a request no longer forgets what the request knew.** The solutioning planner used to replace a request’s whole `solution_target` with its own hints, so classifying a request filed from the in-app widget could drop the app it was about, the screen it was filed from and the filer’s address — which is the field `filed_by` is read from, so a classified request could read as anonymous. The planner’s hints are now merged onto what the request already carried, and `setProposalTarget` merges rather than replaces. A classification that names no target of its own leaves the request’s target exactly as it was. * **Share a file into Slack with a new `file` record on `source_system: 'slack'`.** It takes two requests: POST the bytes to `/v1/data/file/uploads` with `content-type: application/octet-stream` and keep the `upload.upload_id` it answers, then create the record with that handle on `raw.upload_id`, the destination on `raw.channel` (a channel name, a channel id, or a person’s email address) and the file’s name on `path`. `raw.thread_ts` puts it in a thread, and `raw.initial_comment`, `raw.title` and `raw.alt_txt` are optional. The row settles `write_status: 'synced'` with Slack’s own file id as its `external_id`, so read the status rather than treating the create’s 200 as a file in the channel. `list_record_types` publishes the required paths as `required_inputs` — read them rather than hard-coding the list. * **Do not edit or delete a Slack `file` row.** Both are refused — the create with 422 at the boundary, a privileged enqueue or conduit with `code: 'FILE_SHARE_IS_FINAL'` — because Slack publishes no verb that replaces a shared file, and sending it again would put a second copy in the channel. Share a new file instead. `list_record_types` publishes `ops: ['create']` for it. * **Pin the source when you ask the catalog about `file`.** Four connectors now publish that record name and the bare-name fallback in `GET /v1/catalog/records/file` and `GET /v1/catalog/columns?types=file` resolves to Slack rather than Google Workspace, which changes the table and the read capability it describes. Pass `?source=google_workspace` (or `github`, `azure_blob`, `slack`) and nothing collapses. The four `source:recordType` couples are unchanged. * **Read the number out of an over-size upload refusal.** `POST /v1/data/:recordType/uploads` one byte over the 8 MiB ceiling used to answer Fastify’s own `Request body is too large`, which named no limit; it now answers 413 with `rejection.code: 'UPLOAD_TOO_LARGE'`, `field: 'body'` and the byte limit in `rejection.message`, the same shape it already sent for a larger overage. Nothing to change if you were reading the status; parse the message rather than hard-coding 8 MiB if you surface the limit. * **Expect `files:write` on the Slack app.** A workspace connected today already grants it. One connected before file sharing existed does not, and a share then ends `write_status: 'blocked'` with `reason_code: 'source_permission_missing'` and a message naming the permission — re-install the Slack app from the instance’s Settings → Slack. No change to any app, but surface that message rather than retrying: no number of attempts grants a permission. * **Read `label` on a FeedHive instance to get the workspace’s labels.** A new read-only record type, `feedhive:label`, lands on the shared `label` relation with each label’s name: `/rest/v1/label?source_system=eq.feedhive`, or `list_records` on `label`. It needs `read:label`, which is the capability `github:label` and `asana:asanaTag` already use, and it appears in `/v1/catalog/records` and in `turbobase project types`. It refreshes on every sync, so a label added or renamed in FeedHive arrives on the next one. * **Set `raw.labels` on a FeedHive `social_post` to file a post under a label.** Accepted on the create and on the update, as an array of label **ids** — read them off the `label` relation above; the name is not accepted. A mirrored post now also carries its own label ids in `raw.labels`, so an app that reads a post and writes it back keeps the labels it had. Omit the key to leave a post’s labels alone: `labels` REPLACES them, so an edit sending `[]` would strip what somebody set in the FeedHive calendar — which is also why clearing a post’s labels is not something this field can express yet. * **Expect `reason_code: 'LABEL_UNKNOWN'` on a FeedHive post filed under a label the workspace has not got.** The post is SAVED and then refused when the write is sent, so read it off `record_write_state` rather than from the response to your write — the label is named in the message. Nothing reaches FeedHive, and one unrecognised label refuses the whole post rather than publishing it under the rest. A workspace whose labels have not been mirrored yet refuses too, and says that is why — wait for the next sync rather than treating the label as missing. Only a label you are ADDING is checked, so an edit that leaves a post’s labels as FeedHive reported them is never refused for them; a post naming no labels is unaffected and is published exactly as before. **“Already there” means what FeedHive told us about that post, not what the row says** — writing an unknown label onto a mirrored post’s `raw.labels` and then editing the post does not establish the label, and is refused like any other. * **Read a line’s cost-tracking label from `class_name` / `department_name` / `location_name`, not out of the line’s `raw`.** Every line-bearing Xero record — invoice, bill, credit note, supplier credit note, purchase order, spend money and manual journal — now projects each line’s `Tracking[]` entry into the line table’s existing dimension columns, so an invoice line tagged with a “Region” option reads `class_name = 'West'` and `class_id = 'xero:{TrackingOptionID}'`. **When the source sends an entry with no option id the `_id` column stays NULL and the `_name` is still filled**, so match on the name when the id is absent rather than treating the line as untagged. The whole `Tracking[]` array is still kept in the line’s `raw`, so nothing you read today stops working. * **Select a source’s cost-tracking axes from `/rest/v1/segment` and their values from `/rest/v1/segment_value`.** These are new and they are the canonical store for every source’s dimension values: one `segment` row per AXIS (a Xero tracking category, a Business Central dimension, a NetSuite custom segment, and NetSuite’s own class / department / location), and one `segment_value` row per value on it. A value carries `segment_pg_row_id`, `canonical_segment`, `name`, `parent_id` / `parent_name`, `is_inactive` and `raw`. Count one axis’s values with `segment_value?select=*&segment_pg_row_id=eq.{id}` and `Prefer: count=exact`. An archived value is present with `is_inactive = true` rather than missing, so filter on that column if you only want the ones a user can still assign. * **`/rest/v1/classification`, `/rest/v1/department` and `/rest/v1/location` keep working unchanged, and are the ones to stop writing to.** They now hold the subset of `segment_value` whose axis fills that typed slot, kept in step by the database, with the same `pg_row_id` on both sides — so every list read, filter and `pg_row_id` reference you have today is unaffected. They become read-only views in the release after this one, so **move any WRITE you make to one of them onto `segment_value`** (set `segment_pg_row_id`; the slot is derived). Reads need no change then either. * **Replace an embed of a dimension — `order?select=*,location(*)` — with `order?select=*,segment_value(*)`.** PostgREST resolves an embed through a foreign key, and every `_pg_row_id` link now references `segment_value`. No app in either repository embeds one today (all reads measured were plain list reads), so this is here for completeness rather than as a break anybody is expected to hit. * **Stop binding a source axis to a canonical one in your own code.** Which typed slot a source axis fills is now a row — `segment_mapping`, one per `(connection_id, source_system, segment)`, carrying `canonical_segment` and the source field for the outbound direction. Read it instead of hard-coding a pairing: a migration app that assumed “QuickBooks class → NetSuite class” should take the pair from this table. The mapping is created the first time an axis is seen and is never recomputed, so it is stable across a rename of the axis upstream. **Read it, do not rewrite it** — changing `canonical_segment` on an existing row is not supported in this release: the values move slot but the `classification` / `department` / `location` mirror does not follow them, and lines already projected under the old slot keep their typed columns until they are next synced. * **Stop treating “this source keeps no class or department” as a fact about the source, and ask `/rest/v1/segment?source_system=eq.{source}` per connection instead.** Xero is the case that changes: it mirrored no dimension record of any kind before this release, so an app that offered a Xero customer no region or cost centre — or asked them to type the names in by hand — was right and is now wrong. Every source that mirrors an axis answers through the same two tables, so this is one read per connection rather than a per-source list to keep up to date, and `segment_mapping` says which canonical slot each axis fills. An empty answer still means the connection has no axes, so the hand-typed path is a fallback rather than something to delete. * **Read every axis a line carries, including the ones with no typed column, from `/rest/v1/line_segment_value`.** A canonical line has three typed dimension slots and a source may define more axes than that — Business Central allows eight, a NetSuite account any number of custom segments — so each line’s full set of assignments is listed here, one row per axis, keyed by `line_table` and `line_pg_row_id`. It is read-only in this release: the values beyond the typed columns still reach the source through the line’s `raw`. **A row is never more readable than the line it names**, under the caller’s own rights and the line table’s own rule — so a user who cannot read an invoice reads none of its lines’ labels, and a reader restricted to one class reads neither the journal line tagged outside their scope nor its labels. It can be LESS readable, on the one line table whose own rule names no dimension column: `turbobase.check_line` cascades from its header’s subsidiary, so a class-restricted reader keeps a spend-money line and loses its labels. So a short label list is a scope answer rather than a sync gap, and the thing to reconcile it against is the line’s own typed dimension columns, not the line count. * **Expect a scope-restricted reader to see FEWER journal lines than before.** `turbobase.journal_entry_line`’s read rule tests `class_id` and `department_id`, so now that those columns carry a value, a user whose class or department access is restricted stops seeing journal lines tagged outside their scope. That is correct scoping rather than a regression, and a reader with no restriction configured sees exactly what they saw before — but if your app shows journal lines to restricted users and assumed they saw all of them, re-check those screens. Spend-money lines are unaffected: `turbobase.check_line`’s rule cascades from its header’s subsidiary and names no dimension column. * **A Xero line’s tracking is read-only outbound, and that is now recorded rather than implied.** Editing a Xero line’s `class_id` does not reach Xero: the writer rebuilds `Tracking` from the line’s inbound `raw`. The mapping row says so — `direction = 'inbound'` and a NULL `source_field` — so check it before offering an edit. NetSuite’s class / department / location round-trip as they always did. * **Stop branching on `record_write_state.message` for a held write — three of those sentences changed.** A write the connected system refused on its own access control (`reason_code: "permission_denied"`), a write the write-back breaker is withholding (`reason_code: "connection_refused"`), and a write whose target record is gone upstream (`reason_code: "NOT_FOUND"`) each used to carry the generic *“Syncing is held — your team has been notified. No action is needed from you”*. All three now carry a sentence about the actual condition: what refused, whether editing the record releases it, and who has to act. `status` is still `blocked`, `reason_code` is unchanged on all three, and `fields` is still empty — so an app branching on the code or the status needs no change. An app that matched the prose, or showed it as proof that nothing was needed, must stop: the permission and gone-upstream sentences now say somebody at the other end has to act. * **Read `summary` off `GET /v1/events` instead of looking the event code up yourself.** Each row in the response now carries a resolved human sentence for that occurrence. It is derived from the row, never stored, so correcting a wording reaches rows already written. For `sync.failure_recorded` the sentence depends on the failure’s classification: a held refusal no longer claims the record “is re-attempted on the next run”, while a retryable one still does. Every other code resolves to the same sentence it always had. The field is optional on the wire — an instance older than this release sends none — and `turbobase logs --json` now prints it. The rendered one-line form is unchanged. * **Stop treating `500 write_failed` on a retried `POST /v1/data/:recordType` as “it may or may not have landed”.** A retry carrying an `idempotencyKey` the caller itself committed **on or after this release** is now answered `200 { ok: true, replayed: true }` with the first attempt’s `pg_row_id` and `external_id`, for every caller — including the three that used to get the failure back every time, forever, for that key: a role holding `write:
` without `read:
`, a non-member posting into a private channel through a content grant, and a session whose row has since moved outside the dimensions it may browse. The answer now turns on who **committed** the key rather than on who may read the row back, so a key committed by a different principal is still refused and still discloses neither identifier. If your code treats that 500 as “assume it landed and carry on”, or stopped sending the key because the retry never worked, send the key and read the reply. * **Expect `403 forbidden` from `POST /v1/data/:recordType` when a create or edit names a dimension outside the session’s own scope.** A user restricted to one location, department, class or custom segment could commit a row into a different one and then not see it; those writes — and the matching deletes — are now refused before the statement runs, on `invoice`, `order`, `purchase_order`, `item_receipt`, `item`, `time_bill`, `vendor_bill` and seventeen more. Nothing changes for an unrestricted session, for an `app_role: 'admin'` one, or for a restricted session writing inside its own restriction. If a screen lets someone pick a location they do not hold, filter the picker to the session’s scope, or handle the refusal. * **The replay above covers keys committed from this release on; an older key keeps exactly the answer it has today.** The replay turns on a new `idempotency_actor` column, written beside `idempotency_key` at the moment the row commits, and nothing backfills it — so a retry under a key already in your instance falls back to the read-the-row-back check it has always used. Nothing regresses: if that check answers your retry today, it still does. But if you are one of the three callers above, a key you committed before the upgrade keeps answering the old failure, for that key, forever, and your app cannot recover it — the record did land, so stop resending it rather than writing the record again under a new key, and ask an administrator for the row’s identifiers if you need them. The column itself sits on every table carrying `idempotency_key`, your instance’s own `cr_*` / `cl_*` / `ext_*` record types included, and is rejected on a request body like `external_id` and `originating_system`; regenerate with `turbobase project types` if you select `*` into a fixed-shape type. * **Read a line’s `UNKNOWN_COLUMN` refusal from `lines[n].`, not from the bare column name.** A write to `POST /v1/data/:recordType` whose `lines[n]` names a field the line table has no column for is still refused `422` with `code: "UNKNOWN_COLUMN"` and a message naming the line table, but `field` now carries the path — `lines[0].description` where it used to be `description`. Nothing changes for a refusal on the record’s own fields, which still carry the bare column name. If your app maps `field` onto a form input by exact string, split on the last `.`; if it only shows the message or branches on `code`, there is nothing to do. The refusal is now made before the write is attempted, so it arrives on exactly the payloads it arrived on before, and tells you which of your lines was wrong instead of only which column. * **Send every field your manifest declares `required: true` when you create a row in your own table.** `POST /v1/data/:recordType` on a table your app (or the console) declares now refuses a create that omits a required field, or sends it as `null`, with `422` and `code: "REQUIRED_FIELD_MISSING"`, `field` naming the column (`cf_sa_`), and stores nothing. An edit that leaves the field out is still accepted and keeps the stored value; an edit that sets it to `null` is refused the same way. An empty string is a value and is accepted. Until now the declaration was not enforced and such a row was stored with the field empty, so if your app relied on that, either supply the value, fill it in a `before_save` hook, or drop `required` from the manifest. Tables mirrored from a connected system are unchanged. * **Put `turbobase ext lint --ci` in your deploy lane, including on a lane that ships a process.** `ext lint` now raises each rule only on the kind it is about. The four conduit-wiring rules — a weak `idempotencyKey`, a missing `createdFrom`, an extreme `cycleBudget`, a same-source “conduit” — describe a cross-source bridge, and were being raised on a process too, where none of them is true: a correct process module came back with one `weak-idempotency-key` warning whose fix named `ctx.externalId` and `ctx.fromSource`, which a process has no access to, and `--ci` exited 1 on it. A correct process now exits 0. The deploy-identity rule (`id-not-first-literal`) is unchanged and still raised on all three kinds, so nothing that was warned about correctly has stopped being warned about. If you dropped `--ci` from a lane because of this, you can put it back; if you suppressed the warning in a process module by naming `ctx.externalId` in its idempotency key, you can go back to keying on the row. * **Add `connection_id` to any PostgREST upsert whose `on_conflict=` names `external_id`.** 66 more tables — `journal_entry`, `check`, `subsidiary`, `accounting_period`, `label`, `project`, `subscription`, every procurement line table and the rest — now carry `connection_id text not null default 'default'`, and their unique and primary keys read `(connection_id, …, external_id)`. A request still sending `?on_conflict=external_id` fails `42P10: there is no unique or exclusion constraint matching the ON CONFLICT specification`. Send `?on_conflict=connection_id,external_id`. Filter syntax is unchanged and every existing row reads `default`, but two reads move with it: the subsidiary / posting-period embed resolves through a new column (two entries below), and `external_id` on its own stops identifying one row (three entries below). * **Drop the `!…_fkey` disambiguator when embedding the lines of an `item_receipt`, `return_authorization`, `vendor_return_authorization`, `journal_entry`, `check` or `transfer_order`.** Those six header/line pairs carried two foreign keys — the old source-id one beside `parent_pg_row_id` — so a bare `?select=*,item_receipt_line(*)` returned `300` / `PGRST201`. The source-id constraint is gone and the bare embed now resolves. The explicit `item_receipt_line!item_receipt_line_parent_pg_row_id_fkey(*)` form keeps working; one that names the dropped source-id constraint instead (`item_receipt_line_tran_fk`, `journal_entry_line_je_internal_id_fkey` and their four siblings) has no relationship left to resolve, so drop the disambiguator or point it at `parent_pg_row_id`. * **Embed a transaction’s subsidiary or posting period through the record, not the id column.** `order?select=*,subsidiary(*)` and `cash_sale?select=*,accounting_period(*)` now resolve through new `subsidiary_pg_row_id` / `posting_period_pg_row_id` companion columns rather than through `subsidiary_id` / `posting_period_id`. The embed keeps working and the text columns keep their values; only the key PostgREST follows has moved. A row whose master has not synced yet embeds `null` instead of failing. **The companions start empty, so a row written BEFORE the upgrade embeds `null` until the nightly `system.dimension_ref_backfill` sweep (`17 3 * * *`) reaches it — up to a day.** Rows written after the upgrade resolve on write. If an app needs the subsidiary or period inside that window, read the `subsidiary_id` / `posting_period_id` text columns — and the `*_name` denorm where the table carries one — which keep every value they had. * **Filter on `connection_id` as well as `external_id` wherever you need exactly one row.** `external_id` is no longer unique by itself on any of these tables, so `subsidiary?external_id=eq.1` can match two rows once a tenant connects a second account of the same source — add `&connection_id=eq.default`, or whichever connection you mean. Where a table’s primary key WAS the source id, that key now sits on the `pg_row_id` surrogate each row already carried, so `pg_row_id` is the single-column handle to one row. * **Both of the above apply to your instance’s OWN record types too — the `cr_`, `cl_` and `ext_` tables.** Every one a tenant holds gained the same `connection_id` column and the same `(connection_id, external_id)` key at upgrade, so `cr_widget?on_conflict=external_id` fails `42P10` and `cr_widget?external_id=eq.CR-1` can match two rows. Send `?on_conflict=connection_id,external_id`, and filter on `connection_id` — or reach for `pg_row_id` — when you want one row. The table names are the tenant’s, so they are not listed here; `GET /v1/catalog/records` has the set for the instance you are building against. * **Hold `read:activity` instead of `read:zendesk_ticket_comment`, `read:intercom_conversation_part` or `read:notion_comment`, and read help-desk ticket comments, messaging transcript parts and document comments from `/rest/v1/activity`.** The three tables `zendesk_ticket_comment`, `intercom_conversation_part` and `notion_comment` receive no new rows: every comment now lands on the shared conversation satellite, told apart by `activity_type` (`ticket_comment`, `message`, `comment`) and `source_system`. Existing rows were carried across. An app that posts a reply on a help-desk ticket inserts an `activity` row with `activity_type = 'ticket_comment'` and the ticket in `work_item_external_id`. * **Hold `read:work_item` instead of `read:intercom_conversation`.** The conversation header folded onto the `work_item` row it described: `channel`, `snoozed_until`, `first_response_time` and `resolution_time` are columns on that row, and `intercom_conversation` has no row of its own any more. * **Hold `read:board` instead of `read:cr_jira_board`, `read:cr_jira_project` or `read:ap_project`, and read boards and projects from `/rest/v1/board`.** Jira boards and projects and Asana projects land on the shared `board` table beside GitHub’s; `board.kind` (`board` / `project`) tells them apart and `board.project_key` carries a project’s short key. An app that creates an Asana project inserts a `board` row with `kind = 'project'`, `title` and `description` — `board` accepts its first write, under `write:board`. * **Hold `read:board_field` instead of `read:ap_section`.** A project’s sections are ONE `board_field` row per board (`external_id = 'sections:'`) whose `options` array is the sections in order; a task’s `section_id` is still the section’s own id, which is the option’s `id`. * **Hold `read:entity:employee` instead of `read:worker`, and read HR workers from `/rest/v1/entity?entity_type=eq.employee`.** `hire_date`, `termination_date` and `worker_type` are columns on the employee row; `employment`, `compensation` and `time_off` carry `entity_id` (and `employment.manager_entity_id`) naming that row, beside the `worker_id` columns they already had. * **Hold `read:memory` instead of `read:notion_document` or `read:notion_block`.** A page is a `memory_document` of kind `doc` and its blocks are the page’s `body_text`; a block has no row of its own. `memory_document` now carries `connection_id`, so a filter on `source_system` + `external_id` can match one row per connection. * **Hold `read:time_entry` and `write:time_entry` instead of `read:time_bill` and `write:time_bill`, and read or write NetSuite time from `/rest/v1/time_entry`.** Every source’s time is on one table. `duration_minutes` replaces `hours` and is sixty times larger; `author_id` is the employee, `description` the memo, `entered_on` the date and `work_item_external_id` the task the time was logged against. A `POST /v1/data/timeBill` takes those column names and goes out to NetSuite in hours. * **Hold `read:credit_card_charge` instead of `read:card_transaction` or `read:credit_card_refund`.** Ramp card swipes and NetSuite card refunds land on `credit_card_charge` beside NetSuite’s charges; `record_kind` (`charge` / `refund`) and `source_system` tell them apart, refund lines are on `credit_card_charge_line`, and `merchant_name`, `merchant_category_code`, `receipt_ref`, `transacted_at`, `settled_at` and `currency_code` are columns. * **Stop selecting `tran_internal_id` on `credit_card_charge_line`, `credit_card_refund_line` and `inventory_status_change_line`.** The column is gone from those three line tables; join a line to its header through `parent_pg_row_id`, which every line carries. `order_line` and `purchase_order_line` keep it. * **Re-point every label read at `label_def` and `label_link` — this release, not the next.** Turbobase’s nine internal tagging tables are now two, and the four an app could read — `app_label`, `app_label_def`, `catalog_label_def` and `proposal_epic_def` — stop being maintained the moment this lands and are removed one release later. `label_def` holds the names (`id`, `scope`, `name`, `color`, `description`, `managed_key`, `created_by`, `created_at`) and `label_link` records what carries them (`target_type`, `target_id`, `label_id`, `applied_by`, `applied_at`). The pair is where every label applied, renamed or removed from here on lands; the backfill copies the four across once and nothing writes them again, so a read left on one of them keeps showing the labels as they stood at this upgrade. The removal a release later is when those four stop existing — not when they stop being right. * **Filter on `scope` where you used to pick a table.** `app_label_def` becomes `label_def?scope=eq.app`, `catalog_label_def` becomes `label_def?scope=eq.catalog`, `proposal_epic_def` becomes `label_def?scope=eq.epic`, and the proposal vocabulary — which proposals and solutions share — is `label_def?scope=eq.proposal`. Omit the filter and you get all four vocabularies in one list. * **Filter on `target_type` where you used to pick a join.** `app_label` becomes `label_link?target_type=eq.app`, and the thing’s identity moves from a typed column (`app_id`, `object_id`, `proposal_id`, `solution_id`) to `target_id`, which is **text** on every target. A row id arrives as its uuid spelled out, so compare it as a string rather than casting. * **`scope` and `target_type` are different axes — do not match them up.** An epic on a proposal is a `scope = 'epic'` name on a `target_type = 'proposal'` link, so a read that asks only for a proposal’s links gets its labels AND its epics. Join `label_def` and filter on `scope` to tell them apart. * **Embed an app’s or a proposal’s labels through a named relationship, not the join.** `label_link.target_id` carries no foreign key, so `app?select=*,label_link(*)` does not resolve. Use `app?select=*,labels_on_app(label:label_def(id,name,color))`, and on a proposal `labels_on_proposal(...)` or `epics_on_proposal(...)` — each already filtered to its scope. * **Expect the same rows you see today, under the same row-level security.** An app’s vocabulary is still readable by anyone signed in, an app’s chips still follow that app’s audience, and the catalog, proposal, solution and epic labels are still admin-only. Move the read and nothing a customer sees changes. * **Read a column’s house name, and move off the old one within the next release.** Twenty-nine columns gained the spelling the rest of the schema uses, beside the name they replace. Both names are on every row and a trigger keeps them identical, so nothing breaks today — but the old names are dropped a release from now, so move your reads and writes onto the new one now: `created_at` for `inserted_at` on `account`, `accounting_period`, `ap_project`, `ap_section`, `asana_team`, `expense_report`, `expense_report_line`, `intercom_conversation`, `journal_entry`, `journal_entry_line`, `notion_block`, `notion_document`, `shipping_item`, `system_note`, `time_bill`, `units_type` and `units_type_line`; `currency_code` for `currency` on `card_transaction`, `charge` and `subscription`; `is_archived` for `archived` on `notion_document`; `memo` for `comments` on `project`; `name` for `title` on `board`, `board_card`, `opportunity` and `print_job`; and `line_index` for `line_number` on `item_receipt_line`, `journal_entry_line` and `order_fulfillment_line`. `item_receipt_line.line_number` is frozen app contract and is kept indefinitely; the other two go with the rest. * **Read a reporting view under its `v_` name, and move off the bare one within the next release.** Sixteen views gained a `v_`-prefixed name that returns the identical rows and columns: `app_table_claim`, `app_user_unified`, `bulk_sync_progress`, `channel_config_admin`, `customization`, `dimension_name_drift`, `entity_canonical_view`, `inbound_change_rollup`, `invalid_index_health`, `metric_minute_recent`, `relationship_index_health`, `source_install_sync_state`, `sync_progress`, `sync_record_counts`, `table_size_summary` and `transaction_rollup`. Both names work today; the bare names are dropped a release from now. `customization` is the one of the sixteen you also write to — `v_customization` accepts the same admin-only insert and routes it the same way, so move your writes with your reads. The four entity contract views — `customer`, `vendor`, `employee` and `contact` — keep their names and are not affected. * **Stop reading `customer_unpacked`, `vendor_unpacked`, `contact_unpacked` and `employee_unpacked`; read the promoted columns on `entity`, or the `customer`, `vendor`, `contact` and `employee` views.** Those four views spread `entity.attrs` into one column per field, which the promoted columns on `entity` now carry as typed columns. All four still work and are removed a release from now; each one’s own description says so. * **Expect the full sync envelope on a mirrored table, and expect it empty rather than absent.** 315 standard columns were added across 108 relations, so that every mirrored table of a kind carries the same ones; the register they are read against names 137 relations, and the other 29 were already complete. Every addition is nullable or defaulted, so an existing read is unaffected — but a `select *` returns more columns than it did, and a column that is newly present on a table is not necessarily written on it: `hash`, `idempotency_key`, `write_status`, `synced_at` and `sync_version` are filled by the write-back path, which is armed per record type. Treat a NULL as “not recorded”, never as a value. The register of which table is which kind, and which columns each kind carries, is `src/sync/mirrored-tables.ts`. * **`connection_id` is now on every mirrored table, and on most of them it is not a key.** 41 of the headers gained it as part of that envelope without their natural key moving, so a record the source numbered 14454 is still ONE row however many connections of that source reported it, and the column holds whichever connection wrote it last. Read it as provenance, not as a filter: on a tenant with one connection per source it is that connection, and a query that groups by it on a multi-connection tenant will not separate the accounts on those tables. The tables where it IS part of the key are `src/sync/connection-scoped-tables.ts`. * **Re-read a table or column description in the OpenAPI document if you built anything on its text, and regenerate your types.** 851 of the 1,598 descriptions the database publishes were rewritten in plain language, just over half of them. They reach you through `GET /v1/catalog/openapi.json` — a table’s comment is the description of its definition there and a column’s comment the description of that property — and so through the OpenAPI half of `turbobase project types`, which reads that document from a signed-in browser session. **`GET /v1/catalog/records`, `/columns` and `/fields` are unaffected, and so is the in-product agent**: their `description` comes from the catalog’s own semantic layer rather than from the schema, so a value’s text there has not moved. No meaning changed either way: no column changed type, nullability or what it holds. Ten descriptions on tables that several connectors project onto stopped naming one connector’s field; `docs/sources/CANONICAL_MAPPING.md` is where each connector’s own field names now live. * **Handle 403 alongside 401 on every read and write, and re-authenticate on both.** A session can now stop working before its token expires: the person signed out elsewhere, an administrator suspended them, or their role changed. The instance refuses those at the database, so `/rest/v1`, the live data stream and `POST /v1/data` all start answering **403** where a read used to return rows. Send the user to `/login` — the navigation reaches the instance, which replaces their session with one carrying their current access, so somebody whose session is otherwise fine is not asked for a password. See [App auth wiring](https://docs.turbobase.io/build-apps/auth/). * **Branch on `details`, not on the status, to tell the two kinds of 403 apart.** The error body carries `"details": "turbobase:session_dead"` when the session itself has ended; any other 403 means this session may not touch that table or those rows, and signing in again will not change it. Reading `details` is what lets you bounce the first user to `/login` and show the second a permissions message. Read `message` to show a person what happened — never to decide what to do. * **Stop retrying a 403, and stop reading one as “no data”.** It is a statement about the session rather than a transient failure, so a retry loops; and the refusal is deliberately an error rather than an empty result so your app can tell the two apart. Code that treated a 403 as a reason to back off and try again will spin on a signed-out visitor. * **Check each `POST /v1/batch` sub-result’s own status, not the envelope’s.** The batch stays `200` and the refusal lands one level down, as `results[i].ok === false` with `results[i].status === 403` and the same `details`. Code that reads `results[i].body` after checking only the response status will pass an error object to a screen as rows. * **Do not treat a successful read as proof the session is live.** A query that has no rows to return can still answer `200 []` for a session that has ended, because the check runs only where a statement reaches rows — and that is the clearest case rather than the only one. Handle the 403 wherever it appears instead of inferring liveness from a request that succeeded. * **Re-read your data on reconnect if a screen is long-lived.** Changes stop arriving once the session behind a subscription ends; whether your client is told or simply goes quiet is not something to build on, so silence no longer means “nothing changed”. * **Send the raw invite token to `redeem_channel_invite`, not its SHA-256 hash, and call it under an authenticated or service credential.** The RPC’s first argument is renamed `p_token` and the function hashes it internally, so what the database stores in `channel_invite.token_hash` is no longer something a holder can redeem. `anon` no longer holds `EXECUTE`, so an unauthenticated `POST /rest/v1/rpc/redeem_channel_invite` is refused. An app still passing `p_token_hash` gets `invite not found` with the invite left open. * **Stop calling `adapter.confirm_request`, `adapter.confirm_decide` and `adapter.confirm_expire` under a viewer’s session.** `authenticated` loses `EXECUTE` on all three and `USAGE` on the `adapter` schema; they were `SECURITY DEFINER` with no authorization check of their own. The gated `turbobase.confirm_decide` wrapper is unchanged and is the admin door. * **Open the CLI sign-in link as given, fragment and all.** `open-dashboard` now builds `…/auth/admin/cli#token=` rather than `?token=`, so a tool that re-writes the URL, drops the fragment, or re-issues it as a query string hands the browser a link that cannot sign anyone in. The token no longer reaches an access log or a request trace. * **Pace an authoring MCP client against a per-address budget as well as its own token’s.** `POST /mcp/authoring` now allows 60 requests a minute from one address, applied before the bearer token is read, alongside the 60 a minute per token it already published. The numbers are deliberately equal, so a client pacing itself against its token allowance never meets the new one — but several clients sharing one outbound address (a CI runner, an office NAT, two containers on one host) now share it, and on an instance whose network does not yet pass the caller’s address through, every client of that instance shares it whatever address it calls from. If you run more than one authoring client against one instance, pace them against 60 a minute between them. Tell the two refusals apart by the body: the per-address one answers `{"statusCode":429,"error":"Too Many Requests"}` and the per-token one `{"error":"rate_limited","reason":"too_many_requests"}`. Override the per-address number with `RATE_LIMIT_MCP_AUTHORING_MAX`. * **Give an `external_api` connection a base URL on the public internet.** `POST` and `PATCH /admin/sources/external_api/connections` now refuse a base URL whose host is, or resolves to, a private, loopback, link-local or otherwise internal address — the 400 says so, distinctly from the existing malformed-base-URL refusal. A host that cannot be resolved is refused too: the screen fails closed. A connection already holding such a URL is refused on every patch until the base URL is corrected in that same patch, so a budget change on one needs the host fixed first. * **Expect your inbound `after_save` hooks and your conduits to fire for records that arrive on the scheduled sweep, not only in realtime.** Both seams used to run in one place — the realtime worker — so a hook or a conduit declared on a record type whose source is polled never ran at all, with no error and no `conduit_event` row. They now fire for every row a sweep INSERTS or UPDATES, for every poll-fed source. An unchanged re-read still dispatches nothing, so a four-hourly walk does not re-fire on rows it has already seen. If you declared one of these and had worked around it running only in realtime, remove the workaround; if you declared one and assumed it never fired, it does now. * **Read a Salesforce record’s `raw` without stepping over `attributes`.** Every SOQL row carries an `attributes: { type, url }` envelope describing the query rather than the record, and it was stored verbatim. New and updated rows no longer carry it. An existing row heals on its next inbound write. * **A Salesforce connection now mirrors the whole org, so plan your reads for the real row count.** Each object used to stop at 200 rows — for most of them the same 200 oldest, every run — and now pages to the end and keeps up. An app that was written against a few hundred rows of a large org will see all of them: paginate your `/rest/v1/*` reads and do not assume an unfiltered select returns a page’s worth. * **Expect Salesforce rows to disappear.** Deletions are mirrored for the first time — the org’s deletion log, merge losers, and a line taken off its header — and a tombstoned row is hidden from app reads by the `deleted_at` policy. A record your app held an id for may now read as absent. * **Order Salesforce and HubSpot lines by `line_order`, not by `line_index`.** On `opportunity_line`, `estimate_line` and `order_line`, `line_index` is the line’s IDENTITY — for a source whose lines are independent records it is a hash of the line’s own id, so sorting on it scrambles the document. The new nullable `line_order` carries the position a person sees, counting from 1. It is null for a source that embeds its lines in the header (NetSuite, Zoho), where `line_index` already is the order, so `order by line_order nulls last, line_index` is the portable form. Regenerate types to pick the column up. * **Regenerate types: generated interfaces no longer include adapters this instance is not connected to.** `renderCatalogTypes` and `SEEK_KEYS` now skip a record whose source the instance has no connection to. A NetSuite-only project that imported `SalesforceOrderLine` — which was the only interface describing `order_line`, and described the wrong column names — must switch to `NetsuiteOrderLine`, emitted per source and per line table. If you worked around this by selecting `order_line` columns by hand, drop the workaround. * **Read `opportunity.currency_code` before adding up Salesforce amounts.** The column is new and nullable; in a multi-currency org every row carries its own code, and a total across codes is not a quantity of anything. `estimate` and `order` already had it. * **An embed off a Salesforce opportunity or quote now returns the account.** `opportunity?select=*,entity(*)` and `estimate?select=*,entity(*)` returned null on every Salesforce tenant because `entity_type` was unset and the composite key is MATCH SIMPLE. If your app worked around that by reading `raw->>'AccountId'` and joining in the client, drop the workaround — and note that `entity_name` is now populated, so a list can render a name without an embed at all. * **Salesforce owners resolve to names.** `work_item.owner_name`, `work_item.owner_user_id` and `activity.author_name` are populated on Salesforce rows, and Salesforce users and queues are mirrored as `entity` rows with `entity_type='employee'` (a queue carries `attrs.isQueue`). An app rendering `owner_id` can render the name instead; one filtering a staff list on `entity_type='employee'` will now see queues, so exclude them with `attrs->>'isQueue'` where that matters. * **Expect fewer `work_item` rows for a non-admin reader on a Salesforce tenant.** `work_item`’s read policy admits a row whose `owner_user_id` is null, and no adapter filled that column until now — so every Salesforce case was readable by anyone holding `read:work_item`. A case whose Salesforce owner has a Turbobase account with the same email is now readable by that person and by an admin only. If your app showed a whole-team case list to standard users, it needs an admin reader or a different surface. * **Salesforce activity links moved.** `activity.work_item_external_id` now carries a Salesforce `WhatId` only when it names a Case; an Opportunity or Account `WhatId` moved to `activity.entity_external_id`, which also carries `WhoId` — the person the call was with. An Event’s `created_at` is now its creation date rather than its start time, so an activity feed sorted on it stops putting future meetings at the top; the start time is in `raw->'__salesforce'->>'startsAt'`. * **Stop sending `lines` on a Salesforce opportunity to `POST /v1/data/opportunity` (and MCP `write_record`).** It is now refused with 422 and `code: 'LINES_NOT_SUPPORTED'` instead of being accepted and silently dropped — Turbobase does not write opportunity products to Salesforce. Post the deal on its own; the products stay a Salesforce-side edit. * **Send `last_name` on a Salesforce contact create, `last_name` and `company_name` on a lead, and `title`, `status` and `expected_close_date` on an opportunity.** A create missing one is now refused at the boundary with 422 and `code: 'MISSING_REQUIRED_FIELD'`, naming the field, rather than being saved and refused by Salesforce minutes later onto `record_write_state`. An account still needs something to name it — `display_name`, `company_name`, or `last_name` for a person account. `list_record_types` publishes the declaration as `required_inputs`, so read it rather than hard-coding the list. * **Stop sending an update to a converted Salesforce lead.** It is refused with 422 and `code: 'CANNOT_UPDATE_CONVERTED_LEAD'`; Salesforce will not accept one. Edit the account, contact or opportunity the lead became. * **Address a row by either length of Salesforce record id on a write.** A 15-character id — the form reports, list views and Classic URLs show — now resolves to the same record as its 18-character form when you address a row on `POST /v1/data/*` or MCP `write_record`, where it used to answer 404. A read through `/rest/v1/*` is not converted and still needs the 18-character form, which is what Turbobase stores and returns. * **Expect a row you have just edited to keep YOUR value until the edit reaches the source.** A scheduled sync used to overwrite a mirrored row with the source’s state even while that row’s own write-back was still queued, destroying the edit locally and then pushing the stale value upstream. It now leaves such a row alone and picks the source’s change up on the next sweep, so a read through `/rest/v1/*` can sit one sweep behind the source for as long as the edit is in flight. This holds for every source except Slack channel messages, whose history sweep can still overwrite an edit that has not posted yet. Nothing to change in an app — but if yours polls for the source’s answer to an edit, read `record_write_state` to know the write has landed rather than waiting for the mirrored value to move. * **Read `reason_code` on a refused Salesforce write — it is no longer always `write_failed_unexpectedly`.** A refusal now arrives as `needs_input` carrying the row’s own column in `fields` and Salesforce’s sentence in `message`, after one attempt rather than six. New values an app can see: `source_value_not_accepted`, `source_duplicate_record`, `CANNOT_UPDATE_CONVERTED_LEAD`, and `outcome_unknown` / `source_response_lost` for a create whose answer Salesforce lost — that one means the record may already exist upstream, so look before re-creating it. * **Expect a plain `YYYY-MM-DD` sent to a NetSuite date field to land on that day — the earlier note named only a full instant.** v1.41.0 announced this repair as covering “a full ISO instant written to a date field”, so an app sending a bare calendar day, which most do, read it as not applying. It applies: `"2026-06-24"` on a standard date field such as `trandate`, on a custom `custbody_*` / `custrecord_*` date field, and the same day written the account’s own way as `6/24/2026`, all store 24 June, whatever time zone the account and the user behind the connection are set to. A `datetime` field still names an instant and is unchanged. * **Drop the +1 day your app adds to a date before sending it, once that instance’s NetSuite bundle is TB 2.15.0 or above.** An app that worked around the old one-day loss by sending the next day now writes the day after the one the person picked, and nothing refuses it — the document simply carries the wrong date, and you find out by opening it. The repair runs inside the connected account, so the compensation is still right below TB 2.15.0: ask whoever administers the connection which version of the Turbobase integration package it runs before you remove yours. Dates already stored a day early stay as they were written. * **If your app reads `turbobase.item.item_type` to decide whether a product is tracked by lot or serial number, re-check what it expects there.** A lot-numbered or serialized item now always carries its tracked type — `lotNumberedInventoryItem`, `serializedInventoryItem`, `lotNumberedAssemblyItem` or `serializedAssemblyItem` — whatever order the sync ran in, and on an instance upgrade the rows that were recorded wrongly are corrected in place. Before this, the untracked NetSuite reads could tag a tracked product `inventoryItem` or `assemblyItem` and nothing came along to fix it, so an app that filtered on the tracked type was silently missing those products and one that trusted `inventoryItem` to mean “needs no lot number” sent receipts NetSuite refused. Nothing is renamed and no column moves, so a query naming `item_type` keeps working; what changes is the value on the affected rows. If you filter the item list on `item_type`, list the tracked variants beside the plain one; if you branch on it to decide whether a receipt needs an inventory detail, you can now trust it. * **Address a tracked product by its tracked record type on `POST /v1/data/:recordType` and `DELETE /v1/data/:recordType`.** Both resolve a row by `external_id` AND `item_type`, so an edit or a delete sent to `inventoryItem` or `assemblyItem` for a product that is really lot-numbered or serialized no longer finds the row and is refused. That refusal is new: while the product was tagged wrongly the plain record type did reach it. Send `lotNumberedInventoryItem`, `serializedInventoryItem`, `lotNumberedAssemblyItem` or `serializedAssemblyItem` instead — the value the item row now carries is the one to address it by. Reads are unaffected: `read:item` covers every sub-type and the row-level policy on `item` does not look at `item_type`. * **Handle `reason_code = 'source_response_lost'` on a refused NetSuite write — a brand-new record now reaches it on a second road.** Until now the only NetSuite refusal that ended there was the one carrying the connected system’s own content-free catch-all sentence. A CREATE the connected system refuses at the HTTP level, with a body that explains nothing, used to arrive as an ordinary held row reading “no action is needed from you”; it now arrives as `blocked` with `source_response_lost`. Treat it as [build-apps/skills](https://docs.turbobase.io/build-apps/skills/) now describes: suppress any “try again” affordance, render `message`, and do not offer a correct-and-save prompt — `fields` is empty by construction and the record may already exist upstream, so a re-save on a financial document is how a duplicate is made. There is no id to link and no settle verb to offer, which is the whole difference from `write_settle_conflict`. * **`record_write_state.message` on that ending is plain language, never the source’s raw answer.** It is written for the person looking at the record — that the record may already exist, where to go and look, and that saving again could create a second copy — and it is never longer than 500 characters, so it is safe to render as-is. An app reading the connected system’s own wording out of this column on a blocked row should stop: the column’s own contract has always said it is never the raw source error, and on this ending it is now ours. * **Match a catalog entry by `qualifiedRecordType` when you have the qualified name.** Every entry `GET /v1/catalog/records` (and `/v1/catalog/records/:type`) returns now also carries `qualifiedRecordType` — `"netsuite:invoice"` — the same string `turbobase project types` writes at the top of each generated interface. `recordType` (`"invoice"`) and `source` (`"netsuite"`) are unchanged, so nothing you read today moves. Do not filter `recordType` on a `source:` prefix: it never carries one. Whether a record can be written right now is `writeCapability !== null` here — the same answer MCP `list_record_types` gives as `writable_now`; the catalog route has no `writable_now` or `direction` key. * **Read `turbobase.activity.direction` to tell a conversation apart from a comment, and `turbobase.activity.delivery_status` to tell whether a message you sent arrived.** Both are new, nullable canonical columns on `activity`, and both are NULL on every row that was already there — so nothing you read today changes value. `direction` is `inbound` when the other party sent the event and `outbound` when your business did, and it is NULL on the event kinds that have no direction, which is most of this table: a comment, a note, a status change. `delivery_status` is the source’s own answer about the recipient — `sent`, `delivered`, `read` or `failed` — and is set only on outbound messages. Neither is a second spelling of `write_status`, which is still the platform’s record of whether YOUR write reached the source and is set in both directions. If your app renders an `activity` feed, add `direction` to the select and style the two sides differently; if you were inferring direction from `author_id`, stop. * **Two relations are new and your generated types will grow: `turbobase.whatsapp_phone_number` and `turbobase.whatsapp_message_template`.** Re-run `turbobase project types` to pick them up. Nothing existing is renamed, moved or dropped, so no query you have written needs changing. * **If your instance connects WhatsApp Business, decide who answers each business number before anybody reads a conversation.** Set `whatsapp_phone_number.owner_user_id` (or `owner_role_id`) through `POST /v1/data/phoneNumber`; until you do, that number’s conversations are readable by administrators only. The assignment is what controls visibility — the person it names reads that number’s messages, nobody else does, and an administrator reads everything — and it is never sent to WhatsApp. The write needs `write:whatsapp_phone_number`, which is deliberately **not** in the default capability bundle: a tenant administrator grants it per user, because the capability is the power to decide who may read a customer’s conversations. * **A WhatsApp `activity` row is now filtered per business number for every non-administrator, including through a write’s `RETURNING`.** That is a new restrictive database policy, so a reader who is assigned no number sees zero WhatsApp rows rather than an error — a `[]` where you might have expected a refusal. Rows from every other source on `activity`, `memory_document` and `memory_chunk` are unaffected and keep exactly their current semantics. * **Send a WhatsApp message by creating a `message` record, and put the two addresses under `raw`.** `POST /v1/data/message` takes `body` as a column and the addressing in `raw`, which is the delivery’s own shape: `raw.metadata .phone_number_id` (or `raw.phone_number_id`) for the business line it goes out ON — there is no default, and a reply from the wrong one reaches the customer under another team’s name — and `raw.to` (or `raw.wa_id`) for the customer’s `wa_id`. Neither is a column of `turbobase.activity`, which a dozen sources share, so a top-level `phone_number_id` is refused `UNKNOWN_COLUMN`; a create naming neither address is refused `MISSING_REQUIRED_INPUT` on the call. For a template, set `raw.type: 'template'` and `raw.template.name`, with the variables under `raw.components`. * **The two WhatsApp send rules are ours to decide, not yours, and both are checked before any call is made.** Ordinary text may be sent only within **24 hours** of the customer’s last inbound message on that conversation, and outside that window only a template whose status is exactly `APPROVED`. Both inputs are read from your own mirrored data — the inbound messages WhatsApp delivered on that conversation and the `whatsapp_message_template` row, each scoped to the connection — so `raw.last_inbound_at` and `raw.template.status` in a payload are ignored, and so is a `message` you create yourself with `direction: 'inbound'`: only a message that arrived from WhatsApp opens the window. A customer who has never written in is OUTSIDE the window, not inside it. There is no edit and no un-send: addressing a message that already has an `external_id` is refused as append-only, and a delete is not propagated. Sending is also held behind the per-instance `sync.bidirectional.whatsapp` entitlement, which ships off. * **Do not expect WhatsApp history from before the connection.** WhatsApp publishes no message-history endpoint, so conversations are mirrored from the moment the account is connected onward, and the poll cannot repair a delivery that was missed. An empty conversation list on a fresh connection means nobody has written in yet. * **Expect `write_record` and `delete_record` to be absent from an MCP connection’s `tools/list` after this release, and refused if called — every client connected before it is treated as read-only until the person who connected it approves it again.** Whether an MCP client may change data used to be one instance setting, *Allow MCP clients to change data*, and nothing else. That setting stays exactly where it is, as the administrator’s ceiling, and a second permission now sits beneath it on the connection the request arrived on — so turning the instance setting on no longer arms every client every person has ever connected. Nothing could be carried across the upgrade: the OAuth bridge hands a client the upstream token unchanged and keeps none of them, so a connection that is live at the moment of the upgrade cannot be tied back to the client holding it. The two tools are missing from the list rather than advertised and failing, because a tool that always errors is one a model retries. Reading is unaffected throughout, an access token is unchanged — it writes if it carries `mcp:write` — and a native app your team builds on the same OAuth server’s `data` resource is not affected at all. * **Tell a person whose client has stopped writing to disconnect it and connect it again from the client’s own settings, ticking *Let this client create, change and delete records* when the approval page asks.** That approval is what records which client is asking, which is the half the upgrade cannot recover, and the box is unticked by default — so approving without touching it reconnects a client that reads and nothing more. A client left alone is matched again the next time it renews its token, but it stays read-only until the permission itself is granted, so the reconnect is the instruction worth giving. * **Grant or take back the permission to change records on one connection under Account → Connected apps.** It says of every client whether it may change records, and the next call that client makes sees the new answer. The two refusals are deliberately different sentences, so a client can be told which of the two things to do: *could not be matched to a connected client, so it is treated as read-only* means connect it again, and *does not have permission to change records* means grant it there. See [Letting a client change data](https://docs.turbobase.io/tenant/mcp-server/#letting-a-client-change-data). * **Expect the stage, priority, assignee, customer and reporter words on an already-mirrored NetSuite support case to fill in about half an hour after the upgrade.** `status`, `status_name`, `priority`, `assignee_name`, `entity_name` and `reporter_name` on a `work_item` row whose `item_type` is `ticket` were left null by an earlier full re-scan the connected account refused, and this upgrade schedules a fresh one-shot re-scan thirty minutes after it lands — late enough that the tick claiming it runs the new image. The stage word fills `status` as well as `status_name`, so an app reading either of them sees it. Nothing is pushed back to NetSuite, and a case genuinely unassigned stays null, so an app rendering a fallback for a blank stage should expect it to stop firing rather than need a change. * **Stop matching on the foreign-key constraint name when a write names a subsidiary or an accounting period your tenant does not hold.** The refusal itself is unchanged — `POST /v1/data/` and `POST /v1/batch` still reject it with SQLSTATE `23503` — but it now comes from a check that names the column instead of a constraint, so the message reads `no subsidiary matches subsidiary_id "123"` and no constraint name appears in it. Match on the code and on `subsidiary_id` / `posting_period_id`, not on `
_subsidiary_fk`. * **Expect `403 forbidden` from `POST /v1/data/timeBill` when the time record names a subsidiary, warehouse, department or class outside the session’s own scope.** A user restricted to one of those could create and edit time on `time_entry` anywhere on the instance; those writes, and the matching deletes, are now refused before the statement runs — the same refusal `time_bill` gave before the consolidation moved the rows, and the same one `invoice`, `order` and `purchase_order` already give. Nothing changes for an unrestricted session, for an `app_role: 'admin'` one, for a restricted session writing inside its own restriction, or for a source that tags none of the four dimensions — a work-tracker worklog carries no subsidiary, location, department or class, and an untagged row is visible and writable exactly as it is today. If a screen lets someone pick a warehouse they do not hold, filter the picker to the session’s scope, or handle the refusal. * **Expect a shorter list from `/rest/v1/time_entry` for a user restricted on a subsidiary, warehouse, department or class.** The read policy asked the capability alone, so a restricted user could see every time record on the instance; rows tagged outside their scope are no longer returned. There is no error to catch — the request answers `200` with fewer rows, which to an app looks like records going missing rather than like a rule being applied, so a screen that reconciles a total against its own cached count, or that reads a time entry by an id it stored earlier, should expect a miss. Rows carrying none of the four tags stay visible to everyone who holds `read:time_entry`. * **Point a conduit’s `to` at `turbobase:build_request` to ask for a change to be built in a code repository your instance owns.** The patch carries `repository` (as `owner/name`), `app_slug` and `request` — the instruction, verbatim — and all three are required; `request_id` is optional, and left off it is the run’s own ledger key, so two deliveries of one firing are tellable apart either way. The platform checks that the repository really is your instance’s and that your instance has build budget left for the day, then starts the build with access to that one repository. None of our credentials ever go into your repository, which is why the ask goes through the platform rather than straight to the forge. `turbobase` stays a reserved source for the platform’s own records — it is not a connector and never will be. * **Read the build id off the conduit run rather than out of a table.** Nothing lands on your instance: the ask IS the write. The run shows up on `turbobase logs --conduit` as usual, the build id the platform answered with is on the run’s `to_external_id` — which that line prints as `to_ext=` — and the master columns are empty because there is no local row to point at. A conduit that both asks for builds and reacts to records can tell its own runs apart the same way it always could. * **Expect a refused ask to be recorded, not retried.** A repository that is not your instance’s, a daily build allowance already spent, an instance that has no credential for the platform, or an ask missing one of the three required keys — each parks the run with the reason written on it, so whoever reads the ledger can see which of them it was. A platform that is merely unreachable for a moment is the one case that is not a refusal: that run retries on its own. * **Expect the verb to come from the record you target, never from your patch.** The reserved source now has two of them — `turbobase:proposal` files a draft change request, `turbobase:build_request` spends build budget — and which one runs is fixed by the record named in `to`. A patch key cannot switch between them. * **Send a GetSales contact’s LinkedIn profile as `raw.linkedin`, and stop using any other spelling.** A create or edit of a `getsales` `contact` through `POST /v1/data/contact` now carries `raw.linkedin` (the nickname after `/in/`) or `raw.linkedin_id` (a profile URL or any other LinkedIn reference) to the workspace; before this they were accepted, dropped, and the write still read `synced`. Any other LinkedIn key — `raw.linkedin_url`, `raw.linkedin_profile` and the rest — is now **refused** with `UNKNOWN_LINKEDIN_FIELD` naming the key you used, immediately at the write boundary and as `write_status` `needs_input` on the queued paths. If your project sends one of those, rename it; if it relies on such a write succeeding, it was never reaching GetSales. A profile alone is now enough to identify a new contact, so a create carrying nothing but `raw.linkedin` is no longer refused as incomplete. * **Add `@turbobase/code-standards` to your app’s `devDependencies`** (`npm install -D @turbobase/code-standards`). It is the lint, format, dead-code and duplicate check a new app from `turbobase apps init` now starts with: it catches the mistakes that compile and still break a screen — a React hook called after an early return, a promise nobody awaited, a duplicated object key — and formats your files so no turn is spent on style. * **Add `.oxlintrc.json` at your app’s root** with `{ "extends": ["./node_modules/@turbobase/code-standards/oxlint/react.json"], "ignorePatterns": ["src/types.ts", "src/lib/turbobase.ts", "dist/**", ".claude/**", "node_modules/**"] }`. Keep `src/types.ts` ignored: `turbobase project types` regenerates it every session, so a finding in it is one you cannot fix. `src/lib/turbobase.ts` is the platform’s client, replaced by these notes rather than edited by you. * **Add `oxfmt.config.ts` at your app’s root** containing `export { default } from '@turbobase/code-standards/oxfmt';` — the house format (tabs, semicolons, single quotes, 120 columns). Re-export it rather than copying the settings, so a package upgrade is the only change a format update needs. * **Add `code-standards.json` at your app’s root** with `{ "units": ["."], "ignore": ["src/types.ts", "src/lib/turbobase.ts", "dist/**", ".claude/**", "node_modules/**"] }`. The key is `ignore`, singular: the check refuses any key it does not know. Your app is one unit: duplicated code is counted inside it and never against another app. * **Add `knip.json` at your app’s root** with `{ "entry": ["{hooks,conduits,processes}/**/*.{ts,js}", "oxfmt.config.ts"], "ignoreDependencies": ["@turbobase/extensions"], "ignoreExportsUsedInFile": true, "include": ["files", "dependencies", "unlisted", "exports", "types", "duplicates"] }`, so the unused-code check counts your hooks, conduits and processes as used and does not ask you to install `@turbobase/extensions`, which the extension sandbox provides. * **Remove `"baseUrl": "."` from `compilerOptions` in your app’s `tsconfig.json`** and keep `paths` as it is. The type-aware lint reports `baseUrl` as an error, and `paths` resolve without it. * **Add `"check": "code-standards check"` and `"fix": "code-standards fix"` to your app’s `package.json` `scripts`.** If you already have a `check` script, keep yours and add these under another name (for example `standards` and `standards:fix`). Run `npm run fix` once and commit that as its own commit. Then run `npx code-standards baseline` and commit the `code-standards.baseline.json` it writes: it records the findings your app already has so `npm run check` passes today and fails only on new ones, and `check` refuses to run without it. Run `npm run check` before every push. * **Add the format hook to `.claude/settings.json`** so Claude Code formats each file it edits: run `turbobase apps init ` in a scratch directory and copy `.claude/hooks/format-edited-file.sh` and `.claude/hooks/format-edited-file.mjs` into your project, then add `{ "type": "command", "command": "bash .claude/hooks/format-edited-file.sh" }` to the `hooks` list of your `PostToolUse` entry whose `matcher` is `Edit|Write`. The hook does nothing until the package is installed and never blocks an edit. * **Stop watching for a cost-tracking value to park on `write_settle_conflict`.** A `POST /v1/data/location` — or `department`, `classification`, or any other value on a cost-tracking axis — whose upstream id is also held by a value on a DIFFERENT axis, or by a value belonging to a second connected system, now settles on the first attempt: the row reaches `write_status = 'synced'` on its own and no longer needs `POST /v1/data//settle` run against it by hand. Each axis is numbered separately upstream, so an app creating its first few values hit this routinely. A row already parked keeps that route, and the route now resolves it with `200` where it answered `409 SETTLE_TWIN_NOT_ADOPTABLE` for the same reason the automatic settle refused. Nothing changes for a record type whose table keeps two rows apart on the connection alone, such as `item`. * **Match a settle refusal on its prefix, not on its whole sentence.** Where two rows genuinely hold one upstream id and nothing can tell them apart, the refusal carried onto `sync_failure.agent_notes` and into the route’s `409` message now ends with the rows it refused to choose between — `… refusing to choose one to discard: , ` — so a person or an agent reading it can go and look at them. An app that compares that message for equality should compare the part before the colon instead; the SQLSTATE behind it is still `55000` and the rejection code is still `SETTLE_TWIN_NOT_ADOPTABLE`. * **Order a NetSuite document’s lines by `line_order`, not by `line_index`.** `line_order` is new on twenty line tables — `order_line`, `invoice_line`, `purchase_order_line`, `vendor_bill_line`, `check_line` and the rest — and it holds the position a person sees in NetSuite, counting from 1. `line_index` is no longer that position: it is the line’s identity. A line id is not monotonic with display position even on a document nothing has been removed from, so a surface still sorting on `line_index` shows the lines scrambled. `line_order` is null on a document not yet re-read since this release, so sort on it and fall back to `line_index` while it is — a document’s lines are rewritten with both values the next time it is read, within four hours. PostgREST: `purchase_order?select=*,purchase_order_line(*)&purchase_order_line.order=line_order`. * **Join on `line_index` — it now holds the line’s own NetSuite line id on every path.** This is the value a receipt line’s `po_line_id` and an audit row’s `system_note.line_index` have always carried, and until now the mirror only agreed with them after a REST re-read: a document written by the four-hourly poll stored positions 1..N instead, and one written by a realtime save stored positions counted from zero. So `item_receipt_line.po_line_id = purchase_order_line.line_index` matched nothing on a polled order and the wrong line on any order a line had been removed from, and the per-line audit trail attached each note to the wrong line. Both resolve now, with no change to the query. * **Re-read any NetSuite line number your app or report has stored, because the `line_index` VALUES change.** Every NetSuite line last written by the poll or by a realtime save gets a new `line_index` when its document is next read — the line id in place of a position — so a number captured before this release no longer identifies the same line, and a request keyed on the stale value succeeds while matching nothing or the wrong row. Nothing is dropped: the row is the same row with a different key. Lines written by other sources are untouched. * **An expense line on a payable now carries its own line id, not `1000 +` its position.** `purchase_order_line`, `vendor_bill_line`, `check_line`, `vendor_credit_line` and `credit_card_charge_line` fan their lines across NetSuite’s `item` and `expense` sublists, and an expense line’s `line_index` was the sublist position plus 1000 so the two could not collide. Where NetSuite states the line’s id that offset is gone — an expense line whose id is 3 reads as 3 — so a filter of `line_index=gte.1000` no longer selects the expense lines. Use `line_kind=eq.expense`, which has always been the supported way to tell them apart. The offset survives only where the two sublists would otherwise land on the same `line_index`, which is a document saved from a NetSuite bundle below 2.18.4 — those saves number each sublist’s own lines instead of naming them. A document read back over REST states every line’s own id, so its expense lines carry their real ids too. ## v1.45.0 [Section titled “v1.45.0”](#v1450) * **Expand `caps_by_verb` when you decode `tb_jwt` — re-copy `sessionClaims()` from `src/lib/turbobase.ts`, or apply the same change to yours.** A session whose capability list would make the token too large for a browser cookie (about 4 KB — a standard user holding the default permissions is over it) is now signed with that list packed by verb: `capabilities` holds only the marker `"#packed"`, and `caps_by_verb` maps each verb to its space-separated remainders, so `{ "read": "order invoice" }` is `read:order` and `read:invoice`. Until now such a user could not sign in at all — the browser discarded the cookie — so no app saw these sessions before. A client that has not adopted this reads the marker as holding nothing and DENIES every `hasCapability()` check for those users; it never over-grants. The database and every instance route already read both forms. Sessions small enough to fit are minted exactly as before, so nothing changes for them. ## v1.41.0 [Section titled “v1.41.0”](#v1410) * **Expect `turbobase apps deploy` to record your source now.** Run against a directory holding a `package.json`, it cuts the version exactly as before and then records that directory as the app’s newest source revision — the same thing `turbobase apps push` has always written, through the same collector. Nothing about the build or the live URL changes. It is what keeps the app pullable from another machine, and it is what a user’s in-app change request needs in order to be applied at all. * **Read the two lines it prints after the deploy.** `recorded N source files on the instance (rev R)` is the success; a line beginning `source not recorded:` is the failure, and it names what to do about it. A failed collection never fails the deploy — the version is already live by then — so the printed line is the only signal, and the fix is always one `turbobase apps push `. * **Stop pushing first just to get your findings ingested.** The knowledge ingest a deploy queues reads the newest recorded revision, so a deploy that records source feeds it the `INSTANCE-LEARNINGS.md` / `TURBOBASE-FEEDBACK.md` on your disk. The deploy now says which revision that is (`learning ingest queued … — reading source rev R`); the warning telling you to run `turbobase apps push` first is left for the deploys that really do record nothing — `--no-source`, a prebuilt bundle, a failed collection. * **Pass `--no-source` where a deploy must upload nothing from the working tree.** The version is still cut and nothing from the directory goes over the wire. Reach for it on a CI preview loop that re-deploys on every commit, or anywhere the build machine’s tree is not the source of record. An app deployed this way stays un-pullable, and a change request filed against it cannot be applied automatically. * **Nothing is recorded from a directory with no `package.json`.** A prebuilt bundle handed straight to `--no-build` has no source tree to record, and a directory whose source this CLI pulled is already the newest revision, so neither writes one. * **Credential files are withheld, by name, and they are named on stderr.** `.env`, `.env.` and `.mcp.json` are never uploaded — `turbobase apps init` writes a live token into `.mcp.json` — while `.env.example` is a template and travels as source. `node_modules/`, `dist/`, `build/`, `.git/` and `.claude/` are skipped too, so a deploy records the project, not its output. * **Ask your instance for Turbobase’s documentation instead of fetching docs.turbobase.io — call `search_turbobase_docs` with a phrase.** It ranks our published pages and answers `{count, results:[{slug, title, snippet}], currentVersion}`. The pages are the ones your instance’s own release shipped, so what you read always describes the version you are building against. It sits beside `search_documentation`, which still searches the tenant’s own knowledge base; platform questions go to the new one. * **Follow a hit with `read_turbobase_doc` to get the whole page**, passing the `slug` the search returned. It answers `{found: true, slug, title, body_markdown, currentVersion}`, or `{found: false, slug}` for a slug that is not one of our pages — a miss is an answer, not an error, so there is nothing to retry. Both tools are also accepted inside a `batch_read`, so a search and two page reads can be one call. * **Reach the same corpus over HTTP at `GET /v1/catalog/docs/search?q=` when you are not going through a tool**, with `&limit=` up to 25 (default 8). It needs only the `catalog:read` scope your personal access token already carries for `/v1/catalog/adopt-notes` — no new scope, and no re-issue. An empty `q` is refused with `400 {"error":"q_required"}`. * **Read one page over HTTP at `GET /v1/catalog/docs/document/:slug`**, with the slug percent-encoded (`extending%2Fwriting-hooks`). A slug in no page answers `404` carrying `{found: false, slug, currentVersion}`, and an instance whose build shipped no corpus answers `200 {count: 0, results: [], note}` on the search and `found: false` here rather than failing — so a client can always tell “no match” from “nothing to search”. * **Ask for a custom field your mapping needs with `POST /v1/apps//source-fields`.** Send `fields: [{ name, type, label, record }]`, where `record` is the record the field belongs on (`customer`, `salesorder`) and is required. A definition naming a record the accounting system cannot resolve, or one on a custom record, is refused `400 field_unresolvable` naming the field and what to change; a field the account already manages is refused `409 field_exists` naming it, because this creates fields and never edits them. **At most 25 fields per request** — a longer batch is refused `400 {"error":"too_many_fields","limit":25}`, so split a large mapping run rather than sending it in one call. * **Store the `scriptId` that comes back, not the name you sent.** The answer from `POST /v1/apps//source-fields` carries a solution id and one entry per field, each with the MINTED id the field will actually deploy under — a bare `legacy_ref` on a customer comes back as `custentity_legacy_ref`, because NetSuite rewrites a bare name to its class prefix and truncates at 40 characters. Map against what came back, or your mapping reads a field that never exists. * **Declare `source_field:create` in your manifest’s `roles` and have an admin assign it.** A signed-in session without it answers `403 {"error":"forbidden","capability":"source_field:create"}`, which the shared client raises as a permission error. A Data admin’s `write:*` does NOT include it: this capability writes metadata into the customer’s own accounting system rather than into mirrored data, so it is granted deliberately or not at all. * **Ask for the release with `POST /v1/apps//source-fields/releases`** (`{ solutionId, target: 'auto' }`), then poll `GET /v1/apps//source-fields/`. Each field reads `drafted`, `queued`, `validating`, `deployed` or `failed`; a failed one carries a `reason` you can show the person who asked. One release per request — a second ask answers `409 release_exists` naming the release the request already has. * **`deployed` is not yet selectable — wait for `promotion` to say `promoted to a column`.** A `deployed` field exists in the accounting system and still has no Postgres column, so selecting it returns nothing. Each deployed entry from `GET /v1/apps//source-fields/` carries `promotion`, and exactly one of its values means you can go on and map the field: `promoted to a column — you can select this field now`. Until then it reads `registered for column promotion — not selectable yet`, which can last up to six hours — the gap until the next reconcile pass — so treat the two as separate steps in whatever you show the person, and poll for the second rather than stopping at `deployed`. A `registration for column promotion failed` means the field is created upstream but its column never will be without an administrator. * **`target` is your app’s own choice, and only `auto` can succeed from here.** `POST /v1/apps//source-fields/releases` takes `target: 'auto'` — the capability an admin assigned you is the authorisation, and the deploy is enqueued — or `target: 'approved'`, which an app can never satisfy on its own: it answers `403 approval_required` and marks that release failed, because an app may not name its own approver. So ask for `auto` in a one-click flow, and if you offer `approved` at all, show `approval_required` as “this one needs a person on the instance’s own release surface” rather than as a bug — and never retry it in a loop. * **Expect ONE identity where you read two, for a person two connected systems both carry.** A lead and a contact that are the same person now consolidate: one `entity_canonical` row, with both source records under `entity_source_link`. A paged read of the identity graph returns fewer rows than it did and `Prefer: count=exact` returns a smaller total, so a screen that caches either, or that reconciles a count against a source system’s own, wants re-checking once your instance upgrades. * **Read `entity_canonical.match_scope` — not `entity_type` — to learn what KIND of thing an identity is.** A person identity carries the `entity_type` of whichever member arrived first, `lead` or `contact`, so dispatching on it tells you about that member rather than about the identity. The new `match_scope` column is the stable answer (`person` for both of those buckets, the bucket’s own name for every other), it is published on every identity record type in your instance’s catalogue, and `turbobase project types` picks it up on the next run. A member’s own `entity_type`, under `entity_source_link`, still says what that member is. * **Filter on `match_scope`, not `entity_type`, when you list PEOPLE.** `turbobase:identity_lead` and `turbobase:identity_contact` are two lists over one scope now, and the `entity_type=eq.` filter the catalogue’s own hint and the identity-graph examples hand you splits them by whichever member arrived first — so a list of contacts’ identities stops carrying every person your instance holds a contact for, and the missing ones turn up under leads. Send `match_scope=eq.person` (or `entity_type=in.(lead,contact)`) to list people in one pass. Every other kind is a scope of its own, so `entity_type=eq.customer` and the rest are unchanged. * **Delete the “no counterpart exists” branch a conduit keeps for a person.** `atlas.resolve` now answers for a lead against a contact and the other way round, so a conduit written to treat null as “this person is only in one system” will take its real branch for the first time on rows it used to skip. Null still means no counterpart, so the fallback is not wrong — it is simply no longer the only path. * **Stop reading a web address off a person’s identity: it was their employer’s, and it is now empty.** A lead’s `domain` was derived from its company website, which matched every colleague at one company to one identity — so an app displaying it was showing the employer’s address as the person’s, and an app grouping by it was grouping colleagues. Identities in the `person` scope no longer carry a `domain` at all, two people at one company are two identities, and the ones already merged are separated on upgrade. Read the employer’s web address from the source record’s own `attrs`, where it is unchanged. * **A caller holding only `read:entity:contact` now reaches identities a lead created.** Both are the same person, so the identity is readable to either bucket’s capability holder — and the member list is still narrowed the way it always was: that caller sees the contact behind it and not the lead. A read that returned nothing for such an identity starts returning a row, with no change on your side. * **Handle `awaiting_approval` as a held `write_status`.** It is a new value on every mirror table’s `write_status` column and on `record_write_state.status`, and it means a write your app saved is waiting for an operator to approve it before it is sent. If you branch on that column, treat it as held and not as a failure: there is nothing for the person in front of you to fix, and `record_write_state.message` says so. Code matching on an exhaustive list of statuses will now meet a value it has not seen. * **Stop reading `pending` as “a write is on its way to the connected system”.** A write held for approval used to sit at `pending` indefinitely — the state that means queued and in flight — so an app could not tell a write that was moving from one that had been deliberately stopped. Those rows now read `awaiting_approval`. The record’s `reason_code` carries which guardrail rule held it. * **Expect one `guardrail_hold_expired` failure row per hold that was awaiting a decision when you upgrade to this version.** Every confirmation token minted before this release had been written to the instance’s log store, where it stayed a live approval for a destructive write, so this release retires the holds that had not been answered yet. Each appears on the failures grid in the state the product already uses when nobody answers a hold in time, and the record is left `blocked`. Retry on that row re-submits the write and mints a fresh hold. No record data changes — a held write was never sent. * **Expect a NetSuite duplicate to arrive as `needs_input`, not `blocked`.** A write NetSuite refuses because another record already holds the name — a GL account, an entity, an item — used to land `blocked` with the generic held text, so an app showed “no action is needed from you” for a name the person in front of it could change. Those rows now read `needs_input` and carry NetSuite’s own sentence in `record_write_state.message`, with an empty `fields` array because the refusal names no column. `reason_code` is unchanged, so a branch on that keeps working; a branch on `status` alone will see a row move between the two held values. * **Handle two new refusals on `DELETE /v1/data/:recordType`.** Both arrive as `422` with `{ error: 'validation_failed', rejection: { code, message } }` — the shape a guardrail refusal and a `before_delete` rejection already use — and both are raised BEFORE anything is hidden, so the record is still there and still readable when you get one. Neither is worth retrying as-is: the answer will not change until somebody changes something. * **`SOURCE_BUNDLE_TOO_OLD` — the connected account’s integration package is behind.** You meet it deleting a chart-of-accounts entry, an employee or a subsidiary on a connection whose account is running a package older than the release that allowed it. The message names the version the account is on and the version it needs. Show it and stop: the fix is an administrator updating the package in the connected account, not anything your app or your user can do. Allow up to an hour after an update before the refusal clears — we re-read the deployed version on a schedule, not on the delete. * **`DELETE_BLOCKED_BY_DEPENDENTS` — other documents still point at the record.** You meet it deleting an invoice a payment or a credit memo has been applied to, or a vendor with bills against it. The message names each blocking document by type and document number, so render it as the next action: clear those documents, then delete this one again. **It is the same MESSAGE you already got when the connected system refused the delete after the fact, but not the same string to match on:** this one is `rejection.code === 'DELETE_BLOCKED_BY_DEPENDENTS'` on the `422`, and the late one is still `reasonCode === 'delete_blocked_by_dependents'` on the record’s write state. If you branch on the condition, match both. The change is that it now usually arrives immediately, on the request itself, rather than later on the write state. * **Stop treating a `2xx` from this route as “and nothing will come back.”** That was never what it meant, and these two refusals narrow the gap rather than widening it: a delete that used to be accepted, hidden, sent, refused by the connected system and then un-hidden hours later now answers `422` on the spot in the two commonest cases. The late path still exists for a dependency we do not hold a copy of, so keep whatever handling you have for a record that reappears. * **Expect no new refusal for a record you created in Turbobase and never synced.** Neither check applies to a row with no counterpart in the connected system, so a purely local delete behaves exactly as it did. * **Branch on four new refusal codes instead of retrying a `500`.** A write whose payload names a customer or vendor your instance has not mirrored yet now comes back `422 { code: 'ENTITY_NOT_FOUND', field: 'entity_id' }` rather than `500 write_failed` — and the message says how to link to an entity you created in the same batch. Three more join it: `ENTITY_DISCRIMINATOR_REQUIRED` (a customer or vendor whose `attrs` omits `isCustomer` / `isVendor`, naming `attrs.isCustomer`), `INVALID_ATTRS` (an `attrs` that is not an object), and `UNSTORABLE_TEXT` (a field, or a key inside `raw`, carrying a NUL or an unpaired half of a surrogate pair). Each names the field. A retry loop that treated the old `500` as a transient fault should treat all four as final. * **Send `entity_pg_row_id` when you create an entity and a transaction together.** `entity_id` is a foreign key onto the entity’s id in the connected system, which does not exist until that system mints it — so a batch that creates the customer first has nothing to put there. Set the `entity_pg_row_id` sibling to the `pg_row_id` the entity’s own write returned and the reference is resolved for you once the entity syncs. * **Send a NetSuite transaction’s lines in the same call, or the create is refused now.** A create of a line-bearing NetSuite transaction — sales order, invoice, purchase order, vendor bill, journal entry, credit memo, deposit, vendor credit and the rest — with no `lines` (or an empty array) answers `422 { code: 'ATOMIC_WRITE_REQUIRED', field: 'lines' }` instead of `200`. Nothing has changed about which writes eventually succeed: that record was already refused, minutes or hours later, and appeared on the sync-failure grid rather than in your response. A conversion carrying `created_from_id` is exempt, as it always was, and an UPDATE that omits `lines` still preserves them. * **Stop creating `github:pull_request` records.** A create answers `422 { code: 'EDIT_ONLY' }`, the same way `github:label` already does. GitHub opens a pull request from a branch — its API takes a head and a base ref, not a field set — so the create had no endpoint to reach and the `200` was followed by a write-back failure. Editing a mirrored pull request’s title, body and state is unchanged. * **Create journal entries.** `POST /v1/data/journalEntry` now works; every create was refused by the database before, because `journal_entry.hash` was `NOT NULL` and no caller could supply a value for it. Xero’s `manualJournal` writes the same table and is fixed with it. * **Update an app-owned table through the MCP `write_record` tool.** An update addressed by `pg_row_id` to one of your own manifest-declared tables now succeeds, instead of answering `400 record type "…" is not writable` for a table `list_record_types` had just published as `writable_now: true`. Rows created before 2026-10-06 are the ones that were affected. * **Expect a `lines` refusal to talk about YOUR connected system.** The `LINES_NOT_SUPPORTED` message used to explain NetSuite sublists to every connector; it now reads the explanation off the adapter you are actually on and falls back to a source-neutral sentence. The code, the `field` and the remedy — write the parent, take its `external_id`, post each child to its own record type — are unchanged. * **Handle `-32002` from a `tools/call` as “repeat it, or ask for less”.** A tool call that outruns the instance’s own eight-second deadline now answers that code with `data.retryable: true` and a message naming the tool, instead of holding the connection open until the front door closes it with no JSON-RPC error at all. Its own code rather than `-32001` because the remedy differs: `-32001` means send the same call again, `-32002` means send it again or narrow it — a smaller `page_size`, a tighter filter, fewer items in one `batch_read`. * **Widen any check that reads `-32001` as “the connection pool was busy”.** An upgrade window now answers `-32001` too, with `retryable: true` and a message saying the instance is being updated. Both conditions mean the same thing to a caller — nothing was refused, nothing changed, the call is safe to repeat — so code that branches on the code needs no change; code that matched the WORDING of the busy message does. * **Expect a 503 with a JSON body, never a closed socket, from a request the api cuts off.** `/v1/*` now sets its own request deadline at 120 seconds — a whole proxy window above the front door’s 60, so the front door is still the first to lose patience with a large `apps push` and this is only ever reached by a request nobody is still sending. The answer carries `statusCode`, `error`, `message` and `Retry-After`. * **Expect an unexpected failure under `/v1/*` to answer a redacted 5xx carrying a `correlationId`.** Previously the thrown message reached the caller; it now goes to the instance’s log under that id and the body says only that the request failed and which id to quote. A 4xx — validation, rate limiting, a guardrail refusal — is unchanged, wording included. * **Match a duplicate refusal on `source_duplicate_record`, not on the connected system’s own error code.** When the connected system refuses a save because it already holds a record with that name, code or entity, the `record_write_state.reason_code` your app reads is now `source_duplicate_record` instead of the source’s generic deterministic code (`NS_ERROR` on NetSuite). The status stays `needs_input` and the message is still the source’s own sentence, so a sheet that renders the message is unaffected; only a branch on the old code has to move. The new code is one condition across every record family — a duplicated account name, item name and customer all report it — which is what makes a burst of them groupable on the failures grid. * **Stop re-sending a create your app refused once.** A create the connected system answered with a bare “an unexpected SuiteScript error has occurred” is no longer retried: the record may well have been saved before the script threw, so each retry made a second real one. It now ends immediately on `reason_code = 'source_response_lost'` with the sentence that tells the reader to look upstream before sending it again. An UPDATE or a DELETE refused with the same sentence keeps the retry budget it has today. * **Expect a keyless `customer` create to link rather than duplicate.** A `POST /v1/data/customer` with no `external_id` now asks the connected system whether it already holds a customer with that email — or, when the record carries none, that company name — and sends an update to the one it finds instead of creating a second. Nothing changes for an app that already sets `external_id`, and a lookup that matches nothing, fails, or cannot be asked because the email or company name contains a `"` or a `\` still sends the create. A lookup that matches MORE than one record sends nothing and refuses with `reason_code = 'SOURCE_IDENTITY_AMBIGUOUS'`, whose message names the ambiguity — decide which record it is and link it in the connected system before saving again. * **Stop watching for a non-inventory item create to park on `write_settle_conflict`.** A create whose echo comes back tagged as a different sub-type of the same record now settles on the first attempt, so the row reaches `write_status = 'synced'` on its own and no longer needs `POST /v1/data/:recordType/settle` run against it by hand. Rows already parked keep that route. * **Write a date-only field and get the day you sent.** A full ISO instant written to a date field on an account behind UTC was committing the previous calendar day. It now stores the day in the value. That covers a line’s own date columns and a header’s, and the `expirationdate` on an inventory assignment — so a lot you receive with `raw.inventorydetail = [{ inventorynumber, quantity, expirationdate }]` now expires on the day you sent, where before it expired the day before and was picked a day early for the rest of its life. The fix runs inside the connected system, so it reaches an instance only once its NetSuite bundle is upgraded to TB 2.15.0 or above. * **Set `item_id` on any item record and have it reach the connected system.** `item_id` on an item is that item’s own code there, and it was only sent for four of the twelve item record types — on an `assemblyItem`, `discountItem`, `nonInventorySaleItem`, `otherChargeSaleItem`, `lotNumberedInventoryItem`, `serializedInventoryItem`, `lotNumberedAssemblyItem` or `serializedAssemblyItem` it went out as a reference to some other item, which the connected system accepts and ignores. It now sets the item’s own code on all twelve. Nothing changes for a row you read and write straight back, where the value already matches; an app that sends a DIFFERENT `item_id` on one of those eight was silently having it dropped and will now rename the item. * **Send a payable line coded by `category_id` alone and have it accepted.** A line on a `check`, `vendorBill`, `purchaseOrder` or `vendorCredit` carrying a category and neither `account_id` nor `item_id` now goes to the expense sublist instead of being sent as an item line with no item, which the connected system refused along with the whole document. A line that carries `item_id`, or that declares its own `line_kind`, is unchanged. * **Delete records from a process with a `delete` step.** Declare one as `{ id, recordType, delete: { externalId, sourceSystem } }` — or `pgRowId` instead of `externalId` where you hold our row id. Both address keys are functions of the step context (`(ctx) => ctx.input.externalId`), the same shape `update` takes, and a step addressing a record it cannot resolve is refused rather than widened. It removes records the run did not create, so this is how an app clears a migration in the background instead of sending one delete per page view. * **Write no `build` on a delete step** — it is the third leaf kind, beside the write step and the AI step, and a delete has nothing to build. A step carrying both a `delete` block and a `build` is refused at deploy time naming the field to remove, and so is one declared `rollback: 'undo'`: a delete cannot be undone, so a delete step is always `rollback: 'keep'` and a rollback some other step causes passes over it. * **Declare `onRefused: 'continue'` where a refused delete should not end the run.** It defaults to `'stop'`, which is what every step has always done. Reach for `'continue'` on a tier-ordered cleanup: a record the connected system will not delete because another document still points at it is a record to leave alone, not a reason to abandon the remaining tiers. It is a **delete step’s declaration only** — a refused write still ends the run, so declaring it on a write step is refused when you deploy rather than being quietly ignored. * **Read each delete’s outcome off the run’s own step ledger** — `disposition`, `refusal`, `pgRowId` and `externalId` on each step entry of `GET /v1/processes/runs/:runId`, exactly where a write step’s outcome already lands. Those are the route’s names; through PostgREST the same rows are `process_run_step`, whose own columns are `refusal_code`, `refusal_field`, `refusal_message`, `pg_row_id` and `external_id`. A removed record reads `confirmed`; a refused one reads `failed` carrying the refusal’s own code and sentence, whether the refusing party was your guardrail, a `before_delete` hook or the connected system. A delete the connected system refuses is un-hidden again, so the record comes back and the row explains why. * **Check `refusal` on a `confirmed` delete step, not only on a failed one.** A delete your instance HOLDS for an operator’s approval — which is what a stock instance does to every delete, since Turbobase classifies a delete as destructive — reads `confirmed` with `refusal: { code: 'SOURCE_AWAITING_CONFIRMATION', … }`: the record is hidden in Turbobase and queued, and the connected system has not been asked yet. Every other `confirmed` leaves `refusal` null. The run does not wait for the approval and does not fail; if your cleanup is meant to run unattended, add an `allow_with_audit` guardrail rule for the record types it deletes. * **Expect a delete step to pace itself to your instance’s delete cap.** It waits for the rolling window to free and carries on in the same run rather than failing part-way through, so a background cleanup no longer has to spread itself thin to avoid the circuit-breaker. A step still inside a shut window when its wait runs out stops the run with the cap named on it and nothing deleted — invoke the process again to carry on. * **Delete a process and the schedule that runs it in the SAME deploy.** `turbobase ext deploy --prune` now refuses to remove a process while a recurring job on the instance still names it, with a 400 naming the job, the process and the three ways out — and nothing from the plan is written, not even the additions ahead of the removal. Drop the entry from `turbobase/schedules.json` in the same commit as the process file; a job somebody created on the Scheduled tasks page instead of in your repo is deleted there, then re-run the deploy. A process no job runs prunes exactly as before, and so does one whose job was already dead before this release. * **Move a job onto another process in one deploy, rather than two.** A schedule is screened against the body it will have once the apply lands, so repointing it at a process you keep — or one the same bundle brings — and dropping the old process applies normally. Turning the entry into a chat-prompt or system job in the same commit works the same way. * **Declare a new process and its schedule in one bundle** — `POST /v1/config/apply` used to refuse that, because it resolved the process id against the instance as it was BEFORE the apply while its own refusal told you to “deploy the process in the same bundle”. It now reads the processes the deploy will leave behind, so the advice works and a first deploy of a scheduled process no longer needs two passes. * **Expect `turbobase ext export` to bring your schedules back.** `GET /v1/config/export` now returns the schedules a bundle wrote, which it never did: an export/apply round trip used to lose them, and once written nothing in the product could remove them. If you have been keeping `turbobase/schedules.json` by hand because the export came back empty, export once and diff it against your file before your next `--prune` — a schedule you never declared is now in the plan. * **Stop exporting the recurring jobs people created in the app.** Those rows (`system = false`, listed on the Scheduled tasks page) are no longer in the export, because a bundle entry carries neither their owner nor their audience — and a prune used to list one for removal and then remove nothing. They are the tenant’s data; manage them in the app. * **Delete those in-app jobs from a `bundle.json` you exported BEFORE this release, before your next deploy.** The old export wrote them into `turbobase/bundle.json`, and re-planning that file was a no-op only because the export returned the same rows on the live side. It no longer does, so each such entry is now planned as an add and lands as a second, platform-owned copy beside the person’s own job, firing on the same schedule — the copy is not listed on the Scheduled tasks page, which shows only the jobs people own. Drop the entries from `turbobase/bundle.json` (or `turbobase/schedules.json`); a copy already written is a bundle schedule, so removing the entry and re-running with `--prune` deletes it. * **Stop setting `TURBOBASE_DEBUG` to read the end of a refusal — and widen any script that matches on one.** A refusal the instance wrote (`{ error, reason }` — `invalid_bundle` from `ext deploy` / `ext promote`, `extension_invalid` from a module that will not compile) is printed whole by every CLI verb and every authoring-MCP tool, where the client used to cut it at 200 characters; the refusal above is 396 once its code is prefixed, so its ways out never arrived. A runaway reason — a compiler’s entire output — still stops at 2,000 characters, and a body that did not come from your instance (a proxy’s page, a framework 500) is still shortened to one line. * **Rename any bundle schedule whose `name` starts with `system.`.** That prefix is now reserved for the platform’s own recurring jobs — it is how a schedule the project declares is told apart from one the stack seeded — and a bundle using it is refused at deploy time rather than writing a row no export could carry and no plan could remove. A name that merely contains `system.` is fine. * **Restart your local instance after adding a recipe or an anatomy file.** `/v1/catalog/records` and `/v1/catalog/records/:type` used to re-read the filesystem on every request to decide which anatomy document and which recipes to attach to each record. They now resolve each record’s two lookups once per process. On a deployed tenant nothing changes — those files ship inside the image on a read-only filesystem and never move — but in a local checkout a `sources//recipes//.json` or a `sources//skills/anatomy/.md` you add while the instance is running will not appear in the catalog until you restart it. * **Stop caching the catalog listing on your side to avoid its cost.** The per-request filesystem walk behind it was 562 calls and about 32 ms on a published record set of 270; that is gone, and the listing is now cheap enough to call per page. Everything in the response that can change under a running tenant — whether a source is connected, whether data sync is on, whether write-back is entitled, and which records your capabilities let you see — is still read fresh on every call, so a listing you fetch now is as current as one you fetched before. * **Stop posting a second reply to correct a GitHub comment — edit the first one.** An addressed `POST /v1/data/comment` used to be refused `422 APPEND_ONLY`; it is now accepted and corrects the reply in place, on an issue, a pull request or a discussion alike. Address it by `externalId` (or `pgRowId`) and send the new wording in `body`; the record’s id does not move. If your app worked around the old refusal by posting a follow-up note, that code now writes the correction AND the duplicate — drop the follow-up. * **Take a reply down with `DELETE /v1/data/comment`**, which was refused for GitHub and now removes the comment upstream as well as tombstoning the row. There is no undo. A GitHub `issue`, `pull_request` or `label` delete is still refused, and the refusal now names what to do instead — close the issue, or remove the label in GitHub — in a `422 DELETE_UNSUPPORTED` rejection, where it used to be a `400` saying delete-to-source was “parked” for the whole source. Match on the code rather than on that sentence. * **Re-read any code that writes `channel_message` with an `externalId` set.** An addressed Slack write used to be accepted and POSTED as a second message; it now edits the message in place through `chat.update`, keyed on the `{channel}:{timestamp}` id the first write returned. This is the one change here your app cannot discover from a failure — there was never an error, only a duplicate — so if you have been relying on an addressed write appending, send it without an `externalId` instead. An id Turbobase did not mint (a `conduit:…` placeholder, say) is now refused rather than posted. * **Expect `delete` on `github:comment` from `list_record_types`.** The `ops` a record publishes are read per (source, record) rather than per source, so one GitHub record can report a delete the others do not. Slack messages do not report one yet: the edit is live, and the delete is held until the verb has been checked against a live workspace. * **Declare up to 48 tables in your manifest’s `tables` array — the ceiling used to be 32.** Nothing you have deployed stops working and no manifest needs changing: one within the old limit is accepted exactly as before. The ceiling is still enforced when you push, so a 49th table is refused with `422 turbobase.json: "tables" declares 49 entries; the limit is 48.` and none of that deploy is applied. Every other limit is unchanged — 64 fields per table, 64 relationships per app — so each table you add still has to fit those. ## v1.40.0 [Section titled “v1.40.0”](#v1400) * **Make the first `id: '…'` literal in every hook, conduit and process file that extension’s own id.** `turbobase ext deploy` (and `--to `) now refuses a file whose first literal names something else — a step, a constant, a comment — with `extension_id_mismatch`, and stores nothing; `turbobase ext lint` flags it first. A copy an older instance stored under the wrong id still exists: find it with `turbobase ext plan --to ` and delete it with `turbobase ext remove `. * **Expect an `eachRow` process to act on every visit to a row, and keep your `outstanding` predicate excluding finished rows.** When a later slice or a later run offers a row again, its update steps now write again and a step that declined runs `when` / `build` again. Create steps still run once per row `key`, and a crash re-delivery still resumes without writing twice. `GET /v1/processes/runs/:runId` step entries now carry `rowKey` and `slice`, with one set of entries per visit. * **Add the `write-a-process` skill to an existing project: re-run `turbobase apps init .` in it** — it writes the missing `.claude/skills/write-a-process/SKILL.md` and overwrites nothing. It points your agent at the process guide’s new migration recipe and the traps that look like platform bugs. * **Read the id of a `write_settle_conflict` record from `record_write_state.external_id`, and stop parsing it out of the message.** The message now names the settle verb (see the settle note) instead of telling the reader to set `external_id`, which the write boundary refuses. * **Send the `POST /v1/processes/:id` body bare — it IS the input.** A body wrapped as `{ "input": { … } }` reaches `ctx.input` and `eachRow.outstanding` wrapped. * **Start a process from a terminal or a script with `turbobase processes invoke --input ''`** (or `--input-file `; add `--sync` to wait for the outcome). It needs a personal access token carrying `extensions:deploy` whose owner is an administrator; the run writes as that owner. Drop any workaround that kept a browser session open only to invoke. * **`POST /v1/processes/:id` now accepts that same token as a Bearer.** A token owned by a non-administrator is refused `403 admin_required`, and one without `extensions:deploy` is refused `403` naming the scope — branch on those instead of the old `401 access_token_not_accepted`. * **Make the `input` of every `{ "kind": "process" }` entry in `turbobase/schedules.json` a JSON object that passes the process’s `input` schema.** Scheduled runs now validate it the way `POST /v1/processes/:id` does: input that is not an object, or fails the schema, fails the scheduled run with the field named and queues nothing. It used to be replaced with `{}` or passed through unchecked. Process schedules also stop failing with “not deployed” when the process is deployed. * **Undo a finished process run with `POST /v1/processes/runs/:runId/rollback` (or `turbobase processes rollback `).** It deletes, newest first, only the records that run’s ledger shows it created — never one an `update` step changed — and answers `202`. A run still `queued` or `running` answers `409`; one already `rolled_back` answers `200` with `queued: false`. Poll `GET /v1/processes/runs/:runId` until it reads `rolled_back` or `failed`. * **Stop sizing `guardrails.max_deletes_per_*` for a process rollback.** A run’s own rollback deletes now count against `guardrails.max_rollback_deletes_per_min` / `_per_hour` / `_per_day` (600 / 10,000 / 50,000 by default) and wait when over them instead of failing. Customer deletes keep exactly the old caps. * **Read a `rolled_back` run’s `reason` before assuming the source is clean.** It names the source deletes still being sent; a delete the source refuses restores its record and turns the run `failed` with the refusal named. * **Link a `write_settle_conflict` row with `POST /v1/data//settle`** (body `{ "pgRowId": "…" }`), or `turbobase data settle ` from a terminal, instead of asking support. It links the row to the id on its `record_write_state.external_id` — never one you send — and answers `200 { pgRowId, externalId, adopted: true, discarded }`. Where your app shows a blocked row with `reason_code = 'write_settle_conflict'`, offer this in place of “try again”, and still never re-save the row. * **Handle the two new `409`s** from that route: `NOT_SETTLE_CONFLICT` (the row is not in that state — nothing changed) and `SETTLE_TWIN_NOT_ADOPTABLE` (the inbound copy of the record was edited or has a write of its own, so it is left for a person to review). ## v1.39.1 [Section titled “v1.39.1”](#v1391) * **Drop any fixed delay between saving a file and sharing its link, and keep a retry on `404 content_unavailable` instead.** A file created through `POST /v1/data/file` is readable at `GET /v1/data/file/{external_id}/content` as soon as the write settles — the write records where the bytes went, instead of leaving that to the container sweep up to four hours later. One case still needs the retry: a connection that copies its files into the instance holds no bytes for a brand-new file until that sweep, and your app cannot tell which kind of connection it is on. * **Stop reading `404 content_unavailable` as “saved, but not swept yet” for a file you did not just create.** It now means the contents were genuinely never mirrored, and a file whose record somehow carries no pointer at all is served by deriving where it must be rather than reaching you as that error. ## v1.39.0 [Section titled “v1.39.0”](#v1390) * **Branch on `write_status: 'needs_input'` for an e-signature request the connected system refused over its own content, where you only handled `blocked`.** Any refusal DocuSeal answers with a reason of its own — a rejected party, a value its rules forbid, anything other than a missing template or a refused credential — used to settle `blocked` with the generic held text (“your team has been notified. No action is needed from you.”), which was the wrong instruction: nothing was queued to retry it and the only thing that clears it is the reader correcting the record. It now settles `needs_input` with `reason_code: 'SOURCE_REJECTED'` and `record_write_state.message` carrying what DocuSeal asked for, and its `sync_failure.category` moves from `source_blocked` to `source_validation` with it. **Render a correct-and-save prompt from the message rather than a support notice — and do not gate that prompt on a `record_write_state.fields` entry**, because this refusal names no column and the sheet is empty by design. Every other connector’s fieldless refusal still keeps `blocked` and the generic hold. * **Add `queryRows` to a replay fixture to exercise a conduit that reads.** A conduit declaring `reads` can now be replayed offline: put a `queryRows` map in the fixture JSON, keyed verbatim by the PostgREST path-and-query string your transform passes to `ctx.query`, and `turbobase ext replay --fixture` serves those rows to the isolate. A key you did not supply answers `[]`, and `--trace` prints one line per read — the string asked for, whether the fixture served it, and the row count — so run it once with no `queryRows`, copy the strings your conduit actually composed off the trace, and paste them in. That first run works even when the empty answer stops the conduit: `--trace` now prints the whole walk on a failed replay, with the error as its last line, where it used to print the error alone. The `from.when` gate gets the read too. Rows are served only to a conduit that declared `reads`; one that declared none still has no `ctx.query`, offline exactly as deployed. Replay does not mirror the declaration refusals (an undeclared resource or column, a filter outside `reads[].by`, the per-run budget) — those remain the engine’s. * **Read a GetSales custom field at `raw.gscf_` on `contact` and `company`.** Each value the workspace holds now arrives with the record and lands at its own top-level key of `raw`, so `entity?select=external_id,raw->>gscf_` works and so does a filter on it. The key follows the field’s own id, not its label, so renaming the field in GetSales does not move it; `raw.custom_fields` carries the whole set beside the hoisted keys. Before this the values were discarded as each record was read and no custom field appeared at all. * **Name `raw.custom_fields` to change one, and expect a 422 for a field the workspace does not have.** A write to `POST /v1/data/contact` or `/v1/data/company` sends the custom-field set to GetSales only when it names that column — it is a whole-set declaration upstream, so an unrelated edit no longer risks restoring a stale value. A set naming a field no mirrored GetSales row has carried is refused with `UNKNOWN_CUSTOM_FIELD` naming the field, rather than accepted and quietly applied to nothing; sync the workspace to pick up a field made since the last sync. Writing to GetSales stays held shut by `sync.bidirectional.getsales`, which still ships OFF. * **Redeploy any process that `turbobase ext deploy` refused with `400 process_step_not_writable` on a record type `list_record_types` reports `writable_now: true`.** `POST /v1/extensions` asked whether the record type was writable by bare name only, so it refused the shared-ledger records (`account`, `deposit`, `vendorCredit`) and every record type only one connected system defines (`receiveMoney`, `supplierCreditNote`, `issue`, …), while the write boundary accepted them. A step now deploys whenever a system connected to the instance can write its record type. No change to the process file: keep the bare `recordType` (`'account'`, not `'netsuite:account'`); to pin the system, return `source_system: ''` in the step’s `parent`. A step that is still refused names the record type and the connected systems in the CLI output instead of the bare code. ## v1.38.0 [Section titled “v1.38.0”](#v1380) * **Handle a refusal from `DELETE /v1/data/{recordType}` for a record whose own system cannot be deleted at.** On an instance with more than one system connected, a delete is now routed by the record’s own `source_system` rather than by the one the session signed in against — so a record belonging to a system whose delete support has not shipped is refused immediately, naming that system, where it used to be accepted and then silently reappear. The mirror case also changes: a delete your app used to see rejected because the session’s system is parked now goes through when the record itself belongs to a deletable one. * **Address a delete by `pgRowId` wherever an `externalId` is not unique to one connection.** `DELETE /v1/data/{recordType}?externalId=…` now answers `422 SOURCE_AMBIGUOUS` when that id matches rows in more than one connection or more than one system, instead of deleting whichever copy the session happened to be attached to. `pg_row_id` is on every row a PostgREST read returns. The same lookup is no longer narrowed to the connection the session signed in against, so an id that is unique across your connections now resolves the record it names even when that record sits in another one — where it used to come back `404`. * **Send `sourceSystem` on a delete when two of your connected systems spell one record type the same way.** `DELETE /v1/data/{recordType}` takes a new optional `sourceSystem` query parameter (`source_system` on the MCP `delete_record` tool), and it is the answer to `422 SOURCE_AMBIGUOUS` on the RECORD TYPE — today only `account`, which means a GL account under NetSuite and a customer under Salesforce or Zoho CRM, so with two of those connected the record type alone does not name a table. It is a qualifier and not a destination: it says which table to look in, the record’s own system still decides where the delete is sent, and a key that names no row in the table you qualified answers `404` rather than deleting another system’s copy. Single- system instances never see this refusal and need not send the parameter. * **Expect two more refusals from a delete, both of them immediate.** A record whose system has since been disconnected answers `422 SOURCE_NOT_CONNECTED` rather than being queued to the system the session belongs to; and a record whose own system cannot be read at that moment answers `422 ROW_SOURCE_UNRESOLVED`, which says to retry — it is not a statement that the record is missing. * **Stop resolving an append-only failure by hand for a record a conduit minted.** A conduit that bridges one record onto a different record type inserts the new row under a temporary internal id and clears it in the same transaction. That temporary id could reach the write-back as the record’s identity, so the target system was asked to EDIT a record it had never created — refused, and parked on the failures grid for a person. Such a record now leaves as a create, the way the create-only rule always intended. A `sync_failure` you see for one of these from here on means the write genuinely did not land. The create-only rule itself is unchanged: a record that really does carry an identifier its source minted is still refused as an edit, and still recorded. * **Read `source_response_lost` on a workflow-dispatch row as “a build may already be running”.** A `POST …/dispatches` that GitHub answers with a `5xx` or a `429` now parks `record_write_state` at `blocked` under that reason code on the FIRST attempt, with a message naming the repository, the workflow and the ref. It used to stay `pending` through six retries — each one a second real build in the customer’s repository — and then park under `write_failed_unexpectedly`. Do not re-send the dispatch from your app; the run mirrors in by itself if GitHub started one. * **Filter the failures grid on `github_http_`, not on `write_failed_unexpectedly`.** A GitHub `5xx` or `429` on any write-back now files `sync_failure.classification` named for the status — `github_http_503`, `github_http_429` — so a screen matching the catch-all stops seeing them. * **Stop matching `reason_code = 'write_failed_unexpectedly'` on a GitHub row.** Every OTHER GitHub write-back — an issue, a label, a comment — that GitHub answers with a `5xx` or a `429` now ends `record_write_state` at `blocked` under `HTTP_503` / `HTTP_429` instead, once the retries are spent, and files `sync_failure.category = 'source_blocked'` rather than `source_error` or `rate_limited`. The row still carries the generic held text and still clears on the user’s next save, exactly as before; only the code and the category moved. Read `message` rather than the code if your screen just needs something to show. * **Re-read a stored `sync_failure.root_cause_key`.** A request’s method and path no longer enter the key, so one source outage across many repositories, workflows or branches groups as ONE cause instead of one per record. A key held from before this release will not match the one the same failure computes now. * **Handle the new `reason_code` value `source_record_type_unknown` on `record_write_state`.** A write to a record type the connected system has no record of now lands `status: 'blocked'` with that code and a message saying which record type is missing, where it previously landed under the source’s own generic code (`ns_error` on NetSuite) with “Syncing is held — your team has been notified.” An app that branches on `reason_code` should treat it like the other blocked codes and show the row’s `message`: nothing on the record releases it, and `fields` is always empty. * **A write for a record type the connected system does not have is now REFUSED BEFORE IT IS SENT.** `POST /v1/data/:recordType` and every other door onto the pg→source path previously accepted it, attempted the write and recorded the source’s refusal minutes later; the write is now never sent, and the row is held with the code above on the first attempt. Nothing changes for a record type the catalogue names, or for one an active customization on the instance names — including a `customrecord_*` mirror, which is matched by its table as well as by its spelling. The hold clears by itself: once the record type exists in the connected system, the next write for it is sent with no retry to trigger and nothing on the row to edit. * **Expect `inline` from `GET /v1/data/file//content` for images, video, audio and PDF, and add `?download=1` wherever you meant a download.** The route used to answer `content-disposition: attachment` for every type, so a link you ship as “Download” now opens the file in place instead. Anything not on the render allowlist — including HTML, SVG, XML and a file whose type the connector never recorded — is still an attachment, unchanged. * **Use `fileUrl(row)` from `src/lib/turbobase.ts` to build a file’s URL, rather than composing the path.** It takes the mirrored-file ROW and adds `?connection=` from it; a hand-built path that leaves the connection out resolves on an instance with one storage account connected and 404s on one with two. `fileUrl(row, { download: true })` is the download form. Store that string on any record that points at a file — never the storage account’s own URL: it carries no host and no version marker, so it keeps resolving after the file is replaced. * **Set an `` or `
&master_row_id=eq.` returns each system’s `write_status` (`ok`, `pending`, `blocked`, `divergent`, `muted`) and, for a refused copy, the `last_error` that system gave. One copy being refused never costs you the others. * **Page `entity_canonical` on its new `pg_row_id` instead of on `id`.** The Atlas identity table now carries the same surrogate cursor every other paged relation on the platform does — NOT NULL, unique, added beside the `id` primary key, which is unchanged — so `turbobase project types` publishes a seek key for it and a `/rest/v1/entity_canonical` walk no longer has to fall back to ordering on the primary key. Re-run `turbobase project types` to pick it up. Nothing was removed and no existing query changes behaviour. * **Read the combined identity list over MCP as `turbobase:identity_`.** `list_records` now takes the seven identity record types the record catalogue has always advertised — `turbobase:identity_customer`, `…_vendor`, `…_contact`, `…_employee`, `…_partner`, `…_lead`, `…_item`, and the bare `identity_` form — and pages them with `page.next_cursor` like any other record type. They used to be listed in the catalogue and then refused as an unknown record type. They are read-only, and `get_record` refuses them by name: an identity is addressed by its own `id` rather than by a source-side id, so read it with `list_records` and match on `id`. * **Check `warnings` on a `list_records` read of a record type your other connected systems hold under a different name.** A plain `customer` read on an instance connected to several systems answers with the one that publishes that exact name — unchanged, and `applied.source_filter` has always said which — and the envelope now also names the other systems’ words for the same kind of row (`zoho_crm:account`, `getsales:company`) plus the combined `turbobase:identity_customer`. Rows and status codes are unchanged; this is a new entry in an existing array. * **Treat the `initialize` instructions’ “Records you can CHANGE” line as exactly the pairs `list_record_types` reports `writable_now: true` for.** Both surfaces are now one derivation over one population, so they cannot contradict each other; the line covers admin-added custom records too, which it never did. It states what can be changed at all — whether a system named on it is connected is the “Connected sources” line above it. When a read behind the block fails, both lines now say the posture could not be established instead of reporting that nothing is connected and nothing is writable. * **Subscribe to `entity_canonical` to watch the combined identity list, and write a `DELETE` handler for it.** The Atlas identity table is now in the `supabase_realtime` publication, so a `postgres_changes` channel on it delivers instead of reaching SUBSCRIBED and streaming nothing for ever — which is what a channel opened on the catalogue’s `table` value for a `turbobase:identity_` record type did until now. Frames are authorised by re-running the same read policy a direct read applies against the row being sent, so an insert or an update reaches you only for a bucket and a row you could already have read; no grant and no policy changed. **Unlike every mirrored table, this one really does hard-delete** — it carries no `deleted_at`, and an Atlas merge, a rejected decision, an admin repair or a disconnected connection removes the row outright. So the “deletes never arrive, reconcile instead” rule does not hold here: write a `DELETE` handler, and with default replica identity `payload.old` carries **only the primary key** (`id`) and no `entity_type`. Drop the row by `id`; a channel filtered on any other column can never match a delete frame. Keep the reconciliation sweep as well — a delete frame carries no bucket for that policy to re-check, so do not make dropping a row conditional on one arriving. * **Read `alsoCalled` and `combinedRecordType` off a `/v1/catalog/records` entry instead of asking the AI connector.** Each directory entry for a record that another connected system holds under its own name now carries `alsoCalled` — `["getsales:company", "zoho_crm:account"]` on a `quickbooks:customer` entry — and `combinedRecordType`, the `turbobase:identity_` list that consolidates all of them into one row per real-world thing. They are the same answer the MCP envelope’s `warnings` already gave, from the directory your app already reads on cold load, so a count that looks short no longer needs a second round trip to explain. Both keys are **absent** on an entry with nothing to say, so an entry you read today is byte-identical; test with `'alsoCalled' in entry` rather than for an empty array. Read them off the response rather than out of `types.ts`: like `writeCapability`, they are resolved live per request from what this instance has connected, so `turbobase project types` — which emits record shapes and seek keys — does not carry them. * **Label a GetSales contact or company by writing its whole tag set to `raw.tags`** in a `POST /v1/data/contact` or `POST /v1/data/company` edit (under `getsales`): tag ids to keep, minus the ones to remove; an empty list removes them all. A NAME in that list instead of an id is a tag GetSales does not have yet — it is created there as the record is written, reused if the name already exists (case ignored), and mirrored as a `tag` on the next sync. A `tag` still cannot be created on its own. An edit that does not name `raw.tags` leaves the tags alone. * **Create a GetSales company with `POST /v1/data/company`** under `getsales`, sending `display_name` (and `company_name`, the same name) plus `raw.list_uuid` — the list to file it in, exactly as a new contact needs. A create without a list, or with one this instance has never seen, is refused 422 (`NO_TARGET_LIST`, `LIST_NOT_MIRRORED`). It used to be refused as edit-only. ## v1.30.0 [Section titled “v1.30.0”](#v1300) * **Keep the `refresh_token` your native app now gets back from `/oauth/token`, and renew with it instead of signing the person in again.** The token response for `resource=https:///rest/v1` has always been specified to carry one and never did, because the instance never asked its identity provider for a renewable session; it does now. The bearer token still lasts eight hours, so an app that ignores the field meets the sign-in screen three times a day for no reason. When a renewal returns a fresh `refresh_token`, store that one and discard the one you spent; when it returns none, keep the one you just sent rather than dropping it. * **Treat an `invalid_grant` on a renewal as “start at `/oauth/authorize`”, not as an error to retry.** Renewal works for thirty days from the sign-in and use does not extend it, so a long-lived connection is refused on day thirty-one and has to sign the person in once. The refusal says so in its `error_description`. `/.well-known/oauth-authorization-server` lists `offline_access` under `scopes_supported` alongside the `refresh_token` grant it already advertised. * **Send only the six top-level keys `POST /v1/data/:recordType` reads — `parent`, `lines`, `pgRowId`, `externalId`, `idempotencyKey`, `expectedSyncVersion`.** Any other top-level key is now refused `422 UNKNOWN_BODY_KEY` naming it, where it used to be ignored: a body putting the record’s fields under `fields` or `data` was read as an empty `parent`, so the boundary created a row of NULLs and answered `200 { ok: true }`. If your app sends an unrecognised key today it starts failing on upgrade — which is the point, but check before you take the version. An empty body is refused the same way (`EMPTY_BODY`). Custom fields still go under `parent.raw`. * **Read `item.quantity_on_hand` instead of `raw.totalquantityonhand`.** The total on hand is now a promoted, filterable, real-time-fresh column on `turbobase.item`, and existing rows are backfilled on upgrade. **NULL means unknown, not zero** — a service, a discount and a non-inventory sale item keep no stock figure at all, and some accounts keep none on an inventory item either. Render a blank for NULL rather than a 0; a picker told “0” goes to an empty shelf. * **Group money on `invoice`, `credit_memo` and `estimate` by their new `currency_code`.** The portable ISO 4217 key is now on all three, beside `currency_id` and `currency_name`, resolved the same way as on `order` and `entity`, and backfilled on upgrade. `total` on these rows is the document’s own face value, so a per-currency total built on `currency_id` alone was a cross-currency sum on any NetSuite ledger. NULL when the currency could not be resolved — render the bare number, never a default symbol. * **Filter placeholder parties out of `entity` with the new `is_placeholder` column.** A transaction referencing a party the sync has not pulled mints a nameless FK-guard row so the foreign key holds, and until now nothing told an app that the row was one — so a portal rendered a customer with no name and could not tell it from a real party with a blank one. Read it as `coalesce(is_placeholder, false)`: NULL is a row written before the column existed. It is source-agnostic and maintained by the instance, so it clears by itself the moment the real party arrives. On upgrade the instance also re-fetches the placeholders whose party is inactive upstream; the ones it cannot fetch stay flagged and appear on the sync-failures grid. * **Stop working around blank `company_name`, `display_name`, `entity_id`, `email`, `phone`, `first_name` and `last_name` on `entity`.** The mirror only read the flat REST spelling of those fields, so every row written by the RESTlet / user-event path — the real-time and webhook path, i.e. exactly the records an app creates — landed with all of them NULL while the values sat in `raw.fields`. They are now read from both payload shapes, and existing rows are re-projected on upgrade, so an app can drop any per-account fallback that reached into `raw` for a customer’s name. * **Declare your native apps in `public/turbobase.json` → `native` and run `turbobase apps deploy` — that is now the whole setup.** The instance serves the enabled app’s current version in `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json` with no instance configuration; the operator trust list (`api.nativeAppTrust` / `NATIVE_APP_TRUST`) is gone. To stop vouching, disable the app or deploy a version without the block. An iOS id also grants the app the instance’s passkeys and saved passwords, and an Android package opens any link on the host, so declare only binaries you sign. ## v1.28.0 [Section titled “v1.28.0”](#v1280) * **Ship an app’s own agent skills as files: put each one at `skills//SKILL.md` (frontmatter `name` and `description`, then the body) and run `turbobase skills push --from skills --app `.** Each file becomes the skill `` in the instance’s skill registry with its authored name and description — the `description` is what an MCP client reads in `prompts/list` to choose it — and its body is replaced whole when the file changes. It lands as a DRAFT. Add `--publish` to publish exactly the skills that push wrote (new or changed); that needs a token carrying `skills:publish` as well as `extensions:deploy`, so keep it a separate secret from the deploy token. A slug already held by a skill your app did not write is refused (the command exits non-zero) rather than overwritten, an unchanged file writes and publishes nothing, and a deleted file deletes nothing — the orphan is reported and stays until you `skills unpublish` / `skills delete` it. The route is `POST /v1/apps/:slug/skills`; the app must already be deployed. * **Declare your app’s native twin under `native` in `public/turbobase.json` to give it a sign-in callback on the instance.** Native sign-in refuses a custom scheme, and until now an instance vouched for no app, so a mobile app had no https callback it could claim. Add `"native": { "ios": { "appIds": ["."] } }` (and/or `"android": { "packages": [{ "name": "", "sha256": [""] }] }`) and deploy, then ask for the same ids to be added to the instance’s trusted native apps list: the instance names only apps on it, and no deploy can add to it. It then serves `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json` naming that app, with its callback at `/oauth/app-callback/`. Only the iOS universal link is limited to that path — `webcredentials` reaches the instance’s passkeys and saved passwords, and an Android app link can open any link on the host — so trust only apps you sign. Register exactly that URL at `POST /oauth/register` and add `applinks:` and `webcredentials:` to the iOS app’s associated domains. A malformed id fails the deploy with the field named. The claim follows the LIVE version: a preview claims nothing until it is promoted, and dropping the block un-claims on the next deploy. * **Expect an https callback to be caught by the sign-in sheet on iOS 17.4 and later only.** `ASWebAuthenticationSession` matches an https callback by host and path from 17.4; on an older iOS the redirect loads `/oauth/app-callback/` in the sheet, which says to return to the app and never displays the code. Set the app’s deployment target to 17.4 rather than shipping a sign-in that cannot finish. * **Handle `422 SYNC_CONNECTION_PAUSED` on every write — a customer can now pause one source connection, and your writes to records that came from it are refused.** `POST /v1/data/:recordType` answers it on a create and on an update, for the connection the addressed row belongs to rather than for the whole source: a tenant holding two accounts of one source keeps writing through the other one throughout. The message is customer-ready and says to resume the connection, so showing `rejection.message` is enough — do not map this onto your bidirectional-sync copy, which sends someone to sales about a switch they flipped themselves. **Stripe and External API answer it too**, which is the pair that may surprise you: neither has a write entitlement for the pause to hang off, so neither previously refused anything at this boundary. Any source in that posture answers it — do not special-case by source id. * **Branch on the STATUS when a DELETE is refused — `DELETE /v1/data/:recordType` answers `409` with a prose `error` and no `rejection`.** A pause stops a delete for the same reason it stops an edit, but the delete route reports it as a conflict rather than a validation failure, and only a 422 carries the machine-readable `rejection` object — so there is no `SYNC_CONNECTION_PAUSED` to check for on this path, and a check written for one would never match. Read `error` and show it; the prose is customer-ready and names the resume, exactly as the write’s does. An unreadable pause check answers `403` here rather than a retryable code, so a refused delete is never worth an automatic retry. * **Retry `422 SYNC_CONNECTION_STATE_UNKNOWN` on a write; do not report it as a pause.** It means Turbobase could not read whether the connection is running, so the write was refused rather than guessed at. Nothing was sent and nothing was stored. Treat it as transient — a second attempt normally succeeds — and never fold it into the paused case, because telling someone their connection is paused when it is not sends them to a setting they do not need to change. * **Expect `_connection_paused` as a `sync_failure` classification, under category `source_blocked`.** An event already on the queue when the customer paused is refused before the source is called: a pause parks it as quarantined and resuming makes the retry succeed. An unreadable pause check (`_connection_pause_unreadable`) is retried instead and only lands a failure row once the attempts are spent. If you surface sync failures in your app, a parked one is waiting on the customer and a retried one is waiting on nothing. * **Branch on `400 record type "" is not writable` from `POST /v1/data/:recordType` when the record type is not one the connected system being written to actually has.** That write used to be accepted and the row saved — and then, depending on the table, either nothing was ever sent and the row stayed `pending` with no failure recorded anywhere, or the change was queued and the connected system refused it later as a held failure naming a record type that had nothing to do with it. The refusal is now immediate and nothing is saved. Read `/v1/catalog/records` for what each connected system can write — writability is a property of the PAIR, so a record type one system takes is not one every connected system takes, even where they share a table. `list_record_types` answers the same way over MCP, with no write operations offered for such a pair. * **Read one identity per real company or person from `/rest/v1/entity_canonical?select=*,entity_source_link(*)` instead of fetching both copies and merging them yourself.** Atlas has always worked out that a business in two of your connected systems is one thing; nothing you could build on was able to read that answer, so every app re-implemented the merge client-side and none of them could page a merged list server-side. One request now returns one row per identity with every source record behind it nested under `entity_source_link`, pages by canonical (so a page boundary never cuts an identity in half), and carries the exact total under `Prefer: count=exact`. To go the other way — from a source record you already hold to the identity it belongs to, and to the other records behind it — filter the link table and embed the parent with its members nested inside it: `/rest/v1/entity_source_link?connection_id=eq.default&source_system=eq.getsales&external_id=eq.&select=canonical_id,entity_canonical(*,entity_source_link(*))`. That nested embed is what saves the second request; drop it for `entity_canonical(*)` when the identity alone is all you want. Always filter on `connection_id` as well: the link’s key is `(connection_id, source_system, external_id)`, so two workspaces of the same source can each hold the upstream id you are asking about. **Both reads need the bucket’s own read capability** — `read:entity:customer`, `read:entity:vendor`, `read:entity:contact`, `read:entity:employee`, `read:entity:partner`, `read:entity:lead` or `read:item`. The graph was admin-only until now and is gated per bucket from here on, exactly as `entity` is: you see the identities whose member rows you could already read, and the member list is narrowed to those same rows. It fails quiet rather than with an error, so an app asking without the capability gets zero rows and a `200` and will render “no duplicates” — check the capability before you conclude the instance has nothing consolidated. **If your instance uses scopes as a confidentiality boundary, do not read a confidential field off the identity row.** An identity is visible to anyone who can read at least one of its member records, and while the member list under it is narrowed to the records that user can open, the identity’s own `display_name`, `primary_email`, `tax_id` and `domain` are whichever member Atlas recorded them from — so they can come from a record outside that user’s subsidiary, department or class. Read those fields from the member records instead — resolve the ids under `entity_source_link(...)` against `entity` (or `item`), where row-level security has already narrowed them. * **Re-generate your types (`turbobase project types`) to pick up `entity_canonical` and `entity_source_link`.** Both now appear on `/v1/catalog/records` — one entry per bucket, `identity_`, carrying the canonical’s columns with the member table named as its embed — so the generator emits a shape for each and a seek key for the link table. * **Expect the answer for some records to CHANGE on upgrade, in both directions.** Businesses that had listed a social profile, a shortened link or a free mail host as their website were being treated as one company; they are now separate, and a lookup that used to return a link may return none. People carrying no email or phone but sharing a name and an employer were separate identities; they now consolidate, and a lookup may return a different canonical id than it did before. Both are the correct answer and both are a different one — if you have cached a `canonical_id`, re-resolve it from the member id rather than assuming it is stable across this release. * **To let a person correct a merge from your own tool, write a decision to `POST /admin/atlas/decisions` — never a link.** An administrator’s `{ "kind": "apart" | "together", "a": {…}, "b": {…} }` about two source records is applied at once and obeyed by the matcher on every later sync; `GET` lists them and `DELETE /admin/atlas/decisions/` forgets one. Admin session only, and the same ledger the `/data/atlas` queue and the admin MCP tools (`atlas_match_decisions`, `atlas_decide`) write. A link written any other way is overwritten by the next sync. * **Create and edit Zoho CRM tasks, calls and meetings through `POST /v1/data/task` · `/call` · `/event`, and drop any workaround for a write that stayed `pending` for ever.** `/v1/catalog/records` already published all three as `writeCapability: 'write:activity'` and the write boundary already answered `200`, but nothing was ever sent — so the row committed, its `record_write_state` parked `pending` with no `event_id`, and there was no attempt to see and nothing to retry. The write now reaches Zoho and the row settles `write_status: 'synced'` carrying the Zoho id, with the sidecar cleared. Two consequences to handle: a value Zoho refuses now settles `needs_input` with `reason_code: 'source_value_not_accepted'`, a message naming the value, and a `record_write_state.fields` entry — a refusal these three records could not return before, so branch on it rather than waiting for `pending` to clear; and a row already stranded `pending` with no event is re-enqueued the next time the instance’s listener starts, so an app has nothing to re-send by hand. Writes stay gated per tenant by `sync.bidirectional.zoho_crm`, which defaults **off**, so expect a deterministic refusal until staff enable it. * **Read `write_status` on `/rest/v1/automation_run` to tell a run you started from one you are only watching** — the table now carries `write_status` and `idempotency_key`, the two columns every writable table carries. A run you dispatched with `POST /v1/data/workflow_run` reads `pending` until it reaches GitHub and `synced` once it has, carrying the run’s own id in `external_id`; one that went nowhere reads `needs_input` or `blocked`, and `record_write_state` says why. A run mirrored inbound is always `synced`. Until now the column did not exist, so a dispatch showed as settled the instant it was written and a run that never started looked identical to one that succeeded. * **Stop offering a retry on a dispatched run whose `write_status` is `needs_input` or `blocked`.** Those two are holds: Turbobase will not push the row again while it carries one, so a button that re-saves the same row does nothing. Surface the reason from `record_write_state` and let the reader start a fresh run instead. * **Call an outside web service by writing an `external_api:request` row, not by fetching from your app.** `/v1/catalog/records` gains `external_api:request` → table `request`, `readCapability: 'read:request'`. Create one with `POST /v1/data/request` (`parent: { connection_id, method, path, request_body?, request_headers? }`) and read the answer back off the same row at `/rest/v1/request`. The instance makes the call server-side with the connection’s stored credential attached, so nothing about that credential reaches your bundle or a browser. An administrator registers the service first — one CONNECTION is one service — so address it by `connection_id` and do not assume `'default'` exists. * **Ask a tenant admin for `write:request` and `read:request`; neither is in the standard capability bundle.** Creating a request spends the tenant’s own credential against an outside service, so the write is granted per user rather than carried by every session. Without `write:request` the create is refused 403 with the capability named in the body envelope; without `read:request` the row is invisible to `/rest/v1`. * **Poll the row’s `status` and branch on `reason` — a refusal is a state, never a thrown error or a dropped call.** `status` is `queued` → `succeeded` | `failed` | `refused`; `reason` is `null` on success and otherwise one of `host_not_allowed`, `timeout`, `cap_exceeded`, `non_2xx`, `network_error`, `connection_not_found`, `auth_not_configured`. On `cap_exceeded` the row carries `window_resets_at`, so wait for that instant rather than retrying in a loop. `elapsed_ms` is set on every attempt that left the process. * **Treat `response_body` as bounded and check `response_truncated` before you parse it.** A body is cut at 64 KiB and at most 32 response headers reach `response_headers`; `set-cookie` and the other credential-bearing headers are dropped entirely. `request_headers` mirrors what went out with the credential’s own keys replaced by `[redacted]`, so it is safe to show but is not the literal wire. * **A path that would leave the connection’s host is refused before anything is sent, and so is a redirect off it.** `path` resolves against the connection’s base URL — a relative value joins under its path prefix — and an absolute URL, a `//host` authority, userinfo or a backslash authority all land `refused` / `host_not_allowed` with no call made. A connection is confined to a HOST, not to a path prefix. * **Handle a new `422 SOURCE_AMBIGUOUS` from `POST /v1/data/:recordType`.** When your session’s own source cannot write a record type and exactly one other connected system can, the create now resolves to that system instead of being refused `400 not writable` — which is what lets a NetSuite session create a `request`. When more than one could, you get `422` with code `SOURCE_AMBIGUOUS`; send `source_system` in `parent` to say which. No create that succeeds today changes behaviour. * **There is no `sync.bidirectional.external_api` flag to wait for.** The connection is the entitlement: once an administrator has registered the service, its credential, its per-hour budget and its latency ceiling, writes execute. Do not branch on a feature flag for this source. * **On a Xero tenant, `POST /v1/data/:recordType` now reaches Xero for all thirteen writable record types instead of only writing the mirror — but only once staff turn write-back on for that tenant.** `customer`, `supplier`, `invoice`, `bill`, `creditNote`, `supplierCreditNote`, `payment`, `billPayment`, `purchaseOrder`, `item`, `manualJournal`, `spendMoney` and `receiveMoney`. The entitlement ships OFF, so an app that writes one of those today still gets the refusal it gets now, with `BIDIRECTIONAL_DISABLED` naming `sync.bidirectional.xero`. Branch on the refusal rather than assuming the write landed. * **Read the direction off `/v1/catalog/records` rather than hard-coding read-only for Xero.** Those thirteen of a Xero tenant’s fourteen record types publish `direction: "bidirectional"` and a `writeCapability`. `account` — the chart of accounts — stays read-only. * **Send a bill, credit note, supplier credit note, manual journal or bank spend WITH its lines, in the one call.** Those five commit the header and the lines together in a single transaction, so post `parent` and `lines` in the same `POST /v1/data/:recordType`. Do not create the header and then add lines: Xero refuses a bill, credit note, supplier credit note or manual journal that arrives with none, so a header saved on its own is a row that reads as stored and can never leave. Editing the lines means re-sending the whole set with the header — the set is replaced, not appended to. * **A bank receipt (`receiveMoney`) is the exception: post the HEADER only, and put its lines in `raw`.** `POST /v1/data/receiveMoney` with a `lines` array is refused 422 `LINES_NOT_SUPPORTED`, because a Xero bank receipt has no line table on this side — Turbobase mirrors it as a header and the write path reads its lines from the record’s own `raw` as `LineItems`, in Xero’s shape (`Description`, `UnitAmount`, `AccountCode`, …). Every other Xero record takes its lines the normal way. * **A direct canonical write still does not work, for any of the thirteen.** A write over `/rest/v1/*` or `rpc/write_transaction` from a browser session is refused `42501` at the grant layer, exactly as the build-apps skills page has said since the writer-role cutover: `INSERT`/`UPDATE`/`DELETE` are revoked from `authenticated` on every canonical table and only the write boundary can reach the role that holds them. `POST /v1/data/:recordType` is the door. This is unchanged by anything above. * **Check `writeCapability` off the catalog before drawing an edit affordance — all thirteen now ride a capability the built-in Standard role carries.** `write:deposit` and `write:vendor_credit` were the two exceptions while this change was in review; both joined the default bundle in the same release, with the NetSuite shared-ledger writes, and existing users receive them on their next sign-in. A hand-restricted user still will not have them, so read the session’s own capabilities rather than assuming the floor. * **Set a Xero line’s GL account in the right place — it is not the same column on every table.** `bill`, `supplierCreditNote`, `spendMoney` and `purchaseOrder` lines carry it as `account_name` (Xero’s `AccountCode`) and it is sent as written. `invoice` and `creditNote` lines do NOT: their line table is projected without an account column, so put the code in the line’s own `raw` as `AccountCode` and it will reach Xero. A line that sends neither is coded by Xero to the organisation’s default account, silently — set `item_name` (Xero’s `ItemCode`) or one of the two above on every line. * **Keep a line’s `external_id` when you re-send lines.** It is Xero’s `LineItemID` and it is now sent back, so an edit matches each line in place instead of making Xero mint fresh ids for the whole set. Drop it and anything holding a line id — a reference, a report, your own cache — points at a line that no longer exists. Never invent one: a new line simply has none. * **Stop sending totals to Xero.** `subtotal`, `tax_total`, `total`, `amount_paid` and `amount_remaining` are computed by Xero from the lines and the allocations and are never sent, so a write that sets them changes nothing upstream and the values you read back are Xero’s. Set the lines instead. * **Do not write a `status` of `PAID`, `VOIDED` or `DELETED` on a Xero record.** `PAID` is derived; the other two are Xero’s delete verb, which this release does not open — a delete against a Xero record is refused `UNSUPPORTED_OPERATION`. Settable statuses are `DRAFT` / `SUBMITTED` / `AUTHORISED` on documents, `AUTHORISED` on payments and bank transactions, and `DRAFT` / `POSTED` on manual journals. * **A Xero validation failure now names Xero’s own reason.** The error message carries every `ValidationErrors[].Message` Xero returned (for example `Account code 'Z99' is not a valid code for this organisation`) rather than a truncated envelope, so it can be shown to a user as-is. * **Creating a Xero record has required fields, and they are refused before any call.** An invoice, bill, credit note or purchase order needs a contact and at least one line; a payment needs the document it settles, a bank account and an amount; a bank transaction needs a contact and a bank account; an item needs its `item_id` (Xero’s `Code`); a manual journal needs a `memo` and lines. * **Create a supplier credit with `POST /v1/data/vendorCredit`, lines in the same call.** `vendor_credit` was read-only on NetSuite and is now writable end to end: the row persists against its own `pg_row_id` with `external_id` null, reaches NetSuite through the write path and settles to `write_status = 'synced'` carrying the id NetSuite assigned. Editing an already-mirrored credit works the same way, addressed by `pgRowId`. A CREATE must carry its `lines` array in the same request — NetSuite replaces a sublist in entirety and has no per-line endpoint, so a line-less create is refused with `ATOMIC_WRITE_REQUIRED` before anything is enqueued. * **Set `line_kind` on a credit line, or set `account_id` and leave `item_id` empty.** A credit fans its lines across NetSuite’s two sublists exactly as a vendor bill does: `line_kind: 'item'` for an item-coded line, `'expense'` for a GL-coded one. A line that names neither goes out as an item line and NetSuite refuses the whole record for carrying no item. Set `ap_account_id` to choose the payables account — it previously never reached NetSuite, so a credit posted to the subsidiary’s default. * **Create a bank deposit with `POST /v1/data/deposit`, and read its lines from the new `deposit_line` table.** `deposit` is writable, and its lines are the “other deposits” — money banked that is not a customer payment. Each line takes `entity_id`, `account_id`, `amount` and `memo`; the account is mandatory on NetSuite’s form, so a line without one cannot commit. The customer payments already banked on a deposit stay READ-ONLY and stay in `deposit.payment_summary`: sending them back would un-bank them, so that sublist is never written. `cash_back` is NetSuite’s own arithmetic and is dropped on the way out, while `cash_back_account_id` travels — posting a row you just read is safe. * **`deposit_line` holds the lines of deposits NetSuite sent you too, not only the ones your app created.** The table is filled on read from this release, and deposits mirrored before it are backfilled on upgrade. That matters when you EDIT a deposit rather than create one: NetSuite replaces a sublist in entirety, so the write sends every line under the deposit — if you add one line to a deposit whose other lines you cannot see, the ones you cannot see are the ones that come off it. Read the table before you write, and if a deposit’s lines look short of what NetSuite shows, its backfill has not run yet. * **`turbobase.deposit` now keys on `pg_row_id`, and `external_id` is nullable.** If your app addresses a deposit by `external_id`, it still resolves — the column keeps a UNIQUE carrying exactly what the primary key carried — but a deposit your app created has `external_id: null` until the write reaches NetSuite. Address it by `pgRowId` in the meantime, as with every other bidirectional record. * **Create and edit a chart-of-accounts entry with `POST /v1/data/account`.** Name, number, description, parent and inactive are all editable on an existing account. The TYPE is not: a patch that changes `account_type_id` on an account that already exists upstream is refused with a `FIELD_VALIDATION` error naming that column, before NetSuite is called, because NetSuite refuses the change on any account with postings and we cannot know whether it has any. A CREATE may set any type. `account.account_number` is now serialized by the bundle too, so it stays real-time-fresh rather than poll-bounded. * **Ask your administrator for `write:vendor_credit`, `write:deposit`, `write:account` or `read:deposit` if your app needs them.** All four are in the default standard bundle from this release and existing users receive them on their next sign-in, but a hand-restricted user will not have them. `read:deposit` is on that list because it was missing: a standard session could not read a deposit at all, even though the catalog published the capability. The matching `delete:` capabilities exist and are deliberately NOT in the default bundle — destructive operations stay with administrators. `GET /v1/catalog/records` now reports all three records as `bidirectional` with a `writeCapability`, which is the string to name when a write is refused. * **On a tenant with more than one ledger connected, check which ledger owns the row before you offer a save on these three.** All three canonical tables are shared — `turbobase.account` is every ledger’s chart of accounts, and `turbobase.deposit` and `turbobase.vendor_credit` are Xero’s as well as NetSuite’s — and writing them is a NetSuite capability, not a table capability. A row whose `source_system` is anything Turbobase only reads from is refused `400 record type "…" is not writable`, the same answer it gave before this release, while a NetSuite-owned row on the same table saves. `GET /v1/catalog/records` answers this per source and is what to branch on: it returns one entry per `(source, recordType)`, and the entry for the ledger that owns the row carries `writeCapability: null`. * **Take bundle 2.10.0 on the NetSuite account before your first write.** The integration role a customer install’s RESTlet runs as held these three records at VIEW, so a create raises `INSUFFICIENT_PERMISSION` until the new bundle is installed — and it passes in a sandbox driven as Administrator, which is not what your customers run. * **Read NetSuite cash refunds at `/rest/v1/cash_refund`, and their lines at `/rest/v1/cash_refund_line`.** Embed the lines with `cash_refund?select=*,cash_refund_line(*)` — the line table’s only foreign key to the header is `parent_pg_row_id`, so a bare embed resolves and needs no constraint hint. Needs `read:cash_refund`, which is in the default Standard bundle. * **Create and edit one with `POST /v1/data/cashRefund`.** Pass `created_from_id` naming a mirrored cash sale to raise the refund against that sale — NetSuite’s own conversion sources the lines and links the two — or omit it for a counter refund with no sale on file. `status`, `total`, `subtotal` and `tax_total` are computed by NetSuite and are stripped from the outbound payload; sending them changes nothing. Needs `write:cash_refund`. * **Take bundle 2.11.0 on the NetSuite account before your first write.** The integration role’s `TRAN_CASHRFND` grant ships with it, and a customer install’s RESTlet runs as that role rather than as Administrator — so on an older bundle the create is refused upstream with `INSUFFICIENT_PERMISSION` after the row has already been written here. Reads need nothing new. * **Send the WHOLE line set on an edit, not just the line you added.** The sublist is replace-mode: a patch carrying one line replaces all of them. Read the record’s lines back and post the full array. * **`turbobase.refund` rows now carry `record_type`**, naming the source’s own record type — `refund` (Stripe), `refundReceipt` (QuickBooks), `transactionRefund` (Shopify), `customerRefund` or `cashRefund` (NetSuite). If you key or de-duplicate refunds on `(source_system, external_id)`, add it: those two no longer identify a row on their own, because a source’s ids are unique per record type. Regenerate your types with `turbobase project types`. * **`GET /v1/catalog/records` lists `netsuite:cashRefund` as bidirectional.** A generated client and an app-template brief pick it up with no change on your side. * **Edit and create NetSuite employees with `POST /v1/data/employee`.** The record was read-only and is now writable end to end: a create persists against its own `pg_row_id` with `external_id` null, reaches NetSuite through the write path and settles to `write_status = 'synced'` carrying the internal id NetSuite assigned; an edit is addressed by `pgRowId` the same way. Name, email, phone, the inactive flag and the subsidiary are typed columns. `title`, `department`, `class` and `location` travel through the `raw` passthrough — they are not queryable columns, so filter on them in your app rather than in the request. A supervisor is `parent_entity_id`, which now reaches NetSuite as the employee form’s own `supervisor` field. * **Ask your administrator for `write:entity:employee` if your app changes staff records.** It is deliberately NOT in the default standard bundle, and it will not arrive on a sign-in: the ability to change a staff record is granted by name. `read:entity:employee` is unchanged and every member still has it. `GET /v1/catalog/records` now reports `netsuite:employee` as `bidirectional` with a `writeCapability`, which is the string to name when a write is refused. * **Do not send login access, roles, passwords or HR data — the write is refused whole.** `giveaccess`, `roles`, `accessrole`, `password`, `password2`, `sendemail` and `requirepwdchange` are refused with `FIELD_NOT_WRITABLE` naming the field, and so are pay (`salary*`, `compensation*`), `birthdate`, `socialsecuritynumber`, `taxidnumber` and the banking fields. Giving somebody a NetSuite login stays a NetSuite administrator’s act. The refusal arrives on your own request, before any row is written, and it refuses the WHOLE payload rather than stripping the field and saving the rest — so remove the field and send the request again. * **Send the fields you changed, not the row you read.** A mirrored NetSuite employee already carries `giveaccess`, `birthdate` and the rest in `raw` — NetSuite is asked for the whole record on every save — so reading a row and posting it straight back is refused on the first protected key it finds, even though you changed none of them. Narrow the payload to what you edited. What you never have to guard against is the reverse: the mirror’s own copy of these fields is dropped before anything is sent to NetSuite, so an edit can never push somebody’s date of birth back upstream. * **Employees from every other connected system stay read-only, and that is not a permission you can be granted.** `turbobase.entity` holds QuickBooks employees, Intercom admins, HubSpot owners, Ramp employees, Workday workers and Zendesk agents — six other sources — under the same `entity_type = 'employee'`. Writing one answers `400 record type "employee" is not writable` whatever capabilities the caller holds, because the answer is decided by the row’s own `source_system`. Check `source_system` on a row before offering an edit control for it. * **Read NetSuite support cases at `/rest/v1/work_item?item_type=eq.ticket`.** Scope by `source_system=eq.netsuite` when your tenant also connects a source that lands tickets on the same table — Salesforce’s and Zoho CRM’s Case, HubSpot’s and Intercom’s ticket all use `item_type='ticket'`. The case’s opening message is `description`, its subject is `subject`, and the customer it belongs to is `entity_id` / `entity_name` with `entity_type='customer'`. Needs `read:work_item`, which is in the default Standard bundle. * **Read the conversation from `/rest/v1/activity`, with a SECOND request — not an embed.** Filter `work_item_external_id=eq.&order=created_at.asc`. `turbobase.activity` carries no foreign key to `work_item`, deliberately, so `work_item?select=*,activity(*)` does not resolve and no constraint hint makes it. Each row’s `activity_type` is `case_message_incoming` (the customer wrote it) or `case_message_outgoing` (the business did) — that pair is how you draw the two sides of the thread. Needs `read:activity`. * **Raise a case with `POST /v1/data/supportCase`, and always name the customer.** Send `entity_id` — NetSuite refuses a case with no customer on it, and the row is written here before the refusal arrives, so the reply comes back as a `needs_input` write state naming `Company` rather than as a failed request. `subject` and `description` are the fields that carry the case’s title and opening message. Do not send `item_type`; the route stamps `ticket` from the registry. Needs `write:work_item`. * **Answer a case with `POST /v1/data/caseMessage`.** Send `work_item_external_id` naming the mirrored case and `body` carrying the reply. Do not send `activity_type` — the route stamps `case_message_outgoing`, the business answering. Needs `write:activity`. The record type is `caseMessage` and not `message`, which NetSuite calls it: Slack already publishes `message`, on a different table, so a request naming it would be ambiguous. * **Record the CUSTOMER’s own message by writing `turbobase.activity` directly**, with `activity_type='case_message_incoming'`. Both halves of a thread reach NetSuite and both settle, so a portal that logs what the customer said keeps the two sides in one place. Use PostgREST rather than `POST /v1/data/caseMessage`, which stamps the outgoing direction; the same `write:activity` capability covers it. Before 1.1 such a row was sent and never acknowledged, so it kept `write_status='pending'` and every later edit posted another copy on the case — if you wrote any, re-check them against the case in NetSuite before editing them again. * **Do not treat a reply’s `external_id` as a NetSuite internal id.** NetSuite keeps case correspondence on two surfaces, and the reply is written to whichever one that case already uses — a `message` record when the case has them, the case’s own message field when it has none. On the second the reply has no record of its own upstream, so it settles with a synthetic id of the form `:case-message:out` — or `:in` for the customer’s own message, since the case form holds exactly one of each. Both read back through the same `activity` rows and both are stable across re-syncs, so use `external_id` to identify a message and nothing else; an app that deep-links into NetSuite by it must check the shape first. * **Set a case’s priority through `raw.priority`, not the `priority` column.** The column holds the display label NetSuite returned (“High”) because that is what a ticket list renders, and NetSuite’s own field takes the option’s internal id. `priority` is therefore dropped from the outbound payload; sending it changes nothing, and `raw.priority` travels unchanged. `item_key` (the case number), `status` and `created_at` are NetSuite’s to compute and are dropped the same way. * **Take bundle 2.12.1 on the NetSuite account before your first write.** The integration role’s `LIST_CASE` grant at `FULL` ships with it, and a customer install’s RESTlet runs as that role rather than as Administrator — so on an older bundle every create and every reply is refused upstream with `INSUFFICIENT_PERMISSION` after the row has already been written here. That refusal now settles as `blocked` with reason `source_permission_missing` and quotes the permission the source named, instead of parking as a generic hold. Reads need nothing new. * **Read a case’s status back after answering a closed one; do not assume the reply moved it.** A reply to a closed case does reopen it — the status the case moves to is resolved from the account’s own cases, by finding one NetSuite has already returned under a “re-opened” status, so the id is always one that account really has. An account where no case has ever been re-opened resolves nothing yet, and the case keeps its status until one is or until the tenant pins `NS_CASE_REOPEN_STATUS`. Either way the reply itself always lands. * **`GET /v1/catalog/records` lists `netsuite:supportCase` and `netsuite:caseMessage` as bidirectional.** Regenerate your types with `turbobase project types`; a generated client and an app-template brief pick both up with no change on your side. * **Read a GetSales tenant’s activity feed at `/rest/v1/activity` with `source_system=eq.getsales`.** `getsales:activity` is a new row in `/v1/catalog/records` against table `activity`, under the same `read:activity` capability Zoho CRM’s and Salesforce’s activity rows already use — so an app that already holds it needs no new grant. `activity_type` carries the GetSales verb verbatim (`email_sent`, `linkedin_message_replied`, `note_left`, `pipeline_stage_changed`, …), not the cross-source `task` / `event` / `call` vocabulary, and `body` is a one-line summary Turbobase renders — the full upstream payload is in `raw`. Read-only: a write against the couple is refused `READ_ONLY`. * **Join an activity to the person or organisation it is about through the new `activity.entity_external_id`, and re-run `turbobase project types`.** It is nullable and matches `entity.external_id` within the same `connection_id`. Every GetSales activity hangs off a contact or a company and none off a work item, so `work_item_external_id` is null on all of them — a query that reached for the work-item pointer to find an activity’s parent will return nothing. The column is source-agnostic and empty for every other adapter until one projects into it. * **Do not present a GetSales tenant’s mirrored activity as a complete history, or use it for lifetime totals.** The first pass over the history that was already there when the workspace connected reaches only as far back as the source will page — on a busy workspace that stops short of the beginning, and no row, column or flag marks where it stopped. The shortfall is written to the instance’s log and nowhere an app can read it, and the mirror does not go back for it later. Label such a view from the oldest row you can actually see rather than calling it “all activity”, and read a per-person count as a floor. * **Do not build a live activity feed on it.** GetSales has no webhook, so the four-hourly sync is the only ingress: an activity logged upstream appears in `activity` on the next tick, up to four hours later, and `created_at` is the upstream time rather than the time it arrived. Subscribing to realtime changes on the table gets you the tick, not the event. * **Create a GetSales contact by POSTing `/v1/data/contact` with the list in `raw.list_uuid`** — for example `{ "parent": { "display_name": "Dana Okafor", "email": "dana@globex.example", "raw": { "list_uuid": "9c2e7a10-…" } } }`. This create was refused outright before. GetSales files every contact in a list and there is no default, because the list decides which automations start working on that person, so a create is refused `422 NO_TARGET_LIST` naming `raw.list_uuid` when it names none, and `422 LIST_NOT_MIRRORED` when it names a list this instance has not mirrored — sync the workspace, or check the id. A create that identifies nobody — no name, no email, no phone — is refused `422 INCOMPLETE_CONTACT_CREATE`. All three answer before anything is saved. Add `raw.company_uuid` to link the contact to the company they work at. `list_record_types` lists `raw.list_uuid` under `required_inputs`, and it is still gated by `sync.bidirectional.getsales`, which ships OFF — read `writable_now` before offering the create. `company` and `tag` remain edit-only. * **Re-pace any unattended `/mcp` client — the allowance is now 2,000 requests per minute, up from 600.** An automation that writes one row per call and sleeps to fit inside a window can cut that sleep to under a third, and finish the same work in roughly a third of the wall-clock. Nothing else about the endpoint changes: one bucket still covers reads and writes together, it is still counted per instance process rather than per connected client, and a refusal is still a `429` carrying `Retry-After` and the `-32000` JSON-RPC envelope with the numbers repeated in `error.data`. **Pace against `X-RateLimit-Limit` and `X-RateLimit-Remaining` on the responses you are already getting, not against this figure** — you share the window with every other caller on the same process, and it is the headers that know who else is calling. The raise reaches an instance on its next upgrade. ## v1.27.0 [Section titled “v1.27.0”](#v1270) * **Set a Zoho field Turbobase has no column for by writing `raw.` on an UPDATE to `POST /v1/data/:recordType` — and audit what your app already puts in `raw` on a Zoho-sourced row, because those keys now reach Zoho.** Only the keys a write NAMES travel, under Zoho’s own api\_name (`raw.Industry`, and the whole mirrored record is never echoed), and a key this organisation’s layout does not carry or that Zoho maintains is dropped while the rest of the write still goes. Where a mapped column and a `raw` key address the same Zoho field, set the COLUMN — it wins. If your app was using `raw` on a Zoho row as local scratch space, move that to a column or a table of your own before upgrading: it used to stop at Postgres and no longer does. * **On a CREATE a `raw` key is NOT sent** — a create carries no changed-field hint, so only the mapped columns (and a custom module’s `cf_zc_*` columns) travel. Set a field with no column of its own in a follow-up update, after the create has come back with its Zoho id. * **Write to a Zoho custom module’s records by addressing the record type Turbobase mirrors it under (`zc_`) — updates and creates both.** They were refused `record type "…" is not writable`; a create now comes back with the id Zoho minted, stamped onto the row. Two new refusals to branch on, both `422` with `rejection.code`: `CUSTOMIZATION_NOT_ACTIVE` while the module’s table is still being built (retry after the next reconcile — it is not a bad request), and `SOURCE_NOT_CONNECTED` when Zoho has been disconnected, which is terminal until someone reconnects it. Deleting a custom module’s records from Turbobase is still not supported. ## v1.26.0 [Section titled “v1.26.0”](#v1260) * **Handle `record_write_state.reason_code = 'write_settle_conflict'` as “the record exists — do NOT save it again”.** It is the one blocked ending where the write demonstrably landed: the connected system created the record and handed back its id, and only the step that links that id onto your row failed. The row therefore reads as unsynced over a record that already exists, and a re-save creates a second one in the customer’s account. The `message` names the id — render it, and suppress any “try again” affordance your screen offers on a `blocked` row for this code alone. Nothing is re-queued; someone has to set the row’s external id to the id in the message. * **Send `raw.repository` as `owner/repo` on every GitHub issue you create through `POST /v1/data/issue`,** with the title in `subject` — for example `{ "subject": "Widget spins backwards", "raw": { "repository": "acme/widgets" } }`. A create missing either is now refused with `422` before anything is saved (`NO_TARGET_REPOSITORY` naming `raw.repository`, or `INCOMPLETE_ISSUE_CREATE` naming `subject`), and a repository that is not `owner/repo` is refused as `INVALID_TARGET_REPOSITORY`. Before, such a create answered `200` and the issue never opened. There is no default repository, and the one you name must be one the GitHub App installation selected. `list_record_types` lists both under `required_inputs`. * **Read `writable_now` before writing one of the nine newly writable record types, and expect `false` until staff enable the source.** `zoho_crm`‘s `call`, `event`, `task`, `product`, `quote` and `quote_line`, and `getsales`’ `contact`, `company` and `tag`, now publish a write posture to `/v1/catalog/records` and to MCP `list_record_types`. Every one is gated by `sync.bidirectional.`, which ships OFF on every tenant, so an app that branches on the field keeps working unchanged and starts writing the moment a tenant is entitled. * **Write a Zoho quote’s lines by POSTing the QUOTE with its `lines`, never `POST /v1/data/quote_line`.** Zoho publishes no line module: the items ride `Product_Details` on the quote’s own call, so `quote` commits header and lines in one atomic write and a direct line write is refused as not writable. Omit `lines` to edit the header alone; an empty array does the same thing and is NOT how you clear a quote’s items. Zoho reads an empty `Product_Details` as “delete every line”, so Turbobase never sends one — there is no way to clear a quote’s lines from here today. * **Expect `POST /v1/data/` to refuse a CREATE for `getsales`’ `company` and `tag`, and to accept an edit.** GetSales files a new record in a list and its API requires that list’s id, which Turbobase does not mirror, so the create is refused with a sentence naming where the record does come from. Create it in GetSales and edit it here once it has been mirrored. A `contact` is the exception and can be created — see the note about `raw.list_uuid`. * **Expect a Zoho CREATE of a quote, an event or a call to come back `needs_input` naming a field you did not send.** Each needs a value Turbobase has no column for — a quote’s `Subject`, an event’s `End_DateTime`, a call’s `Call_Duration` and `Call_Type` — so Zoho refuses it with its own `MANDATORY_NOT_FOUND` rather than Turbobase inventing one. Editing a mirrored record is unaffected. * **Re-read the record counts for `zoho_crm`’s `call`, `event`, `task` and `product` if your app displays them.** Each now counts only its own rows rather than every row in the shared table it lands on, so a Zoho task reports the tasks instead of every activity. * **Create a stock adjustment with `POST /v1/data/inventoryAdjustment`, lines in the same call.** `inventory_adjustment` was read-only and is now writable end to end: the row persists against its own `pg_row_id` with `external_id` null, reaches NetSuite through the write path and settles to `write_status = 'synced'` carrying the id NetSuite assigned. Editing an already-mirrored adjustment works the same way, addressed by `pgRowId`. A CREATE must carry its `lines` array in the same request — NetSuite replaces the sublist in entirety and has no per-line endpoint, so a line-less create is refused with `ATOMIC_WRITE_REQUIRED` before anything is enqueued. * **Send ONE quantity per line, and send the one you mean.** A line may carry `adjust_qty_by` (move the count by this much) or `new_quantity` (set the count to this). If it carries both — which every row mirrored from NetSuite does, because NetSuite computes each from the other — the relative one is sent and the absolute one is dropped. To set an absolute count on a mirrored row, clear `adjust_qty_by` first. `quantity_on_hand` and `estimated_total_value` are NetSuite’s own readings and are never written back, so posting a row you just read is safe. * **Ask your administrator for `write:inventory_adjustment` if your app creates one.** It is in the default standard bundle from this release and existing users receive it on their next sign-in, but a hand-restricted user will not have it. `delete:inventory_adjustment` exists and is deliberately NOT in the default bundle — destructive operations stay with administrators. `GET /v1/catalog/records` now reports this record as `bidirectional` with a `writeCapability`, which is the string to name when a write is refused. * **Give a lot- or serial-tracked line its `inventory_detail`.** Same rule as the item sublist on an order: NetSuite refuses the whole record without the assignments, and an adjustment is the record that creates the quantity. ## v1.25.0 [Section titled “v1.25.0”](#v1250) * **Pace an MCP client against the `X-RateLimit-*` headers on `/mcp` instead of a delay you fitted by trial and error.** Every response from that endpoint carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (whole seconds), and a refusal adds `Retry-After` equal to `-Reset`. They were always sent; what is new is that they are published, so they are now something to rely on. The allowance itself is **2,000 requests per minute** — one bucket shared by reads and writes, counted per instance process rather than per connected client, so a thousand-row import now finishes inside one window. `docs/tenant/mcp-server.md` carries the whole account and is held to the code by a fence, so read the number there rather than hard-coding this one. * **Read a `/mcp` `429` out of `error.data`, not the old top-level `message` — this is a breaking change to that one response.** The refusal is now the same JSON-RPC 2.0 envelope every other refusal on that endpoint returns: `{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":…,"data":{"limit":2000,"remaining":0,"reset_seconds":34}}}`. The flat `{"statusCode":429,"error":"Too Many Requests","message":"Rate limit exceeded, retry in 45 seconds"}` it used to return is gone, so any client reading `statusCode` off the body, or parsing the seconds out of the prose, must move to `error.data.reset_seconds`. This matters most for a client speaking MCP through a library: it is handed a message and never sees an HTTP header, so the body is the only place it can learn the budget at all. Match on `error.code === -32000`, which is this refusal and nothing else. * **Stop treating the edge rule as your instance’s only rate limit.** `/build-apps/reading-data/` said there was one limit and that `Retry-After` was never sent; both were only ever true of the Cloudflare edge budget. `/mcp`, `/v1/batch`, the auth endpoints and the webhook ingress each carry their own counter inside the instance on top of it, and those counters do report `Retry-After` and a live remaining count. The page now says which layer each statement is about. * **Branch on `422` / `UNKNOWN_COLUMN` from `POST /v1/data/:recordType` when a save names a field the record has no column for — not on `500` `write_failed`.** That refusal was only ever reaching apps correctly for a handful of record types; for every other one — every custom record, and `invoice`, `entity`, `item` — the same mistake came back as an opaque server error naming nothing, so there was no way to tell a typo’d field from an outage. It now answers `{ code: 'UNKNOWN_COLUMN', field, message }` for all of them, naming the field and the record type, and the addressed row is left unchanged. Render `field` against the offending input rather than showing a retry; the same payload will keep being refused until the field is dropped or the column exists. * **Retry a `POST /v1/data/:recordType` create with the same `idempotencyKey` and read the row off the response — it no longer fails.** The documented guarantee (“retries that carry the same key dedupe and return the original row”) was not honoured on a create: the second submission of a key the first had already committed came back as a generic failure with the cause withheld, so a client retrying after a lost response failed on every attempt. It now answers with the row the first call created, in the same envelope a first-time write returns, and the record is not queued for the connected system a second time. If your app guards against that failure — skipping the retry, or treating it as fatal — you can drop the guard, provided the role making the call can also READ that record type: the row is re-read as the caller, so a role granted write without read still gets the old failure. A key that has not been used before is unaffected. * **Sign a person in from a native app by registering at `POST /oauth/register` and naming `resource=https:///rest/v1` on `/oauth/authorize` and `/oauth/token`.** That resource indicator is new and it decides which credential you are handed: name it and `/oauth/token` returns the instance’s own session token — the same RS256 JWT a browser session carries — which `/rest/v1` (as both `apikey` and `Authorization: Bearer`), `POST /v1/data/:recordType` and `POST /v1/batch` all accept, scoped to that person’s capabilities and scopes. Omit it and nothing changes: you get the brokered upstream token for the MCP server, exactly as before. The two are listed as `resources_supported` on `/.well-known/oauth-authorization-server`. The token lasts eight hours and renews with `grant_type=refresh_token` for as long as the person’s approval stands. * **Register an https callback your app’s own domain claims — an iOS universal link or an Android app link — not a custom scheme.** `com.example.app:/cb` is refused with `invalid_redirect_uri`, and that is what most mobile OAuth libraries default to, so set the redirect explicitly when you configure the client. A desktop or CLI client registers `http` on `127.0.0.1` or `localhost`, on any port, unchanged. The refusal message now names both accepted shapes. * **Send the session token as `Authorization: Bearer` from a native app on `POST /v1/data/:recordType` and `POST /v1/batch`.** Those two routes took the session cookie only; they now take the cookie or a bearer, because an app has no browser to keep a cookie in. Nothing else changed about them — same verification, same RLS, same refusals — and `/v1/catalog/*` still treats a bearer as a personal access token and still 401s a session JWT presented that way. * **Expect `/.well-known/oauth-authorization-server` and `POST /oauth/register` to answer on an instance with the MCP server switched off.** They used to 503 with the rest of `/oauth/*`. Mobile sign-in has no switch of its own and is not behind MCP’s, so discovery and registration are always available; an `/oauth/authorize` or `/oauth/token` naming the MCP resource — or naming none, which means MCP — still refuses `503 mcp_disabled`, and the metadata document stops advertising the MCP resource while it is off. * **Point people at Account → Connected apps rather than “MCP access”.** The tab is renamed, because it now lists native apps beside MCP clients, and each row says which it is. The revoke action, its URL and its response are unchanged. * **Send `raw.channel` and `body` on a create of `slack:channel_message` — `POST /v1/data/channel_message` now refuses the call when either is missing.** Both were always mandatory and were enforced by the connector after the write had been queued, so a create with no channel answered `200` with a `pg_row_id`, nothing was posted, and the refusal surfaced minutes later on `record_write_state` as a held row that named no field to fix. It is now an immediate `422` carrying `error.code` (`NO_CHANNEL` / `NO_TEXT`) and `error.field` (`raw.channel` / `body`), no row is written, and nothing is queued. `raw.channel` takes a channel name or an id; it is the only key that addresses the post, so an id parked on another column — `work_item_external_id` — is stored and ignored. Same behaviour through `write_record` on the MCP data server, where it comes back as a tool error in the same turn. An app that already sends complete creates is unaffected; one that relied on the `200` must read the refusal on the call. Distinct from the existing `CHANNEL_NOT_MIRRORED` refusal, which is a channel name the workspace directory does not hold. * **Read `required_inputs` for `slack:channel_message` on `list_record_types` instead of hard-coding what a post needs.** The entry now publishes `raw.channel` and `body` beside `ops`, from the same declaration the write boundary refuses on, so an app that supplies everything published cannot be refused for something it was not told about. * **Expect `needs_input` rather than a held row when a Slack post is refused for a missing channel on a path that skips the boundary** (a conduit, a replay). `record_write_state` now reads `NO_CHANNEL` with `raw.channel` on `fields` and says what is missing, where it previously read “Syncing is held — your team has been notified. No action is needed from you.” with an empty fix sheet. The matching `sync_failure` row moves from the `source_blocked` bucket to `source_validation`. ## v1.24.0 [Section titled “v1.24.0”](#v1240) * **Declare `reads` on a conduit that has to look something up, and read it with `ctx.query(...)`.** Each entry is `{ recordType, columns, by?, source?, maxRows? }`, and the declaration is the whole boundary — a resource, a selected column or a filtered column that is not declared is refused by name, as are `select=*`, embeds and any operator outside `eq, neq, gt, gte, lt, lte, like, ilike, is, in`. `q` is one PostgREST path-and-query, the same shape `defineProcess`’s `ctx.query` takes. A conduit that declares nothing has no `ctx.query` at all, so nothing changes for one you have already written. * **Cap a conduit’s reads with `maxQueriesPerRun` (default 8, max 32) and a per-read `maxRows` (default 50, max 500).** The query budget belongs to the RUN, so a `sequence` shares one across every entry rather than earning a fresh budget per target. * **Stop adding the type discriminator and the connection filter to a conduit’s reads yourself.** The engine applies both to every `ctx.query` — a read of `channel_message` is narrowed to `activity_type = 'message'`, and a read of a connection-scoped table to the firing connection — so a query that omits them is correct rather than over-broad. * **A two-way conduit pair no longer needs your own loop break.** The opposite-direction guard now recognises an echo by the master as well as by the id, and the cycle budget reads a record’s hop off the `conduit_event` ledger instead of trusting the sync seam, which always said zero. If you wrote a break of your own against the old behaviour, it is now redundant rather than wrong. * **`ctx.bindings[].externalId` is populated for a replica binding once the write-back confirms it.** It was always `null` before, because nothing called the stamp; branch on it rather than treating a bound source as unidentified. ## v1.23.0 [Section titled “v1.23.0”](#v1230) * **Stop treating `write_status = 'pending'` as “still in flight” — a write that never reached the connected system now ends `blocked`.** It used to stay `pending` for ever once the retries were spent, so a screen that waited for it to clear waited on nothing. Handle the two new `record_write_state.reason_code` values and render the row’s `message`: `source_unreachable` (the connected system could not be reached, so nothing was sent — the reader’s next save is what re-sends it) and `write_failed_unexpectedly` (the write failed in a way nothing recognised; the generic held text applies and the team has the failure). * **Expect a `blocked` row on an UPDATE and a DELETE whose write was lost, not only on a create.** `source_response_lost` previously stamped a create alone; an update or delete whose response never came back is now stamped with the same reason code once its attempts are spent, and its message is about whether the CHANGE landed rather than whether the record exists. * **Expect `422 SOURCE_NOT_CONNECTED` on a create naming a system your instance has not connected, if the only thing it had connected was Slack.** The write boundary can only judge a `source_system` once it has seen at least one connection: with none recorded it cannot tell a wrong name from a right one, so it accepts every name. On an instance whose Slack workspace was installed before v1.21.0 nothing was recorded at all, and a create naming any system was accepted and left with nowhere to sync. The instance now records that connection by itself on its next restart, so the same create is refused, with the connected list in the message. v1.21.0 announced this for a workspace installed after it; this is the same change reaching the workspaces installed before it. Send a system from the connected list, or omit `source_system` to use the one your session signed in against. * **Stop treating `SOURCE_NOT_CONNECTED` on a Slack `channel_message` as “the customer must reconnect their workspace”.** On an instance whose workspace predates v1.21.0 that refusal was never true — the workspace was installed and mirroring — and reconnecting was the only remedy it implied. After the restart that repairs the connection, the same create is refused `SYNC_BIDIRECTIONAL_DISABLED` instead, which names the real gate: writing back to Slack is a per-tenant entitlement our staff turn on. Both are `422` and neither writes a row, so an app that only surfaces the message needs no change. * **Match `record_write_state.reason_code` on `source_changed_since_read`, not `WRITE_CONFLICT`, for a connected item somebody else edited first.** A write refused because the record moved in the connected system since Turbobase last read it now carries that reason code and a message naming what the reader can do — open the record again and re-apply the change. It was reaching apps as `WRITE_CONFLICT` with the generic held text (“your team has been notified. No action is needed from you.”), which was the wrong instruction: nothing was going to clear it except the reader. * **Expect ONE `record_write_state` row per refused connected-item write, not two.** A refused write used to open a second row for the same record under the source’s own id, saying `needs_input` where the row your app reads said `blocked`. A screen that showed both, or that took the first row it found, was showing whichever arrived first; there is now one row, keyed on the record’s `pg_row_id`. * **Stop shipping Approve, Reject, Submit for approval or Mark cancelled as a live button if your app took it from the time bill, expense report or sales order recipe.** All six wrote the row’s `status`, which NetSuite recomputes for itself and strips from every write — so the button reported success and the customer’s change never left Turbobase. They now ship as `kind: 'noop_today'`, which the chat grammar already renders disabled, and each keeps its label so the operation is still advertised. An app that renders the grammar itself needs no new code; an app that hard-coded one of the six should disable it or drop it. * **Check a `patch_status` value against the connected source before you ship the button.** The rendered recipe action line now names the column the action writes and says the value has to be one that source accepts. Turbobase carries no list of accepted values for any source, so the record’s anatomy is where to look — and a column the source derives refuses every value rather than disagreeing with one. * **Set a Zoho CRM deal’s `Stage` to a stage that deal’s own Pipeline carries.** Zoho scopes the stage list per Pipeline, not per organisation, so a stage from another Pipeline is refused even though both deals sit in the same organisation. The refusal comes back with `failure_cause: source_value_not_accepted` naming `Stage` and the value sent. * **Send `template_external_id` and `raw.submitters` on a create of `esignSubmission`, and `body` and `raw.accounts` on a create of `social_post` — `POST /v1/data/:recordType` now refuses the call when one is missing.** These were always mandatory and were enforced by the connector after the write had been queued, so an incomplete create answered `200` and failed minutes later on `record_write_state`. It is now an immediate `422` carrying `error.code` and `error.field` (`MISSING_REQUIRED_FIELD` / `EMPTY_TEXT` / `NO_ACCOUNTS`), no row is written, and nothing is queued. An app that already sends complete creates is unaffected; one that relied on the `200` must read the refusal on the call. Same behaviour through `write_record` on the MCP data server. * **Read what a create needs from `required_inputs` on `list_record_types`, instead of hard-coding it.** Every entry carries the field paths a create requires beside `ops` — `[]` where none, never absent — and it is the same declaration the write boundary refuses on, so an app that supplies everything published cannot be refused for something it was not told about. * **Call `get_write_state` when a write comes back parked.** New read tool on the data MCP server: pass the `pg_row_id` `write_record` returned (or an `external_id` once the record has one) and it answers `{found, status, reason_code, message, fields, attempts, updated_at}` — the source’s own recorded reason and the fields to fix. It is present whether or not the instance has MCP writes enabled, because a parked row outlives the setting that produced it. `write_record`’s description already told callers to read the source’s diagnosis off the failure; this is the tool that does it. * **Edit a DocuSeal signer with `POST /v1/data/esignSubmitter` addressed by `pg_row_id` or `external_id`.** `docuseal:esignSubmitter` is now writable as an UPDATE — `list_record_types` reports `ops: ["update"]` for it — and the edit reaches `PUT /submitters/{id}`. A create is still refused, now with `EDIT_ONLY` and the reason, where it previously came back `400 "not writable"`: an app that branched on that status for this record type should read `error.code`. * **Branch on `write_status: 'needs_input'` for an e-signature request whose template the connected account no longer has.** That refusal used to arrive as `blocked` with the generic held text (“your team has been notified. No action is needed from you.”), which was the wrong instruction — the only thing that clears it is the reader changing `template_external_id`. It now settles `needs_input` with `reason_code: 'source_value_not_accepted'`, a message naming the refused id, and a `record_write_state.fields` entry carrying `{ field: 'template_external_id', label: 'Template External Id', submitted: true, requested: '' }` — so offer an edit and re-save rather than a support link. Every other e-signature refusal keeps `blocked` and the generic hold. * **Stop editing a sent signature request — `POST /v1/data/esignSubmission` addressing an existing row now answers `422 APPEND_ONLY` where it answered `200`.** It applies to both addressing keys, `pgRowId` and `externalId`, and the refusal arrives before anything is written locally. The e-signature provider publishes no update verb for a request that has gone out, so the write was always going to be refused — it was just being refused in the background, after your copy had already changed and stopped matching the document your counterparty was signing. **Send a new request instead**: the create is an ordinary `POST` and is unchanged. Changing a party on the request that already went out is done in DocuSeal, not from your app — `esign_submitter` is granted read only and `POST /v1/data/esignSubmitter` answers `400 record type "esignSubmitter" is not writable`. **If you branch on `rejection.code`, read this one too:** on an instance where the DocuSeal write entitlement is off, that same addressed write used to answer `422 SYNC_BIDIRECTIONAL_DISABLED` and now answers `422 APPEND_ONLY`. The new code is the durable one — turning the entitlement on does not make the edit work — so branch on it rather than on the entitlement’s. A CREATE is unchanged and still answers `SYNC_BIDIRECTIONAL_DISABLED` while the entitlement is off. * **Expect `create` alone in `list_record_types` for `docuseal:esignSubmission`.** The record-metadata surface published `create, update`; an assistant that planned an update off that list was planning a round trip the write boundary now refuses up front. * **Expect rows in `source_file` for a connected GitHub repository, where the table used to be empty.** Every path on the repository’s base branch gets a row — `path`, `ref`, `commit_sha`, `size_bytes` — and a text file within the 1 MiB policy also carries `content_text`. Nothing about the table’s shape, its route or its read capability changed; an app that read it and rendered “no files” now has files to render, and one that skipped the table because it was always empty should look again. * **Treat a `source_file` row whose `storage_ref` is null as content that has not arrived YET, not as a file with nothing in it.** A newly connected repository gets all of its rows on the first sync and its contents a batch at a time over the following ones, at **200 files per sync and one sync every four hours — about 1,200 a day, shared across every repository on the connection.** So a few thousand files is a day or two rather than minutes, and a screen that reads `content_text` right after a connect finds some of it null for that long. Design for the null instead of waiting it out: filter on `storage_ref is not null` when a surface needs the bytes. * **Do not read `content_text` for a file over 1 MiB — there is not one, by design.** Such a file still gets a row saying it exists and how big it is, with `storage_ref` and `content_text` null, so a repository’s file count is honest without the mirror carrying its generated artefacts and binary assets. `content_sha256` may or may not be set on one — it is there when the file was read before the policy declined it — so `storage_ref is not null` is the predicate for “the bytes are here”, never the digest. ## v1.22.0 [Section titled “v1.22.0”](#v1220) * **Read the five new keys on `list_record_types` instead of guessing what a record does.** Every entry now carries `direction` (`bidirectional` or `read_only`), `writable_now` (whether this tenant can change it right now), `ops` (the operations actually reachable — `create` / `update` / `delete`, and `[]` where none is), `summary` (one line saying what the record is for) and `prompt` (the record’s anatomy prompt, passable to `prompts/get` verbatim). `writable_now` is the same answer `GET /v1/catalog/records` gives as `writeCapability`, so the two can no longer disagree. Nothing was removed — `record_type`, `source_system`, `list_path` and `table` are unchanged. * **Name `source_system` on a `write_record` CREATE to choose which connected system the record is routed to.** The parameter used to say it could not redirect the write; it always could, and that is how an app posts into a second connected system — Slack, say — from a tenant whose other records come from somewhere else. On an UPDATE it stays a hint used to find the row and is stripped rather than written, which is unchanged. * **Send a standing cross-source flow to the extension-authoring server rather than looking for a record type for it.** The data server’s `initialize` instructions now name `/mcp/authoring` and what the two extension kinds are — hooks and conduits — so “every new note goes into that channel” resolves to `author_conduit` there instead of a record write that does not exist. Nothing about the authoring server’s own surface changed. * **Add `?mode=sync` to `POST /v1/processes/:id` when your app wants the answer on the call.** It returns `200 { runId, processId, state, reason, outstanding, steps }` once the run settles, or `202 { error: 'sync_budget_exceeded', runId, … }` when it outlasts the instance’s budget — which is not a failure, so watch `GET /v1/processes/runs/:runId` for the run that is still going. Branch on `error`, not on the status: in this mode a `202` is the one answer that does not mean finished. Naming no mode is unchanged: `202` and a run id. * **Handle `cancelled` wherever you read a process run’s state.** It joins `PROCESS_RUN_STATES` in `@turbobase/extensions`, so a `switch` over that union stops type-checking until you add it. A cancelled run is not a failed one: records it had already sent upstream are left in place. * **Stop treating `committed` as “there is nothing left to do.”** A run now carries `outstanding`; a run that took a slice of a migration ends `committed` with a non-zero count, and that is your signal to ask for the next slice. The platform never schedules one for you. * **Read the queue with `GET /v1/processes/queue` instead of polling run ids one at a time.** Optionally `?mode=async`. It answers `{ runs, counts }` with every failure attached to the run it belongs to, scoped exactly as the single-run read is. * **Stop a migration with `POST /v1/processes/:id/cancel`** (every queued run of that process, in one call, answering `{ cancelled }`) or one run with `POST /v1/processes/runs/:runId/cancel`. A running run stops at its next boundary — the next step, and for a migration the next row — and reports the rows it never started in `outstanding`. * **Declare `eachRow: { outstanding, key }` on a process that moves many records rather than invoking it once per record.** `outstanding` returns one PostgREST query naming the rows still to migrate — re-run at the start of every slice, so write the predicate that excludes what is done — and `key` names the column identifying a row. Each step then reads its row from `ctx.row`, and the write’s idempotency key is derived from that row, so re-offering it is a no-op rather than a second record upstream. * **A step’s `awaitSource` wait running out no longer fails the run.** Nothing was refused — the write is on the trail and the source has not answered — so the run ends `committed` with the steps it never reached in `outstanding` and its `reason` naming the step still waiting. If you branched on `failed` to detect a slow source, branch on `outstanding > 0` and the reason instead. * **Read the tracked item types your customer actually stocks.** `turbobase.item` now carries seven more `item_type` values — `lotNumberedInventoryItem`, `serializedInventoryItem`, `lotNumberedAssemblyItem`, `serializedAssemblyItem`, `nonInventorySaleItem`, `otherChargeSaleItem`, `discountItem`. A screen that filtered `item_type=in.(inventoryItem,serviceSaleItem,nonInventoryResaleItem,kitItem,assemblyItem)` was excluding most of a lot- or serial-tracked catalogue and will keep doing so; widen it, or drop the filter and let the row count speak. Nothing is renamed and no existing row changes. The tracked four only appear on a tenant whose NetSuite account has Lot-Numbered or Serialized Inventory enabled. * **Create a warehouse, a department or a class with `POST /v1/data/location` (also `/department`, `/classification`).** All three were read-only and are now writable end to end: the row persists against its own `pg_row_id` with `external_id` null, reaches NetSuite through the write path and settles to `write_status = 'synced'` carrying the id NetSuite assigned. Editing an already-mirrored row works the same way, addressed by `pgRowId`. * **Ask your administrator for `write:location` / `write:department` / `write:classification` if your app creates one.** They are in the default standard bundle from this release and existing users receive them on their next sign-in, but a hand-restricted user will not have them. Reads are unchanged and still need no capability — `GET /v1/catalog/records` continues to report `readCapability: null` for these three and now reports a `writeCapability` beside it, which is the string to name when a write is refused. * **Tell your customer to take NetSuite bundle 2.9.0 before relying on the write.** The bundle raises the Turbobase integration role from VIEW to FULL on locations, departments and classes; on an older bundle the row is accepted in Turbobase and the write back to NetSuite fails `INSUFFICIENT_PERMISSION`, surfacing as a `sync_failure` rather than as a refused save. * **Read FeedHive posts from `feedhive:social_post` on `activity`, not from `feedhive:post`.** `/v1/catalog/records` gains `feedhive:social_post` → table `activity`, `readCapability: 'read:activity'` (`/rest/v1/activity?source_system=eq.feedhive&activity_type=eq.social_post`). It is the **writable** FeedHive record — what Turbobase scheduled — while `feedhive:post` stays the read-only knowledge row on `memory_document`. An app that publishes or edits a post addresses the new one; an app that only reads published history is unaffected. Writes are gated per tenant by `sync.bidirectional.feedhive`, which defaults **off**, so expect a deterministic refusal until staff enable it. ## v1.21.0 [Section titled “v1.21.0”](#v1210) * **Start a build by POSTing a keyless `workflow_run` to `/v1/data/workflow_run`.** Send `ref` (the branch or tag), and `repositoryFullName` (`owner/repo`) plus `workflow` under `raw`. **`workflow` is the workflow’s FILE NAME (`ci.yml`) or its numeric id — never the `workflow_external_id` your mirror holds**, which is a GraphQL node id for any run that arrived by webhook and is refused. Add the workflow’s own declared `inputs` beside them if it has any. The run appears in `automation_run` and advances in place exactly as one started by hand does. Starting a run is the ONLY write on this record type: a patch addressing an existing run — re-run it, cancel it, approve its deployment — is refused, as is a delete. * **Read `writeCapability` on `/v1/catalog/records` before rendering a “run this” control.** `github:workflow_run` now reports `write:automation_run` for a tenant entitled to GitHub write-back and `null` otherwise, so an app can hide the control instead of discovering the refusal at the first press. The caller also needs the `write:automation_run` capability itself — it is in the default standard bundle and converges onto existing users at their next login, but a hand-narrowed role will not have it, and the refusal names it. * **A write refused for a missing permission now NAMES it, on every record type — read the `error` string instead of guessing.** `POST /v1/data/:recordType` used to answer a permission failure with a bare `forbidden` on a create, and on an edit with something that read as “no such row”, because the row the policy did not cover was simply invisible. It now answers `403` with `record type "X" needs the write:Y capability for Z, and this session does not hold it`, for every writable record type rather than only `workflow_run`. The capability it names is the same `writeCapability` that `/v1/catalog/records` publishes for that record, so an app can surface “ask your administrator for `write:Y`” verbatim. **Nothing that used to succeed now fails** — the check asks exactly what the database already asked, including honouring a Data admin’s `write:*`; only the message changed. If you match on the old response, match on the status and the named capability instead. * **Write to a table your instance OWNS at `POST /v1/data/{recordType}`, using the table’s own name, and send `source_system: "standalone"` on a create in any table that is shared with a connected system.** A table an administrator created in the console (no counterpart in NetSuite, Stripe or any other connected system) used to answer `400 record type "…" is not writable` on every write, delete and MCP `write_record` — the row could be read and not created. It now takes a create, an update by `pg_row_id` and a `DELETE /v1/data/{slug}` under exactly the read / write / delete permissions the permissions surface already grants per role, and its rows are never queued to any source. The same is true of a row you create YOURSELF in a table that is otherwise mirrored — a local customer in `entity`, a local GL account in `account` — and that is what `source_system: "standalone"` asks for. It is never a fallback: a create that names nothing behaves exactly as it does today, including being refused when the instance’s write-back to that system is off. * **Stop pointing a record that belongs to a connected system at a local one.** A create whose `entity_pg_row_id` (or a dimension reference) names a `standalone` row is now refused `422 REFERENCED_RECORD_IS_LOCAL`, naming the field. Before this the write was accepted and then waited forever for a source id that is never issued, reporting nothing. Point it at a record the source knows, or send `source_system: "standalone"` so the whole record stays local. Linking to a record you created moments ago in the SAME system is unchanged and still works while it is pending. * **Expect `403` and `404` from `POST /v1/data/{recordType}` where you used to get `500 write_failed`.** An update whose key matches no row you may write now answers `404 {"error":"row not found or not permitted"}`, and a write your permissions refuse answers `403 {"error":"forbidden"}` — the same two answers the MCP tools already gave. Nothing that returned `200`, `400`, `409` or `422` changes. * **Re-read `writeCapability` from `/v1/catalog/records` before you branch on it — twenty-two entries that published one now report `null`.** Each declared a write its adapter cannot perform and that `POST /v1/data/{recordType}` already refused with `record type "…" is not writable`, so the field is now computed from the write boundary as well as the adapter’s declaration. Nothing that worked stops working; an app that trusted the field stops attempting a write that could never land. Three change on every instance, because Stripe’s write-back is ungated: `stripe:product`, `stripe:paymentIntent`, `stripe:refund`. The other nineteen only ever published a capability on an instance where the source’s write-back had been turned on, and are `jira:issue`, `jira:sprint`, `jira:worklog`, `jira:comment`, `jira:issue_link`, `asana:asanaProject`, `asana:asanaTask`, `billdotcom:bill`, `billdotcom:billPay`, `dynamics-365-bc:order`, `dynamics-365-bc:vendor_bill`, `dynamics-365-bc:purchase_order`, `dynamics-365-bc:journal_entry`, `zendesk:ticket`, `zendesk:ticket_comment`, `github:label`, `github:discussion`, `github:file` and `docuseal:esignSubmitter`. * **Write a Slack `channel_message` from an app or an outside AI client on any workspace-installed instance — the create is no longer refused `SOURCE_NOT_CONNECTED`.** Slack’s connection was never registered, so every generic question the platform asks — is `slack` connected, may this instance create a `channel_message`, does the catalog list it — answered no on an instance that was plainly mirroring Slack channels and answering in them, and the documented payload could not work. Completing the Slack workspace install under Settings now registers the connection, and an instance connected before this converges to the same row with no re-authorisation. Nothing else about the payload changes. **One behaviour change comes with it:** on an instance whose ONLY connected system was Slack, every create naming any system was accepted, because the boundary treats “no connections known” as “cannot judge”. Now that Slack is known, a create naming a system this instance has not connected is refused `SOURCE_NOT_CONNECTED` with the connected list in the message — correct, and new. * **Put a channel NAME in `raw.channel` if you want to, and expect a synchronous 422 when the name is unknown.** `raw.channel` on a `channel_message` now takes `#general`, `general` or Slack’s `<#C0GENERAL|general>` markup as well as a channel id, resolved against the mirrored directory before the post. A name the directory does not hold is refused at the write boundary with `rejection.field = "raw.channel"` — where it previously returned 200 and failed asynchronously as `channel_not_found` in `record_write_state`, which is the shape to stop coding around. A channel **id** passes through untouched, mirrored or not, so keep sending an id whenever you have one. * **Read a Slack channel, a Slack person and a Slack reaction as ordinary records, and re-run `turbobase project types`.** `/v1/catalog/records` gains three entries: `slack:channel` on the new `channel` table, `slack:user` on `entity` (`entity_type = 'contact'`, `read:entity:contact`), and `slack:reaction` on the new `reaction` table. A Slack person whose Slack profile email matches a contact mirrored from another system resolves to the same `entity_canonical`, so your people views stop showing them twice. `channel` carries no read capability and `reaction` carries `read:reaction`, but for a SLACK row neither is what decides: a Slack row is visible exactly as far as that channel’s messages are, so a private channel a user is not in returns no row rather than an error. * **Read reactions from `/rest/v1/reaction`, not from the message’s `raw.reactions` — that snapshot is gone.** It was only ever accurate as of the last read of the message, so it disagreed with the workspace the moment anyone else clicked an emoji. One row per `{channel}:{ts}:{user}:{emoji}`, fed by the `reaction_added` / `reaction_removed` events, which makes “which messages did this person react to” a query. There is no history: the table starts at the first reaction this instance receives. * **React from an app — adding an emoji only; taking one off is not available yet.** `POST /v1/data/reaction` with `source_system: 'slack'`, `raw.channel`, `raw.ts` and `raw.reaction` calls `reactions.add`. **Deleting the row does NOT call `reactions.remove`** — the delete boundary refuses it, because Slack is not one of the sources cleared for delete-to-source, so do not build a flow that un-reacts through this instance. An emoji removed IN SLACK still reaches you: `reaction_removed` arrives as an event and the row goes away, which is unchanged by this. Adding is behind the same `sync.bidirectional.slack` staff switch (default OFF) and the same operator guardrails as a post, and refused the same deterministic way by both. **Ask an administrator for `read:reaction` and `write:reaction`** — neither is in the capability bundle a standard user is seeded with, so on that session `/v1/catalog/records` omits `slack:reaction` altogether and a write to the table is refused. An admin, or any role holding `read:*` / `write:*`, already has both. Reading the rows through `/rest/v1/reaction` is unaffected either way: a Slack row answers to the channel rule above, not to a capability. ## v1.20.0 [Section titled “v1.20.0”](#v1200) * **Ask an administrator for the new `mcp:write` permission when your script or unattended agent has to CHANGE data, not only read it.** A personal access token carrying `mcp:data` reaches the data MCP’s read tools; adding `mcp:write` beside it reaches `write_record` and `delete_record` as well, and those run the same write boundary `POST /v1/data` runs — your hooks, your guardrails, the token owner’s own row-level permissions, and the normal queue onward to the connected system. It is ticked separately, is refused if requested without `mcp:data`, and **Edit** on the token’s own row at `/settings/tokens` adds it to a token you already hold without changing its secret. Two things must both be true for a write to land: the token carries `mcp:write`, and the instance’s **Allow MCP clients to change data** setting is on — the two refusals stay distinguishable, so a refusal says which one to fix. `/rest/v1` is unaffected and still refuses a token outright. * **Correct the sentence about tokens and writing in your project’s `CLAUDE.md`.** The scaffold `turbobase apps init` writes said a token carrying the data permission “can never write, whatever the instance’s MCP write setting says”; as of this release that is false, and a project scaffolded before it still carries the old wording. Re-read it from a fresh `init` or correct it in place — an agent session that believes the old sentence will not ask for the permission it now needs. ## v1.19.0 [Section titled “v1.19.0”](#v1190) * **Send `source_system` in `parent` when you CREATE a record on an instance with more than one system connected, and expect a 422 `SOURCE_NOT_CONNECTED` if you name one that is not.** `POST /v1/data/:recordType` used to take the target system from the session — which is whichever system was connected first — so only that one was writable and a create naming another was accepted and left with nowhere to sync. Each write is now routed by the record itself. On an UPDATE `source_system` is VERIFIED against the addressed record and never written onto it: restating the value you read back still works, a different one is a 422 `SOURCE_MISMATCH`, and a record cannot be moved between systems by editing it. Editing a record whose system has since been disconnected is refused with `SOURCE_NOT_CONNECTED` naming that system rather than silently saved. Omitting `source_system` is unchanged on a single-system instance. * **Name any connected system in the MCP `write_record` tool** — `record_type: "zoho_crm:account"` from a session signed in against another system now reaches the write boundary instead of being refused `SOURCE_MISMATCH`. `delete_record` is unchanged and still limited to the system the session signed in against. * **Read rows from a script or an unattended agent with an access token, which you could not do before.** Ask an administrator to mint a personal access token carrying the new **`mcp:data`** permission, or to add it to one you already hold — **Edit** on the token’s own row at `/settings/tokens` changes what a live token may do without changing its secret, so nothing has to be re-pasted. Present the token as a bearer at the instance’s data-MCP endpoint (`/mcp`, the `turbobase` server in the `.mcp.json` `init` writes, which authenticates over OAuth today). Reads run as the administrator who minted it, under that person’s row-level permissions, and the session behind them never reaches your client. **Three things that are not changing:** it is never granted by default and has to be ticked deliberately; a token carrying it **can never write**, whatever the instance’s MCP write setting says — writes are still `POST /v1/data` as a signed-in person; and PostgREST still refuses a PAT outright (`PGRST301`), so `/rest/v1` is unaffected. The app CLAUDE.md `turbobase apps init` writes said in terms that no token could read data; a project scaffolded before this release still carries that sentence, so re-read it from a fresh `init` or correct it in place. * **Edit a QuickBooks bill, customer payment or bill payment from your app with `POST /v1/data/bill`, `POST /v1/data/payment` and `POST /v1/data/billPayment`** — all three answered `400 record type "bill" is not writable` before this release, on a QuickBooks-connected instance, and an edit made anywhere else stopped inside Turbobase without reaching QuickBooks. Send canonical columns on the table each one lives on: `vendor_bill` for `bill` (`entity_id` / `entity_name` is the vendor, plus `tran_id`, `tran_date`, `due_date`, `memo` and its `vendor_bill_line` rows), `customer_payment` for `payment` (`entity_id` is the customer, `total` is the amount, and `applied_to` names the invoices it pays), and `vendor_payment` for `billPayment` (`entity_id` is the vendor, `total` the amount). **Creating a bill payment from a row is still refused** `MISSING_LINES` — `vendor_payment` carries no column naming the bills being paid, so update an existing one rather than creating one. Record types on that connection that Turbobase has no mapper for are still refused by name, and nothing changes for any other source — a Bill.com or Xero `bill` is refused exactly as before. * **`POST /v1/data/item` is newly accepted on a QuickBooks-connected instance too**, and it used to answer `400 unknown record type "item"` — a different refusal from the three above, because `item` was already open at the write boundary and the route could not work out which table it meant. Send `item` columns (`item_id`, `display_name`, `sales_description`, `base_price`, `is_inactive`). **On a create, set `item_type` yourself**: QuickBooks keeps every item under the one `item` record type, so Turbobase cannot infer the sub-type for you, and `item.item_type` is NOT NULL — omitting it fails the insert rather than returning a field-level `422`. An update addressed by `pgRowId` or `externalId` is unaffected; the stored sub-type stays as it is. * **Expect a QuickBooks bill, payment, bill-payment or item edit to finish settling instead of staying `hybrid`.** Those four never marked the row `synced` once the write landed, so `write_status` sat unsettled and the QuickBooks id was never written back to `external_id`. If your app reads `write_status` to decide when an edit is done, or keys off `external_id` appearing, those now resolve on the same schedule as every other QuickBooks record. * **On ANY CRM tenant, read the businesses you sell to from `customer` rather than `company`, and hold `read:entity:customer`.** A Zoho CRM or Salesforce Account, a HubSpot, Intercom or getsales company and a Zendesk organization are all businesses you sell to, so all six now project onto `turbobase.entity` as `entity_type='customer'`: they are served by `/rest/v1/customer` and read with `read:entity:customer`, and the `company` surface they used to sit on is retired (see below). `/v1/catalog/records` reports each of them under `read:entity:customer` with the predicate `entity_type=eq.customer`, so re-running `turbobase project adopt` and regenerating types is enough for anything that reads the catalog. Rows already mirrored are moved by the upgrade, together with the `entity_type` half of every reference pointing at them — a Deal, Case, Quote, Sales Order, Order or Ticket join written against `('company', entity_id)` has to move with them. The upgrade carries the capability itself only in stored Zendesk permission sets — a custom role is not widened onto `read:entity:customer` (see the retirement note below). * **If your app EDITS a Zoho CRM or Salesforce account through `/rest/v1/*`, it now needs `write:entity:customer` instead of `write:entity:company`.** The write policies on `turbobase.entity` are armed per `entity_type` the same way the read policy is, so the capability the row answers to moved with its discriminator. `write:entity:customer` is in the default standard bundle and `write:entity:company` never was, so this only touches a custom role that was granted the company capability by hand — and the upgrade does NOT carry it: that role loses `write:entity:company`, and an admin has to grant it `write:entity:customer` if it should keep editing those accounts. * **A Zendesk connection’s `read` scope now grants `read:entity:customer` in place of `read:entity:company`.** Permission sets already stored for a Zendesk connection are carried across by the upgrade, so there is nothing to re-connect. * **Read `/rest/v1/customer`, not `/rest/v1/company` — the `company` entity type is retired on every tenant, and re-run `turbobase project types`.** Once the six connectors above moved, nothing wrote `entity_type='company'`, so the value is removed rather than left as a surface nothing fills: the `turbobase.company` view behind `/rest/v1/company`, the `read:entity:company`, `write:entity:company` and `delete:entity:company` capabilities, and the RLS policies that checked them are gone. Every row still carrying the value — including rows your app wrote there itself — is re-labelled `customer` by the upgrade, never deleted, together with every reference pointing at it. **Grants are carried only for stored Zendesk permission sets**, which match Zendesk’s new scope mapping. A custom role that relied on `read:entity:company`, `write:entity:company` or `delete:entity:company` loses it and gains nothing in its place: if that role should keep seeing those accounts, an admin grants it `read:entity:customer` / `write:entity:customer`, knowing that reads every customer — the finance system’s included — and not only the former companies. A request to `/rest/v1/company` or a SQL query naming `turbobase.company` now fails instead of returning rows — point it at `turbobase.customer` and filter on `source_system` if you only want one connector’s rows. * **`POST /v1/data/comment` on a GitHub connection to reply to an issue, pull request or discussion.** Send `work_item_external_id` (the parent’s node id) and `body`, with no `pgRowId` or `externalId` — the reply lands on `activity` as `write_status = 'pending'` and picks up the id GitHub mints when it is posted. Creates only: addressing an existing reply is refused `422 APPEND_ONLY`, and `DELETE /v1/data/comment` stays refused. The session needs `write:activity`. * **Send a signature request with `POST /v1/data/esignSubmission`, signers in `raw.submitters`.** The route answered `400 record type "esignSubmission" is not writable` before this release. Send `template_external_id` (required) in `parent`, and the parties as `parent.raw.submitters` — a list of `{ email, name, phone, role, source_external_ref }` — because `esign_submission` has no `submitters` column and a top-level key is still refused `422 UNKNOWN_COLUMN`. You get back a `pg_row_id` with `external_id` null: watch that row for `write_status` `pending` → `synced` and the provider’s id filling in, then read each party’s `signing_url` off `esign_submitter`. The caller needs `write:esign_submission`, which is deliberately NOT in the built-in **Standard** role — an admin grants it per user — and the whole capability stays behind `sync.bidirectional.docuseal`, which ships off and refuses with `BIDIRECTIONAL_DISABLED`. `esignSubmitter` is unchanged: still no standalone create. Full page: [Embedding a signing page](https://docs.turbobase.io/build-apps/embedding-signatures/). * **Filter `activity_type = 'comment'` if you list a GitHub work item’s conversation from `/rest/v1/activity`.** Transition rows — who closed it, who it was assigned to, what it was renamed to — now land on the same table against the same `work_item_external_id`, so a query that selected everything for an item gets them too. Their `activity_type` is one of `status_change`, `assignment`, `labeling`, `planning` or `rename`, `body` is a ready-to-render one-line summary (`added the label bug`, `renamed to …`), and `raw.event` carries GitHub’s own verb. * **Re-run `turbobase project types`** — `/v1/catalog/records` gains `github:issue_event`, backed by `activity` and read with `read:activity`. * **Expect FEWER `/rest/v1/*` rows on a GitHub connection if your app runs as a repository-scoped principal.** Rows written by the backfill walk — issues, pull requests, comments, labels, milestones, Actions runs — previously carried an empty repository scope slot, which is visible to every principal regardless of their scope; they are now stamped with the repository they came from and are filtered like everything else. An app that relied on seeing rows from repositories its principal is not scoped to should ask for that scope. Rows already mirrored keep their empty slot until they are next walked. * **Read the new `applied.source_filter` on every MCP `list_records` / `get_record` answer to know whose rows you were given.** On a table several connectors publish onto — `entity`, `memory_document`, `activity`, `work_item` and 34 others — a read the source DECIDED is now narrowed with `source_system=eq.`, and that key says so (`null` when nothing narrowed). It applies when you pinned a source (`source_system`, or the `source:record_type` form) or when only one connected source publishes the name, so `list_records {record_type: "post"}` returns FeedHive posts and no longer the meeting notes sharing `memory_document`. A BARE name that several connected sources publish is deliberately still unnarrowed: those answers now carry a `warnings` line naming the sources and the qualified forms that separate them — pin one wherever your app assumed a single connector was answering. `get_record`’s `applied.source_system` changes meaning with it, from the source the NAME resolved to, to the source YOU passed (`null` when you passed none), which is what `list_records` has always meant by that key. * **Drop any stored MCP `page.next_cursor` and restart that walk from its first page.** Cursors minted before this release are refused — *“cursor does not match the current query … Start the walk again with no cursor”* — because a cursor hashes the predicates the server applied and the source predicate is new. Re-seeking one against the narrower set would silently skip every row that sorted before the last row of the old mixed page, so the refusal is the safe outcome and there is nothing to fix but the stored value. Cursors minted after the upgrade are unaffected. * **Edit and create a CRM lead with `POST /v1/data/lead`.** Every save of a lead was refused `new row violates row-level security policy for table "entity"` before this release — on a Zoho CRM or Salesforce connection, for every caller including an administrator — while `/v1/catalog/records` advertised `writeCapability: 'write:entity:lead'` throughout, so an app that trusted the catalog built an editor that could not save. Send `entity` columns (`display_name`, `first_name` / `last_name`, `company_name`, `email`, `phone`, `status_name`) and leave `entity_type` to the route. **Handle the refusal first: until an operator switches on `sync.bidirectional.zoho_crm` / `sync.bidirectional.salesforce` for that instance — and both ship OFF — the `POST` itself is rejected**, `422 validation_failed` carrying `rejection.code: "SYNC_BIDIRECTIONAL_DISABLED"`, and no row is touched. That entitlement is the first thing consulted, so everything below decides nothing until it is on; an editor that treats a 422 as a bad payload will tell the user to fix a field when the answer is to ask their operator. Once it is on, the caller needs `write:entity:lead`, plus `read:entity:lead` to read the row back from `/rest/v1/entity` — two separate grants — and the lead must be inside the caller’s subsidiary scope, or Postgres refuses it. **Do not tell a customer’s admin to grant one user lead access: no control in the product does that.** Neither capability is in the built-in **Standard** role, and the role editor’s record grid has no lead row, so the only holders today are an administrator and a Data admin (`read:*` / `write:*`) — both far wider than leads. **Declare the grant your app needs instead:** a `roles` entry in your `public/turbobase.json` carrying `read:entity:lead` and `write:entity:lead` is created as an ordinary role when you deploy (see v1.5.1), and the admin assigns it in Admin → Users. **`DELETE /v1/data/lead` is still refused** `400 record type "lead" is not writable` — delete a lead in the CRM. * **Ask your assistant to search the customization catalog over the DATA MCP endpoint (`/mcp`), instead of wiring up the authoring endpoint to do it.** `search_catalog` (“which script handles rebates?”) and `explain_object` are now on both endpoints and answer identically. On `/mcp` they need no personal access token — the OAuth sign-in your client already has is the credential — so a project whose `.mcp.json` carries only the data endpoint no longer hears “there is no tool for that” when it asks about a script or a custom record. **Both are instance-admin only on `/mcp`**, and a non-admin caller is not offered them at all rather than being refused mid-call. Nothing changes on `/mcp/authoring`: its copies, its `catalog:read` scope and the scaffold `turbobase project init` writes are all unaffected. * **Handle `source_value_not_accepted` as a `reason_code` on a `needs_input` `record_write_state` row, and render `message` — it now quotes the value.** It means the connected system refused one named field because the value the record holds is not one its own configuration carries: a picklist option the customer renamed or retired, a choice their layout no longer offers. It arrives where a Zoho CRM `MAPPING_MISMATCH` refusal used to — the row is still `needs_input`, because changing the value is what makes the write land — and what moves is that `reason_code` is now the cause instead of the connected system’s own code, and `message` is plain language naming the field and the value that was refused. The connected system never repeats the value (Zoho’s whole sentence is “mapping mismatch”), so this is the only place it appears; a row whose value could not be quoted names the field alone rather than inventing one. `fields` carries the same field with `submitted` and `requested` beside it, so ordinary fix-sheet rendering works and an app can show what was sent without parsing the sentence. An app matching on `reason_code === 'MAPPING_MISMATCH'`, or rendering the old message, sees neither after this release. ## v1.18.0 [Section titled “v1.18.0”](#v1180) * **Read a customer’s signature requests from the new `esign_*` tables, and re-run `turbobase project types`.** `/v1/catalog/records` gains `docuseal:esignTemplate`, `docuseal:esignSubmission` and `docuseal:esignSubmitter`, and `/rest/v1/esign_template`, `esign_submission` and `esign_submitter` serve them under RLS to any standard user — the three read capabilities are in the built-in **Standard** role. The tables are named for the RECORD CLASS rather than for DocuSeal, so an app written against them keeps working if the instance later mirrors a different e-signature provider. Nothing appears until an operator enables DocuSeal for that instance and the customer connects it. * **Mount a signing page from `esign_submitter.signing_url`, not from `raw`.** It is a promoted, typed column precisely so an app can read it, and there is deliberately NO Turbobase embed endpoint and no client helper: read the column and mount DocuSeal’s own widget on it. **Treat the value as a capability** — anyone holding it can act as that party, so render only the URL belonging to the person who is signed in, and never log it. Check `status` and the parent submission’s `expires_at` before mounting: an already-signed or expired request loads a page that quietly will not submit. Full page: [Embedding a signing page](https://docs.turbobase.io/build-apps/embedding-signatures/). * **Expect a write to `esign_submission` / `esign_submitter` to be REFUSED until an operator turns it on.** Both record types are bidirectional, and every write is held shut per-instance by `sync.bidirectional.docuseal`, which ships off — the refusal is `BIDIRECTIONAL_DISABLED`. When it is on, two shapes are fixed and worth building around: a submission can be created and never edited, and a submitter can be edited and never created on its own. `esign_template` is read-only in every case. ## v1.17.0 [Section titled “v1.17.0”](#v1170) * **Write a GitHub issue or pull request from your app with `POST /v1/data/issue` / `POST /v1/data/pull_request`** — both answered `400 record type "issue" is not writable` before this release, on a GitHub-connected instance. Send canonical `work_item` columns: `subject` retitles, `status` (`open` / `closed`) closes and re-opens, `labels` is the WHOLE label set (send the set you want the item to end with — Turbobase works out which to add and which to remove, and labels you leave in place are not touched), and `assignee_id` assigns or, set to `null`, unassigns. Sending `assignee_name` beside `assignee_id` saves a lookup and is not required. Other record types on that connection are still refused by name, and nothing changes for any other source. * **Handle a `record_write_state` row with `reason_code = 'WRITE_NOT_APPLIED'` alongside `'WRITE_CONFLICT'` when you surface GitHub write failures.** A write that could not be carried out — an assignee Turbobase cannot resolve to a person in GitHub, a label set it could not read back — now lands `needs_input` with its own plain-language `message`, instead of reporting success and being reverted by the next sync. Nothing on the item is changed when this lands: show the `message` and let the customer re-try or make the change in GitHub. `'WRITE_CONFLICT'` keeps its old meaning, which is somebody-else-changed-it-first. * **Read `work_item.description` for the body of a GitHub issue or pull request** — it was always `NULL` for GitHub rows and is now projected on every delivery, so an app that fell back to `raw->>'body'` can read the typed column. A description written from Turbobase now reads back the same on the next sync. * **Read a GitHub project board from the new `board` family instead of decoding card order and dates out of label names, and re-run `turbobase project types`.** `/v1/catalog/records` gains `github:project` and `github:project_item`, and `/rest/v1/board`, `board_field`, `board_card` and `board_card_field_value` serve them under RLS to any standard user — the four `read:` capabilities are now in the default bundle and reach users who already exist (a holder of the built-in Standard role gets them when this release’s migration runs; a user carrying their own bundle picks them up on next sign-in). Read a whole board in one request with `board?select=*,board_field(*),board_card(*,board_card_field_value(*))` — no constraint hint needed. Sort a column with `board_card?status_option_id=eq.&order=position`: `position` is an ORDER and not a count, so expect gaps, because a card in a repository the installation was never granted is refused and still holds its place. A card is NOT the issue it points at — join `board_card.content_external_id` to `work_item.external_id`, and do not expect a board’s Status on the work item, which is deliberately never written (one issue can sit on two boards). Read per-board custom fields from `board_card_field_value` in the column matching the field’s `data_type` (`value_text`, `value_number`, `value_date`, `value_option_id`/`value_option_name`, or `value_json` for a multi-valued one); `board_field.options` is an ORDERED array, so render it in that order rather than sorting it. Read-only for now, and boards appear only once an organisation owner has accepted the App’s new Projects read permission — the connection Health tab reports `projects_permission` when they have not. * **Post to Slack by writing a `channel_message` through `POST /v1/data`, and re-run `turbobase project types`.** `/v1/catalog/records` gains `slack:channel_message` against the `activity` table (`read:activity`), and a row written there is posted into a Slack channel. **Set `source_system` to `'slack'` on the row you write** — `turbobase.activity` is shared by six connectors, so a row that does not name Slack is stamped with your session’s own source, is never dispatched, and posts nothing with no error to show for it. Put the channel in `raw.channel` — required; that shared table carries no channel column — the message in `body`, and, to reply inside an existing thread rather than start a new one, the parent message’s `ts` in `raw.thread_ts`. Since v1.21.0 `raw.channel` takes a channel **name** (`#general`, `general`, or Slack’s `<#C0GENERAL|general>` markup) as well as an id, and a name the mirrored directory does not hold is refused synchronously rather than failing later; an id still passes through untouched. Also since v1.21.0, completing the Slack workspace install under Settings is what registers the connection the write boundary checks — before that this whole payload was refused `SOURCE_NOT_CONNECTED` on every instance. Once Slack accepts the post, `external_id` on your row becomes `'{channel}:{ts}'` — that is what comes back on the row. The permalink and the thread it landed in are handed to a tenant `after_sync` hook as `__result.rewrittenFields` and are **not** written to the row. **Nothing reaches Slack until staff turn on `sync.bidirectional.slack` for the tenant** (default OFF), and the workspace’s bot token needs the `chat:write` scope the read-only install does not grant — the connection’s Health tab now says which. A write refused by either is a deterministic refusal in `record_write_state`, not a retry. Slack’s other record type, `slack:message`, is channel history in `memory_document` and is still read-only. * **Join a work item’s conversation on `activity.work_item_pg_row_id`, not only `work_item_external_id`, and re-run `turbobase project types`.** `/rest/v1/activity` gains a nullable `work_item_pg_row_id` holding the parent’s `work_item.pg_row_id`. It is the ONLY pointer a card created in Turbobase has — `work_item.external_id` is null until a source mints one — so a conversation view keyed on the text column alone silently shows nothing on those cards. The text column is unchanged and still set whenever the parent has an external id, so an existing join keeps working; add the new one where you render replies. Slack is the first writer: a reply in a thread bound to a work item lands as an `activity` row with `activity_type='comment'` beside the `memory_document` the message already produced. ## v1.16.0 [Section titled “v1.16.0”](#v1160) * **Read GitHub Actions runs from the new `automation_run` table, and re-run `turbobase project types`.** `/v1/catalog/records` gains `github:workflow_run`, and `/rest/v1/automation_run` serves it under RLS to any standard user — `read:automation_run` is now in the default capability bundle, and it reaches users who already exist: a holder of the built-in Standard role gets it when this release’s migration runs, and a user carrying their own capability bundle picks it up on next sign-in. One row per run for its whole life: filter `status=eq.in_progress` for what is running, `conclusion=eq.failure` for what broke, and page on `started_at`. Both those columns are LOWER-CASE where GitHub’s API shouts them, `conclusion` is NULL while a run is still going, and a re-run bumps `run_attempt` on the same row rather than adding one — so counting rows counts runs, not deliveries. Jobs, steps, logs and artifacts are not mirrored; `permalink_url` is the link to them. * **Call `POST /v1/processes/{id}` — with the process’s input as the JSON body, under the app’s ordinary session — to run a multi-record sequence on the server instead of orchestrating it from the browser.** It answers `202 {runId, processId, state: 'queued'}` before the first step is written; the ordering, the wait for the source’s verdict between steps, the validation through the same write boundary as `POST /v1/data`, and the undo when a step fails all happen on the server and survive a worker restart. Refusals never carry a `runId`: `404 {error: 'process_not_found'}` for an id nothing deployed, and `400 {error: 'invalid_input', rejection: {code, field, message}}` when the body fails the process’s declared schema. Poll `GET /v1/processes/runs/{runId}` for `state` (`queued` → `running` → `committed` | `failed` | `rolled_back`) and one entry per step with its `disposition` and, on a failure, the refusing party’s own `code`/`field`/ `message`; the same rows are readable through PostgREST as `process_run` and `process_run_step`, scoped to the invoking user. * **Import `defineProcess` from `@turbobase/extensions` (or the `@turbobase/extensions/process` subpath) to author one, and scaffold it with `turbobase ext new process --record [--then ] [--on-failure rollback]`.** A process declares a JSON-Schema `input` and ordered `steps`, each a pure `build(ctx)` returning the `{parent, lines?}` payload for one record type; `ctx.query(q)` reads as the invoking user exactly as `POST /v1/batch` does. `ext deploy` refuses a module whose `steps` are not a list, whose step has no `build`, or whose step names a record type the write boundary would not accept, naming the field. * **Expect two writes of the same record type to reach the connected system one at a time, in the order you made them** — the outbound worker now serialises same-type writes per source, for every writer, so a system that numbers documents per type can no longer hand one number to two of them. A second same-type write waits for the first’s round-trip; writes of different record types do not wait for each other. Nothing changes on the request: `POST /v1/data` still answers as soon as the row is committed locally. * **Share a rule between two extension files with a relative import (`import { requireMemo } from './shared-rule.js'`) instead of carrying two copies.** A sibling inside the extension’s own directory is folded into the module by `ext test`, `ext lint`, `ext deploy` and the whole-project deploy; an npm package, a Node builtin, or a path that escapes the directory is still refused with the same message as before. * **Declare `sequence: [{ to, transform, awaitSource? }, …]` on `defineConduit` — instead of `to` + `transform` — when one inbound record has to become several records in another system in a fixed order.** Entries run one after another on the server; each later `transform` reads the ones already landed off `ctx.materialized` (`{ source, recordType, masterTable, masterRowId, externalId }`, in order), and `awaitSource: true` on an entry holds the next one until the source has minted that entry’s id so `externalId` is set rather than `null`. Every target is checked for writability on its own at `ext deploy` (`conduit_target_read_only` names the entry’s pair; an `awaitSource` against a record type with no write trail is `conduit_target_no_write_trail`, and against a target of the firing record’s own type — which merges rather than mints — is `conduit_target_await_same_type`). A failing entry parks its own ledger row, skips the rest with `earlier target failed`, and undoes nothing already materialized — use a process with `onFailure: 'rollback'` when you need the undo. `ext replay` prints one transform block per target. A single `to` + `transform` conduit is unchanged, and `ctx.materialized` is `[]` there. * **Call `POST /rest/v1/rpc/record_change_history` — passing the canonical record type and the record id — to show who changed a record, instead of reading `turbobase.system_note`, which hands every non-admin session an empty array.** You get one row per recorded field change on that one record: `field`, `old_value`, `new_value`, `changed_at`, `actor`, `is_own_traffic` (Turbobase’s own write-back is returned, flagged, not hidden), plus `line_index` and `line_pg_row_id` — both null on a body-level change, and on a line-level one already resolved to the mirror line row, so you never have to know that `item_receipt_line`, `journal_entry_line` and `order_fulfillment_line` spell the ordinal `line_number` while every other line table spells it `line_index`. Branch on the two answers rather than rendering both as “no history”: a session lacking the record’s own read capability gets an **error** naming that capability (`read:order`, `read:item_receipt`, …), while a record nothing has ever edited comes back **empty** with no error. A record the session may not see — outside its scope, or deleted upstream — is also **empty**, exactly as reading that record’s own table would be for them: the call never reveals a record their other reads hide. And on a tenant holding **two connections of one source**, where an id exists under both — two NetSuite accounts reuse internal ids — each change is matched to the account it came from and gated against that account’s own record, so your user gets the history of the record that is theirs and none of the other’s; if neither is theirs the answer is **empty**, and if *both* are, both records’ changes come back as **one list**, because the call names a record id and not a connection. Prefer “no changes are visible to you” over “this record has never changed” when you render the empty case. Both arguments are required and neither has a default — there is no call that spans more than one record — and the account-wide `system_note` table stays admin-only. * **Handle `422 {error: 'validation_failed', rejection: {code: 'INVALID_UUID', field: 'pgRowId', …}}` from `DELETE /v1/data/{recordType}` where a `500 {error: 'delete_failed'}` came back before.** A `pgRowId` that is not a Turbobase row id is now refused at the boundary and names the field, instead of reaching Postgres and escaping as a generic server error that quoted your value and named no key — the same refusal `POST /v1/data/{recordType}` already gives for the same mistake. No delete that succeeded before stops succeeding: a value Postgres could not parse as a uuid addressed no row either way. To address a row by the source system’s own id, send it as `externalId`, which is free-form text and is never screened. * **Add `connection_id=eq.` to any read of `system_note` that filters on `external_id`, and re-run `turbobase project types`.** The change-audit trail now carries the same per-connection discriminator every other mirrored table already had, and its unique key is `(connection_id, source_system, external_id)` — a source’s own note id is unique only WITHIN a connection, because NetSuite numbers `systemnote` per account. So on an instance holding two connections of one source, `system_note?external_id=eq.1001` returns two rows where it returned one. The table’s primary key also moves from `external_id` to `pg_row_id`, which is the column to page on and the one to persist as a reference. Every existing row keeps every value and reads back under the permanent `default` connection id, so a single-connection instance sees no change beyond the new column. `system_note` is gated at RLS on the admin / data-admin `read:*` wildcard, so this reaches your app only if it runs on such a principal. * **Add `source_system=eq.` to any read of `entity`, `invoice`, `vendor_bill`, `credit_memo`, `vendor_credit`, `customer_payment`, `vendor_payment`, `purchase_order`, `account`, `item`, `journal_entry`, `check` or `deposit` that assumed one accounting system — Xero is now a connectable source and writes into all thirteen.** `/v1/catalog/records` gains fourteen `xero:*` entries (`customer`, `supplier`, `invoice`, `bill`, `creditNote`, `supplierCreditNote`, `payment`, `billPayment`, `purchaseOrder`, `account`, `item`, `manualJournal`, `spendMoney`, `receiveMoney`), each landing on a table you already read, so on an instance that connects Xero those rows appear beside the ones your app reads today. No table, column or capability changed; the new rows are the whole of it. Re-run `turbobase project types`. * **Read `status` on a Xero `invoice` or `vendor_bill` as XERO’s word, not as the NetSuite/QuickBooks vocabulary.** Those rows carry `AUTHORISED`, `PAID`, `DRAFT`, `SUBMITTED`, `VOIDED` or `DELETED` verbatim, where a QuickBooks row on the same table carries `open` / `partial` / `paid`. An app that filters `status=eq.open` returns nothing for Xero — filter on `amount_remaining=gt.0` instead if you want a source-agnostic “still owed”. * **Join a Xero line to its item or account BY CODE, not by id.** On `invoice_line`, `vendor_bill_line`, `credit_memo_line`, `vendor_credit_line`, `purchase_order_line` and `check_line`, a Xero row leaves `item_id` and `account_id` NULL and puts Xero’s `ItemCode` / `AccountCode` in `item_name` / `account_name`; those strings join to `item.item_id` and `account.account_number`. Xero’s API publishes no id on a line at all, so an embed keyed on `item_id` returns nothing for these rows. `credit_memo_line` has no `account_id` / `account_name` pair at all — read a Xero credit-note line’s `AccountCode` out of that line’s `raw`. * **Expect nothing writable from Xero.** Every `xero:*` catalog entry is `read_only`, so `POST /v1/data/:recordType` refuses one; there is no flag that changes it. `turbobase project types` marks them accordingly. * **Handle `422 { error: 'validation_failed', rejection: { code: 'INVALID_UUID', field, message } }` from `POST /v1/data/:recordType` wherever your app sends a `pgRowId` or a `_pg_row_id` reference.** A value that could never be a UUID in one of those slots — a source system’s own id, a placeholder carried over from sample data, a number — used to reach Postgres and come back as `500 { error: 'write_failed' }`; it is now refused before the write, and `field` names the exact slot (`pgRowId`, `entity_pg_row_id`, or `lines[].` for a line). Nothing that wrote before stops writing. Render the message against the named field rather than retrying: unlike the 500 it replaces, this refusal is deterministic. Address a row by the source system’s own id with `externalId`, which stays free-form text. * **Stop building a return’s lines by hand — set `created_from_id` and let the order supply them.** A `returnAuthorization` or `vendorReturnAuthorization` created through `POST /v1/data/:recordType` with `created_from_id` naming a sales order (customer returns) or a purchase order (vendor returns) is now sent upstream as a CONVERSION of that document: the source copies the items, quantities and prices across and links the two records. It used to be sent as a standalone create, so a project that populated the return’s own lines is now sending OVERRIDES matched to the sourced lines by `item`, not a line set of its own — and every shape that can take is a problem: a line naming an item the order did not carry FAILS the whole write (`unsourced_line`: the sublist is static, so a line the conversion did not source cannot be added); a line naming one it did carry silently overwrites the quantity and price the order just supplied; and a line with no `item_id` set is matched by POSITION instead, overwriting whichever sourced line happens to sit at that index, or failing the write if the order sourced fewer lines than that. Drop them and let the order supply the lines. A return created with no `created_from_id` is unchanged and still a plain create. * **Name the ORDER in a return’s `created_from_id`, not the invoice or the bill.** One parent is supported per return type — sales order → customer return, purchase order → vendor return — and whatever id you set is sent AS a sales-order (or purchase-order) id, with no check that it names one. So an invoice id or a bill id no longer produces the unlinked standalone return it produced before: it produces a conversion request for an order that does not exist under that id, and the WHOLE WRITE FAILS with no return created at all. Use the order the goods were sold or bought on. ## v1.15.0 [Section titled “v1.15.0”](#v1150) * **Re-read the column `label`, `format` and `currencyKey` your app takes from `/v1/catalog/records` — an entry no longer inherits authored facts from a connector that did not author them.** Those facts come from the matching recipe’s `table_columns`, and they used to be joined on the record-type NAME alone, so any connector publishing the same name picked them up: a NetSuite or Business Central `vendor_bill` entry carried Bill.com’s labels and money formats. They are now joined to the authoring connector’s own entries, so an entry shows its own connector’s labels, a cross-source recipe’s, or none. Some labels therefore change and some disappear; re-run `turbobase project types` and stop caching a label you read once. * **Expect authored column facts on fourteen connectors that previously had none** — `zoho_crm`, `salesforce`, `github`, `zendesk`, `intercom`, `granola`, `fireflies`, `feedhive`, `slack`, `google_workspace`, `dynamics-365-bc`, `ramp`, `workday` and `getsales`. Their `/v1/catalog/records` entries now carry a label and format on the columns their recipe names, and their intents appear in the MCP `recipes:///` resource. Nothing was removed and no column changed; if your app renders its own headings, prefer the catalog’s `label` where one is now present. * **Handle the new `url_column` row-action kind if your app renders the chat grammar’s `actions` itself.** A row action may now be `{ kind: 'url_column', url_key: '' }`, which opens the absolute `https://` address that column holds on that row — as opposed to `external_link`, which composes one from a fixed prefix. Use it when the provider’s own URL is mirrored whole (GitHub `permalink_url`; Slack, Granola, Fireflies and Google Workspace `url`); keep the named column in your select list, because a row that carries no address renders the button disabled. `rowColumnUrl(action, row)` in `$lib/chat/ui-grammar` is the resolver, and it returns `null` for anything that is not `https://`. ## v1.14.0 [Section titled “v1.14.0”](#v1140) * **Add `source_system=eq.` to any read of `entity`, `work_item`, `opportunity`, `activity`, `item`, `estimate`/`estimate_line`, `order`/`order_line` or `memory_document` that assumed one CRM — Zoho CRM is now a connectable source and writes into all nine.** `/v1/catalog/records` gains fourteen `zoho_crm:*` entries (`lead`, `contact`, `account`, `opportunity`, `case`, `task`, `event`, `call`, `product`, `quote`, `quote_line`, `order`, `order_line`, `solution`), each landing on a table you already read, so on an instance that connects Zoho those rows appear beside the ones your app is reading today. Nothing about the tables, columns or natural keys changed; what changed is that a table you may have treated as single-source no longer is. Copy `sharedTable.hint` from `/v1/catalog/records` rather than composing the predicate yourself — `entity` still needs `entity_type` beside it (`lead` / `contact` / `company`), and `work_item` needs `item_type=eq.ticket`. Re-run `turbobase project types`. An instance whose Zoho organisation has custom modules also gains one `cr_` catalog entry per module, with `source: 'zoho_crm'` and `origin: 'admin'`, discovered per tenant — so enumerate `/v1/catalog/records` at run time rather than hard-coding the list. * **Check `connected` before you offer `granola:note` as a browsable table — it joins `/v1/catalog/records` on every instance, carrying `connected: false` until a tenant connects Granola.** It is a memory-class record: rows land in `memory_document` (`kind = 'transcript'`, `source_system = 'granola'`), are admin-read at RLS (a standard user’s read returns an EMPTY set, not a 403, so branch on the body), and are read-only — a write to it is refused. `note` is a record-type name no connector registered before, so a bare `note` query that used to resolve to nothing now resolves to Granola’s rows. ## v1.13.0 [Section titled “v1.13.0”](#v1130) * **Check `source_detail.envelope_present` before you read a sync failure’s `source_detail` as a diagnosis.** A NetSuite refusal that carried nothing readable now records `{ "envelope_present": false, "raw_message": "…" }` on `turbobase.sync_failure.source_detail` where it previously recorded nothing at all — so a non-null `source_detail` no longer implies `error_name` / `field_id` / `implicated_script` are in it. Render `raw_message` (the source’s own sentence, capped at 500 characters) when the marker is `false`, and leave your existing rendering alone when the marker is absent, which is every other failure. On a failure that RECURS the row keeps the earlier, richer diagnosis rather than the newer fallback, so a repeat never costs you detail you could already see. * **Check that the signed-in user can READ the parent before you offer a transform — `POST /v1/sync/events/from-chat` now refuses one they cannot.** The record named in `created_from_id` is read under the caller’s own RLS before anything is queued, so `write:order_fulfillment` no longer lets a user fulfil a sales order they are not permitted to see. The refusal is `403 { error: 'forbidden' }` with nothing queued, and it is byte-identical to the answer for a `created_from_id` that matches no record at all — the endpoint will not tell your app whether a record it cannot see exists, so render it as “that record isn’t available to you”, never “not found”. A screen that lists parents through PostgREST already shows only readable rows and is unaffected; one that takes a typed-in id, or reuses an id from another user’s context, is the case that starts failing. Everything else about the route is unchanged. * **Check `connected` before offering `feedhive:post`, and stop reading a bare `post` record-type name as “no such record”.** `/v1/catalog/records` gains one entry, `feedhive:post`, against `memory_document` with `readCapability: 'read:memory'` — carrying `connected: false` until a tenant connects FeedHive, exactly as the GetSales entries do. `post` is a record-type name no connector registered before, so a bare query for one used to resolve to nothing and now resolves to FeedHive; pin `?source=` if your app means something else by it. Rows are read-only (`writeBack` rejects) and carry no Atlas identity, so nothing consolidates into `entity_canonical`. Regenerate your types if you enumerate record types from the catalog. * **Read a FeedHive post’s social accounts from `participants`, not `scope_ref`.** One post is ONE row however many accounts it was cross-posted to, so `scope_ref` — a single `text` column — is null on every FeedHive row and the account ids live in `participants` instead. A query filtering these rows by `scope_ref` matches nothing rather than erroring. * **Handle `inventory_detail_required` as a `reason_code` on a `needs_input` `record_write_state` row — these rows used to arrive `blocked`.** It means the record moves stock for an item the connected system tracks by lot or serial number and those numbers were never supplied, so `fields` names `inventory_detail` and ordinary fix-sheet rendering works; write the assignment to that column on the line and save again. The `message` is plain language and names the line the connected system objected to, so render it as-is rather than substituting your own. If your app treats a `blocked` row as an outage — hiding the edit surface, or telling the reader their team has been notified — these records no longer belong in that path. * **Read repository files from `source_file` — a new readable table, gated by `read:source_file`.** A tenant connected to a code host now mirrors the files on each selected repository’s base branch as ordinary rows: one row per `(repository, path)`, with `path`, `ref`, `commit_sha`, `content_sha256`, `size_bytes` and — for text under the adapter’s policy — `content_text`. Read it the way you read any canonical table (`/rest/v1/source_file`, `turbobase project types`); there is deliberately no `/v1/files` endpoint to learn. **It is read-only from your app**: `POST`/`PATCH /v1/data/github:file` answers `400` and will keep doing so, because a file edit has to arrive at the code host as a branch and a pull request rather than as a commit on the branch the customer ships from. Two things to code against rather than discover: `storage_ref` and `content_text` are NULL for a file over the adapter’s size policy — the row still tells you the file exists and how big it is — and **only the base branch is mirrored, with no history**, so a changed file repoints its row rather than adding one. A pull request’s changed files are `source_file_change`, joined to the pull request through `work_item_external_id`. * **Expect pull requests and discussions in `work_item`, under new `item_type` values.** A list built on `work_item` and filtered by `item_type` will not show them until you add `'pull_request'` and `'discussion'`; a list that does NOT filter will start including them. Six new nullable columns come with them — `merged_at`, `is_draft`, `head_ref`, `base_ref`, `review_state`, `changed_file_count` — plus `answered_at` and `answer_external_id` for a discussion, and they are NULL for every other item type, exactly as the existing per-source columns are. **Treat every row from a code host as read-only from your app**, not just the files above: writing one back to the source is not wired yet, so do not build an edit affordance over these rows expecting it to reach the code host. * **Read a code host’s milestones from `sprint` and its linked issues from `work_item_link` — two shared tables that were Jira-only until now.** A list built on either and NOT filtered by `source_system` will start including GitHub rows; add the filter if you meant one source. On `sprint`, `name` is the milestone title, `start_at` / `end_at` / `complete_at` are its opened / due / closed dates (`end_at` is NULL for an undated milestone — an ordinary case, not a broken row), and `board_id` carries the repository. On `work_item_link` the row reads **inward `link_type` outward** — the pull request `closes` the issue — with both `*_work_item_external_id` columns joining to `work_item.external_id`. Two things to code against rather than discover: **neither table carries a scope slot**, so these rows are visible across repositories where a `work_item` is not, and a `work_item` filed under a milestone carries it in `sprint_id`, which is the join from a bucket to its contents. Read-only from your app, like every other row from a code host. * **Scope `work_item` and `activity` reads by `scope_5` if your tenant registers a custom segment.** Both tables now carry the generic scope slots and their read policies consult `scopes_match()`. Nothing changes for a row whose slots are NULL — which is every row that exists today — but a session whose `scopes` claim restricts a registered dimension will now see fewer rows than before, by policy rather than by query. If your app renders “no results” as an empty state, that path is newly reachable. * **Read `exchange_rate` off the transaction row when you convert a figure, and stop reaching for `turbobase.currency.exchange_rate`.** Every transaction table — `order`, `invoice`, `cash_sale`, `credit_memo`, `estimate`, `vendor_bill`, `purchase_order` and `journal_entry` — now carries the rate the source stamped on that document at posting time. Multiply the row’s own face value by it to reach the subsidiary’s base currency. `turbobase.currency.exchange_rate` is the CURRENT rate on the lookup row, so any total built from it changed after every sync; drop that lookup and any per-document join into `currency_rate` you built to work around its absence. NULL means the rate is genuinely unknown — leave the figure unconverted rather than assuming parity. History is filled in automatically on upgrade; a row whose payload never carried a rate stays NULL. * **Set the currency on an `invoice`, `cash_sale`, `credit_memo` or `estimate` through `currency_id` now — `raw.currency` is no longer the only way.** These four record types carry `currency_id` and `currency_name` columns, so the currency is filterable, embeddable and writable like every other ref instead of being reachable only inside `raw`. This closes the gap v1.4.x’s note left open (“open an issue if you need one”). `currency_name` is the source’s own display name and is source-owned in a conflict. For a portable ISO key, read `currency_code` — `invoice`, `credit_memo` and `estimate` gained it in a later release (this file is newest-first, so see the entry above); as of this one only `cash_sale` still needs resolving through `turbobase.currency`. ## v1.12.0 [Section titled “v1.12.0”](#v1120) * **Read `writeCapability` on every `/v1/catalog/records` entry before you offer an edit, and drop any write-it-and-see probe you built to find out.** It is `readCapability`’s twin — `write:order` beside `read:order` — and it is `null` whenever a PG-side edit of that record would be refused on this instance: either the record is inbound-only, or the whole source is. Before this, a read-only tenant looked identical to a writable one and the only way to tell them apart was to attempt a write per record and read the refusal back. The value is resolved live per request, so a tenant granted write-back starts reporting a capability string on the very next call — render your editing affordances off it rather than caching it at build time. Older instances omit the field; treat `undefined` as “unknown”, not as read-only. * **Stop putting `owner` inside `raw` when you write a standard record — it is now dropped before the request leaves.** On a NetSuite record type that is not a custom record, `owner` is not a field an app sets: nothing projects it to a column, nothing renders it, and the value the mirror holds came from a background refresh. It was nonetheless restated on every outbound write, so a project task whose captured owner had since gone inactive refused EVERY edit — including one that changed only how complete the task was — naming a field the editor never touched and could not correct. That refusal is gone, and affected rows go through on their next write with no re-sync, so drop any retry-with-fewer-fields workaround you built for it. A `customrecord_*` write is unchanged: `owner` is a genuine setter there and still travels. * **Filter shared canonical tables with the concrete predicate `/v1/catalog/records` now publishes, and drop any `pg_row_id`-and-check-each-row workaround you built for these record types.** `sharedTable.hint` used to say the instance could not name a value for seventeen record types stored alongside a same-source sibling — Google Workspace’s `doc` / `email` / `file` / `sheet` / `slide`, Salesforce’s `account` / `lead` / `task` / `event`, Intercom’s `admin` / `company` / `conversation` / `ticket`, HubSpot’s `company` / `owner`, Zendesk’s `organization` and getsales’s `company`. Each now publishes a real predicate (`kind=eq.doc`, `entity_type=eq.company`, `activity_type=eq.task`, `item_type=eq.ticket`), and `selectTarget.discriminator` carries the same one. Three rows still cannot be named and say so as before: `zendesk:user` (a contact or an employee per row) and NetSuite’s `salesTaxItem` / `taxGroup`. * **Expect a read of one of those seventeen record types to return only that record type.** The MCP `list_records` / `get_record` path applies the predicate above, so a read that previously came back with the source’s whole slice of `entity`, `work_item`, `activity` or `memory_document` — siblings included, labelled as the type you asked for — now comes back narrowed. If your app filtered those siblings out itself, that code is now a no-op and can go; if it relied on the extra rows, ask for the sibling record type by name. * **Declare your app’s launcher labels in `public/turbobase.json` — a `labels` array of `{ name, color? }` — instead of asking an admin to re-tick them on every instance.** They render as a chip on your app’s card and a filter pill above the `/apps` grid. `color` is one of `slate`, `violet`, `amber`, `emerald`, `rose`, `sky`, `indigo`, `fuchsia` — any other value now FAILS the deploy rather than rendering grey, so check yours if you were already writing the key speculatively. Three things to know before you add it: the block is authoritative for your app, so a name you remove is a chip removed and `"labels": []` clears them all (leave the key out entirely to keep today’s behaviour of not touching them); the colour applies only when the label is NEW to the instance, because the vocabulary is shared with every other app, so yours will never repaint an existing chip — the deploy prints what it kept; and a label is navigation only, so it changes nothing about who can open your app. Max 12 labels, 40 characters each. A preview deploy reports what it would do and attaches nothing; `turbobase apps plan` shows which already exist. * **Stop retrying a write refused with a bare `An unexpected SuiteScript error has occurred` — the platform now does it for you.** That sentence is what the source says when one of its own server-side scripts threw and it had nothing more specific to add, so it covers a passing blip as readily as a real rejection; a write-back refused that way used to be abandoned on the first attempt. It now rides the same backoff every other transient refusal does, and reaches `record_write_state` as `blocked` with `reason_code = 'ns_error'` only once the attempt budget is spent. Nothing changes in your payloads and no terminal outcome is new — delete any resend loop you built around this message, and do not re-prompt the user while the row is still in flight. The same sentence WITH a cause or a script appended (`…has occurred: You cannot post to a closed accounting period.`) is a specific refusal and still lands immediately. * **Send `created_from_id` on every `POST /v1/data/itemReceipt` create — it is now required, and a create without it is refused `422`.** The rejection code is `CONVERSION_SOURCE_REQUIRED` and `rejection.field` is `created_from_id`; set it to the `external_id` of the purchase order the goods arrived against. Nothing is written and nothing is queued, so a caller that retries after filling it in leaves no half-record behind. This restricts a create that used to return `200` and then fail silently hours later against the connected system, which has no standalone item receipt at all — the receipt is a conversion of the order, and the order is what supplies its lines, quantities and costs. Updates are unaffected, and so is every other record type. * **Handle `source_receipt_not_transformable` as a `reason_code` on a `needs_input` `record_write_state` row.** It means a record that only exists as a conversion was written without naming the document it converts from, and it can only reach you now for a write queued before the refusal above shipped. `fields` names `created_from_id`, so ordinary fix-sheet rendering works; the `message` is plain language and does not repeat the connected system’s own sentence, which talks about the record’s lines and is misleading. * **Stop relying on a direct `/rest/v1/` write to resend `subsidiary`, `currency` or `customform` at the value the record already holds — those keys are now left off, and a genuinely different value still travels.** The v1.8.0 note told you creates and direct PostgREST writes “still send the whole row”; that is no longer true of those three. A write made outside `POST /v1/data/:recordType` (direct PostgREST or SQL, a backfill, a hook) records no changed-fields hint, so it is now diffed against the record’s last successful sync and a key matching it is dropped from the outbound patch — from the typed column and from `raw`. That is what stops NetSuite re-sourcing the transaction, clearing a dependent and refusing the save naming a field nobody touched. Name a different value and it reaches the source exactly as before; a record that has never synced, and every create, are unchanged. ## v1.11.0 [Section titled “v1.11.0”](#v1110) * **Handle `insufficient_stock` as a `reason_code` on a `needs_input` `record_write_state` row, and render `message` rather than a fix sheet.** It means the connected system does not have enough of the item for the quantity requested, and it arrives where a `field_validation` refusal used to: the row is still `needs_input`, because lowering the quantity is what makes the write land. What changed is that `reason_code` is now the cause instead of the generic validation code, and `message` is plain language telling the user to reduce the quantity or add stock — the source’s own words never said which field. `fields` is empty (the refusal names none), so an app that renders a fix sheet and nothing else will show a `needs_input` row with no instruction; show `message` for this reason code. It deliberately does not quote the stock level, which is stale by the time you read the row — read the item’s own quantity if you need a current one. ## v1.10.0 [Section titled “v1.10.0”](#v1100) * **Stop joining on a column that `/v1/catalog/columns` now marks `scopeDimension` — it is a permission boundary, not a link.** `subsidiary_id`, `location_id`, `department_id`, `class_id`, `scope_5`…`scope_8` and their `_name` twins carry a new `scopeDimension` field (and a description saying so) on every scope-slotted record type. They are the positional arguments of the row-access policy: two rows sharing one are filed under the same part of the business and nothing more. Using one to decide which document a row belongs to correlates on some records and silently returns the wrong rows on the rest — no error, just a wrong number on the screen. Follow the record’s own foreign key (`entity_id`, `created_from_id`, a declared relationship) instead. Additive: every existing `role` / `idFor` / `displayFor` value is unchanged, and the same column on the `location` / `department` / `classification` masters, where it IS a real parent link, is not marked. * **Send `client_id` on every `POST /oauth/token` request to your instance’s MCP OAuth bridge — the `authorization_code` grant as well as `refresh_token`.** A request without it answers `400 invalid_request`. Every MCP client Turbobase supports already sends it; a hand-rolled client that omitted it must add it. A refresh for a client the user has since revoked under Account → connected MCP clients now answers `400 invalid_grant` with no token: re-run the authorization instead of retrying the refresh. The `refresh_token` your client receives is bound to the `client_id` that redeemed the code — a refresh naming any other client is refused, and so is a refresh token issued before v1.10.0, which predates the binding. Expect one re-authorization per connected client after the upgrade; nothing else changes for a client that keeps using its own `client_id`. * **Pass `approve: true` to every shipping tool on `/mcp/authoring`, not only `deploy`.** `apps_push`, `tables_apply`, `bundle_deploy`, `release_deploy`, `release_push`, `release_rollback` and `instrument` now answer the same `approval_required` result `deploy` always did until the call carries it. Read-only tools are unchanged. The endpoint also refuses a JSON-RPC array body (`400 invalid_request`): send one request per POST. * **Read the correlation id, not a cause, out of an unexpected `tools/call` failure on `/mcp`.** A failure that is not about your request — a database or data-layer error — now returns a fixed message carrying the request’s correlation id instead of the underlying error text; the cause is in the instance log under that id. A PostgREST 4xx still surfaces PostgREST’s own `message`, and refusals of your input are worded as before. * **Expect a header-only update through `POST /v1/data/:recordType` to commit even when the record holds a stale reference elsewhere — and handle the one new way it can fail.** An update to an existing row that changes header columns only (no lines, nothing inside `raw`) is now applied to NetSuite as a field patch, validated field by field, instead of as a whole-record save. An Owner, location or list value that went inactive after it was chosen no longer refuses a patch that never mentioned it, so drop any retry-with-fewer-fields workaround you built for that. The new failure: a patched field that did not take — NetSuite left it UNCHANGED, so the readback still holds the value the record had before the patch (a field that is not inline-editable on that record type, or a script that put the previous value back) — lands as `netsuite_field_validation` with `detail.fieldId` naming the field and `detail.explain.diverged` carrying sent-vs-committed; treat it exactly like any other `field_validation`, and never read the write as having succeeded. A value NetSuite stored in another form is NOT that failure: a phone number read back in the account’s phone format, or a percent read back with its `%`, counts as applied. The one edge: a value re-sent in a different form from what the record already holds (raw digits for a phone already stored formatted) reads back unchanged and fails the same way, so send the value as the row holds it, or a genuinely new one. Creates, line edits, a change inside `raw`, and writes that bypass the write boundary (a direct PostgREST PATCH) keep the whole-record save and its behaviour. ## v1.9.0 [Section titled “v1.9.0”](#v190) * **Write `approval_status` as the source’s internal id (`'2'`), never the display label (`'Approved'`).** The mirror STORES the label — `approval_status` projects the ref’s name with no id fallback on `purchase_order` and `vendor_bill` — so an app that read a row and PATCHed one unrelated field posted that label straight back, and the source refused the WHOLE save for a field nobody edited. Nothing landed. Two things change, and neither affects a payload that already sends an id. A label that merely restates the row’s own last-synced value is now DROPPED from the outbound payload, so the rest of your write commits. A label that is a genuine change is now REFUSED before anything reaches the source: the label cannot be resolved to an id (the codes are not portable across record types, and guessing would write the wrong approval state into the customer’s ledger), so the record’s write state goes to `needs_input` naming `approval_status`. If you need to change an approval status, send the id. * **Expect `fields` on a `needs_input` write state to be POPULATED where it used to be empty.** A source refusal of the “invalid value for the following field” shape named no field at all, so the sidecar arrived with `fields: []` and an app rendering a fix sheet from it had nothing to show. It now carries the field the source named. Code that treated an empty `fields` as “this refusal has no field” should render the list instead. * **Re-read the row after `del(recordType, …)` — a `2xx` from `DELETE /v1/data/:recordType` is no longer final.** The tombstone is committed before the delete reaches the source, and when the source terminally refuses it (or a guardrail denies it, or an approval hold expires) the row is now UN-hidden instead of staying invisible for good. It comes back readable, and on every ending that records a write state it carries `write_status = 'blocked'`. **Do not assume a reverted row always has one** — a delete stranded because the source could not be signed in to at all comes back with no `record_write_state` row, so branch on its absence rather than waiting for a `blocked` that never arrives. If your UI removed the record from a list on the `2xx` and never looked again, it is showing a deletion that did not happen — re-read it on a sweep, the way you already handle removals. Do NOT wait for a Realtime frame: a row crossing `deleted_at` is the one transition Realtime is documented not to deliver (see *Build apps* → the dashboard recipe’s “do not write a DELETE handler”), and nothing here changes that. * **Handle `delete_blocked_by_dependents` as a `reason_code` on a `blocked` `record_write_state` row.** It means the source will not release the record while other documents point at it, and `message` names them (up to five by record type and document number, then “and N more”); when none could be resolved it says so rather than claiming none exist. Render the message as-is — it is written for the customer. Do not re-prompt for a field and do not resend: nothing on this record fixes it, and it clears only when the named documents go. * **Read `outcome` on each `turbobase.record_write_state.fields` entry before you re-prompt the user for a `needs_input` row — the source now says what it did with each field it named.** Every entry still carries `field` and `label`, unchanged. When the source answered, the entry also carries `submitted` (whether your write sent that field at all), `requested` and `actual` (what you sent and what the record held when it refused, each cut to 200 characters), and `outcome`: `applied` (the value landed, so the refusal is about something else on the record), `diverged` (it was cleared or changed on the way, so resending the same value will not help), or `empty` (you never sent it and the record requires it). A label-named entry (`field: "Vendor"`) gains `field_id` (`entity`) so you can map it to the column your form edits. Branch on `outcome` when present and keep today’s behaviour when it is absent: a refusal the source could not diagnose, or a named field the readback could not be tied to (`submitted` is only ever stated where the source proved it), still writes the two-key entry, at most 25 entries per row are enriched, and `status`, `reason_code` and `message` are unchanged either way. ## v1.8.0 [Section titled “v1.8.0”](#v180) * **Convert a dated amount through `currency_rate`, not `turbobase.currency.exchange_rate`.** `currency.exchange_rate` is the CURRENT rate, so a figure computed with it changes its own history after every sync. Dated rates are now mirrored, one row per pair per `effective_date`. Read the rate that was in force: `/rest/v1/currency_rate?base_currency_code=eq.USD"e_currency_code=eq.GBP&effective_date=lte.&order=effective_date.desc&limit=1`. An amount in `quote_currency_code` × `exchange_rate` is the amount in `base_currency_code` — read that direction rather than inferring it from the names, which mirror NetSuite’s, whose *base* currency is the subsidiary’s. An empty result means no rate is held for that date; it does not mean 1. If your tenant holds more than one connection to the same source, add `&connection_id=eq.` — each mirrors its own rate table, so `limit=1` would otherwise pick between two tied rows arbitrarily. * **Delete the “cannot total across currencies” guard you adopted at v1.6.6 — but only where you convert.** That note said a consolidated total could not be computed from the mirror, and it no longer holds: total it, converting each figure through the as-of read above. A row whose currency ref could not be resolved carries a NULL code and never matches a pair filter, so keep showing those in their own currency instead of folding them in. * **Regenerate your types (`turbobase project types`) — `/v1/catalog/records` now lists `netsuite:currencyRate` against table `currency_rate`,** and the PostgREST schema carries the table with it. A project pinned to the old output will not type-check a read of it. * **Handle a `guardrail_*` `reason_code` on a `blocked` record — a write our own policy stopped now reaches `record_write_state` instead of sitting on `pending`.** Until now a policy-stopped write left the row on `pending` forever, so an app polling for the terminal state never got one and showed the write as still in flight. Match on the `guardrail_` prefix rather than the whole string: the code names which policy stopped it (`guardrail_denied`, `guardrail_rate_limited`, `guardrail_confirm_denied`, `guardrail_policy_unreadable`, `guardrail_rate_unavailable`, and `guardrail_hold_expired` for an approval nobody answered in time) and the set can grow. Treat them like every other `blocked` code: do not re-prompt the user and do not resend unchanged — no edit satisfies a policy rule. Resending a genuinely EDITED record does re-attempt it, unchanged from before. If you were reading a stuck `pending` as “still working”, that timeout branch can go. * **Read the new `description` on a `status` / `*_status` column from `/v1/catalog/*` before you render the value as a state.** It says the code/label vocabulary is scoped to (record type, field) and is never portable, and that the value is one party’s assertion about one aspect of the document rather than an agreed state — a carrier’s “delivered” is not the warehouse’s “counted in”. The record’s `anatomyMarkdown` names the asserting party and the as-of column. Nothing in the response shape changed and an authored description still wins, so no code breaks; what breaks without it is a screen presenting a supplier’s or a carrier’s claim as your own confirmed state. * **Do not read `write_status` as one of those.** Its description now says plainly that it is Turbobase’s own standing on writing the row back to the source (synced / pending / retrying / needs\_input / blocked / muted), not a state anyone upstream asserted — so it has no asserting party and no as-of date. * **Stop sending `external_id` inside `parent` when you CREATE a record through `POST /v1/data/:recordType` — it now comes back `422` with `rejection.code = "PROTECTED_COLUMN"`.** The same holds for `pg_row_id`, `synced_at`, `sync_version`, `last_modified_pg` and `originating_system`: those columns belong to the platform. `external_id` is the record’s link back to the source system and it is minted UPSTREAM — a row you create carries `null` until the write-back worker commits it and back-fills the id the source issued. A value you supply points the row at a record the source never issued, so the first write-back addresses an id that does not exist and fails permanently with no retry, parking a `sync_failure` that cannot heal itself. Create the record without the field and read the id back off the row. `connection_id` and `source_system` are unchanged and still yours to send. * **Keep posting a row straight back the way you read it — that still works.** On an UPDATE, a protected column whose value EQUALS the row’s current one is accepted as the no-op it is, so read-modify-write against the whole row (`external_id` and all) needs no change; only a value that would CHANGE one is refused. One status moved with it: an update addressed by a `pgRowId` your session cannot see, carrying a protected column, now answers the same `422` where it used to answer `500 write_failed`. * **Read `sync_attempt.error_detail -> 'detail'` as JSON — stop `JSON.parse`-ing it as a string.** The source’s failure envelope was stored stringified inside the jsonb column, so the keys that actually diagnose a refused write were present but unreachable: `error_detail -> 'detail' -> 'fieldReadback'` errored and `error_detail -> 'detail' ->> 'scriptsOnRecordType'` came back null, which reads as “my instance doesn’t capture this” rather than “it is one parse away”. It is now stored as an object and is addressable in SQL. **Handle both shapes** — attempt rows written before this release keep the string and are never rewritten — so try the object first and fall back to parsing a string. This is the column to read for `scriptsOnRecordType` / `explain` / `fieldReadback` when a write is refused naming a field you demonstrably sent. * **Name `subsidiary_id` / `currency_id` in the patch when you mean to change one — setting `subsidiary` / `currency` under `raw` no longer reaches the source.** A `PATCH /v1/data/:recordType` is narrowed to the columns you named, and that narrowing now drops those two keys out of the `raw` passthrough as well as out of the typed columns: restating either at its own unchanged value makes NetSuite re-source the transaction and CLEAR its `entity`, so the save is refused naming a field you never touched (“Please enter value(s) for: Vendor” on a bill whose vendor is right there). Naming the typed column keeps it travelling, and it wins over `raw` exactly as before. On a record type with no promoted `currency_id` column (`invoice`, `cash_sale`, `estimate`, `credit_memo`, `item_fulfillment`, …) `raw.currency` is therefore no longer a way to change currency on an existing record — open an issue if you need one. Creates and direct PostgREST writes are unaffected: neither records a changed-fields hint, so both still send the whole row. * **Re-read `customer_id` on any payable line your app cached — some values change with this release.** On `vendor_bill_line`, `purchase_order_line` and `check_line`, rows written by the bulk backfill could carry a serialised ref object (`{"id": "3", "refName": null}`) in `customer_id` instead of the id, and others were left `null` when the customer was in fact known. A one-shot rewrites both. If you stored `customer_id` outside the instance, or matched on it as a string, refresh from `/rest/v1/` rather than trusting the cached value. * **Stop treating a `null` `customer_name` on a rebillable payable line as the normal case.** The v1.4.0 note told you to embed or join `customer` because the bulk path set `customer_id` with no name beside it. The name is now resolved from the mirrored customer, and stays `null` only where that customer has not synced or where its id is ambiguous across two connections. The embed is still correct and still the way to be certain — it is no longer needed for the ordinary row. * **Stop diffing `subsidiary` / `currency` / `customform` yourself before a NetSuite UPDATE — the platform now omits them when they are unchanged.** v1.4.3 told you to do this in your own app (“If your app builds updates by resending the whole record, diff against the prior row and omit unchanged trigger fields”); that work is now done for you, against the row’s mirrored `raw`, so a whole-record update no longer clears the record’s Customer and Order Type. Keeping your own diff is harmless. **One thing you must adopt:** `raw.customform` no longer changes a form on an UPDATE — it is dropped with the rest. Creates are unaffected, so the v1.2.0 guidance (“Set `raw.customform` if your record needs a specific NetSuite form”) still holds exactly as written for a create. To change the form on an existing record, do it in NetSuite. * **Drop `entity_type` from any filter you built out of `sharedTable.discriminators` on a transaction table, and re-copy the `hint` from `/v1/catalog/records`.** It was named on `invoice`, `order`, `estimate`, `opportunity`, `credit_memo`, `customer_payment`, `expense_report`, `order_fulfillment`, `purchase_order`, `vendor_bill`, `vendor_payment` and `work_item` — 50 entries — where it is a per-table CONSTANT carried for the `entity` foreign key and separates nothing, and since no value could be named for it the advertised predicate was a placeholder you were asked to fill in and could not. A query following it returned zero rows. `source_system=eq.` is unchanged and is now the whole answer on those tables; `entity_type=eq.customer` on `entity` and `item_type=eq.inventoryItem` on `item` / `work_item` are unchanged too. * **Handle an EMPTY `sharedTable.discriminators` — the note no longer always names a predicate.** `tax_code` holds NetSuite’s `salesTaxItem` AND `taxGroup`, so the old `source_system=eq.netsuite` excluded nothing while reading as a complete answer. The array is now `[]` and the `hint` says to match on `pg_row_id` and check the row you read — the same wording `/v1/catalog/objects/:id` already used. * **Re-read the `hint` where a filter narrows only PARTLY — it now names what still matches.** Filtering `activity` by `source_system=eq.salesforce` leaves `salesforce:event` behind, and `entity` for a record type this instance cannot name a type value for leaves that connector’s other entities behind. Both used to end “or you will read (and label) the wrong row”, which reads as “filter and you are safe”. They now name the record types the predicate does not exclude. * **A type column you cannot fill in is no longer named where the source predicate already isolates you — `sharedTable.discriminators` shrinks to `['source_system']` for fourteen more record types.** `dynamics-365-bc:item`, `hubspot:product`, `quickbooks:item`, `salesforce:product`, `shopify:product` and `stripe:product` each store exactly one record type on `item`, so `source_system=eq.` returns precisely that record type; the note used to add `item_type=eq.` beside it and still close “or you will read (and label) the wrong row”, so following it literally returned nothing. The same eight on `entity` and `work_item` — `asana:asanaTask`, `asana:asanaUser`, `hubspot:ticket`, `jira:issue`, `jira:user`, `notion:notion_user`, `salesforce:case`, `zendesk:ticket` — are fixed with them. The placeholder is KEPT where your answer still changes the result set, e.g. `hubspot:company` on `entity`, because `hubspot:contact` and `hubspot:owner` rows carry `source_system=hubspot` too — those hints now end “…or hubspot:contact, hubspot:owner will still match” rather than implying the filter is complete. * **Re-check any branch on `turbobase.record_write_state.status` for the anchored-line refusal — it now lands `blocked`, not `needs_input`.** When the source refuses a write because one of the record’s lines has already been received, shipped or billed, the row used to arrive `needs_input` with an EMPTY `fields` array, so a fix sheet asked the user to correct nothing at all. It now arrives `status='blocked'`, `reason_code='anchored_line'`, and a `message` naming the receipt, shipment or bill to amend. If your app renders `needs_input` as a form and `blocked` as an admin notice, this one is the notice — no field the user can edit releases the line. * **Stop pre-checking for downstream documents before you patch an order — editing a line now succeeds when a DIFFERENT line has a receipt or fulfillment against it.** NetSuite bundle 2.5.0 matches the lines it is given against the ones the record already carries and edits them in place, instead of clearing the sublist first and being refused on the anchored line. Adding a line to such an order, or dropping one from it, is still refused — but now names which line and why, instead of failing on a line you never touched. This arrives per tenant, when that tenant’s NetSuite bundle is redeployed at 2.5.0 or later; until then the write still fails, and the classification above is what changes first. * **Stop hiding a `blocked` write from the user when its `reason_code` is `source_not_transformable` — show `record_write_state.message`.** Every other `blocked` row carries the fixed “no action is needed from you” hold, so an app that suppresses `blocked` entirely was right until now. This one is different: it means the document the record was created from has nothing left to receive or convert, and the fix is that document’s status — something the user owns and can already see. The row is still `blocked` (nothing to type, `fields` is empty), so surface the message rather than a fix sheet. * **Add `source_system=eq.` to every read of `label`, `customer_status` and `lead_source` — each of the three now holds rows from two connectors, and each carries a `sharedTable` note on `/v1/catalog/records` where it carried `null`.** The GetSales connector projects `tag` onto `label` beside Asana’s `asanaTag`, `pipelineStage` onto `customer_status` beside NetSuite’s `customerStatus`, and `dataSource` onto `lead_source` beside NetSuite’s `leadSource`. None of the three is connection-scoped — the key is a bare `external_id` — so both sources write one id space and an unfiltered read returns both connectors’ rows. Copy the `hint` from the catalog entry rather than composing the predicate yourself; `discriminators` is `['source_system']` on all three and it is the whole answer, with nothing left matching after it. An instance with no GetSales connection reads exactly as before, so adopting this ahead of one costs nothing. * **Pin `?source=` on a bare `contact` or `company` record-type query if your app must keep reading a particular connector’s rows — on a tenant where GetSales is the only connected contact source, those two names now resolve to GetSales.** Unpinned resolution puts CONNECTED sources ahead of registry order, and `getsales` is registered after `hubspot`, `salesforce` and `intercom`, so nothing moves on a tenant that has one of those connected; it moves on a tenant that does not. Five entries also join `/v1/catalog/records` on every instance — `getsales:contact`, `getsales:company`, `getsales:tag`, `getsales:pipelineStage`, `getsales:dataSource` — carrying `connected: false` until a tenant connects GetSales, so keep checking `connected` before you offer one as a browsable table. `tag`, `pipelineStage` and `dataSource` are names no connector registered before: a bare query for one used to resolve to nothing. ## v1.7.0 [Section titled “v1.7.0”](#v170) * **Send `''`, not `null`, under `raw` when you mean to blank a field UPSTREAM.** A `null` or `undefined` value under `raw` is no longer written to the source — it is dropped from the outbound payload, leaving the source’s own value standing. It used to travel, and because the source sets every key it is given, it cleared fields nobody edited: a vendor patch that never touched the name arrived with Company Name blank and the whole write was refused. An empty string still travels, so that is the way to ask for a blank. Typed columns (`company_name: null`) have always behaved this way; `raw` now matches them. **This does not change what `null` does to the MIRROR** — under v1.5.5 it still removes the key from the stored `raw` column, and that is unchanged. The two meanings are now distinct: `null` edits your mirror, `''` edits the source. * **Cross a finding out in `INSTANCE-LEARNINGS.md` when you stop standing behind it — don’t delete it.** Mark the finding’s FIRST LINE in any one of three ways: strike it through (`## ~~40. Outbound stage reconciliation~~`), write `WITHDRAWN` or `RETRACTED` on it in capitals, or say “do not publish” on it. Until now nothing read the mark, so a finding you had withdrawn stayed in the review queue, could still be published as a knowledge-base page or as a standing rule injected into every agent turn, and was still shared as an anonymised lesson. Take the mark off and re-push and it is offered again. Deleting the section still does nothing — an absence reads as a restructured file, never as a withdrawal. * **Keep `AMEND` / `CORRECT` / `CLOSE` / `CORRECTED BY §N` for what they already mean** — this block REPLACES an earlier claim, and still publishes. They are deliberately not a withdrawal; a lead carrying only one of them is unaffected by the change above. ## v1.6.6 [Section titled “v1.6.6”](#v166) * **Change every money column from `currency.usd` to `currency.native`, and give it `currencyKey: 'currency_code'`.** `currency.usd` and `currency.eur` hard-code a symbol, and mirrored money is the DOCUMENT’S own face value — so a sterling or euro order was rendered as dollars on every table that used them. The new `currency.native` reads the ISO code from the column `currencyKey` names and renders in that currency; a row that records none renders a bare number rather than inventing a symbol. `currency.usd` / `currency.eur` still work unchanged, so nothing breaks until you move — but a money column on a mirrored record is wrong until you do. * **If you call `format()` yourself, pass the row: `format(value, col.format, { row, currencyKey: col.currencyKey })`.** The third parameter is new and optional; a two-argument call still compiles and still renders, but a `currency.native` column reached that way has no row to read and falls back to a bare number. * **Regenerate your types (`turbobase project types`) — `/v1/catalog/*` columns carry a new `currencyKey`,** and the `Formatter` union carries `currency.native`. A project pinned to the old union will not type-check a recipe or grammar that uses it. * **Read `currency_code`, not `currency_id`, when you need to know a row’s currency.** `currency_id` holds a NetSuite internal id on a NetSuite row and an ISO code on a QuickBooks / Shopify / Dynamics one, and `currency_name` is account-configurable free text. `currency_code` is on `order`, `vendor_bill`, `purchase_order`, `return_authorization` and `entity`, backfilled on upgrade, and NULL where the currency could not be resolved. * **Do not sum money across rows in mixed currencies.** No historical exchange rate is mirrored (`turbobase.currency.exchange_rate` is the CURRENT rate), so a consolidated total cannot be computed correctly from the mirror today. Group by `currency_code` and label each figure. ## v1.6.0 [Section titled “v1.6.0”](#v160) * **Delete your `apps push` worktree workaround — `.git` is skipped by name now, file or directory.** `turbobase apps push .` used to fail from a git worktree with `push rejected: invalid_path in .git`, because a worktree writes `.git` as a one-line FILE (`gitdir: …`) rather than a directory, and the walker only skipped directories. If you were rsync-ing the tree to a staging dir minus `.git` and pushing that, push `.` directly again. ## v1.5.5 [Section titled “v1.5.5”](#v155) * **A partial patch to a TRANSACTION no longer deletes the rest of `raw` — and if you added `raw` to a patch to work around that, take it out.** v1.5.2 said `raw` merges on update, and v1.4.3’s `lines` entry repeats it (“this is the OPPOSITE of `raw`, which now merges”). Read that as covering every record type and it is wrong — **it was only ever true on the types with no line table** (`customrecord_*`, customer, item, customerPayment, customerDeposit, timeBill). Every transaction carrying a sublist — salesOrder, purchaseOrder, invoice, vendorBill, itemReceipt, journalEntry, check, transferOrder and the rest — goes through the `write_transaction` RPC, which kept REPLACING the column: a patch naming `raw` at all deleted every key it did not mention, silently, with a `200`. Measured on a live tenant: three marker fields cut a purchase order’s `raw` from 58 keys to 3, taking the custom body field an app read with it. Both legs merge now, so the rule is finally one rule — the keys you send win, the keys you omit survive, and **clearing a key needs an explicit `null`, not omission.** Rows already flattened by this are repaired in place on upgrade, restoring from the platform’s own copy of the last synced payload; nothing to re-sync and no action needed. `lines` is still the opposite and still needs care — omitted preserves, `[]` clears, a list replaces. * **Reading a document’s lines out of `raw` was always the fragile shape — move to the line table.** The repair above cannot restore what a patch removed on a row the platform has never synced, and `raw` is poll-fresh for anything that is not a declared link or a promoted column. `order?select=*,order_line(*)` (and the `purchase_order_line` / `invoice_line` equivalents) is resolved server-side, RLS-scoped, in one request, and survives every write path above — the line rows were untouched even on the six documents this bug flattened. Six pairs need the constraint named — `item_receipt`, `return_authorization`, `vendor_return_authorization`, `journal_entry`, `check`, `transfer_order` — as `item_receipt_line!item_receipt_line_parent_pg_row_id_fkey(...)`. ## v1.5.4 [Section titled “v1.5.4”](#v154) * **Retry any record stuck on `RCRD_HAS_BEEN_CHANGED` — it writes now, and you should delete the back-off you added for it.** A record NetSuite had been edited in through its own UI could end up permanently unwritable: the mirror’s `raw` captured `version`, NetSuite’s optimistic-lock token, and `raw` is spread onto the wire on every patch — so every write re-sent a token that could never match again and was refused with “Record has been changed”. The error names a concurrency race, so the obvious reading is “retry it”, and no retry could ever succeed. Nothing to change in your payloads: the write path now drops `version` and the rest of NetSuite’s framework machinery (session and workflow ids, browser-form scaffolding, `record.save()` option names) before anything reaches NetSuite. Affected rows unblock on their next write with no re-sync. * **Stop reading `version`, `sys_id`, `nsapi*`, `nl*`, `wf*`, `_csrf` and the other NetSuite form keys out of `raw` — they are being REMOVED from it.** They were never data: each was frozen at whatever the record’s state was when the payload was captured, so anything rendering one has been showing a stale value. They are now dropped on the way in (so a row loses them on its next sync), on the way out (setting one through `raw` on `POST /v1/data/:recordType` is silently ignored rather than sent), and from rows already holding them. If a screen displays one, promote a real field instead. * **A NetSuite date-only field now arrives in `raw` as `YYYY-MM-DD`, not as an ISO instant — drop any `new Date(...)` that assumed a timestamp.** It used to serialize as whatever UTC instant the *executing user’s* timezone made of local midnight, so the same field came back as `2026-03-22T07:00:00.000Z` from one path and `2026-03-22T00:00:00.000Z` from another, and a `::date` in any other zone read the wrong day. A date-only field has no time and now says so. The promoted `cf_ns_*` column is unchanged in TYPE (`timestamptz`) and lands at UTC midnight, so a range predicate keeps working; only the `raw` string shape changed. `datetime` / `datetimetz` fields are untouched — an instant is still an instant. ## v1.5.3 [Section titled “v1.5.3”](#v153) * **Re-test the header-only patch you were told worked in v1.5.2 — on transactions it did not, and now does.** The Postgres half landed correctly (omitting `lines` has preserved your line rows since v1.5.2), but the NetSuite half never engaged for any record written through the atomic header+line path — sales order, purchase order, invoice, vendor bill, item receipt, journal entry, check, transfer order, expense report, and the rest. Those patches still went upstream as the whole row with the sublist attached, so a header-only edit on a received or billed document was still refused (“would leave the Item Receipt … unlinked”, or a mandatory line field you never sent). Nothing to change in your code — if you added a workaround after v1.5.2 appeared not to help, remove it. * **Stop re-sending `lines` just to keep a line’s tax code.** Outbound lines now carry `tax_code_id`, so a sublist you DO replace keeps its tax code instead of coming back blank. Previously the only way to set one was a raw override (`raw: { taxcode: "5" }`) per line; that override still works and still wins over nothing, but the promoted column is now the default and you no longer need it. Applies to the item sublist and to the expense sublist of a bill / PO / check. ## v1.5.2 [Section titled “v1.5.2”](#v152) * **Stop sending a `lines` array you did not change — omitting it now PRESERVES the lines instead of deleting them.** On `POST /v1/data/:recordType`, an update that carries no `lines` key used to run an unconditional `DELETE FROM ` and return 200 over a document it had just emptied. Three states now, and they are distinct: **omit `lines` (or send the string `"preserve"`) → the existing lines are untouched**; `lines: []` → cleared, because you asked; `lines: [ … ]` → replaced. If you have been re-sending the whole line array on a header-only patch to work around the old behaviour, delete that code — it is what makes NetSuite refuse the write (next item). Anything other than an array or `"preserve"` is now a 422 `INVALID_LINES` rather than being read as “absent”. * **A header-only patch no longer restates the NetSuite sublist — you can finally set `custbody_*` fields on a received or billed transaction.** The worker used to attach the full line set to EVERY update of a record type that has lines, and the RESTlet treats a supplied sublist as REPLACE (remove every line, then re-add). On a purchase order with item receipts against it, that asked NetSuite to delete the lines those receipts point at, and was refused with “would leave the Item Receipt … unlinked”. No payload shape avoided it — restating the lines verbatim produced the identical failure, because NetSuite compares nothing, it just performs the removals. Patch the header alone and the sublist is now omitted entirely. Still send only the fields you are changing: restating `subsidiary_id` / `currency_id` at their unchanged values re-sources line tax codes and fails naming a line you never sent. * **Stop reading display-only NetSuite fields out of `raw` — Inline HTML, Label and Help fields are no longer captured.** They hold no stored value (`getValue` returns whatever the form rendered), and capturing them made every later write fail: `raw` is spread back onto the wire on every patch, so a record carrying one was unwritable by every writer — app, chat agent and reconciler alike — with a message naming a field none of them had set. If a screen displays one, compute it in your app or promote a real field. Already-affected rows unblock without a re-sync: the write path drops these fields and reports them as `skipped_display_only`. * **If you call the `turbobase.write_transaction` RPC directly, note that `p_lines` now defaults to NULL, not `'{}'`.** NULL means “leave the lines alone”; pass `'{}'::jsonb[]` explicitly if you want them cleared. Omitting the argument no longer deletes the sublist. ## v1.5.1 [Section titled “v1.5.1”](#v151) * **Declare the roles your screens need in `public/turbobase.json`, and gate on the same capability strings.** A new optional `roles` block — `[{ "name": "Warehouse clerk", "description": "…", "capabilities": ["read:order", "write:order_fulfillment"] }]` — creates each entry at production deploy as an ordinary role, the same one an admin manages in Admin → Roles. **Declaring grants nobody anything: a declared role has zero holders until an admin assigns it in Admin → Users**, so never tell a user a deploy gave them access. You cannot declare `appRole` (admin is assigned, never authored) or `scopes` (row narrowing is the admin’s) — both are deploy errors, not ignored keys. Redeploying ADDS missing capabilities to an existing role and never removes any, so trimming your manifest does not revoke anyone; a name that collides with a built-in fails the deploy. Preview deploys report the roles without creating them; promoting applies them. Run `turbobase apps plan` to see the verdicts before shipping — it writes nothing and exits non-zero if a role cannot be created. * **Add `hasCapability()` / `requireCapability()` / `sessionClaims()` and the `TurbobasePermissionError` class to your `src/lib/turbobase.ts` from the current scaffold, then gate every screen and catch every denied write.** This fixes two failures your app cannot currently explain. A read the user may not perform is filtered by RLS and comes back as an EMPTY ARRAY, not an error — so check `hasCapability('read:invoice')` *before* rendering, or a permission problem is indistinguishable from “no data”. A denied write now throws `TurbobasePermissionError` (not a generic `Error`), so `catch (e) { if (e instanceof TurbobasePermissionError) … }` can name the role to ask for instead of showing a raw message. `hasCapability` mirrors the database’s `has_capability()` including the admin bypass — it is UX only; RLS remains the guarantee. * **Re-run `turbobase apps init` in an existing app project to pick up the new `roles-and-permissions` skill.** Existing files are never overwritten, so this is safe. The skill teaches your agent the two rules above — that deploying a role grants nobody anything, and that a denied read is silent — plus the checklist for adding a gated screen. * **Stop reading `document_share` and `kb_guest_access_code` over `/rest/v1` — both tables are dropped.** Knowledge-base guest access (the external email-code reader) is removed; a `select` against either now fails as an unknown relation rather than returning zero rows, so a stitched embed that names one takes the whole request down with it. `document_comment` is unchanged and stays readable, except that `author_kind` is now constrained to `member` / `admin` — drop any `guest` branch in your rendering, and any guest-authored rows your app had cached are gone. * **Send only real columns in the `payload` of `POST /v1/sync/events/from-chat`, and make sure the signed-in user holds the record’s `write:
` capability.** The transform route now applies the same two gates as `POST /v1/data/:recordType`: a caller without the capability gets `403 { error: 'forbidden', capability }`, and any key that is not a column of the record’s canonical table gets `422 { error: 'validation_failed', rejection: { code: 'UNKNOWN_COLUMN', field, message } }` with nothing queued. `created_from_id` is always accepted. Move custom fields into `raw` (same as the write boundary); the undocumented `{ fields, sublists }` envelope and array-valued sublist keys are no longer accepted. ## v1.5.0 [Section titled “v1.5.0”](#v150) * **Check `dataSyncEnabled` on every `/v1/catalog/records` entry before you render an empty grid.** An instance can run CUSTOMIZATIONS ONLY: the tables exist, the columns are real and kept in step by reconcile, and no records are mirrored — permanently, by the customer’s choice at connect time, not because a backfill is still running. `connected: true, dataSyncEnabled: false` is that state, and it is the one your empty-state copy should name (“data sync is off for this instance”) instead of a spinner or a “no results” message. The field is on every entry from `/v1/catalog/records` and `/v1/catalog/records/:type`. * **For ONE refused write you no longer need that log — read `source_detail` on the sync failure itself.** Every write the source refuses now records its own diagnosis on the failure row: `error_name`, `implicated_script` (the file the throwing stack names, parsed out for you), `foreign_script_errors` (what any script LOGGED in the same two-minute window), and `mandatory_empty` (the fields the source still considers mandatory-and-empty, **by field id and label** — which is how you resolve a refusal that names a display label like `Status` onto something you can set). It is visible in the failures drawer and on the row. Read `scripts_deployed_on_record_type` as “whose code COULD have run”, never as the cause — naming a script from that list is a guess, and it has been made and been wrong. Reach for `source_script_log` when you need the account-wide picture instead. Needs NetSuite bundle 1.24.0. * **To read a NetSuite script’s SOURCE or its plain-English explanation, use the AUTHORING MCP (`turbobase mcp`) — `search_catalog`, then `explain_object`.** Those tools are not on the data MCP, so an agent connected only to the data server will report that it cannot read the script; that means “not from here”, not “not available”. `explain_object` returns the captured definition or script source, the generated explanation, and `modifiability` — whether this account can change the object at all, which is worth checking before planning a change. ## v1.4.3 [Section titled “v1.4.3”](#v143) * **Remove any workaround you added for customer writes being rejected — and match the rejection’s `field` in snake\_case, not camelCase.** Every write to `POST /v1/data/customer` came back `422 MANDATORY_FIELD_MISSING` naming `companyName`, however the request was spelled: the check read camelCase keys while that endpoint hands hooks the canonical column names, so it never saw the name it was asking for. Customer creates and updates now go through, and a partial PATCH (touching only `phone`, say) no longer has to restate the name. If you branch on the rejection payload, the `field` is now `company_name` / `first_name` / `last_name`. * **New skill `ns-write-failure-classifications` — decode a failure before you edit the payload.** The classification says WHO can fix it, the message says what: `netsuite_field_validation` names a field and never a cause (five unrelated bugs share it), `netsuite_ns_error` covers one retryable race and several that are not, `netsuite_has_dependencies` is a fact about the document graph and never retryable, and `guardrail_hold_expired` means the write never reached the source at all. Also: a climbing `occurrence_count` means the same broken write is being resent, which only `Record has been changed` will ever recover from. * **`Please choose an item to add` means one of your lines is EMPTY.** NetSuite does not skip a blank line — it refuses the whole record for it, and does not say which line. Measured live: a purchase order whose second line had `item_id`, `quantity` and `rate` all null, from a builder that emitted one row too many. Filter empty objects out of `lines` before sending; nothing downstream ignores them. * **Three more write diagnostics, all per-request.** `options.explainSourcing` diffs every field between “your values applied” and “immediately before save”, naming what NetSuite **cleared** in between — that is the re-sourcing trap reporting itself instead of you having to know the rule. `options.sublistReadback` gives requested-vs-actual per LINE, including whether an `inventorydetail` subrecord’s assignments actually landed. `options.capturePayload` echoes the wire body the RESTlet received, which is the only way to see what the adapter sent after the raw spread, ref stripping, derived-field strip and delta narrowing. **`capturePayload` is gated** (it returns your own data, not metadata) and truncated at 8 KB; the other two are not. Needs NetSuite bundle 1.23.0. * **A transaction with downstream paperwork cannot be DELETED either.** The linkage that makes a document append-only also blocks its removal: `has_dependencies — This transaction cannot be deleted because it is linked to by one or more transactions`. It lands as `blocked`, and it bites hardest in cleanup: a create → convert → delete loop leaves behind exactly the records with the most linkage. Delete the downstream document first, or leave both; retrying never helps. * **A write refused for a field you did send: check what else is deployed on the record.** The failure page now lists every script deployed on that record type at the moment the write failed (`/data/sync/failures?event_id=`), each linking to its catalog entry — one measured account runs 13 User Events on `salesOrder`, all invisible from the app side. Pair it with `options.diagnostics: true` to get a requested-vs-actual readback proving whether your value landed. New skill: `ns-whats-deployed`. * **`connection_id` is no longer sent to NetSuite.** It was reaching the wire as `connectionid` and being STORED on the customer’s record — Turbobase bookkeeping written into their data. No action needed; noted because a record written before this may carry the stray value. * **Debugging a NetSuite write that fails for a field you demonstrably sent: add `options.diagnostics: true`.** The failure envelope then carries a requested-vs-actual readback of every field the write applied, the body fields NetSuite considers mandatory AND empty, the same per line across every sublist, and how many lines / inventory assignments the RESTlet actually received. Off by default (the scans walk the whole record), no redeploy needed, and it reaches `sync_attempt.error_detail` so the developer who hit the failure can read it without account access. Needs NetSuite bundle 1.22.0. * **Shipping an order: read the new `ns-item-fulfillment` skill before writing the code.** A fulfillment is a TRANSFORM of a sales order, never a standalone create (`created_from_id`), and its `status` is a MANDATORY setter — the opposite of `status` on every other transaction, where it is derived. The value is a prefixed code: `ItemShip:A` picked, `:B` packed, `:C` shipped. Only `ItemShip:C` posts to the GL (debit COGS, credit Inventory), so a fulfillment left at A or B moves the document and not the ledger. A lot/serial item also needs per-line `inventory_detail`, and the lot must already hold stock because a fulfillment ISSUES rather than creates one. * **The append-only rule covers BILLING, and the refusal renames the child.** An invoiced sales order is refused for a *Revenue Commitment* — the same invoice under its accounting name — so the noun in the message is not a reliable guide to what to go looking for. Check for downstream documents across all four pairs before patching: `salesOrder`→`itemFulfillment`/`invoice`, `purchaseOrder`→`itemReceipt`/`vendorBill`. * **A transaction with paperwork against it cannot be patched — and that now includes the receipt side.** `purchaseOrder`↔`itemReceipt` behaves exactly like `salesOrder`↔`itemFulfillment`: NetSuite refuses with “would leave the Item Receipt … unlinked”. Updating the RECEIPT’s own header fails too, even with no `lines` in the payload, because the write path replaces the sublist regardless and a transform-sourced sublist is static. Check for downstream documents before patching, in both directions. * **`invalid reference ` on a transform is about the SOURCE’s state, not the id.** The id is the originating document and it is usually perfectly valid; NetSuite means the record cannot be converted from. Most often there is nothing to convert — a purchase order for an item that moves no stock is `Pending Bill` on creation, so receiving it refuses. Check the source’s status before changing the payload. * **`Record has been changed` is a retryable race, not a bad payload — re-read and resend.** NetSuite refuses the save when the record was modified between your write reading it and saving it, which happens routinely: the inbound echo of your own previous write rewrites the row seconds later, and the account’s own User Event scripts fire on every save (one measured account carries 13 on `salesOrder`). Measured live: committed, then refused 3s later, then committed again 4s after that. It lands as `blocked` with `reason_code = ns_error`, so do NOT re-prompt the user — reapply your change to a fresh read and resend. * **Narrowing `raw` is not narrowing the patch — cut the canonical columns first.** A write body has two levels: the canonical columns (`subsidiary_id`, `currency_id`, …) and `raw`. The re-sourcing trigger lives in the COLUMNS, but `raw` is the level people trim first. Measured live: two attempts on the same order seconds apart, one with `raw` at 76 keys and one cut to 3, failed identically — both still carried all 47 canonical columns. Send the columns you are changing and nothing else. * **Restating a transaction’s lines: don’t build the restatement from the mirror right after your own write.** A line your app wrote carries `tran_internal_id` NULL; the inbound echo of that same line arrives from the source carrying it, and for a window the mirror holds BOTH — measured live as three `order_line` rows where NetSuite held two. Since a patch replaces the line set, restating from the mirror in that window sends a duplicate line and fails for a reason that looks nothing like the cause. Read the sublist from the header’s `raw->'item'->'items'` (the source’s own copy), or wait until `record_write_state` is `committed` and the echo has applied. * **Verifying a write landed: filter line tables on `parent_pg_row_id`, never on the source-id column (`tran_internal_id`).** The source id is populated by the inbound sync only, so it is NULL on any line your app wrote — a query like `order_line?tran_internal_id=eq.` returns zero rows for a header whose lines are present and correct, which reads as “my patch emptied the sublist” and sends you hunting a data-loss bug that isn’t there. Prefer the FK embed (`order?select=*,order_line(*)`), which cannot pick the wrong column. * **A NetSuite write refused for a LINE field can be caused by a header-only patch — stop restating `subsidiary_id` / `currency_id` on updates.** Sending a dimension back at its own unchanged value makes NetSuite re-source everything that depends on it, and that now demonstrably includes **line** tax codes. So a patch carrying four `custbody_*` fields and no `lines` at all is refused with `Please enter value(s) for: Tax Code`, naming a line you never sent, whose tax code is correct in the mirror. On an update, send only the fields you are actually changing. This bites the two-phase shape hardest — write the record, then come back to stamp cross-reference ids — because the *second* call is the one that fails; carry the cross-references in the first write where you can, or send the follow-up with only the `custbody_*` fields and no dimensions. * **When a source write is refused for a field you demonstrably sent, read the account’s script log before touching your payload — MCP `source_script_log`, or `GET /admin/sources/netsuite/script-log?sinceMinutes=15&type=DEBUG`.** Run the failing write first (the window is clamped to 60 minutes / 200 rows), then read it; `window.truncated: true` means you are looking at a capped slice, so treat “nothing here” as “look again, narrower”, never as “no script fired”. Full-instance-admin only, and every read is audited. Needs NetSuite bundle 1.16.0. * **BREAKING — stop reading rows with a PAT: `POST /auth/pat-exchange` and the `data:read` scope are REMOVED.** Any script, CI step or agent that traded a PAT for a session and then called `/rest/v1/*` now gets a `404` on the exchange. Replace it with one of: the visitor’s session cookie (inside a hosted app — nothing to change, `select()` already does this), or the **data MCP**, which signs the developer in over OAuth and exposes `list_records` / `get_record` / `search_fields` as tools; connect it from Settings › Integrations. The exchange minted the token OWNER’s ordinary session, so for an admin owner it carried write — a 90-day token in a project file was standing write access to production data. Reading rows is a per-user act now, carrying the real person’s identity and RLS. * **Drop `Authorization: Bearer ` from `POST /v1/data/:recordType`, `POST /v1/batch` and the data delete route — send the session cookie.** Those three accepted a bearer session solely for pat-exchange callers, and with the exchange gone nothing can obtain one; the option was removed rather than left as an auth path no credential satisfies. A same-origin app was already using the cookie and is unaffected. * **`turbobase project types` no longer emits the OpenAPI half headlessly — it degrades to record shapes and says so.** The record-shape half (PAT + `catalog:read`) is unchanged and is what generated clients are built from; only `/v1/catalog/openapi.json`, which is session-gated, is skipped. If your CI asserted on the OpenAPI half, pass `--records-only` to make the intent explicit rather than relying on the degrade path. * **Confirm a write landed by reading `turbobase.record_write_state` — a `2xx` from the write endpoint means QUEUED, not applied.** Writes to a source are asynchronous, so an app that reports success on the HTTP response reports it over failures. Poll the row and branch on `status`: `pending` (in flight), `needs_input` (the source rejected the payload — fix and resend), `blocked` (anything the end user CANNOT fix — check `reason_code`: `ns_error`, `permission_denied`, closed period, workflow lock, or a guardrail hold. Do not re-prompt the user; resending unchanged will not help). The split is deliberate: `needs_input` is a field problem with plain-language text and a `fields` list, `blocked` is everything else and stays on the admin plane — the writer defaults to `blocked` when unsure, because wrongly asking a user to fix something they cannot is the worse failure. Read it through your normal session — it is already granted; do NOT reach for `turbobase.sync_failure`, which is admin-only by RLS and returns zero rows to an app session whatever grants you add. * **After creating a record, keep the `pg_row_id` your insert returned — do not re-key on a row fetched by `external_id` until the write settles.** With real-time inbound working, a create briefly exists as TWO Postgres rows: yours, and one the source’s webhook inserted before write-back could stamp the `external_id` back. The platform reconciles them and **keeps your originating row**, deleting the twin (`write-back echo twin reconciled` in the worker log). A row you re-fetched by `external_id` inside that window may be the twin, so anything stored against its `pg_row_id` dangles once it is collapsed. This only appears once inbound webhooks are actually arriving — an instance with no tunnel never shows it. * **Debug a NetSuite write yourself with `options.explain` — no platform change needed.** `op=write` / `op=transform` now take an `options` object beside `fields`: `explain` (default false) returns `{applied, diverged}` comparing what you sent against what the record actually holds — on success the committed record, and **on failure under `detail.explain`**, which is the case you actually want it for. Check `phase` before reading it: `committed` means the value is live; `pre_save` means the save threw, nothing committed, and `applied` there means only “assigned in memory” — NOT that NetSuite accepted the field. A field `setValue` accepted and NetSuite then cleared shows up in `diverged` instead of silently succeeding, turning `Please enter value(s) for: Customer` into `entity: sent "35", current -1`. `enableSourcing` (default true) turns off the re-sourcing that clears dependents; `isDynamic` (default false) loads the record in dynamic mode and is REFUSED alongside `sublists`, since dynamic records have no `setSublistValue`. `ignoreMandatoryFields` is **gated per connection** — honoured only while that connection is in debug mode (`source_install.debug_mode_expires_at` in the future, auto-off after 24h); otherwise it is stripped before the write reaches NetSuite and the refusal is logged, so a bypass you asked for and did not get never reads as a bypass that was tried and did not help. **Absent `options`, behaviour is unchanged** — this adds nothing you must adopt, only something you can reach for. Unknown keys are ignored, so a newer caller is safe against an older bundle. * **`Please enter value(s) for: X` is FIVE different bugs — do not start from the field name.** The same sentence has produced `Customer`, `Tax Code`, `Ship From` and `Order Type` from four unrelated causes. Triage in order: (1) is the field actually empty on the source’s copy — and check BOTH levels, a header tax item and a line tax code fail identically; (2) does your payload restate `subsidiary` / `currency` / `customform` even unchanged (this is the most common cause, and it clears a field you never touched); (3) does the record have lines your patch omitted; (4) is it genuinely mandatory and never supplied; (5) only then suspect another vendor’s User Event. Fixing cause 2 in one code path and leaving another is how the same bug returns wearing a different field name. * **Check for downstream documents before patching a transaction — an order with a fulfillment/invoice/receipt cannot be patched at all.** **SUPERSEDED by bundle 2.5.0 — see v1.7.0 above; the lead clause above is no longer true.** Restating the lines WAS refused (`Items on this line have been fulfilled. To delete this line, you must first delete the corresponding line(s) in the associated transaction(s).`) because the write replaced the sublist and NetSuite would not let the clear touch a referenced line. Bundle 2.5.0 matches the lines in place instead, so an ordinary patch of such an order now goes through; only adding a line to one, or dropping one from it, is still refused. Measured on a sales order with one line and one linked item fulfillment, changing a single header field with the line restated exactly as NetSuite held it. One clause here was wrong when written: omitting the lines does NOT empty them — a payload with no lines omits the sublist key entirely and NetSuite keeps what it has. (The “omitting them DELETES them” rule below is a different layer: the PG-side write RPC, not the source payload.) Do NOT delete the fulfillment to get a write through — the message describes what NetSuite would need, it is not an instruction. * **Restate lines COMPLETELY — a partial line wipes the fields you leave off.** The replace is per line as well as per set, so `{item, quantity, tax_code}` on a line that also had a rate and an amount removes the money, and NetSuite refuses with `Please enter a value for Amount` — the same underlying mistake wearing yet another field name. Read the current line set and send it back with your change applied; never assemble a line from just the fields you are changing. * **Always restate `lines` on a transaction UPDATE — omitting them DELETES them.** `write_transaction`’s update leg runs an unconditional `DELETE` on the line table before it reads the lines you sent, then re-inserts whatever arrived. So a header-only patch (a status, a custom body field, a closing stamp) empties the document and returns `200 OK` with nothing logged. Re-sending the same lines is safe; sending a subset replaces the sublist with that subset. Note this is the OPPOSITE of `raw`, which now merges — the same payload obeys different rules per field. Records with no line table (`customrecord_*`, customer, item, customerPayment, customerDeposit, timeBill) take another path and are unaffected. * **Before reporting that Turbobase damaged a mirrored record, check the source.** A row that looks wrong is either what the source holds, or a bad write, or local-only damage — indistinguishable from the mirror alone. Check in order: (1) `write_status='blocked'` or all `sync_event` rows `failed` means **nothing reached the source**, so the source is untouched by definition; (2) `op=read` shows what the source actually holds; (3) never infer damage by comparing a row against a *different* record — a neighbouring document with more lines is not a “before” picture. * **Do NOT resend `subsidiary` (or `currency` / `customform`) on a NetSuite transaction update unless the value actually changed — writing it clears `entity`.** Measured live: on a sales order already holding `subsidiary=7` and `entity=35`, a write of `{entity, subsidiary}` — subsidiary set to its OWN current value — came back `Please enter value(s) for: Customer, Order Type, Ship From`, while `{entity}` alone succeeded on that field. NetSuite re-sources dependent fields whenever a trigger field is written, changed or not, and re-setting `entity` in the same payload does not survive (ordering does not help). If your app builds updates by resending the whole record, diff against the prior row and omit unchanged trigger fields. Creates are unaffected, which is why this presents as “creates work, updates fail”. * **You cannot unwind an order by clearing its lines — delete the downstream documents first.** A line referenced by an item fulfillment, receipt or invoice is anchored: NetSuite refuses `Items on this line have been fulfilled. To delete this line, you must first delete the corresponding line(s) in the associated …`. For a rollback, delete youngest-document-first. **Updated for bundle 2.5.0:** sending in-place line overrides is no longer something an app arranges — the platform now detects an anchored line and matches in place by itself, so an ordinary edit works. What remains true is the part above: the anchored line itself cannot be removed, and neither can a line be added alongside it. * **A `404 NONEXISTENT_ID` in `pg→source read-back failed` right after your own delete is expected — ignore it.** The worker re-reads every record after a successful write-back; a rollback or undo that deletes the record moments later wins the race. The warning says “PG may lag the source until the next full sync”, which is wrong in that case: nothing is lagging and no sync restores it. Treat it as confirmation the delete landed; it only matters if you did NOT delete the record. * **If you saw `failed to set links[0].rel` or `failed to clear links[0]`, stop sanitising your payload — the platform was re-introducing it.** `links` is NetSuite’s REST HATEOAS array, an array of objects, so the write path classified it as a sublist and tried to address lines on it. It reached the wire from the mirror’s `raw` (the read-back stored the REST envelope verbatim and the header mapper spreads `raw`), which means a clean app payload still hit it. Now stripped on the write path; no app change is needed and an app-side fix could never have closed it. * **Read structured write failures from `turbobase.sync_attempt.error_detail`, NOT `sync_failure` — the latter has no detail column.** `sync_failure` is one row per root cause (deduped, `occurrence_count`); `sync_attempt` is one row per attempt, and `errorName` / `stack` / `foreignScriptErrors` / `scriptsOnRecordType` live there. Querying `sync_failure.error_detail` returns “column does not exist”, which reads as the enrichment being unavailable on your instance when it is one join away (`sync_attempt.event_id = sync_failure.event_id`). Use `sync_failure` for the reason text and occurrence count; `sync_attempt` for the evidence. * **A NetSuite mandatory-field error naming a field you DID send is a customization problem — read `scriptsOnRecordType` before re-checking your mapping.** `sync_failure.error_detail` now carries the script context on `field_validation` as well as `ns_error` (bundle 1.11.0). Read the three fields together: `stack` says whether the value was ever set (thrown at `save` = it was), `foreignScriptErrors` says whether any third-party script crashed, and `scriptsOnRecordType` lists every User Event deployed on that record type. An empty `foreignScriptErrors` does NOT mean no third-party involvement — a `beforeSubmit` that clears a field without throwing logs nothing, and that is the common case. Measured live: a `salesOrder` update failing `Please enter value(s) for: Customer` while sending `entity_id` had **16 User Event scripts** on the record. * **Stop sending NetSuite REST envelope keys in a write payload — strip `links` before you post.** If you build a write body by spreading a record you fetched from the NetSuite REST API, you are also sending its HATEOAS `links` array; NetSuite then fails the whole save with `failed to set links[0].rel`. Rows read from Turbobase are already safe (the read path drops `links`) — this only bites when an app round-trips a raw source response. ## v1.4.0 [Section titled “v1.4.0”](#v140) * **Set `line_kind: "expense"` on account-coded lines when you write a `vendorBill`, `purchaseOrder` or `check`.** These three records hold their lines in two NetSuite sublists — item-coded and account-coded (a GL account plus an amount, no item) — and `line_kind` is what routes each line to the right one. Until now every line was written as an item line, so an account-coded line was rejected by NetSuite: the read path could show a bill your app was unable to edit. Send `{ "line_kind": "expense", "account_id": "54", "amount": "250.00", "description": "November electricity" }` and omit `item_id` / `quantity` / `rate`; a line with `line_kind` set to `"item"` still goes to the item sublist exactly as before, so existing item-only writes need no change. Reads are unchanged — `line_kind` has always been on the row. (Later narrowed: a line that leaves `line_kind` absent or null is no longer sent to the item sublist unconditionally — one naming an `account_id` with no `item_id` is routed to the expense sublist by its own shape. Setting `line_kind` explicitly is still the only unambiguous form.) * **Treat `is_billable` on a payable line as THREE-valued — `null` means “not observed”, not “not billable”.** `vendor_bill_line` and `purchase_order_line` gain `customer_id` / `customer_name` / `is_billable` (`check_line` already had the columns), the pair that lets a pass-through cost be rebilled to a customer. If your app lists rebillable costs, filter `is_billable=eq.true` rather than treating falsy as not-billable: rows synced before this release read `null` until the one-shot backfill and the next delta sync fill them. `customer_name` can stay `null` on rows that came from the bulk backfill even once `customer_id` is set — embed or join `customer` if the name must always render. ## v1.3.0 [Section titled “v1.3.0”](#v130) * **Read `source`, `recordType` and `recordId` off the query string to know which NetSuite record opened your app.** Two new bundle scripts — a button on the record form (“TB Open in Turbobase”) and a record subtab (“TB Turbobase Subtab”) — append `?source=netsuite&recordType=&recordId=` to whatever app path the NetSuite admin sets on the script deployment, so `/a/orders/detail` arrives as `/a/orders/detail?source=netsuite&recordType=salesorder&recordId=4321`. Treat all three as OPTIONAL: the same app is still opened from the dashboard portlet and from a bookmark with none of them, and `recordType` is NetSuite’s internal type id (`salesorder`), not the canonical Turbobase name (`salesOrder`). The user arrives signed in with their own NetSuite role either way — the scripts route through the same mint step the portlet uses, so RLS sees the real person, not a service identity. ## v1.2.0 [Section titled “v1.2.0”](#v120) * **Add `.github/workflows/turbobase-app-deploy.yml` to get previews on pull requests and production on merge.** Re-run `turbobase apps init .` in your project (it never overwrites an existing file) or copy the template from `templates/ci/turbobase-app-deploy.yml`. A pull request gets its own preview URL — updated in place on every push, so reviewers keep ONE link — plus a plan of what merging would change in your config; merging to your default branch applies the config and puts the app live. Set the repo variables `TURBOBASE_BASE_URL` and `TURBOBASE_APP_SLUG` and the secret `TURBOBASE_PAT` (scope `extensions:deploy`); until `TURBOBASE_BASE_URL` is set every job skips rather than fails. * **Delete `.github/workflows/turbobase-tables-plan.yml` if you have it.** Its job is now the `turbobase apps plan .` step inside the deploy workflow above, which also checks guardrail-blocked writes. Keeping both runs two checkouts and two logins to answer one question. * **Declare the source writes your app needs in `public/turbobase.json`, and a blocked write fails the DEPLOY instead of failing per-row in production.** Add `"writes": [{ "source": "netsuite", "recordType": "salesOrder", "ops": ["write"] }]` — `ops` is `write` and/or `delete`, defaulting to `["write"]`, and `delete` is checked separately because opening a write never implies the right to delete. This is a REQUEST, not a grant: `apps deploy` refuses with the blocking guardrail named, and an admin opens it (Compliance → Guardrails). An app cannot grant itself a write. * **Run `turbobase apps plan .` to preflight a deploy.** Reports table conflicts with other apps AND the guardrail verdict on every declared write, writing nothing. Exits non-zero on either, so it works as a PR check. * **Your `hooks/`, `conduits/`, `turbobase/sync.json` and `turbobase/schedules.json` are now the desired state — `turbobase ext plan --to prod` diffs the REPO against your instance and `ext deploy --to prod` applies it.** Previously the desired state came from `turbobase/bundle.json`, which only `ext export` wrote from the instance, so a pull request could not show what merging would change. **Removals stay opt-in:** deleting a hook file does not delete it from the instance until someone runs `ext deploy --to prod --prune`, and every apply prints what it declined to remove. Adopt an existing instance’s config into your repo with `turbobase ext export`, then move the extensions into `hooks/`. * **Get the preview URL for a PR with `turbobase apps preview list --json`, and update a preview in place with `turbobase apps deploy . --replace-label ""`.** `--replace-label` opens a preview when none carries that label and re-deploys onto it when one does, so scripting “one preview per pull request” needs no bookkeeping. `--json` exists because the human output is prose and breaks any regex the moment a label contains two spaces. * **New projects now ship Tailwind v4 + shadcn/ui, matching the published design system — adopting it in an existing project is a manual migration.** `apps init` now scaffolds the exact stack [docs.turbobase.io/build-apps/design-system/](https://docs.turbobase.io/build-apps/design-system/) specifies: Tailwind v4 as a Vite plugin (no `tailwind.config.js` — the theme is `src/styles.css`), shadcn/ui New York on the neutral base with a `components.json` so `npx shadcn@latest add ` works with no init step, six primitives under `src/components/ui/`, lucide-react, and Inter (latin subset only). A `@/*` path alias is declared in BOTH `tsconfig.json` and `vite.config.ts` — keep them in sync, or the editor and the build disagree. `apps init` never overwrites, so an existing project keeps its current styling until you migrate: take the `package.json` deps, `vite.config.ts`, `src/styles.css` and `components.json` from a fresh scaffold. The design-system page was previously unfollowable in a hand-scaffolded project, which is what this fixes. * **Expect the agent in your project to sound different, and to be shorter.** `apps init` now writes `.claude/output-styles/turbobase-consultant.md` and turns it on in `.claude/settings.json`. It is written for ERP consultants, NetSuite administrators and business analysts: business outcome first, source-system vocabulary, brief unless you ask for detail. Pick another style in `/config`, or delete the `outputStyle` line, to get stock Claude Code back. * **A new PostToolUse hook warns when a business rule is written into the SPA.** `.claude/hooks/check-rule-placement.{sh,mjs}` fires on edits under `src/` that both write through the client and read like a rejection, and points at `hooks/`. It never blocks an edit. Delete both files to remove it. * **`ADOPT.md` now refreshes at the start of every Claude Code session**, so entries like these reach your project without anyone running a command. If you scaffolded before this release, re-run `turbobase apps init .` to pick up the session hook, or run `turbobase project adopt` yourself. * **Link two records you just created by naming the other’s `pg_row_id` — stop round-tripping to get its source id first.** Creating a customer and its order in one go used to mean POST the customer, poll until the platform minted its source id, then POST the order naming that id; every link between two new records cost a full upstream round-trip. Now you name the row you already have. Two shapes, and which one you use is decided by the column: * **A ref column with a foreign key** (`entity_id` on every transaction) will not accept anything but a real source id. Set the `_pg_row_id` sibling instead and leave the ref column NULL: `POST /v1/data/salesOrder {"entity_pg_row_id":""}`. The platform fills `entity_id` on the way out. **`entity_pg_row_id` now exists on all twelve entity-referencing tables** — `cash_sale`, `credit_memo`, `customer_deposit`, `estimate`, `opportunity`, `purchase_order`, `vendor_bill` and `vendor_payment` gained it, joining `order`, `invoice`, `order_fulfillment` and `customer_payment`. So vendor + purchase order in one pass works, not just customer + sales order. If a leg create was failing seconds later with `Please enter value(s) for: Vendor` (or `: Customer`), that is the shape to use — the error meant the entity was unnameable for an unsynced record, not that you had omitted it. * **A ref column with no foreign key** (`created_from_id`, line-level refs, custom-record links) takes the sentinel directly: `{"created_from_id":"pg:"}`. It must be `pg:` plus a full UUID — anything else is treated as a literal source id and passed through untouched, so an existing id that happens to start `pg:` is never hijacked. An explicit source id always wins if you set both. If the referenced record has not reached the source yet, the write WAITS for it rather than failing — but only while that record’s own write is still in flight, and only up to 8 attempts / 10 minutes. Two records that reference EACH OTHER can never resolve (intercompany pairs do this): that fails with `reference_cycle` naming both rows. Write one side without the back-reference, then patch it in. * **Create a customer or vendor directly — `POST /v1/data/customer` with no key now works.** It used to answer `422 MISSING_KEY` (“requires externalId, pgRowId, or an external\_id in the payload”), on the since-obsolete grounds that the entity table had no `pg_row_id` and a NOT NULL `external_id`. Both changed: entities mint their own `pg_row_id` and `external_id` is nullable until the record reaches the source. `customer` and `vendor` were the only bidirectional record types an app could not actually create; now they behave like every other one. Send the fields you have — `POST /v1/data/vendor {"company_name":"Acme Supply"}` — and read `pg_row_id` off the response. * **Name the entity with `entity_pg_row_id` and the required-entity check will accept it.** `ns:transactions:require-entity` read only `entity_id`, so a transaction naming a not-yet-synced customer by its sibling was refused `422 MANDATORY_FIELD_MISSING: entityId` at the boundary — before the outbound resolver ever saw the sibling. It now treats a valid `entity_pg_row_id` as an entity reference, exactly as it already treats `created_from_id`. Still refused: a transaction naming no entity at all, or a sibling that is not a UUID. * **Stop sending a full `lines` array just to edit a header field.** A header-only PATCH of a record that has already synced was being refused with `ATOMIC_WRITE_REQUIRED` across all 16 transaction types, even though a header-only edit is a legitimate operation that leaves existing lines untouched. It now succeeds. A fresh CREATE still must ship its lines in the same write — that rule is unchanged and is what the error is for. * **Send `inventory_detail` on a line to move a lot- or serial-tracked item — without it the source refuses the whole record.** Receipts and fulfilments of a tracked item previously had no expression at all: there was no key for it at the boundary, so the write failed with `Please configure the inventory detail in line 1` no matter what you sent, and an app could raise every document around a deal without moving a unit. Put the assignments on the line: `lines: [{ item: '485', quantity: 10, inventory_detail: [{ inventorynumber: 'LOT-A', quantity: 6 }, { inventorynumber: 'LOT-B', quantity: 4 }] }]`. Optional per assignment: `binnumber`, `expirationdate`, `inventorystatus`. Use the one key `inventorynumber` in BOTH directions — the platform resolves it to the source’s inbound or outbound field, so you never encode that split yourself. Quantities must sum to the line’s quantity. An empty array CLEARS the assignments; omitting the key leaves them untouched. Requires NetSuite bundle **1.8.0** — an older bundle silently ignores it. * **Read which lots landed with `v_inventory_assignment`, not by parsing `inventory_detail` yourself.** One row per assignment across every inventory-affecting line table, carrying `record_type`, `line_pg_row_id`, `parent_pg_row_id`, `item_id`, `inventory_number`, `quantity`, `bin_number`, `expiration_date`. `GET /rest/v1/v_inventory_assignment?parent_pg_row_id=eq.`. Its `pg_row_id` is derived from (line, ordinal) and is **read-only** — to change an assignment, write the whole `inventory_detail` array on `line_pg_row_id`. * **Re-read `created_from_id` on `order` and `vendor_bill` — it was null on every mirrored row and now populates.** The column exists to dispatch the PG→source transform (`estimate → salesOrder`, `purchaseOrder → vendorBill`), but the bulk backfill wrote the source value into `raw` under a key nothing read, so an app filtering on it matched nothing and a transform keyed off it could not fire for any historical record. Both paths are fixed and a one-shot re-scan repopulates the mirror on upgrade; no app change is required, but any logic that treated “no `created_from_id`” as “raised standalone” was wrong for backfilled rows and is now correct. Read “what was billed from this PO” as `vendor_bill?created_from_id=eq.`. * **Set `raw.customform` if your record needs a specific NetSuite form — the platform no longer picks one for you.** Turbobase used to inject a per-record form id on every create. Those ids were found against one sandbox and a form’s internal id is assigned PER ACCOUNT, so on any other account it named a form that does not exist and failed the whole record with `Invalid Field Value for the following field: customform`. Creates now omit `customform` unless you supply it, and NetSuite picks its own default. If a line comes back `Invalid Field Value for the following field: item`, that is the default form’s item-options list not exposing the item — send the right form as `raw.customform`, or ask your administrator to set `NS_CUSTOMFORM_` for the instance. Updates never inject a form and never did. * **Read a custom record’s children by filtering the link column — there is no `parent_pg_row_id` and no bare embed.** A custom record has no sublist: what its form draws as one is a SECOND record type whose rows carry a List/Record field pointing at the parent, so the two mirror tables have no foreign key between them. Read children as `cr_?cf_ns_custrecord_=eq.`, matching the parent’s `external_id` (the link is a bare id, never a `pg_row_id`). To get parent+children in one request the pair must be DECLARED as a computed relationship — it is not discovered. Native transactions are unaffected: `
_line` still embeds on its FK. * **Stop stripping derived fields before you POST — and stop expecting a write to fail because you sent one.** `POST /v1/data/:recordType` now drops the fields NetSuite COMPUTES rather than forwarding them: `status` (and `order_status` / `fulfillment_status` / `financial_status`), `total`, `subtotal`, `tax_total`, `discount_total`, `shipping_total`, `amount_paid`, `amount_remaining`, `applied`, `unapplied`. These have no setter upstream — a transaction is “Pending Fulfillment” because of its fulfillments, and a total is the sum of its lines — so sending one previously failed the whole write with `Invalid Field Value … for the following field: orderstatus`. This bit hardest on the most natural pattern there is: read a row, change one field, post it back. That now works. The strip also covers the same key arriving inside `raw`. `approval_status` is NOT stripped — it is a real setter and still writes. To move a status, create the record that moves it (a fulfillment, an invoice, a payment), not the field. * **Send per-line overrides on a transform as `lines`, matched by item.** When you create a record from another (`created_from_id` — item fulfillment from a sales order, item receipt from a purchase order), NetSuite sources the lines from the originating document and that sublist is STATIC. Your `lines` array is now applied as in-place overrides matched **by `item_id`**, not by position, so you can set a per-line `location_id` the originator wouldn’t accept. A line you omit keeps what the document gave it; omitting is “no opinion”, not “remove it” — to exclude a sourced line send its own exclusion field. Naming an `item_id` the document did not source is a `422`, not an insert. Previously any `lines` on a transform failed the write outright with “trying to add or remove lines from a static sublist”. * **Surface `rejection.message` on a `422` from `DELETE /v1/data/:recordType` — a delete can now be refused by a business rule, not just by a guardrail.** Hooks gained `before_delete` / `after_delete`, so a tenant rule (“this order has a fulfillment”, “this customer has open invoices”) rejects a delete in the same structured envelope `before_save` already uses for writes. Treating any non-200 as a generic delete failure now hides the one message that says why. `before_delete` can reject but cannot patch: a delete is addressed by key (`pgRowId` / `externalId`), not by row body. * **Expect the write-back rate caps to bind on a BURST now, and handle the denial.** `guardrails.max_{updates,deletes}_per_{min,hour,day}` reserve a slot per write instead of counting settled work, so concurrent requests no longer all pass on the same pre-state — a parallel burst gets exactly `cap` through and the rest come back `422 SYNC_GUARDRAIL_BLOCKED` / `rate_limit_exceeded`. Code that fanned out writes and never saw a denial will start seeing them. Retry the denied ones with backoff, or throttle on your side. * **Count a failed write against your cap — a slot is not refunded.** The cap governs what you ATTEMPT against the source, not what succeeded, so a write that errors upstream still consumed its slot. A retry of the SAME write is free (it is one write, attempted again), but a retry loop that mints a fresh idempotency key each time will walk through your quota. Reuse the key. An operator can clear a wedged cap in Settings → Guardrails. * **Stop working around the duplicate-key failure on a PG-originated create — it is fixed, and the workaround is now the bug.** Creating a header row and polling it for its source-minted `external_id` used to be able to fail: the record came back inbound before the id was stamped, was ingested as a SECOND row holding the id, and the stamp then died on `
_external_id_*`, leaving the row you were polling permanently keyless. Your poll should now resolve normally. If you added retry-on- duplicate, a “pick the newest row for this external\_id” read, or a de-duplicating pass over headers, remove it — there is one row again, and it is the one whose `pg_row_id` you were given. * **Handle a new `sync_event.state`: `resolved_local`.** If you read `sync_event` (or use generated types with a state union), add it — a `TypeScript` union that omits it will fail to compile, and a `switch` without a default will fall through. It means a delete that completed WITHOUT being sent upstream: the row had no `external_id`, so the tombstone was the whole delete. It is terminal and successful, but it does NOT mean the source has the change — do not treat it as a synonym for `committed`. * **Deletes now actually reach the source.** Every `pg→source` delete previously failed inside the adapter before NetSuite was called (a stored auth context that could never resolve), so a `del()` that returned `{deleted:true, enqueued:true}` was never applied upstream. If your app assumed a queued delete had landed, that assumption was wrong before this release and is correct after it. Re-check any records you deleted and believed were gone from the source. * **Send transaction lines as a `lines` array in the SAME `POST /v1/data/:recordType` call — never to `/rest/v1/rpc/write_transaction`.** Body is `{"parent":{…},"lines":[{…}]}`; the boundary commits header + lines atomically. The RPC is not callable by an app (it is SECURITY INVOKER and `authenticated` holds no write DML, so it 403s — and it 404s first, because PostgREST matches functions by argument name). The `ATOMIC_WRITE_REQUIRED` rejection used to name that RPC; it now names this call. * **For a CUSTOM record, do not send `lines` at all — its children are separate records.** A native transaction (`salesOrder`, `purchaseOrder`, `invoice`, …) carries a NetSuite sublist that has no per-line endpoint, which is what `lines` is for. A custom record’s children are their own record type pointing at a parent, so write the parent, take `external_id` off the response, then POST each child to its own record type. Sending `lines` to a custom record is a `422 LINES_NOT_SUPPORTED`. * **Expect a `422` rate-cap rejection from `DELETE /v1/data/:recordType` even for a row that never synced upstream.** A delete of a row with no `external_id` still completes locally and still returns `enqueued: false`, but it is now recorded and COUNTS against `guardrails.max_deletes_per_{min,hour,day}` — it previously counted for nothing, so a bulk local cleanup ran unbounded. Batch such a cleanup under the cap, or raise it in Settings → Guardrails; the rejection arrives as `SYNC_GUARDRAIL_BLOCKED` with the cap named in `rejection.message`. * **Replace your read-only `src/App.tsx` with the new starter — it now demonstrates `write()` and `del()` alongside `select()`.** It reads with a projected column list rather than `select=*`, renders the `{ deleted, enqueued }` shape verbatim, and distinguishes the loud delete modes: a guardrail deny, rate cap or parked source throws; `require_confirm` returns `enqueued: true` but is held for an operator before it reaches the source; a source refusal lands as a `sync_failure` rather than a throw. * **Move validation out of `src/App.tsx` into `hooks/` — `turbobase apps init` now scaffolds `hooks/` and its `samples/` fixtures.** Your app cannot bypass `POST /v1/data` and its mandatory `before_save` (the `authenticated` role holds no write DML), so a rule written in the app binds only that app. Run the scaffolded example with `turbobase ext test hooks/.ts`. * **Re-run `turbobase apps init` in an existing project to pick up `hooks/`, `samples/` and the new `CLAUDE.md` boundary section.** It is non-destructive and SKIPS files that already exist — rename your current `CLAUDE.md` and `src/App.tsx` first if you want the new text. * **Promote and retract hook proposals from CI with `turbobase ext proposals list|promote|retract|reject`.** Bulk promotion is `promote --auto`, a dry run by default — add `--apply`, plus `--all` if you passed no filter. Needs a PAT with `extensions:deploy` whose owner is a live admin. A `provenance='learning'` proposal is refused on activation by design: those arm through operator sign-off, not bulk promotion. * **Join a change-audit row to its LINE on the new `system_note.line_index`.** `turbobase project types` now emits it. NetSuite writes the trail per line — a four-line receipt writes four notes — and every one of those rows previously carried only the parent transaction id, so a per-item history was not buildable. Read it as `JOIN _line l ON l.tran_internal_id = sn.record_id AND l.line_index = sn.line_index`; it is NULL on body-level notes, and on `item_receipt_line` / `journal_entry_line` / `order_fulfillment_line` the line column is spelled `line_number` (`l.line_number = sn.line_index`). Existing rows fill in on the tenant’s next audit-trail run — treat NULL as “not yet backfilled” until then. * **Page a LINE table by passing its key as a tuple: `selectPage('vendor_bill_line?select=*', { sortKey: ['parent_pg_row_id', 'line_index'] })`.** `selectPage`’s `sortKey` now takes an ordered tuple, and the page predicate is a row-wise comparison over it, so a line table pages with no drops and no repeats — there was previously no correct single column to seek on. Add `{ descending: true }` to walk the same key backwards. `SEEK_KEYS` in your generated types carries the tuple where a table has one, so `{ sortKey: SEEK_KEYS.vendor_bill_line }` needs no change on your side — regenerate with `turbobase project types`. Line ordinals are `line_number`, not `line_index`, on `order_fulfillment_line` / `item_receipt_line` / `journal_entry_line`. * **Discard any `nextCursor` your app persisted across this upgrade — re-read from the first page.** The cursor now carries every column of the seek key, and a cursor minted by an older client is REFUSED (`TurbobaseCursorError`, “minted by an older client”) rather than reinterpreted, because reading it as a one-column tuple would silently drop rows. Cursors held in memory for one scroll session need nothing; a cursor written to storage or a URL does. * **Stop reading a skill’s `enabled` flag — publish/unpublish is the whole gate.** The column is gone from `turbobase.skill`, and `GET /v1/skills` no longer returns it. `turbobase skills list` reports `draft`, `unreleased` or `live`; the `disabled` status it used to print does not exist. Rules pushed by `turbobase skills push` still land as drafts and still reach nobody until an admin publishes them — that is unchanged. On upgrade, any skill that was published but switched off is moved to draft, so nothing an operator had turned off starts injecting; publishing it brings it back. * **Structure a `TURBOBASE-FEEDBACK.md` finding with `###`, never `##`.** Every `##` section of that file is read as a SEPARATE finding, so sub-headings inside one write-up (“The three gaps”, “What we’d use instead”) were each shipping to the product team as a finding of their own. Headings that read as discourse labels are now dropped, and the scaffold says so — but the reliable fix is yours: one `##` per finding, `###` for everything under it. Existing findings need no edit; numbered sections (`## 3. …`) are always kept. * **Write your finding’s title as a CLAIM, not a topic.** The title is now the sentence that reaches the fleet — it is redacted, capped at 240 characters and shipped verbatim beside the pattern label. “Line totals are pre-tax; the header total is tax-inclusive” is actionable; “Tax handling” is not, and an unnumbered section titled like the latter may be dropped as a discourse label. * **Expect what you write in `TURBOBASE-FEEDBACK.md` to actually reach us — sharing is now ON by default, and everything already queued goes with it.** The setting (`telemetry.share_learnings`, Settings › Telemetry → “Share learnings and platform feedback”) used to be off until someone ticked it, so a project could accumulate findings that were never sent and, because each entry was stamped with the setting as it stood when it was ingested, never could be. Consent is now read at SEND time: turning the toggle off stops everything including the current queue, turning it back on releases it. **If your instance has a backlog you did not intend to send, turn the toggle off before the reporter’s next hourly tick.** An explicit off stored before this release is re-enabled by the upgrade. What leaves is unchanged — anonymized pattern cards, and prose from `TURBOBASE-FEEDBACK.md` with instance name, user names, ids and source-system identifiers masked; `"learnings": { "share": "off" }` in a project’s `turbobase.json` still narrows it for that project. `turbobase auth login` now prints all of this and where to turn it off. * **Read rows headlessly: mint a PAT with the `data:read` scope and POST it to `/auth/pat-exchange`.** The endpoint returns a 15-minute session JWT — send it as both `Authorization: Bearer` and `apikey` for `/rest/v1/*`, and as `Authorization: Bearer` for the session-gated `/v1/catalog/openapi.json`. It existed since v1.0.70 but no tenant could reach it (the ingress never routed the path), so a headless agent could read a table’s schema and never one of its rows; `turbobase project types` reported it as an older instance and silently degraded to `--records-only`. `data:read` is opt-in and not in any token’s default scopes — an existing PAT does NOT gain this, re-mint to use it. * **Stop copying `ADOPT.md` aside before you run `turbobase project adopt` — it now MERGES.** A run that finds a new release keeps every item you have not ticked, carries the ticks you have made, drops the ticked items the instance no longer lists, and appends the new release’s. Earlier runs replaced the file, so an incremental run deleted the unticked work of every previous release. A file this command did not write (no generated header, no `## v` section) is now refused rather than overwritten — pass `-o ` if you keep your own. Ticking an item is now the one thing that can remove it: a ticked line is dropped once the instance stops listing it, recoverable with `git show HEAD:ADOPT.md` only if you commit the file, so commit it if the checklist is a record you want to keep. * **Add a `pretest` to your app’s package.json: `"pretest": "turbobase project types -o src/types.ts || …"` (the scaffold now writes it).** `src/types.ts` is generated from the instance catalog and gitignored, so a second working copy — a worktree, a CI clone, a new laptop — has no copy, and any test importing it failed at module load with a bare `ENOENT` that named neither the file’s origin nor the command producing it. The scaffolded line regenerates before the tests run; if the instance is unreachable it falls back to the copy on disk with a “may be stale” warning, and only fails when there is no file at all — naming the two commands that produce one. `turbobase apps init` writes it into new projects; existing projects should copy the line across (the scaffold never overwrites your package.json). * **Delete `2>&1` from `.claude/hooks/session-start-refresh-types.sh` in projects scaffolded before this release.** The hook sent BOTH streams to `/dev/null`, so when the v1.0.82 verb rename broke `turbobase types`, the refresh failed silently for weeks and every session ran on a stale `types.ts`. The scaffold now mutes stdout only and its fallback line names the command that failed instead of guessing “not signed in?”. Re-run `turbobase apps init` in place to pick it up (existing files are kept — delete the hook first to have it rewritten). * **Treat anything you have EVER put in `public/` as published, and rotate it.** A file there keeps the same `/a//` URL on every deploy, and until this release that URL was served `Cache-Control: public, max-age=31536000, immutable` — so a shared cache in front of your instance could store a copy made for an authorized user and hand it to callers who never signed in, for a year, including after you deleted the file. The origin now serves `/a//…` as `private, no-cache`: no shared cache may keep it, and a removed file stops being served on the very next request. Copies a cache took BEFORE this release are not reachable from the instance — ask your operator to purge the edge, and treat any key, token or diagnostic script that has ever sat in `public/` as disclosed. * **Import an asset you want cached for a year instead of dropping it in `public/`.** A bundled import gets a content hash and is served from the build-scoped base (`/_tb/app-assets//…`), which keeps `max-age=31536000, immutable` and re-mints its URL on every deploy. A `public/` passthrough file is served from the mount path, which now revalidates: expect a conditional request answered `304` from the new `ETag` rather than a cache hit, so the bytes still only move when they change. No code change — this is why a `public/` file no longer behaves like a fingerprinted one. * **Run `turbobase auth status` first when the authoring MCP’s tools are missing from a session.** It now asks each stored instance whether it still accepts your token and prints the instance’s own reason — `revoked`, `expired`, `unknown` (usually: the token belongs to a different instance) — plus the command that fixes that specific case. Previously it only listed what was in `~/.turbobase/credentials`, so a revoked token and a server that was never configured looked identical: `.mcp.json` present, 0600, holding a well-formed bearer, and every tool silently absent. It prints no token material. Add `--no-probe` for the old purely-local listing. * **After any `turbobase auth login`, re-run `turbobase project init` to rewrite `.mcp.json`.** That file holds its OWN copy of the PAT, taken at init time, so a fresh sign-in does not reach it and the authoring MCP keeps presenting the dead token. Relatedly, `POST /mcp/authoring` now answers a rejected token with the reason on both the `WWW-Authenticate` challenge and the JSON body (`{"error":"unauthorized","reason":"revoked","message":"…"}`) instead of a bare 401, so a client that surfaces either one can say why the server failed to connect. * **Trust `turbobase kb push --dry-run` as the contract — its plan is now exactly what the push executes.** A page whose unpublished work is YOUR OWN is no longer reported as `blocked … this page will be SKIPPED` (it was, and then the push appended to it anyway); a page genuinely held by another editor now reads `action SKIP — nothing is written`, gets no write-set diff, and is excluded from the plan’s `N page(s) / N appended` counts and from the confirmation prompt’s numbers. If you script `kb push`, those counts changed meaning: they now count only pages that will actually be written. * **Project a PATH into `raw` instead of the whole column: MCP `list_records` `select` now takes `lines:raw->item->items` / `tag:raw->>key`, and `filters[].field` takes `raw->>key`.** A custom field that was never promoted lives only in `raw`, and both arguments validated every entry against the live column set — so it was unreachable at any price, and the only way to see one was to pull the entire payload. Use `->>` when you want text comparison (`like`, `eq`); `->` compares as jsonb, where `is_null` also matches the JSON `null` literal. The accepted shape is deliberately narrow: bare-word keys (letters/digits/underscore), `->>` only as the last arrow, a rename allowed in `select` but not in `filters`, and no other PostgREST select syntax inside a path. * **Name a promoted custom field the way the record does: MCP `list_records` `select` / `filters` now accept the source-side id (`custbody_x`) as well as the `cf__` column.** The record, the anatomy docs and the source UI all use one name and `select` accepted only the other, so an agent that had been told the right name was still refused. Responses and `applied.select` echo the `cf_` column, so the canonical name is what you learn back. * **Drop the `{ sortKey: … }` override you were passing to `selectPage` for 56 mirrored tables and regenerate types — `pg_row_id` now exists on all of them, and it is what `selectPage` defaults to.** `selectPage`’s default seek key has always been `pg_row_id`, and on these tables the column did not exist, so a plain `selectPage('subsidiary?select=*')` errored and the workaround was an `external_id` override — a column that goes nullable the moment a table is made writable, and a nullable seek column silently drops every row it is NULL on. The default now works. If you read `SEEK_KEYS` instead of hard-coding, regenerating is the whole change. * **Re-read from the first page on those tables — page ORDER changes and a persisted cursor is refused.** Where the derived key moves from `external_id` to `pg_row_id`, rows come back in surrogate order rather than source-id order. The seek key is pinned into the cursor, so a cursor minted under the old key raises `TurbobaseCursorError` rather than being reinterpreted — nothing is silently dropped, but anything you persisted to storage or a URL must be discarded. * **Do NOT page `sync_event`, `record_write_state` or `data_manipulation_snapshot` on `pg_row_id` — use `id`.** All three have a column *called* `pg_row_id` that points at ANOTHER table’s row and is nullable (on `data_manipulation_snapshot` it is `text`, not `uuid`). They are keyed `id`, and `SEEK_KEYS` does not offer them a `pg_row_id` entry. * **Do not persist a LINE row’s `pg_row_id`, and keep paging line tables on the tuple.** Line tables gained the column too, but a parent’s whole line set is deleted and re-inserted on every sync of that parent, so each line’s `pg_row_id` is a fresh uuid afterwards — fine within one paging scan, wrong as a stored reference or a saved cursor. `SEEK_KEYS._line` therefore still returns `['parent_pg_row_id', 'line_index']` (`line_number` on `order_fulfillment_line` / `item_receipt_line` / `journal_entry_line`), unchanged by this release — if you read `SEEK_KEYS` rather than hard-coding, nothing on your side changes. * **Check any code that positionally destructures an entity-view row — `pg_row_id` is appended LAST on `customer`, `vendor`, `contact`, `employee`, their `_unpacked` variants, `v_current_compensation` and `v_current_employment`.** A `SELECT *` against those views gains a trailing column; selecting by name is unaffected. On `entity_canonical_view` the new column arrives through a LEFT JOIN and is therefore NULLABLE — an identity for the source-link row, not a seek key for that view. * **Re-run `turbobase apps push` if your project declares `"learnings": { "share": "off" }` — that declaration now actually takes effect, and it is read from your PUSHED source, not from your built bundle.** It was parsed and validated at deploy time and then never consulted, so a project that had opted out was still shipping both channels — and with sharing now ON by default (the entry above), that meant everything. The reporters read the newest `turbobase apps push` of `public/turbobase.json` on every hourly tick, so a project that changes the declaration is honoured on the next tick — but a project whose last push predates the declaration is not. Nothing already suppressed is consumed: it stays queued and ships if you later revert to `"inherit"`. Two things to know: the instance setting is still the ceiling (`"off"` narrows, and there is deliberately no `"on"`), and a manifest we cannot PARSE now suppresses that project’s material entirely — run `turbobase apps push` and check the file is valid JSON if you expected findings to arrive and none did. * **Drop any `entity_type` / `item_type` filter you were adding to MCP `list_records` — it now narrows to the record type you asked for, and a persisted `next_cursor` from before this release is refused.** `customer`, `vendor`, `contact`, `employee` and `partner` all live in `entity`, and the tool issued its query with no type predicate at all, so `list_records {record_type:"customer"}` returned every entity sub-type your capabilities let RLS through (the same held for `inventoryItem` and friends on `item`). Row counts and totals on those record types will DROP — that is the leak closing, not data going missing. A filter of your own on the same column now INTERSECTS with the record type’s rather than replacing it, so asking `customer` for `entity_type in (customer,vendor)` returns customers only; ask for `vendor` separately. The response echoes what was applied as `applied.discriminator`. Cursors held for one scroll session are fine; anything written to storage must be re-read from the first page. * **Handle a `{"ambiguous": true}` result from MCP `get_record` — it no longer returns an arbitrary row.** One source record can hold several roles at once (a NetSuite entity whose type reads “CustJob, Partner, Vendor” is one record served from three endpoints, and the mirror correctly holds one row per role under one `external_id`), and `get_record` matched on `external_id` alone with no type predicate and no order — so asking for the customer could hand back the partner. It now applies the record type’s own predicate, and when more than one row still matches (a tenant with several connections of one source) it returns `found:false, ambiguous:true` with the column that separates them, instead of guessing. List those rows with `list_records` filtered on `external_id` and pick by `connection_id`. * **Copy `sharedTable.hint` from `/v1/catalog/records` verbatim — it now names the type VALUE, and it names one on `item` / `work_item` where it never did.** The hint said `entity_type=eq.` and, on the item tables, offered only `source_system` — which does not separate `inventoryItem` from `kitItem`, so a “filter by source\_system” reader still got every item type back. It now reads `entity_type=eq.customer`, `item_type=eq.inventoryItem`, and `sharedTable.discriminators` lists the type column alongside. Where this instance cannot name a value for a record type, the placeholder now says so explicitly instead of implying you can supply it — do not substitute the record type’s name, which is not the type value for several connectors’ records. * **Handle a `null` `selectTarget.discriminator` from `/v1/catalog/objects/:id` — it is now null when no single predicate narrows the target’s table.** It always returned a string, falling back to `source_system=eq.` even where that does not discriminate (`tax_code` holds NetSuite’s `salesTaxItem` AND `taxGroup`, so filtering by source narrows nothing), which read as a complete answer and was not one. The `note` now says what to do instead — match on `pg_row_id` and check the row you read. Where a type predicate does exist you get it (`item_type=eq.inventoryItem` in place of the old `source_system=eq.netsuite`), and it is byte-identical to what MCP `list_records` / `get_record` apply for the same record type. * **Read and write QuickBooks estimates: `quickbooks:estimate` is now in `/v1/catalog/records`, projecting onto `estimate` + `estimate_line`.** Re-run `turbobase project types` to pick it up. Embed lines with `estimate?select=*,estimate_line(*)` — a plain embed works, no constraint hint needed. Reading a quote out of `raw` on a QBO tenant now has a typed alternative, and `read:estimate` is the capability that gates it. * **Do not read `estimate.created_from_id` to find the invoice raised from a QuickBooks estimate — it is NULL by design.** The link lives on the invoice (`invoice.created_from_id` → the estimate), because the canonical column means “transformed FROM” and QBO’s own `LinkedTxn` on an estimate points forward at its child. Join from the invoice side. * **Delete your `on DELETE → remove from the list` Realtime handler and put a reconciliation sweep in its place — the handler can never fire.** Earlier guidance (the dashboard brief, the app skills page, `build-apps`) told you to remove the row on a DELETE frame; no such frame is ever sent, so rows deleted anywhere else stayed on screen until a reload. Nothing here hard-deletes a mirrored row: a delete stamps `deleted_at`, and the restrictive `deleted_at is null` SELECT policy every mirror table carries hides the tombstone from the Realtime authorisation probe — so the subscriber receives **no frame of any kind**, not an INSERT, not an UPDATE, not a DELETE. Instead re-run each list’s own seed query on a 30–60s timer and on tab focus, batched through `POST /v1/batch`, and replace the list with the result: the tombstoned row is simply absent from it. Realtime carries arrivals and edits; the sweep carries removals. Mechanism and worked pattern: `/build-apps/#deletes-never-arrive-so-reconcile-instead`. * **Ask for a paged total by wrapping that one sub-query, and stop reading `contentRange` as a total — the v1.0.77 note promising one was wrong.** That note told every project “each batch result’s `contentRange` carries the total”; `POST /v1/batch` never sent a `Prefer: count=` header, so every sub-response came back `0-24/*` and the total was never there to read. It is real now, and opt-in per sub-query: `batch([{ query: 'order?select=external_id,total&limit=25', count: 'exact' }, 'currency?select=external_id,symbol'])`, then read `page.count` — the rows matching your filter ignoring `limit`/`offset`. Bare strings behave exactly as before and still pay for no count; their `count` is `null`, and `rows.length` is the page, never the total. * **Branch on `result.ok`, never on `result.status === 200`, for any `POST /v1/batch` sub-result.** A counted sub-query comes back `206` — PostgREST answers Partial Content whenever the page it returned is shorter than the total it just computed — and the instance passes that status straight through. A `=== 200` check turns every counted page into a phantom failure. * **Ask for exact counts only on the pagers you actually render — four per `POST /v1/batch` request, or the whole request is a `400`.** `count: 'exact'` makes Postgres scan your entire filtered set rather than the page, and no timeout on this path can call one back once it starts, so `BATCH_READ_MAX_EXACT_COUNTS` (4) is enforced before the first fetch and rejects the request as a whole rather than degrading per sub-query. The refreshed `batch()` chunks COUNT-AWARE, so passing more than four splits them across requests instead of failing on where they happened to sit in your array — but each is still a full scan and the requests go out one after another, so that buys ordering safety, not headroom. Posting to `/v1/batch` yourself: keep counted sub-queries to four per request. Leave the lookup reads as plain strings. * **Refresh `src/lib/turbobase.ts` from the current scaffold.** `batch()` now takes `BatchQuery[]` (a new export — an existing `string[]` call site still compiles unchanged), `BatchResult` gained `count` and `contentRange`, and the chunking that splits a long list into requests is count-aware, so a stale copy of this file is what turns “five totals” into a `400` (see the entry above). Its header comments also now name the embeds armed on every instance with nothing declared (`created_from_po`, `receipt_lines`, `source_po` — any other alias resolves only once something declares it), and record that `del()` emits no Realtime frame. Scaffold into a scratch directory (`turbobase apps init /tmp/tb-scaffold`) and copy that one file across — don’t `--force` over your project. * **Re-run `turbobase project types` after a custom field is promoted — a NEWLY promoted `cf__` column now carries the source field’s real type, or `jsonb` when the source reported none.** It used to be `text` for every field selected in the drawer whatever the source said, and the source’s SCOPE string (`BODY`) for anything proactive learning added — which could not be approved at all. Discovery resolves the type from the source’s VALUE type now: a NetSuite Currency lands `numeric`, a Date or Date/Time `timestamptz`, a Check Box `boolean`, a Multiple Select `jsonb`. So `/rest/v1/vendor_bill?select=cf_ns_custbody_acme_inbound_shipment` returns a JSON number or boolean where it returned a string, and a `POST /v1/data/:recordType` write to that column must send the matching JSON type. **Read a `jsonb` `cf_*` column as “the type is unknown”, not “the field is structured”:** NetSuite reports the value type only from bundle **1.2.0**, below that the instance falls back to the Customization Analyzer capture, and with neither it stores `jsonb` — which holds the value but cannot be `SUM()`ed or range-filtered. * **Ask your operator to Remove and re-add any `cf_*` column whose type you depend on — including every one promoted before this release, which is `text` and stays `text`.** Columns already promoted keep the type they have: nothing re-types a live column behind you, and the one-shot repair shipping here only touches selections that were never promoted. The operator’s path is two steps on `/data/sync/records`: Remove the field, then re-select it under “Add fields” — removing first is mandatory, because the drawer disables a field that is still selected. Re-selection re-resolves the type against current discovery, and the next reconcile re-types the column in place with a value-preserving cast; anything that will not convert is nulled and re-projected from `raw` on the next sync. On a bundle older than 1.2.0, run a Customization Analyzer capture first, or the re-resolution has nothing to read and lands `jsonb` again. * **Stop pattern-matching the `POST /v1/batch` `400` text for a bad sub-query — the message changed.** It named one accepted shape (“is not a valid PostgREST query string (path must be a bare resource name)”) and now names both, the string form and `{ "query": …, "count": "exact" }`. Surface `error` as it comes back rather than matching on it. * **Replace `turbobase ext logs ` with `turbobase logs --extension `, and `turbobase ext logs --follow` with `turbobase logs --conduit --follow`.** `ext logs` is REMOVED — all log access is now one noun. Both old modes are unchanged behind the two new flags; update any script, Makefile or CI step that called the old path, because it now exits with an unknown-command error rather than doing nothing visible. * **Read `GET /v1/events` to find out why a write was refused, instead of asking someone to read the server’s terminal.** One PAT-scoped stream covers guardrails, hooks, scheduled tasks, conduits and sync, with a shared envelope and a shared `correlation_id`. Filters: `subsystem`, `severity` (a THRESHOLD — `warn` includes errors), `code`, `since` (`10m` / `2h` / `3d` / ISO), `correlationId`, `extension`, `conduit`, `limit` (max 500), and `after` as a poll cursor. Same `extensions:deploy` scope your deploy PAT already carries. * **Send `x-request-id` on a write and you get it back as the `correlation_id` on every row that write causes** — the guardrail verdict, each hook outcome, the sync event. `turbobase logs --correlation-id ` then returns the whole story of one write across subsystems. Omit the header and Turbobase mints one; the thread still works, you just have to read the id off the log rather than choosing it. * **Expect log text to be masked, and expect your own text NOT to be.** Stored rows are redacted at write: secrets, absolute paths, stack frames, SQL and internal identifiers are replaced with `` markers, and the row’s `redaction` array names which rule groups fired. Your hook’s `ctx.reject()` message, your guardrail rule’s reason, your record types, your external ids and your `custbody_*` / `cr_*` / `cf_*` customization ids all survive verbatim — so keep writing rejection messages that tell the reader what to do. * **Know that the log is SAMPLED and expires.** High-volume events (hooks that fired, CDC guardrail decisions, sync-event transitions) are aggregated with an `occurrences` count rather than written per row, and `info`/`debug` events are only collected when the instance sets `PLATFORM_EVENT_LOG`. Retention is 90 days for `error`, 30 for `warn`, 7 for `info`, 1 for `debug`. Do not build a report on it — it is a diagnostic, not a system of record. * **Delete a record with `del(recordType, { pgRowId })` (or `{ externalId }`) — the new `DELETE /v1/data/:recordType` boundary.** Copy the refreshed `src/lib/turbobase.ts`; the client gains `del()` alongside `write()`. It soft-deletes (tombstones) the row locally and, for a delete-capable bidirectional source (NetSuite today; others parked), queues the delete to the source. NEVER issue a hard SQL `DELETE` against a table — apps hold no DELETE grant and it would not propagate. Returns `{ deleted, enqueued }` (`enqueued` is false for a row that never synced upstream). * **Open a record’s delete with a `.delete` guardrail allow rule — a `.write` allow does NOT permit deletes.** Deletes are gated separately as a destructive op: denied by default under deny-by-default, held for operator confirmation otherwise, and capped by the new per-minute/hour/day delete rate limits (`guardrails.max_deletes_per_{min,hour,day}`). A capped or denied delete surfaces on the guardrail audit trail. * **Expect a delete to fail loudly, not silently, when the source refuses it** — e.g. NetSuite “Use Deletion Reasons” (`deletion_reason_required`) or a record with dependents (`has_dependencies`). These land as `sync_failure` rows, not retries; supply the reason or remove the dependents. * **Run `turbobase apps push` — not `apps deploy` — after editing INSTANCE-LEARNINGS.md or TURBOBASE-FEEDBACK.md.** Ingest reads your pushed source tree; a deploy uploads the built bundle, which contains no markdown at all. `apps push` now parses your markdown itself and prints what it found (`N file(s) scanned · N new finding(s)`), so a deploy against an unpushed edit is no longer silent. If it prints `no markdown found`, nothing you wrote has reached the instance. * **Read the line `skills push` / `kb push` prints before planning.** Both now re-read your markdown first, so “nothing pending” is a statement about your file rather than about whatever a background job last happened to see. `21 already known` means your edit landed and every finding in it was already dealt with — which is the expected answer after a regroup. * **Use `turbobase skills push --reapply` to land a corrected `###` grouping.** Re-filing a rule under a new heading is deliberately the same finding, so it queues nothing; `--reapply` re-emits rules already applied so the new grouping reaches the instance. Rules still land draft and disabled, and a finding you dismissed stays dismissed. * **Mint a PAT with the `docs:publish` scope to manage the knowledge base from the CLI.** New verbs: `turbobase kb list | publish | unpublish | versions | rollback | delete`. Your existing PAT will NOT work for them — the scope is opt-in and is not granted to tokens minted before this release. `kb push` is unchanged and still runs on `extensions:deploy`. * **`kb publish ` makes a page readable in one step**, cutting a release scoped to the pages you name so it cannot ship someone else’s half-written one. `kb list` reports whether each page is actually in the current release, which `visibility` alone does not tell you. `kb delete` is SOFT — restorable from the editor’s trash, unlike `skills delete`. * **Rename `guardrails enable` / `disable` to `activate` / `deactivate` in any script that calls them.** The old verbs are gone. Guardrail rules carry a three-state lifecycle (`proposed` | `active` | `inactive`) — the chat agent can stage a rule for an admin to review — and a boolean verb pair could not name the third state. * **`turbobase guardrails create` now takes `--decision`, and defaults to `deny`.** A rule can `allow`, `allow_with_audit`, `require_confirm` (hold the write for operator approval) or `deny`. `--message` and `--note` are replaced by a single `--reason`, which is both what a blocked writer is shown and what `guardrails list` prints. * **Rewrite any `--when 'recordType not_in a,b'` condition as two rules.** Envelope fields (`recordType`, `source`, `origin`) are no longer part of the `--when` grammar — they are flags (`--record`, `--source`, `--actor`). `--when` now expresses only a test against the row being written (`total gt 10000`). “Everything except X” is an allow rule at a low `--priority` plus a deny rule at a higher one; first match wins. * **Expect `guardrails test` to report a decision, not just blocked/clear.** The JSON gains `decision`, `phase` and `degraded`; `undecided` is gone. It also no longer lowercases the record you pass, so a camelCase type like `salesOrder` reports what the write path actually does. * **Your write guardrails now appear on the Compliance → Guardrails page.** CLI rules and UI rules were two separate stores; they are one. A rule created by either surface is visible and editable from both. * **Drop any reference to a skill’s `audience` / `audience_role`.** Both columns are gone from `turbobase.skill` and `turbobase.mcp_server`, along with the RLS clause that read them. A skill’s reach is now decided by its surfaces plus `visibility` alone. If you had a skill scoped to a single role, it is now visible to everyone on the instance. * **Group your `## Agent rules` under `###` sub-headings.** The rules beneath one heading now become ONE agent skill, named after that heading. Without headings every rule becomes its own skill — 22 rules produced 21 separate rows, slugged off whichever `` `identifier` `` each rule mentioned first, which is unreviewable. Write `### Reading this mirror safely` above the rules it covers. `turbobase skills push --no-group` keeps the old one-rule-per-skill behaviour. * **Expect `turbobase skills push` to report fewer rules than findings.** It now prints `N finding(s) → M rule(s)`; M is your heading count. Re-running is still a no-op — findings are claimed once. * **Mint a PAT with the `skills:publish` scope to publish from the CLI.** New verbs: `turbobase skills list | publish | release | rollback | delete`. Your existing PAT will NOT work for them — the scope is opt-in and is not granted to tokens minted before this release. `skills push` is unchanged and still runs on `extensions:deploy`. * **`skills publish ` makes a rule live in one step.** It sets visibility and cuts a release scoped to the skills you name, so publishing one rule cannot ship someone else’s half-reviewed one — the same shape as publishing a knowledge-base page. There is no separate `release` verb. Undo with `skills unpublish `; `skills rollback ` restores an earlier set; `skills delete ` is permanent. ## v1.1.19 [Section titled “v1.1.19”](#v1119) * **Re-deploy onto an existing preview with `turbobase apps deploy --replace [preview-id]`.** `--preview` on its own opens a NEW URL every time, so the second build of a change is a second link and the reviewer you already sent one to is still looking at the old bytes. `--replace` keeps the URL and the label; omit the id when the app has one open preview. It implies `--preview`, resets the 7-day expiry (so it revives an expired link), and leaves the build it displaced in deploy history. ## v1.1.15 [Section titled “v1.1.15”](#v1115) * **Delete your NetSuite item staleness workarounds.** Items never pushed in real time — they arrived only on the four-hourly sweep, so an item read could be 4h behind NetSuite. Polling `item` on a timer, a “may be stale” banner, a cache-buster on item reads: all now dead weight. Items push on save like every other record type. * **Delete the same workarounds on sales orders, purchase orders and customer deposits.** Their status and amount fields moved only on the sweep whenever NetSuite recalculated them from a child transaction — fulfilling an order, billing a PO, applying a deposit. Those land within seconds now, so an app polling for `status` to advance can read it directly. * **Query `item_type = 'assemblyItem'` — assembly items were absent entirely.** They now sync and appear in `/v1/catalog/records`. Any query or count over `turbobase.item` was silently missing them; re-check anything that treated that table as the complete item list. * **Stop coalescing nulls on transaction fields you did not expect to be null.** An inline edit in NetSuite (`record.submitFields`, or editing a field from a list view) could push a webhook whose unrelated fields were empty, blanking `tran_id`, `entity_id`, `status` or `total` on the row. If you added defensive `?? previousValue` handling for that, it is no longer needed — and it was masking real blanks. * **Name the concrete NetSuite item type; there is no `item` record type.** `/v1/catalog/records/inventoryItem`, `serviceSaleItem`, `nonInventoryResaleItem`, `kitItem`, `assemblyItem`. All five project into the one `turbobase.item` table — filter on `item_type`, do not expect five tables. `?source=` still matters here: `item` IS a record type for Dynamics 365 BC and QuickBooks, so an unpinned catalog call for it resolves to a different connector’s record. ## v1.1.13 [Section titled “v1.1.13”](#v1113) * **Pin `?source=` on any `/v1/catalog/records/:type` or `/v1/catalog/columns?types=` call for `account`, `project` or `organization`.** Those three names are published by several connectors onto DIFFERENT tables — `account` is NetSuite’s `account` and Salesforce’s `entity`; `organization` is Zendesk’s `entity` and Workday’s `department`. An unpinned call used to resolve by adapter-registry order regardless of what the instance had connected; it now prefers a CONNECTED connector, so the answer can change on a tenant whose first-ranked publisher was never installed. Every other record type is unaffected (the connectors agree on the table). * **Stop hard-coding a record type’s spelling if you were working around a 404.** These routes now accept the snake\_case spelling of a camelCase record type and vice versa — `/v1/catalog/records/sales_order` describes `salesOrder`. The response still reports the registry’s own spelling in `recordType`; read it from there rather than echoing what you sent. * **Audit every `selectPage({ sortKey })` override you pass, and delete the ones on tables that now have `pg_row_id`.** A seek key must be NOT NULL and UNIQUE. `external_id` stopped being both on `vendor_credit`, `credit_card_charge`, `credit_card_refund`, `inventory_adjustment` and `inventory_status_change` when those five gained a `pg_row_id` primary key and their `external_id` became nullable, unique only per connection (v1.0.82). An override written before that is now a silent row-dropper: `=gt.` skips every row sharing a boundary value and never passes a NULL, and the column still exists, so an “is the seek column present?” check stays green. Your generated types are the test — a table with `pg_row_id` declares `external_id?: string | null`, one without declares `external_id: string`. Keep `{ sortKey: 'external_id' }` only for the latter. * **Read the seek key from `SEEK_KEYS` in your regenerated types instead of hard-coding it.** `turbobase project types` now emits `export const SEEK_KEYS = {
: '' }`, derived from live column nullability, so `selectPage('vendor_credit?select=*', { sortKey: SEEK_KEYS.vendor_credit })` self-corrects the next time you regenerate. A table absent from the map has no safe single-column key — line tables, whose key is the composite `(parent_pg_row_id, line_index)`; page those by filtering to one parent instead of seeking across all of them. * **Expect `selectPage()` to throw** in two cases where it previously returned a short list. When the seek key is null on the last row of a page that has more rows: it used to return `nextCursor: null` there, indistinguishable from the end of the list. And when you pass a `sortKey` other than `pg_row_id` on a table whose rows come back carrying `pg_row_id` — that combination is always a stale override. If you hit either, the fix is the two lines above, not a catch. * **Run `turbobase apps push .` before `apps deploy` when you have edited `INSTANCE-LEARNINGS.md` or `TURBOBASE-FEEDBACK.md`.** Deploy TRIGGERS the learning ingest but does not carry your markdown — it uploads the built bundle, and the ingest reads the tree your last `apps push` recorded. Deploy alone queues a job that succeeds against the previous revision and writes nothing, after which `turbobase skills push` / `kb push` report an empty queue. `apps deploy` now names the source revision it will read, and warns when there is none or when your local files are newer than it. The docs said this backwards (“your instance ingests those sections on the next `apps deploy`”); [Connect Claude Code](/build-apps/claude-code/) and the CLI reference now state the dependency. ## v1.1.12 [Section titled “v1.1.12”](#v1112) * Handle a new write rejection code **`SYNC_GUARDRAIL_BLOCKED`** wherever you already handle `SYNC_BIDIRECTIONAL_DISABLED`. Both mean the write did not reach the source, but the remedies differ and are not interchangeable: the old code means two-way sync is not sold to this tenant (“contact sales”), the new one means an operator has deliberately paused write-back for this record while rolling it out. Show the message verbatim — it is operator-authored and often names the record and a date. Treat it as temporary and retryable-by-a-human, never as a validation error on the user’s input, and do not auto-retry it. * Expect writes to be permitted **per record type**, not per tenant. An instance can now have `customer` writable and `salesorder` blocked at the same time, so an app that probed one record type once and cached “writes work here” will be wrong for the others. Check per record type, or handle the rejection where it happens. * Declare tables your app needs in `public/turbobase.json` under a new `tables` key — `{"tables":[{"slug":"task","fields":[{"name":"title","type":"text"}]}]}` — and `turbobase apps deploy` creates them. Field types are `text` / `number` / `date` / `bool` / `select`, not Postgres types. Read the columns as `cf_sa_`, not ``; regenerate with `turbobase project types` to pick them up. Omitting the key changes nothing, so an existing project needs no edit until it wants a table. * Expect a **409 `table_conflict`** on deploy if another app already declares one of your field names at a different type. The message names the other app and both types; rename your field or adopt theirs. Run `turbobase tables plan` in CI to catch it before the deploy. * Remove a field from your `tables` block only when you mean to stop using it. Releasing a claim shrinks the declared definition but never drops the column or its data — the column stays readable, and only `turbobase tables drop` (confirm-gated, irreversible) removes anything. * Commit the new `.github/workflows/turbobase-tables-plan.yml` that `turbobase apps init` now scaffolds, and add the two repo settings it needs: a secret `TURBOBASE_PAT` (an `extensions:deploy` token — the same scope `apps deploy` uses) and a variable `TURBOBASE_BASE_URL`. It runs `turbobase tables plan` on every PR and fails the check when another app already declares one of your field names at a different type, so you meet that conflict on the PR instead of as a 409 on the ship. An existing project can copy the file from `templates/ci/turbobase-tables-plan.yml`; delete it if your app declares no tables. * Use `turbobase tables list` to see what a table’s columns are actually called before writing a query against one you did not create. Two apps can share a table, so its column set may be wider than your own declaration. * Record hard rules under a new `## Agent rules` heading in `INSTANCE-LEARNINGS.md`, and push them with `turbobase skills push`. Use it for constraints that must hold on EVERY turn (“read lines through the `parent_pg_row_id` FK, never header `raw`”); keep narrative in `## Suggested KB edits`. The knowledge base is retrieved, so a rule filed there only applies when a question lands near it — that is why the same constraint keeps needing to be re-explained. Rules land as drafts and reach nothing until an administrator publishes them. * Re-run `turbobase project grounding -o INSTANCE.md` after upgrading. The brief now leads with your instance’s standing rules, so a session starts with them instead of rediscovering them. * Add `data-tb-id` to the major regions and interactive controls in an app you maintain by hand — `data-tb-id="orders-table"`, `data-tb-id="status-filter"`. The in-app change-request widget walks up from the clicked node and captures the nearest one, so a pinned annotation names the component to edit instead of a bare `div > span` plus visible text. Generated apps now emit it; an app without it keeps the tag/text fallback and loses nothing else. The attribute is inert — never read it in app code and never style off it. ## v1.1.7 [Section titled “v1.1.7”](#v117) * Run `chmod 600 .mcp.json` in every existing project. That file holds a live personal access token and the scaffold wrote it `0644` — readable by every account on the machine. New projects get `0600`. * Add `.mcp.json` to your `.gitignore` if it is not already there. The scaffold now writes the rule (and merges it into a `.gitignore` you already have), but a project scaffolded before this release does not have it, and the CLAUDE.md in that project already told you it did. * Rotate the PAT in `.mcp.json` if the project was ever pushed to a remote — mint a new one in Settings › Access tokens and delete the old one. Check `git log --all -- .mcp.json` first; an empty result means it never landed. * Rename `--version` to `--pkg-version` in any `turbobase ext publish` command or CI step. The old flag collided with the CLI’s own `-V, --version`, so the command printed a version banner and exited 0 instead of publishing — and because the flag was required, `ext publish` could not be run at all. * Ask Turbobase support for a **registry token** if you use `ext install` / `ext publish`, and set **both** `TURBOBASE_REGISTRY_URL` and `TURBOBASE_REGISTRY_TOKEN` from it, with the URL `https://` (loopback excepted). The marketplace now takes its own credential: the CLI no longer falls back to `PROVISIONING_API_URL` / `AI_METER_TOKEN`, and no longer pairs one variable with a credential resolved from another, so a marketplace command that used to work off a half-set environment now reports the marketplace as not configured. The new token is revocable on its own and is not your instance’s internal credential — see [Marketplace](https://docs.turbobase.io/extending/marketplace/#the-registry-credential). * Drop `--no-browser` and `--name` from `turbobase auth login` invocations. Both only configured a browser sign-in leg that never completed; `auth login` now takes `--base-url` and `--token` (and `--no-verify`). Passing a removed flag is an error, so a CI step carrying one fails until it is updated. ## v1.1.0 [Section titled “v1.1.0”](#v110) * Read the item subtype on receipt, fulfillment, transfer-order and check lines from the new `item_type` column instead of `raw ->> 'itemSubtype'`. The raw key was only ever populated by the periodic REST path, so it went stale between polls; the column is real-time-fresh once the tenant’s NetSuite bundle is **>= 1.5.0**, and reads NULL on rows synced before that. * Read line memo and dimensions from typed columns on `item_receipt_line` (`description`, `department_id`/`_name`, `class_id`/`_name`), `order_fulfillment_line` (`description`) and `transfer_order_line` (`department_id`/`_name`, `class_id`/`_name`, `location_id`/`_name`). Existing rows backfill automatically on upgrade. * Read `opportunity_line.tax_amount` as a column — it now exists, matching every other line table. Apps that summed tax from `raw` can drop that branch. * Expect NULL, not absence, for four line fields that NetSuite does not have: `rate`, `amount` and `tax_amount` on `order_fulfillment_line` (a fulfillment does not price) and `tax_amount` on `item_receipt_line` / `transfer_order_line`. They are part of the row shape and always NULL — do not treat one as a sync failure. * Stop reading `units_type_line` through the item vocabulary. It is a units-of-measure conversion sublist (`unit_name`, `plural_name`, `abbreviation`, `conversion_rate`, `is_base_unit`) and never had item, quantity or rate columns. * Ship a change for review before it goes live with `turbobase apps deploy --preview --label "what changed"`. It builds and uploads exactly as `deploy` does, then serves the result at its own URL and leaves `/a/` untouched. The URL opens for anyone who can already open the app, so sending it grants no new access. * Promote with `turbobase apps promote `, or close it with `turbobase apps preview discard `. `turbobase apps preview list ` shows what is open. Promotion re-points the app at the bytes that were reviewed — nothing is rebuilt. * Expect preview URLs to stop resolving after 7 days. The build stays in deploy history and can still be promoted; only the link expires. * Do not pass `--preview` with `--no-build`. A bundle this CLI did not build has its asset paths pinned to `/a//` and would render blank at a preview URL, so the combination is rejected rather than shipped. * Change nothing if you do not want previews. `turbobase apps deploy ` with no flags behaves exactly as before. * Update `vite.config.ts` **only if you replaced the scaffolded one**. Newly scaffolded projects read the base from the environment: `const ASSET_BASE = process.env.TURBOBASE_ASSET_BASE || '/a//';` then `base: ASSET_BASE`. A config that hard-codes `base: '/a//'` keeps working and keeps deploying to production — it just cannot be previewed. * Set your app’s display name and description in `turbobase.json`, not in the admin UI. The manifest is applied on every deploy and the admin rename control is gone — a name set there was reverted by the next ship, so the manifest is now the single writer. A manifest that declares neither leaves both untouched. * Treat your app’s slug as fixed once created. It is the URL, nothing redirects the old one, and it is deliberately NOT manifest-driven so a deploy can never move it. To change it, create a new app and re-deploy. * Deploy with `turbobase apps deploy` (or the proposal engine) — the admin page’s upload button and drag-and-drop are gone. Both CLI paths record your source on the instance, so `apps pull` keeps working from any machine; the browser upload recorded only the built output. * Rename any app whose slug ends in `-preview-<24 hex characters>`. That shape is now reserved for preview URLs and is rejected on every write — `apps create`, the management page’s create and rename, and any direct PostgREST write. An existing app keeps working and keeps its URL, but cannot be saved again until its slug changes. * Retry any `POST /rest/v1/proposal` that was answering `403 permission denied for sequence proposal_number_seq`. The `number` column added earlier in this release shipped without a grant on its sequence, so every proposal insert under a user JWT failed; the grant is now in place and the call returns `201`. No app change is needed — the request body is unchanged. ## v1.0.83 [Section titled “v1.0.83”](#v1083) * Key rows in `account`, `activity`, `currency`, `expense_category`, `refund`, `time_entry`, `work_item_link`, `location`, `department` and `classification` by the new `pg_row_id` uuid. `external_id` is no longer their primary key, is no longer unique on its own, and is now nullable — a row your app creates has none until write-back commits it. * Add `&source_system=eq.` to every `external_id` filter on those ten tables that must hit exactly one row. Each is written by two or more connectors (`account` by eight, `location` by five), so a bare `external_id` match can now return another source’s row: `GET /rest/v1/refund?external_id=eq.re_123` → `GET /rest/v1/refund?external_id=eq.re_123&source_system=eq.stripe`. * Add `&connection_id=eq.` as well if your tenant runs two connections of one source. The natural key is `(connection_id, source_system, external_id)`; single-connection tenants are all `connection_id=default`. * Send `pg_row_id`, not `external_id`, as the `on_conflict` column when POSTing to those ten tables with `Prefer: resolution=merge-duplicates`. An `on_conflict=external_id` request now fails `42P10` — there is no single-column unique to conflict on. * Embed `account` from `cash_sale` / `customer_deposit` through the new `account_pg_row_id` companion: `cash_sale?select=*,account!cash_sale_account_pg_row_id_fkey(*)`. **This supersedes the earlier note in this section** that told you to hint `!cash_sale_account_id_fkey` — that constraint was a 3-column key requiring the account row and the transaction to agree on `connection_id`, which they never do, so it rejected every write from a non-`default` connection with `23503`. It has been dropped. `account_id` is unchanged and still carries the value. * Switch every `location` / `department` / `classification` embed to the new companion column. The old text foreign keys are gone, so PostgREST no longer resolves `order?select=*,location(*)` — use `order?select=*,location!order_location_pg_row_id_fkey(*)`. The hint is always `!
__pg_row_id_fkey`, where `` is the old column minus `_id` (`class_id` → `class`, `from_location_id` → `from_location`). * Expect `_pg_row_id` to be populated on write, and NULL only for rows that predate the upgrade. **This supersedes the earlier note in this section** that said to expect NULL “within the hour”: as first shipped nothing populated these columns on the write path, so every newly synced row read NULL for up to an hour, permanently — not just once after upgrading. Rows are now resolved by a `BEFORE INSERT/UPDATE` trigger as they land, and `system.dimension_ref_backfill` has dropped to a nightly backstop for pre-upgrade rows and for transactions that synced ahead of their dimension master. The `_id` text columns are unchanged throughout. * Re-subscribe with `pg_row_id` if you filter realtime DELETEs. These tables’ replica identity follows their primary key, which moved from `external_id` to `pg_row_id` (and on `vendor_credit_line`, `credit_card_charge_line`, `credit_card_refund_line`, `inventory_adjustment_line` and `inventory_status_change_line` from `(tran_internal_id, line_index)` to `(parent_pg_row_id, line_index)`). A DELETE event’s `old_record` now carries the new key columns, so a subscription filtering deletes on `external_id` stops matching. INSERT/UPDATE payloads are unaffected. * Keep reading `location_id` / `department_id` / `class_id`. They are retained, still populated, and are not deprecated in this release — only the foreign key behind them moved. * Re-run `turbobase project types` — those ten tables gained `pg_row_id` / `connection_id`, their `external_id` changed from `string` to `string | null`, and every table referencing a dimension gained a nullable `_pg_row_id`. * Scope line writes by `connection_id` if you write lines directly. `order_line`, `invoice_line`, `cash_sale_line`, `credit_memo_line`, `estimate_line`, `opportunity_line` and `order_fulfillment_line` now key on `(connection_id, , )`; `connection_id` defaults to `default`, so single-connection apps need no change. ## v1.0.82 [Section titled “v1.0.82”](#v1082) * Stop selecting secret columns from `/rest/v1/*`. `source_install.session_private_key`, `source_install.source_signing_secret`, `channel_config.bot_token`, `user_email_alias.token` and `inbound_email.matched_alias_token` are no longer readable by the session role — a `select=*` or an explicit select naming one of them now returns `permission denied` for the whole request. Name the columns you need instead of `select=*`. * Read channel config from `/rest/v1/channel_config_admin`, not `/rest/v1/channel_config`. The view exposes `bot_token_set` (boolean) and `bot_token_hint` (last 4, masked) in place of `bot_token`. * Treat an app’s access as the FULL access of the signed-in user. An app can read every table that user’s RLS allows and subscribe to every published table — the app manifest is not an authorization boundary. Do not install an app you would not trust with that user’s account. * Embed the lines of `vendor_credit`, `credit_card_charge`, `credit_card_refund`, `inventory_adjustment` and `inventory_status_change` instead of reading them out of `raw`: `vendor_credit?select=*,vendor_credit_line(*)` now resolves through `parent_pg_row_id`, and no `!` hint is needed — each pair has exactly one foreign key. * Key those five headers by `pg_row_id`, not `external_id`. `external_id` is now nullable and unique only per connection (`(connection_id, external_id)`), so `?external_id=eq.` can return more than one row on a multi-connection instance. Their `*_line` rows are identified by `(parent_pg_row_id, line_index)` — `tran_internal_id` is still present but is a plain lookup column, not a key. **BREAKING — every `turbobase` command moved under a group.** The CLI’s 25 top-level verbs are now seven nouns (`auth`, `project`, `ext`, `apps`, `kb`, `sandbox`, `mcp`). There are no aliases: an old path fails with `error: unknown command`. Update your scripts, your CI workflow, and any `package.json` script that shells out to `turbobase`. * Rename in your CI and local scripts: `turbobase login` → `turbobase auth login` · `logout` → `auth logout` · `whoami` → `auth status` · `use` → `auth use`. * Rename the project-file generators: `turbobase init` → `turbobase project init` · `types` → `project types` · `grounding` → `project grounding` · `adopt` → `project adopt` · `samples init` → `project samples` · `ci init` → `project ci`. * Rename the extension verbs: `turbobase new hook|conduit` → `turbobase ext new hook|conduit` · `test` → `ext test` · `lint` → `ext lint` · `replay` → `ext replay` · `deploy` → `ext deploy` · `plan` → `ext plan` · `promote` → `ext promote` · `logs` → `ext logs` · `binding inspect` → `ext binding` · `config export` → `ext export` · `extras install|status` → `ext extras install|status`. `ext install` and `ext publish` are unchanged. * Replace `turbobase tail ` with `turbobase ext logs --follow`. The `tail` alias is removed. * Rename the knowledge-base push: `turbobase learnings push-kb` → `turbobase kb push`. Same flags, same confirmation, same plan output. * Leave `turbobase mcp` alone — it did NOT move. The `.mcp.json` in your project spawns it by path and keeps working untouched. * Re-run `turbobase project ci` if you scaffolded the GitHub Action before this release: the committed workflow calls `turbobase login`, which no longer exists. The regenerated file uses `turbobase auth login`. (`project ci` will not clobber your existing file — delete it first, or edit the one line.) * Edit `.claude/hooks/session-start-refresh-types.sh` in projects scaffolded before this release. It calls `turbobase types` and `turbobase grounding`, both of which now fail — and the hook discards their output, so `types.ts` and `INSTANCE.md` just quietly stop refreshing at the start of each session. Change the two lines to `turbobase project types` and `turbobase project grounding`. ## v1.0.81 [Section titled “v1.0.81”](#v1081) **BREAKING — two changes need action before you upgrade.** (1) The `ns_account_id` session-JWT claim is gone, not deprecated, with no compatibility window: sessions minted before this release are refused with `401 session_revoked` at every route until the user signs in again. (2) `source_system` no longer defaults, so a canonical INSERT that omits it now fails `23502` instead of silently landing as a NetSuite row. * Rename the JWT claim you read: `ns_account_id` → `source_tenant_id`. A decode that reads `ns_account_id` now gets `undefined`. A `source_user_id` claim ships alongside it, carrying the same value as `sub`. * Rename the columns you filter on in `/rest/v1/source_user_permissions`: `ns_account_id` → `source_tenant_id`, `ns_user_id` → `source_user_id`. A query naming the old columns now returns `400`. If you were already filtering on `source_tenant_id` / `source_user_id` (they existed as read-only generated columns), nothing changes — they are now the real base columns. * Drop any call to `turbobase.jwt_ns_account_id()`; use `turbobase.jwt_source_tenant_id()`, which no longer coalesces to the old claim. * Repoint any `postgres_changes` channel on `sales_order` to `order`, and any channel on `customer` / `vendor` / `employee` / `contact` to `entity` with `filter: 'entity_type=eq.'`. Those four are views, and Postgres logical replication never fires on a view — the channel reaches `SUBSCRIBED` and then streams nothing, silently. * Match Realtime deltas on `pg_row_id` for transaction tables and on `external_id` for entity sub-type rows. `external_id` is null on a row your app just created and stays null until write-back commits it, so an `external_id` match drops every app-created row. * Replace any PostgREST read of `sales_order` with `order` — `.from()`, `/rest/v1/`, and `?select=` all `42P01` on a live instance. * Key rows in `work_item`, `payout`, `fee`, `worker`, `employment`, `compensation`, `time_off`, `pay_run` and `pay_statement` by the new `pg_row_id` uuid, and add `&source_system=eq.` to any `external_id` filter on them that must hit exactly one row — two sources can now hold the same id. * Send `pg_row_id`, not `external_id`, as the `on_conflict` column when POSTing to those nine tables with `Prefer: resolution=merge-duplicates`. * Read the customer/vendor/employee/contact name as `display_name`, never `name` or `entity_name`; query the class dimension as `classification`, not `class`; order transaction lists by `tran_date`, not `created_at`. One unknown column `42703`s the whole `select=`. * Treat the names in `docs/sources/CANONICAL_MAPPING.md` → “App-read name freeze” as a stable contract: they will be added to, never renamed, so it is safe to hard-code them in your PostgREST reads. * Re-run `turbobase types` — the nine tables above gained `pg_row_id` / `connection_id`, `work_item` gained `write_status`, and their `external_id` changed from `string` to `string | null`. * Send `source_system` on every canonical INSERT through `/rest/v1/*`. The column no longer defaults to `'netsuite'` (105 columns, plus every per-install `cr_` / `cl_` / `ext_` custom-record mirror) and is still `NOT NULL`, so an insert that omits it now fails `23502` instead of silently landing as a NetSuite row. Updates are unaffected, and `POST /v1/data/:recordType` sets it for you from your session. * Pass `source_system` in `p_parent` when calling `POST /rest/v1/rpc/write_transaction`, or call it with a session. It resolves `p_parent->>'source_system'`, then the session’s `source_system` claim, then raises. PostgREST always plants the claim, so a normal authenticated call is unchanged. The signature is unchanged. Lines now inherit the parent’s source, so editing a non-NetSuite transaction no longer relabels its lines. * Re-mint any session whose JWT lacks a `source_system` claim. `POST /v1/data/:recordType` now returns `400 {"error":"session is missing the source_system claim"}` instead of falling back to `'netsuite'`. Every mint stamps the claim, so this should only fire on a hand-rolled or truncated token. * Stop writing `audience: 'users'` to `turbobase.scheduled_task` over `/rest/v1` — the value is gone from the CHECK constraint and now 400s. Use `'private'` (it was already behaving identically) or `'tenant'` / `'role'` if you meant to share. * Drop any read of `turbobase.scheduled_task_audience` and re-run `turbobase types` — the table is dropped. * Re-read `source_system` on custom-record mirrors (`cr_*` / `cl_*` / `ext_*`) if your app filters or groups on it. Those mirrors carried `'netsuite'` on every row regardless of which connector supplied the record; new rows now carry their real source. Rows written before this release keep their old value — this release does not rewrite stored data. * Optionally pass `sourceSystem` in the body of `POST /v1/sync/events`. Omitted, it uses the admin session’s own claim rather than a NetSuite default, so an enqueue on a non-NetSuite tenant now routes to that tenant’s adapter. * Stop reading `/rest/v1/sales_order_v`. The view is dropped. It was already stale — the `sales_order` → `order` rename repointed it at the new table while its column-gate lookups still passed the literal record type `sales_order`. Read `/rest/v1/order` and select the columns you need. * Stop reading `/rest/v1/user_scope`, `/rest/v1/field_restriction`, `/rest/v1/scope_dimension` and `/rest/v1/skill_audit`. All four tables are dropped. Each carried `GRANT SELECT … TO authenticated`, so each was reachable even though nothing in the platform read them. Row-level scoping is now `turbobase.scope_slot` plus the `scopes` JWT claim, resolved server-side by `scopes_match()`; there is no client-readable restriction registry replacing `field_restriction`. * Drop `user_scope.*` and `field_restriction.*` actions from any `/rest/v1/rpc/record_user_event` call — the RPC now accepts only the `user.*` namespace and raises `unsupported action: ` otherwise. Its signature and admin-only gate are unchanged. * Stop reading `oauthClientId` from `GET /admin/proactive-learning/account`. The field is removed from both the single-account and `accounts[]` shapes; it had been unconditionally `null` since the different-production-account wizard was retired. * Optional: read `/rest/v1/v_sync_attempt_recent` for a 24h operator rollup of sync attempts grouped by `(outcome, error_code)` — attempt/event counts, retry depth, in-flight count, latency, and one sample event id per group. It is `security_invoker`, so a non-admin session sees zero rows. * Rename the endpoint `GET /rest/v1/failure_resolutions` → `GET /rest/v1/failure_resolution`. The old path 404s; no alias is left behind. If you pinned the FK constraint hint, it is now `failure_resolution_failure_id_fkey`. Re-run `turbobase types`. * Pin the connector with `?source=` when a record type has more than one. `GET /v1/catalog/records/:type?source=stripe` and `GET /v1/catalog/columns?types=customer,invoice&source=stripe` now take an optional source id — 29 record types are published by several connectors. Omit it and the first connector in adapter-registry order answers, the same rule on both routes. * Re-check `project`, `organization` and `account` on `GET /v1/catalog/columns` — the answer changed. The route used to collapse a duplicated type to the LAST registered connector while `/v1/catalog/records/:type` collapsed to the FIRST; both now resolve first-in-registry-order. `project` was jira’s `cr_jira_project` and is now netsuite’s `project`; `organization` was workday’s `department` and is now zendesk’s `entity`; `account` was ramp’s and is now netsuite’s. Pass `?source=jira` / `?source=workday` / `?source=ramp` for the old answer. * Read `resolvedSources` on `GET /v1/catalog/columns` — a new `{ "": "" }` map. An intersection is only meaningful once you know which `customer` was intersected. * Handle `availableSources` on a `/v1/catalog/records/:type` 404. It lists the connectors publishing that type **that your session can already read**, plus `requestedSource` when you pinned one. It is capability-scoped on purpose, so an empty list does not mean “no such record type”. * Find a field by CONCEPT with `GET /v1/catalog/fields?q=exchange+rate` (or the data MCP’s new `search_fields` tool) instead of guessing which record holds it. It returns ranked `{record_type, table, column, pg_type}` hits across every record you can read. Use it before `/v1/catalog/columns`, which still requires `?types=` and so can only narrow a record you have already picked. * Stop concluding a field does not exist because `list_record_types` and `/v1/catalog/records` did not mention it. Both enumerate record TYPES, so any field not named after its record was unfindable — exchange rates are on `currency.exchange_rate`, an invoice’s open amount is `invoice.amount_remaining`, and nothing in the record list said so. If your app or its agent worked around a “missing” field, search for it and delete the workaround. * Expect one result per `table.column`, not per record type. Records share tables on purpose (`entity` backs 14 of them), so a hit names the record type to query plus an `also_on` list of the others pointing at the same column. * Pass `source_system` to MCP `get_record` — it is declared in the tool’s `inputSchema` now. The handler always read it, but `additionalProperties: false` had forbidden conforming clients from sending it. * Key your MCP record-type cache on `(source_system, record_type)`. `list_record_types` adapter rows now carry `source_system`; `customer` previously appeared six identical times. * Read `entity_type` on `cash_sale`, `credit_memo` and `customer_deposit` — new `NOT NULL DEFAULT 'customer'` columns. Omit it on writes and the default fills it in. Use it to disambiguate `entity_id`, which is ambiguous alone (customer 91 and vendor 91 share an id space). * Read `entity_type` on `estimate` and `opportunity` — new **nullable** columns. Treat NULL as “the source didn’t say”, not as `'customer'`; a filter written as `entity_type = 'customer'` silently drops every HubSpot- and Salesforce-sourced row. * Replace your second round-trip with `?select=*,entity(*)` on those five tables — it resolves server-side through the new composite FK, RLS-scoped, in one request, and needs no `!constraint` hint (unlike `order`, `invoice`, `order_fulfillment` and `customer_payment`, which carry a second FK to `entity` and return `300`/`PGRST201` on a bare embed). * Stop reading `raw.__hubspot.entityType` on `opportunity` — the value is now the `entity_type` column. The `raw` copy is still written but is poll-bounded, not fence-fresh. * Expect foreign-key errors on writes to those five tables carrying an `entity_id` that doesn’t resolve, or that resolves under the wrong `entity_type`. On `estimate` / `opportunity` you may still send `entity_id` with a NULL `entity_type`; MATCH SIMPLE skips the check. * Filter reads by `connection_id`, not `external_id` alone. Every Layer-A table (`entity`, `order`, `invoice`, `sync_record`, …) is keyed `(connection_id, …, external_id)`, so `/rest/v1/entity?external_id=eq.92` can return **more than one row** on a multi-connection instance. Add `&connection_id=eq.` wherever you assumed one row per source id, or handle the array. * Pass `?connection=` (or the `x-turbobase-connection` header) on `POST /:source/:recordType` to target a specific connection. A value naming no install now returns **404** instead of silently using the first install of that source. Omit it for the default connection, exactly as before. * Enumerate an instance’s connections from `turbobase.source_install`, which gained `connection_id` (text, NOT NULL, default `'default'`) and `label` (text, nullable). Both are non-secret and readable through the existing admin-only policy; `label` is the human-readable name a connection picker should render. * Do **not** join canonical identity through `connection_id`. `entity_canonical` deliberately has no such column — Atlas dedup stays connection-agnostic, so the same real customer under two connections resolves to one canonical entity with two `entity_source_link` rows. * Pass `--connection ` to `sync:source` to scope a backfill to one connection. The flag used to be parsed and discarded; a malformed value now exits 1 instead of being ignored. ## v1.0.80 [Section titled “v1.0.80”](#v1080) * Read item-fulfillment lines with the FK embed `order_fulfillment?select=*,order_fulfillment_line(*)` — no constraint hint needed, `parent_pg_row_id` is the only FK. If your app shows “lines not synced”, a per-shipment line total from the header, or any other fallback for fulfillments, delete it and embed instead. * Stop treating `/v1/catalog/records/itemFulfillment → lines: []` as evidence the mirror has no fulfillment lines. That was a registry omission, not a missing table: `turbobase.order_fulfillment_line` has existed since v0.1 with RLS, the `parent_pg_row_id` FK, and both sync directions. The catalog and the data MCP’s `list_record_types` now both report it. * Re-read fulfillment lines you cached or copied while they were empty. A fulfillment re-synced before this version could have its stored lines deleted by a header-only sync, so a `count = 0` you recorded may be wrong rather than merely stale. Ask your instance operator whether the backfill has run for your tenant before trusting fulfillment line counts in reports or reconciliations. * Never read lines out of a header’s `raw` — `raw.item` on a fulfillment is a REST link stub (`{"links":[…]}`) on any row synced before this version, so `raw->item->items` reads empty even when the line table is fully populated. This was already the rule for every header/line pair; fulfillments were the case where breaking it looked like it worked. * Re-read any promoted custom-field column (`cf_*`) your app treats as populated. A column created before its real type was known stayed `jsonb`, which made every sync of that record fail, so the column held stale or no values — silently, from the app’s side. Reconcile re-types it and the next sync repopulates it from `raw`. Date-typed columns come back NULL until that sync lands, by design. Correction to the note as it shipped in v1.0.80: it said the re-type happens “on upgrade”. It did not — nothing ran a full reconcile, so on an existing install the repair never fired and the affected records kept failing every sync. A recurring reconcile now runs every 6h and picks the work up on the first tick after upgrade. If you read a `cf_*` column between the v1.0.80 upgrade and this release, re-read it now. ## v1.0.79 [Section titled “v1.0.79”](#v1079) * Rename `turbobase learnings propose-kb` to **`turbobase learnings push-kb`** in any script or CLAUDE.md that calls it. There is no alias — the old name is gone, and so is `POST /v1/learnings/propose-kb` (now `POST /v1/learnings/push-kb`). `GET /v1/learnings/pending` keeps its path. * Expect `learnings push-kb` to **write knowledge-base pages directly** instead of creating one documentation proposal per finding. Nothing goes through the Studio any more: an existing page is APPENDED to under a `## From app developers` heading (nothing already on the page is rewritten), and a page that does not exist yet is created **unpublished** under a “From app developers” holding page. Readers see neither until someone publishes a knowledge-base release. * Expect the plan to count **pages, not findings**. Every finding that lands on the same page is now one entry and one write — twelve findings about `REC-01` append once, and several findings naming the same unwritten page create it once. `--dry-run` prints the exact markdown each page receives; `--diff` adds it to the confirmation prompt. * Re-run `push-kb` later for any page it reports as **skipped** — a page whose unpublished work belongs to another editor is left untouched and its findings stay pending, rather than being overwritten. The plan names the owner before you confirm. ## v1.0.78 [Section titled “v1.0.78”](#v1078) * Read the write-set that `turbobase learnings propose-kb --dry-run` now prints under each finding before you apply: the proposal body verbatim, plus the target page’s `visibility` change when a correction withdraws it from readers. Pass `--diff` to get the same block on the confirmation prompt, or `--no-diff` to keep the output to the plan fields. * Publish a proposed page yourself when you want readers to see it — nothing in this chain auto-publishes. `propose-kb` only ever moves a page `published → admin`, and a page authored from one of its proposals lands as a draft in the knowledge-base editor. * Expect a **“Suggest a change” widget** on your served app at `/a/`. Turbobase now injects `/_tb/widget.js` into your app’s entry HTML server-side, so end users can file change requests (with a screenshot of their screen) that land as draft proposals — no code change needed; it rides the instance session. If your app ships its **own** ``, keep `script-src 'self'` and `img-src 'self' data: blob:` so the widget and its screenshot capture load. * Replace any read that pulls a transaction’s lines out of the header’s `raw` (`select=raw`, `select=lines:raw->item->items`) with a line-table embed: `order?select=external_id,tran_date,order_line(line_index,item_id,quantity)`. Every line table carries a real foreign key to its header, so this needs nothing declared and works on your instance today. It is the largest single saving available on the read path — header `raw` reads have been measured at \~10 MB per request. * Add a constraint hint on the six header/line pairs that have two foreign keys — `item_receipt`, `return_authorization`, `vendor_return_authorization`, `journal_entry`, `check`, `transfer_order`. Write `item_receipt_line!item_receipt_line_parent_pg_row_id_fkey(…)`; the hint is always `!_parent_pg_row_id_fkey`. Without it PostgREST returns `300` / `PGRST201` and no rows, and nothing retries it for you — the client’s fallback keys on `400`/`PGRST200`, so an unhinted embed is a hard error in your app. Use the `parent_pg_row_id` constraint rather than the older source-id one: the source-id column is nullable, so hinting it silently drops the lines of records created in Turbobase that haven’t synced back yet. The other nine pairs embed bare. * Replace a separate `select()` of a `*_line` table plus an in-memory join on `tran_external_id` with the same embed. Filter, order and cap the lines by prefixing the parameter with the line table (`&purchase_order_line.order=line_index.asc`); those parameters narrow the lines, not the parents. * Embed a declared `jsonb` link with `select=*,rel(*)` rather than reading both tables — e.g. `item_receipt?select=*,created_from_po(*)`. The join runs in Postgres and is RLS-scoped, so it returns only rows this session could already read. * Declare the relationships this app embeds in `public/turbobase.json` under `relationships` (`name`, `sourceTable`, `sourceJsonbKey`, `targetTable`). Declaring is what puts the link on the instance’s books; a link is never armed just because the field exists, so an embed you never declared will not resolve. * Use a declared relationship the moment you declare it — don’t gate a screen, a fallback query, or a retry on whether it has been materialized yet. `select()` and `selectPage()` serve a declared embed either way: natively once the fast path is armed, resolved by the instance through `/v1/batch` until then, returning the same rows. Arming changes how fast the read answers, never what it answers. * Keep a newly declared embed to the plain `rel(columns)` shape until it is armed. The instance-resolved path reproduces a straight forward or reverse embed exactly and refuses what it cannot reproduce — nested embeds, `!inner`, spread embeds, embed-scoped filters — with a `501` naming the alias, rather than quietly returning different rows. Nested embeds and `!inner` work natively once armed, so for those the constraint only bites in the window after you declare. * Stop using spread embeds (`...rel(col)`) entirely — armed or not. They are rejected outright on PostgREST 12, and on 14 the request succeeds but the spread column is emitted under its own name and collides with the parent’s column of that name, so the parent’s value is lost. Select the embedded column normally and read it out of the array instead. * Index into a declared relationship’s embed as an **array**, always — including a to-one link, where it is a one-element array (`row.created_from_po[0]`, never `row.created_from_po.name`). A plain FK embed still follows the FK’s cardinality, so `item_receipt_line` is an array but `currency:currency_id(symbol)` is an object. Treat an empty array as “the target hasn’t synced yet”, never as “there is no link”: read the link id alongside it (`select=external_id,createdFrom:raw->>createdFrom`) to tell the two apart, and don’t add repair or backfill logic on the strength of an empty embed. * Replace unbounded `select()` calls on tables that can exceed 1000 rows with `selectPage()` — a keyset seek, not `offset`. An unbounded `select()` that comes back at exactly the cap now logs a `TurbobaseTruncationError` naming the query; treat that console warning as a bug to fix, not noise. The same warning now fires by **embed alias** when an embedded array was shortened — a short embed is otherwise invisible, because the page’s own row count is exactly what you asked for. Pass `{ sortKey: 'external_id' }` for read-only master tables that have no `pg_row_id`, and mint a fresh cursor whenever the filter or select changes — replaying a stale one throws `TurbobaseCursorError`. * Refresh `src/lib/turbobase.ts` from the current scaffold if this project predates it: `selectPage()`, `TurbobaseTruncationError` and `TurbobaseCursorError` are new exports. Scaffold into a scratch directory (`turbobase apps init /tmp/tb-scaffold`) and copy that one file across — don’t `--force` over your project. ## v1.0.77 [Section titled “v1.0.77”](#v1077) * Replace `select=*` with the columns you actually render in any `batch()` / `select()` call against a synced table — `*` drags the whole `raw` JSONB blob per row. `POST /v1/batch` now caps each sub-query’s response and the batch total; an over-budget sub-query comes back as its own `{ ok: false, status: 413 }` slot naming the ceiling, while its siblings still return. * Lower any `limit` sized for a table scan rather than a screen, and page instead — each batch result’s `contentRange` carries the total. * Read `error` (not `message`) on a failed batch sub-result if you surface it in the UI; the client now prefers `error` and falls back to `message`. * Nothing to change for long query lists: `batch()` chunks them automatically and preserves order, so passing 40 queries is safe. Width, not count, is what trips the cap. ## v1.0.74 [Section titled “v1.0.74”](#v1074) * Keep `INSTANCE-LEARNINGS.md` and `TURBOBASE-FEEDBACK.md` up to date as you work: `turbobase apps init` now scaffolds both, and a deploy ingests them. The first records how THIS instance’s source system is actually set up; the second records where Turbobase itself cost you time. * Stop relying on `.claude/` reaching the instance — it is no longer uploaded by `turbobase apps push`. Anything the instance must have goes in tracked project files, not in your agent config. * Add a `learnings` block to `public/turbobase.json` if this project should opt out of either standing file, or out of sharing: `{"instance": true, "platform": true, "share": "inherit"}` are the defaults. `"share": "off"` suppresses sharing for this project; it can only narrow the instance’s `telemetry.share_learnings` setting, never widen it. * Run `turbobase adopt` at the start of a session to refresh `ADOPT.md` with anything newer than the version you last adopted, and work the list before starting new feature work. # Access tokens > Mint, scope, and revoke personal access tokens and AI catalog tokens for programmatic access to your instance. Shipped Access tokens let scripts, the CLI, and tools reach your Turbobase instance without a browser session. Administrators manage them at **/settings/tokens**. A token belongs to a Turbobase account, so that page is not offered to administrators who opened the app from inside NetSuite. ## Two kinds of token [Section titled “Two kinds of token”](#two-kinds-of-token) | Kind | Prefix | What it’s for | Owner | | ------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | | **Personal access token (PAT)** | `tb_pat_` | A per-admin bearer for the CLI, the Extensions API, remote authoring, and — when you tick for it — reading and writing your data from a program | The admin who minted it | | **AI / catalog token** | `tb_cat_` | A per-instance, schema-read bearer for the catalog and types/docs surfaces | The instance (no owning user) | A PAT is minted by an administrator and tied to that admin. A catalog token is instance-scoped with no owning user — it carries read access to your schema catalog only. ## Scopes [Section titled “Scopes”](#scopes) Each token carries a scope set, and routes check the scope they require against it. Tick only what the token needs — every scope is revocable on its own by minting a replacement without it. **Build & deploy** | Scope | What it grants | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `extensions:deploy` | Deploy apps, hooks, conduits and processes, author custom tables from a manifest, toggle instrumentation, and file **draft** knowledge-base pages. When the token’s owner is an administrator, also see and cancel process runs and start one (`turbobase processes invoke`) — the run writes as that owner. A PAT’s default scope. | | `catalog:read` | Read the schema catalog — tables, columns, types — so one PAT can drive both the Extensions API and codegen. Schema only, never your data. | | `mcp:authoring` | Connect an MCP client to the authoring endpoint (below); lets it scaffold, test and deploy hooks, conduits and processes. | | `analyzer:run` | Run the [Customization Analyzer](/tenant/analyzer/) — extract, explain, plan improvements — drive managed-bundle operations, and read solutions. | | `releases:write` | Push, pull, deploy, roll back and inspect [releases](/extending/deploy-plan-promote/). | | `solutions:write` | Write a realized solution back to the instance. Reading one uses `analyzer:run` instead. | **Publish** — these change what other people and other agents are served, so neither is granted by default: | Scope | What it grants | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `skills:publish` | List [agent skills](/tenant/skills/) and their releases, and publish, unpublish, roll back and delete them — changing what every AI answer on the instance is told. Every publish records who did it. **Reads need it too:** `skills list` and `skills releases` are refused without it. | | `docs:publish` | List [knowledge-base](/tenant/knowledge-base/) pages and their release versions, and publish, unpublish, roll back and delete them — changing what every member reads and what AI answers are grounded on. **Reads need it too:** `kb list` and `kb versions` are refused without it. Filing drafts with `kb push` does **not** need it. | **Your data** — four scopes reach the rows themselves, and they are the only ones that do. They come in two pairs, one per door, because reading your business’s data and changing it are two decisions and so are the two ways in: | Scope | What it grants | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mcp:data` | Read your instance’s rows through the [data MCP](/tenant/mcp-server/) — `list_records`, `get_record`, `search_fields` — under the row-level permissions of the administrator who minted the token. A token carrying this permission and no other **cannot write**, whatever the instance’s MCP write setting says, and it reaches nothing else: the rest of the product still needs a sign-in. Never granted by default; tick it deliberately. | | `mcp:write` | Create, change and delete those same rows, through the data MCP’s `write_record` and `delete_record` — the same business rules, guardrails, permissions and onward sync a change made in the app goes through. Granted **alongside** `mcp:data`, never instead of it: a request for it on its own is refused at mint and at edit. The instance’s `mcp.server.writes_enabled` setting still has to be on, and an administrator who has switched MCP writes off keeps that answer whatever any token carries. Never granted by default. | | `rest:read` | Read your instance’s rows over the ordinary web interface, at `POST /v1/batch` — the same batched read an app’s own client makes, under the row-level permissions of the administrator who minted the token. This is the permission for a program that is not an AI client: a label printer, a warehouse script, an overnight integration. Never granted by default. | | `rest:write` | Create, change and delete those same rows over that interface, at `POST /v1/data/` and the routes beside it — through the same write boundary your apps call, so the same business rules, guardrails, permissions and onward sync apply. Granted **alongside** `rest:read`, never instead of it. Never granted by default. | **Neither pair implies the other.** Ticking the MCP pair does not open the web interface to the token, and ticking the REST pair does not connect it to the data MCP. An administrator who armed an AI client has not thereby armed an unattended script. A catalog token carries `catalog:read` only — you can’t smuggle a deploy scope onto it, and all four data scopes are refused there too: a catalog token has no owning user, so there is no identity to resolve rows under, or to write as. ## What the data scopes reach, and what they cannot [Section titled “What the data scopes reach, and what they cannot”](#what-the-data-scopes-reach-and-what-they-cannot) Ticking a read permission on the mint dialog is the decision that hands a standing credential access to your business’s data, and the write permission beside it is the one that lets it change that data. Both are worth being precise about. It is also the tick that sets the clock: a token **minted** with either read permission defaults to **7 days** rather than 90, and the lifetime field on the dialog changes as you tick, so the number you see is the number the token gets. This section is about the MCP pair. [What the REST permissions reach](#what-the-rest-permissions-reach-and-what-they-cannot) is the same account of the other pair. **What the read permission can do.** Present the token as a bearer at the [data MCP](/tenant/mcp-server/) endpoint and read rows, as the administrator who created the token. The same row-level permissions apply as when that person reads in the browser — the token is not a lesser identity, and it is not a greater one either. **What the read permission cannot do.** Change anything. With only `mcp:data` ticked, the MCP’s mutation tools are not advertised to the token, and a call that names one anyway is refused before the instance’s write setting is even read. Turning writes on for your instance does not change that; ticking the second box is what does. The token also reaches no other data surface — `/rest/v1` and the rest of the product still require a browser session. **What the write permission adds.** `write_record` and `delete_record`, and nothing else. They are the same two tools a person’s own MCP connection gets, calling the same write boundary your apps call — so a write from a token runs your `before_save` hooks, your write guardrails, the writer’s own `write:` capability and row-level permissions, and is queued onward to the connected system exactly as a change made in the app is. A token can never exceed the person who minted it: if that administrator could not make the change in the browser, neither can the token. **Two things still have to be true for a write to land.** The token has to carry `mcp:write`, and `mcp.server.writes_enabled` has to be on for the instance. They are separate answers on purpose and are refused separately, so a refusal tells you which one to fix. It is two things for the *other* credential too, and the second one is different. A person’s own [connected client](/tenant/mcp-server/#connecting-a-client) is not governed by a token’s permissions: it carries its own permission to change records, granted by the person who connected it and revocable by them from **Account → Connected apps**. So a token’s write authority is an administrator’s decision on the token’s row, a connection’s is its owner’s decision on their account page, and the instance setting sits above both. **Why it is safe to mint one now, when it was not before.** Until 1.3.x there was a scope called `data:read`, and it let a PAT be traded at `POST /auth/pat-exchange` for a session. That session was the token owner’s *ordinary* one, so for an admin owner it carried **write** as well — a long-lived token sitting in a project file amounted to standing write access to production data. Both the scope and the endpoint were removed rather than renamed, and neither is coming back. `mcp:data` is a different shape, on purpose. A session **is** still minted for the token’s owner, because that is what makes row-level permissions fire correctly — but it is minted inside each request, is never returned to the caller or set as a cookie, expires in five minutes, and only ever reaches the MCP’s own bounded tool surface. That is still true of `mcp:write`: what the second permission widens is which of those tools the token may call, not what the credential is. Nothing hands your client a session it can keep. **Treat a token carrying either like a password.** Keep it where you keep one, and revoke it the moment it is no longer needed. A token that reaches data is marked as such in the list at **/settings/tokens** — and one that can *change* data is marked differently from one that can only read it — so you can pick them out without opening any. Reading rows without a token is unchanged and is still the right default for a person: * **In an app** — the signed-in browser session, through PostgREST as usual. * **From an editor, a script or an agent** — connect the [data MCP](/tenant/mcp-server/) as yourself. You sign in once over OAuth and your client gets the same read tools, under your own identity. Connect it from **Integrations** settings; revoke it from the *Connected apps* tab of your [account settings](/tenant/auth-levels/). **Searching your customizations needs no token either.** `catalog:read` below mints a token for the authoring endpoint, but an administrator connected to the [data MCP](/tenant/mcp-server/) gets `search_catalog` and `explain_object` over that OAuth sign-in — nothing to mint, nothing to store in a project file. Mint `catalog:read` when a tool or a script has to reach the catalog on its own, without a person signed in. ## What the REST permissions reach, and what they cannot [Section titled “What the REST permissions reach, and what they cannot”](#what-the-rest-permissions-reach-and-what-they-cannot) `rest:read` and `rest:write` are for a program you run that is not an AI client — a label printer claiming jobs off a queue, a warehouse script, an overnight integration. Before they existed the only door an access token opened was the data MCP, so an unattended program had to speak that protocol or ask a person to sign in. **What the read permission can do.** Present the token as a bearer at **`POST /v1/batch`** and read rows, as the administrator who created the token. That endpoint is the batched read an app’s own client already makes: you send one or more PostgREST query strings and get their rows back, with counts, limits, offsets and embeds — so a program gets the same read grammar an app has, and exactly the rows that administrator would see in the browser. ```bash curl -s https://acme.turbobase.app/v1/batch \ -H "Authorization: Bearer $TURBOBASE_TOKEN" \ -H 'content-type: application/json' \ -d '{"queries":["print_job?select=external_id,status&status=eq.queued&limit=10"]}' ``` **It also fetches a file’s bytes**, at **`GET /v1/data///content`** — the one route under `/v1/data` the *read* permission opens rather than the write one, because reading a file is a read of a row the program can already see. The same capability and row-level permissions apply as in the browser, so a container the token’s owner cannot read stays unreadable. Name the file’s connection with `?connection=`, which is the `connection_id` on its row: without it the owner’s default connection is assumed, and on an instance with two containers of the same kind connected that is the wrong one. `Range: bytes=…` is answered `206`, and the response carries the file’s `etag` for an `If-None-Match` revisit. **A program gets the `401` envelope, never a redirect.** An image, a video or a PDF asked for by a *browser* is served inline, and a browser with no session is sent to sign in and back to the file. That branch turns on the `Accept` header asking for HTML, so a program sending `application/json`, `*/*` or an image type gets `{"error":"session_missing","returnTo":…}` with a `401` exactly as before. A refused token is never a sign-in page. Add `?download=1` if you want the attachment disposition whatever the type. **What the read permission cannot do.** Change anything, and reach `/rest/v1` directly. That prefix is served straight by PostgREST, which knows nothing of access tokens and takes a browser session only — so a program reads through the batch endpoint above instead. The token also reaches nothing outside the data surface: the console’s own screens, every credential and session route, and everything that already has a permission of its own are all closed to it. **What the write permission adds.** Writing at **`POST /v1/data/`**, the file upload beside it, and `DELETE /v1/data/`. These are the same routes your apps write through, so a write from a token runs your `before_save` hooks, your write guardrails, the writer’s own `write:` capability and row-level permissions, honours `expectedSyncVersion` so two programs cannot both claim the same row, and is queued onward to the connected system exactly as a change made in the app is. A token can never exceed the person who minted it. **The session a token gets is one it cannot keep.** A request carrying either permission is turned into a five-minute session for the token’s owner *inside* that request — which is what makes row-level permissions fire correctly — and that session is never returned to you, never set as a cookie, and gone when the response is written. The same shape the data MCP has used since 1.3.x, and deliberately not the retired `POST /auth/pat-exchange`, which handed the caller a session it could hold. **Revocation takes effect within thirty seconds.** Revoking a token, narrowing its permissions, demoting its owner or suspending them all apply with nothing to restart. Most requests pick the change up at once, and thirty seconds is the longest a withdrawn permission can still be honoured on these two routes — the token’s own row is re-read at least that often, which is what keeps a program polling every few seconds from paying for a full sign-in on every call. A request refused for the permission is a **403** naming what is missing (`{"error":"insufficient_scope","reason":"missing_scope","required_scope":"rest:write"}`), so a program can tell “tick another box” apart from “this credential is not accepted here”, which is a **401**. ## The authoring MCP endpoint [Section titled “The authoring MCP endpoint”](#the-authoring-mcp-endpoint) A PAT carrying `mcp:authoring` is the credential for the remote [authoring MCP server](/extending/writing-hooks/#grounding-over-the-authoring-mcp) at **`/mcp/authoring`** — the HTTP face of the same tools `turbobase mcp` serves locally over stdio. The client sends the PAT as a bearer token on every request; requests are rate-limited per token. The scope gate is strict in both directions: a deploy-only PAT (`extensions:deploy` without `mcp:authoring`) is rejected here, and a deploy over MCP still requires the explicit `approve: true` confirmation — the scope alone never deploys anything. **Two budgets apply, and the first one does not know who you are.** Before the token is read at all, the endpoint allows **60 requests a minute from one address** — the same number as the per-token budget, so a single client pacing itself against its token allowance never meets it. It is there to bound an unauthenticated caller, which costs the instance a lookup per attempt. The per-token budget then applies on top, at 60 a minute. Either refusal is a **429**; the per-address one carries the rate limiter’s own body (`{"statusCode":429,"error":"Too Many Requests"}`) and the per-token one carries `{"error":"rate_limited","reason":"too_many_requests"}`, so you can tell which fired. If several clients share one outbound address — a CI runner, an office NAT, a container host — they share the per-address budget, which is what to pace against. **And on an instance whose network has not been configured to pass the caller’s address through, every client shares one budget whatever address it calls from**, so if you run more than one authoring client against the same instance, pace them against 60 a minute between them rather than 60 each. Your per-token budget is unaffected either way. ## Authorization stays live [Section titled “Authorization stays live”](#authorization-stays-live) A token does not freeze its permissions at mint time. For a PAT, authorization is re-derived from the owning user’s **live** role on every request: the instant an administrator is demoted, their PATs stop being able to deploy — no revocation needed. Suspending the owning user denies the PAT too. On the two data routes the role, the suspension and the owner’s own permissions are re-read at least every thirty seconds rather than on every single request, which is the bound described above. ## Stored as a hash, shown once [Section titled “Stored as a hash, shown once”](#stored-as-a-hash-shown-once) Only the token’s secret matters, and it’s never stored. Turbobase keeps just a SHA-256 hash of each token; the plaintext is returned exactly once at mint time and never again. Copy it then — if you lose it, mint a new one. Verification fails closed on every failure mode (missing, malformed, unknown, expired, revoked, suspended, or a transient database error), so a token is never accepted by accident. ## Expiry, permission changes, rotation, revocation and deletion [Section titled “Expiry, permission changes, rotation, revocation and deletion”](#expiry-permission-changes-rotation-revocation-and-deletion) Five things can happen to a token, and **/settings/tokens** offers each of them on the token’s own row. * **Expiry.** A PAT defaults to a **90-day** lifetime — unless you tick `mcp:data` or `rest:read` when you mint it, which defaults it to **7 days** instead, because a credential that can read your business’s data should not also be the longest-lived one you hold. The write permission beside each inherits the same 7 days, since it is only ever granted alongside its read partner. Either way it is a default and not a cap: type any lifetime from 1 to 365 days in the mint dialog and that is what you get. Catalog tokens don’t expire — you rotate them instead. * **Permission changes.** **Edit** changes what a live token is allowed to do without changing its secret, so nothing has to be re-pasted wherever the token is stored. The set you save replaces the old one in full — anything you untick is dropped — and it takes effect on the token’s **next request**: narrowing a token is a partial revocation with nothing to restart, inside the thirty-second bound the data routes carry. Every change is recorded in the [audit log](/tenant/audit-log/) with both the old permissions and the new ones, and an edit that would leave no permissions at all is refused — revoke the token instead. **Adding `mcp:data` or `rest:read` here does not change when the token expires.** The shorter default belongs to the mint: a token you already hold keeps the lifetime it was given, whatever you tick. Editing permissions changes what the token may do and nothing else. If you want an existing long-lived token on the shorter window instead, mint a replacement with the permission and revoke the old one. * **Rotation.** **Rotate** issues a fresh secret for the same token and retires the old one in one step: a replacement carrying the same name, type, permissions and lifetime *in days* is created, its plaintext is shown once, and the original is revoked. The replacement gets a full new window — a token rotated the day before it lapsed is not born expiring. The new secret is minted **before** the old one is retired, so if the replacement cannot be created nothing is revoked and the token you have keeps working. * **Revocation.** An administrator can revoke any live token at any time. Revocation is a soft flip — the row stays, so the list still accounts for the token — and is idempotent and immediate, inside the same thirty-second bound on the data routes. * **Deletion.** Once a token is revoked or expired, **Delete** removes its row from the list for good. A live token has no Delete: revoke it first, so deleting is never the act that takes a working credential away. **Deleting a token does not erase its history.** The [audit log](/tenant/audit-log/) is a separate record with no link back to the token’s row, so long after that row is gone an access review can still answer who changed the token’s permissions, who rotated it, who revoked it and who deleted it — and the deletion carries the token’s name, type and permissions with it, so the trail says *which* token went rather than only that one did. **The mint is the one act that is not on that record:** creating a token here writes no audit row, so a token’s trail starts at the first thing done to it afterwards. ## The two ways to reach the data MCP [Section titled “The two ways to reach the data MCP”](#the-two-ways-to-reach-the-data-mcp) The inbound data [MCP server](/tenant/mcp-server/) takes two credentials, and which one to use depends on who is at the other end. * **A person** connects their own client over OAuth from **Integrations** settings and signs in. Reads run as that person. The resulting connections are listed on the *Connected apps* tab of their own account settings, where each member revokes their own — they are personal grants, so nobody else manages them for them. * **A script, an agent or anything unattended** presents a PAT carrying `mcp:data`, plus `mcp:write` if it has to change anything. Reads and writes run as the administrator who minted the token. This is the credential to reach for when there is nobody to complete a sign-in, or when a session expiring part-way through a job would strand the work. Both are bounded by the same row-level permissions, and for either of them changing data needs the instance’s MCP write setting on. Beyond that the second answer differs by credential: a token needs `mcp:write` on its own row, granted by an administrator, and a person’s connection needs the write permission its owner gave it on the approval screen or from *Connected apps*. Neither is granted by default. ## Related [Section titled “Related”](#related) * [Auth levels](/tenant/auth-levels/) — every way to authenticate to your instance * [Users and permissions](/tenant/users-and-permissions/) — the roles a PAT’s authorization re-derives from # Analytics > The built-in home dashboard, per-minute performance metrics, and AI usage reporting inside your Turbobase app. Shipped Your Turbobase instance ships with a built-in operational dashboard on the app’s home page, so you can see at a glance whether your sync is producing data and whether the instance is healthy — without wiring up an external tool. ## The home dashboard [Section titled “The home dashboard”](#the-home-dashboard) When you open your Turbobase app, the home page leads with the **proposal composer** — the same box, and Inputs drawer, as the [Development](/tenant/development/) pipeline — so the first thing you can do is start a proposal. It stays disabled until the initial sync finishes. Below it, administrators get the dashboard proper, and it comes in **three views** you switch between at the top of the page. One page, three questions: * **Executive** (the default) — what the instance is producing. Proposals by status, objects in the catalog, open Inputs, what has been published to your team, customization risk by family, open Inputs by detector, and the improvement pipeline from proposal through complete and realized to live. * **Operations** — what is costing somebody time. What needs a person, manual edits today, what is waiting on approval, manual re-keying against automated writes, which failure classes cleared themselves over the last 30 days, and where the re-keying lands by record type. * **Developer** — how the instance is behaving. Proposals in flight, solutions open, read p95, active extensions, read latency over the last hour, AI tokens by operation, and each extension’s fires, rejections and errors. Each view loads only its own panels, and a panel that can’t be read shows its own empty state rather than taking the page down with it. Recent proposals and releases are listed alongside, and each proposal links into Studio, the proposal-authoring canvas. The composer leads for everyone; the three views are an administrator view. Non-administrator users still get the dashboard chrome — navigation and the chat sidebar for [Pilot](/tenant/pilot/), your AI assistant — without those operator panels. ## Agents, services, and AI usage [Section titled “Agents, services, and AI usage”](#agents-services-and-ai-usage) Health of the AI agents and background services lives in the account menu — open it and expand **Agents & services**: * **AI availability** — a headline showing whether AI is available right now, with the reason when it isn’t (budget exhausted, quota, rate-limited, or provider overload). * **Agent status** — a live status row for each agent. * **Services** — a collapsed drill-down of process health. You don’t need to open it to know something is wrong: the account avatar shows a red dot whenever anything is down. * **Token usage** — application-wide input and output token totals over 24 hours, 30 days, and lifetime, broken down by tier: **Frontier**, **Chat**, **Fast**, and **Other** for a model that fits none of the three. From the usage block, the breakdown button opens the full **AI usage** modal: per-operation tokens, cache reads and writes, and a stacked usage-over-time chart, all grouped by tier. Zooming in refetches the visible window at finer time buckets — genuinely more detail, not stretched pixels. Usage is reported as token counts by tier; the underlying AI provider and dollar cost aren’t surfaced. ## How the metrics are produced [Section titled “How the metrics are produced”](#how-the-metrics-are-produced) The instance samples its own internal metrics every minute and rolls them up into per-minute buckets, computing throughput, error rates, and latency quantiles (p50 / p95 / p99). High-cardinality dimensions like per-route breakdowns are capped so a misconfiguration can’t explode the dashboard — traffic past the cap collapses into a single `__other__` bucket so the totals stay accurate. ## Querying your own data [Section titled “Querying your own data”](#querying-your-own-data) For analytics over your business data itself, query the [canonical data model](/tenant/data-model/) directly over Postgres or PostgREST, or ask Pilot. Everything you’ve mirrored from your sources is available as typed, queryable tables. # Analyzer > The Customization Analyzer: an admin view of your NetSuite account's customizations, scripts, field usage, features, and risk. Shipped The Customization Analyzer, at **/admin/analyzer** in your Turbobase app (administrators only, with its own main-navigation entry), maps what your connected Oracle NetSuite account has actually been customized into (the custom fields, records, lists, and scripts accumulated over years) and turns that inventory into something you can reason about: what exists, what’s actually used, and what carries risk. Findings flow into the [Development](/tenant/development/) pipeline as signals and proposals rather than staying a static report. The Analyzer is NetSuite-specific today. Its pages render from the last collected snapshot — opening them issues no new calls against your account — so until the first collection runs you’ll see an empty state with the collection progress and any per-step failures called out. ## Tabs [Section titled “Tabs”](#tabs) * **Overview** — the landing view: headline counts across the collected inventory plus a summary of the latest usage analysis. * **Catalog** — the unified customization catalog: every collected object — fields, records, lists, scripts, workflows, roles, custom forms, and saved searches — in one list, joined to any open risk findings. Every object opens a drawer with a plain-English explanation of what it does; scripts additionally show the pulled source and candidates for replacing them with native Turbobase capability, and where a release carries git provenance the drawer links to the source on GitHub. Bundle membership can’t be collected automatically — the page flags when it is missing, so Turbobase can run that collection against your account. The **shared helper files** your scripts load are listed here too, under the same Scripts filter with a script type of **LIBRARY** — most real scripts keep a meaningful part of their logic in shared files, and until they are listed that logic is invisible. A script’s drawer names the helpers it loads, a helper’s drawer names the scripts that load it, and each entry opens the other object; a helper also links out to its file in the NetSuite file cabinet. A helper file has no generated explanation and no source of its own on the Catalog, and it can’t be added to a managed release — it is there to be read and navigated. An import that resolves to no file in your account is kept as written rather than dropped, so nothing is quietly lost from a script’s dependency list. A script’s drawer also carries what your account’s own script record says, without anyone signing in to read it: the API version, the description whoever wrote the script left on it, and whether the record itself is switched off — which is a separate fact from whether a deployment is active, since a switched-off script can still hold a deployment that reads as deployed. A field your account never filled in shows no row at all, rather than an empty one. * **Usage** — per-field usage analysis derived from the NetSuite audit trail, so you can see which customizations are actually exercised and which are dead weight. Run a scan from here; the tab also surfaces read-only hook candidates the analysis suggests, each deep-linked into Development. The old field-usage dashboard under `/data/learning` permanently redirects to this tab. * **Features** — which NetSuite features are enabled on the account, with usage intensity. * **Instrumentation** — runtime telemetry for the scripts Turbobase manages on the account. ## What risk analysis covers [Section titled “What risk analysis covers”](#what-risk-analysis-covers) Collected scripts are classified by origin — uploaded to the account versus installed by a partner bundle — and risk detection flags exposures in them as findings on the Catalog. By default only account-uploaded script source is read. Findings are grouped into five risk families so you can triage by the kind of exposure: **inventory**, **GL posting**, **fulfillment**, **data integrity**, and **security**. An admin setting, **“Read partner/bundle source for risk analysis”**, extends reading to partner/bundle-owned script source so the risk detector can flag exposures there too. It is read-only by design and off by default: enabling it never makes a partner script manageable — partner source is tagged risk-only and can never enter the managed-bundle, proposal, or write-back path. ## Related [Section titled “Related”](#related) * [Development](/tenant/development/) — where Analyzer findings become proposals and releases * [Custom fields](/tenant/custom-fields/) — promoting discovered custom fields into queryable columns # Atlas > Atlas matches and consolidates the same real-world entity across your connected sources, with a human review queue. Shipped When the same real customer, vendor, or other entity shows up in more than one of your connected sources (or twice within one source), Atlas consolidates them into a single canonical entity. So a customer who exists in both Oracle NetSuite and Stripe resolves to one identity in your [data model](/tenant/data-model/), not two. ## How matching works [Section titled “How matching works”](#how-matching-works) As entity records flow in, Atlas reads their identity fields — email, phone, tax ID, an external key, a GTIN, or a domain — normalises each (lowercasing email, reducing phone numbers to E.164, stripping a web address down to a bare host, and so on), and checks them against the existing canonical entities. The outcome is one of: * **Linked** — exactly one existing entity matches. The incoming record is attached to it. * **Created** — no existing entity matches. A new canonical entity is created and linked. * **Review** — two or more existing entities match different identity fields of the same incoming record. Rather than guess, Atlas surfaces it for a human to decide. Matching is deterministic and idempotent: re-running it on the same record produces the same canonical entity, and a record carrying none of those identity fields keeps the entity it was first given rather than gaining a new one each time it changes. It is also connection-agnostic, so the same customer seen under two different accounts of the same source still resolves to one identity. A web address is only an identity when it belongs to the business. A social profile, a shortened link or a free mail host is listed by thousands of unrelated companies, so Atlas does not match on one at all — a record whose only identity field is a host like that keeps an entity of its own. A web address belongs to the business and never to a person, so two people at one company are two people: a lead is no longer matched on their employer’s website. For a person carrying no email and no phone, a name is matched only together with an employer; a name on its own is never enough. ## A person is a person [Section titled “A person is a person”](#a-person-is-a-person) A lead and a contact are the same person, so a prospect one connected system files as a lead and the human another files as a contact resolve to one identity when they share an email address or a phone number. Sales systems file the same person twice over — as a lead before they buy anything, and as a contact once somebody owns the relationship — and Atlas used to keep those two as unrelated identities for good. Kinds of entity are otherwise compared only against their own kind: a customer is never consolidated with a lead, and an employee is never consolidated with a contact, however much they share. People on your own payroll stay separate from the people you sell to. An identity holding both a lead and a contact is read one record at a time, under the capability each of those records needs: a user holding `read:entity:contact` sees the identity and the contact behind it, and not the lead. ## Reading the identity graph from an app [Section titled “Reading the identity graph from an app”](#reading-the-identity-graph-from-an-app) Your apps can read the consolidated identity directly, so nothing has to fetch both copies of a company and merge them itself. Ask for canonical entities with their source records embedded — one request, one row per identity: ```plaintext GET /rest/v1/entity_canonical?select=*,entity_source_link(*)&entity_type=eq.customer&order=display_name&limit=50 ``` The response nests every source record behind each identity under `entity_source_link`, and it pages by identity, so a page boundary never cuts one in half. Send `Prefer: count=exact` and the `Content-Range` header carries the exact number of identities. **Listing people takes a different filter.** An identity’s `entity_type` is the kind of whichever record reached it first, so `entity_type=eq.contact` leaves out a person whose lead arrived first, and `entity_type=eq.lead` leaves out the ones whose contact did. Ask for `match_scope=eq.person` to list people in one pass. Every other kind is a scope of its own, so `entity_type=eq.customer`, `eq.vendor` and the rest read exactly as they did. To go the other way — from a source record you already hold to the identity it belongs to, and to the other records behind it: ```plaintext GET /rest/v1/entity_source_link?connection_id=eq.default&source_system=eq.netsuite&external_id=eq.1234&select=canonical_id,entity_canonical(*,entity_source_link(*)) ``` The nested `entity_source_link` is what brings the other records back, so this answers the whole question in one request rather than two. Ask for `entity_canonical(*)` instead if the identity on its own is all you need. Filter on `connection_id` as well as the source and the id: two workspaces of the same source can each hold the same upstream id. **Who can read it.** Whoever can already read the underlying records. Each identity is visible to a user holding the read capability for one of the kinds it covers — `read:entity:customer` for customers, `read:entity:vendor` for vendors, either `read:entity:lead` or `read:entity:contact` for a person — and the source records listed under it are narrowed to the ones that user could read anyway. A user without the capability gets an empty result rather than an error, so check the capability before concluding your instance has nothing consolidated. **One thing to know if you use scopes as a confidentiality boundary.** An identity is visible to anyone who can read *at least one* of the records behind it — that is what makes the cross-source lookup work, since the second system’s copy is often the one outside a user’s scope. The list of source records under it is narrowed to the ones that user can open. The identity’s own details are not: `display_name`, `primary_email`, `tax_id` and the rest are whichever record Atlas recorded them from, so they can come from a record that user cannot open. If a field must never cross a subsidiary, department or class boundary, do not read it from the identity row. Both tables appear on your instance’s record catalogue, so `turbobase project types` generates types for them. ### Reading it from an agent [Section titled “Reading it from an agent”](#reading-it-from-an-agent) The same identities are a record type on your instance’s [MCP server](/tenant/mcp-server/), one per bucket, named `turbobase:identity_`: | Bucket | MCP record type | Capability | | --------- | ----------------------------- | ---------------------- | | Customers | `turbobase:identity_customer` | `read:entity:customer` | | Vendors | `turbobase:identity_vendor` | `read:entity:vendor` | | Contacts | `turbobase:identity_contact` | `read:entity:contact` | | Employees | `turbobase:identity_employee` | `read:entity:employee` | | Partners | `turbobase:identity_partner` | `read:entity:partner` | | Leads | `turbobase:identity_lead` | `read:entity:lead` | | Items | `turbobase:identity_item` | `read:item` | ```plaintext list_records { "record_type": "turbobase:identity_customer", "page_size": 100 } ``` That is the one combined customer list across every connected system, and it pages like any other record type — pass `page.next_cursor` back to walk it. The bare form (`identity_customer`) works too. `get_record` does not: these rows are addressed by the identity’s own `id` rather than by a source-side id, so read them with `list_records` and match on `id`. `turbobase:identity_lead` and `turbobase:identity_contact` are two lists over one scope: a person appears in whichever one matches the record that reached their identity first. Read both, or read `entity_canonical` with `match_scope=eq.person` over the app surface above, to get every person in one pass. They are read-only on every instance, because Atlas derives them. To change what one says, change the source record it was derived from, or settle it in the review queue below. ## The review queue [Section titled “The review queue”](#the-review-queue) Administrators work the cases Atlas will not decide alone at **/data/atlas**. Each row says which of two decisions it is asking for: * **Merge into** — two or more existing entities matched different identity fields of one incoming record. Confirm the entity it belongs to, or choose **Keep separate** if it is none of them. * **Split off** — an existing entity’s source records no longer share any identity field, so the merge may no longer hold. Confirming moves that one record onto an entity of its own and leaves the others where they are; **Keep merged** settles the question the other way. Nothing is ever split automatically. An automatic un-merge would be the same kind of decision as an automatic merge, so Atlas only ever proposes one. ## Your decisions are remembered [Section titled “Your decisions are remembered”](#your-decisions-are-remembered) Every answer in the review queue is stored as a **decision about two source records**, and the automatic matcher obeys it on every sync from then on: * **Apart** — these two records are never the same entity, whatever they share. A website, an email or a name-and-employer in common no longer pulls them together. * **Together** — these two records are always the same entity, even if they share nothing. If one of them later moves, the other follows it. Confirming a split, or choosing **Keep separate**, records *apart*. Confirming a merge, or choosing **Keep merged**, records *together*. A new record that genuinely matches both sides of an *apart* decision is still raised in the queue — a decision covers the records it names, not everyone who shares their details. Administrators can make and undo the same decisions without waiting for the queue to ask: * **API** — `GET /admin/atlas/decisions` lists them (add `source_system` and `external_id` to see one record’s), `POST /admin/atlas/decisions` with `{ "kind": "apart" | "together", "a": {…}, "b": {…} }` makes one and applies it straight away, and `DELETE /admin/atlas/decisions/` forgets one. Each record is `{ "source_system", "external_id", "connection_id"? }`. For *together*, `a` moves onto `b`’s entity; for *apart*, `a` moves onto an entity of its own. * **MCP** — an administrator’s agent has `atlas_match_decisions` to read them and, when the instance’s MCP server allows writes, `atlas_decide` to make or clear one. Forgetting a decision moves nothing by itself: from the next sync, Atlas decides that pair on the evidence alone again. **Planned:** Today’s matcher is the deterministic tier (exact, normalised identity-field matches). Richer cross-org matching for sources such as Salesforce and QuickBooks Online, and embedding/LLM-assisted disambiguation, are planned and not yet shipped. ## Related [Section titled “Related”](#related) * [Data model](/tenant/data-model/) — the canonical entities Atlas consolidates into # Audit log > The recorded log of every governance action in your instance — sign-in, users, sources, apps, releases, agent rules, the knowledge base, tokens and sync decisions. Shipped Your Turbobase instance keeps an append-only audit log of who did what. Administrators review it at **/audit**. Every column on that page filters, and the filters combine: pick one event family in the toolbar, type part of an actor’s name or id, type part of an action or a target, set a date range, and narrow to the rows that carry a change. Sort by when it happened, by who did it, or by what they did. The whole matching set is reachable a page at a time — not just the most recent screenful — and the filters live in the address, so a filtered view is a link you can send to somebody. Click any row to open it: the panel says when the change happened, who made it, and which fields moved from what to what. An export carries the whole set your filter matched, or refuses and tells you how many rows it would have left out, rather than handing over the first screenful as though it were the answer. A single file holds at most 50,000 rows — narrow the date range or the filter if yours is refused. ## What’s recorded [Section titled “What’s recorded”](#whats-recorded) Each audit row captures the actor, a snapshot of that actor’s name at the time, the action, the affected target, optional before/after snapshots, and free-form context (such as IP address and user agent on a sign-in). This list is generated from the instance’s own verb registry and is checked against it in both directions on every build, so it can be neither ahead of what is written nor behind it. Four more families are recorded outside that registry, because their exact event name is decided as the event happens; they have their own section below, and they are checked too. * **Roles and grants** — Creating, editing or deleting a shared role. The row carries the whole grant before and after, so a capability removed from a role that thirty people hold is reconstructable from the log alone.\ `auth.role.created` · `auth.role.updated` · `auth.role.deleted` * **Sign-in, sessions and credentials** — Who signed in, who could not, and every change to how an account proves who it is. Failed attempts carry the reason (suspended, not invited), so a session timeline reconstructs. A password CHANGE by a signed-in user is a different event from a password RESET of an account somebody may have lost control of, and each filters on its own. Setting or removing a profile picture is here too: the photograph is shown to everyone signed in to the tenant, so who changed it is a question this log has to be able to answer.\ `auth.login.success` · `auth.login.failure` · `auth.login.role_unverified` · `auth.login.etag_blind` · `auth.session.minted` · `auth.session.revoked` · `auth.session.role_switched` · `auth.session.staff_impersonate` · `auth.session.view_as` · `auth.session.view_as_exited` · `auth.user.invited` · `auth.user.access_updated` · `auth.permissions.hydrated` · `auth.oidc_confirm.issued` · `auth.password.reset_requested` · `auth.password.reset` · `auth.password.changed` · `auth.passkey.enrolled` · `auth.passkey.removed` · `auth.totp.enrolled` · `auth.totp.removed` · `auth.otp_email.enrolled` · `auth.otp_email.removed` · `auth.profile_picture.set` · `auth.profile_picture.removed` * **The user directory** — Suspending or reactivating a person, and linking or unlinking their source-system identity.\ `user.suspended` · `user.reactivated` · `user.source_linked` · `user.source_unlinked` * **Connected sources** — Secret rotation and clearing, re-authentication, the deploy-token handshake, the one-way switch that turns data sync on, and the two “re-read” behaviours of a linked record pair — reading the list of pairs, and switching either behaviour on or off for one of them. A switch carries both markers before and after, so when a re-read was turned on, and by whom, is answerable from the log alone.\ `source.secret.rotated` · `source.secret.cleared` · `source.reauth.refreshed` · `source.reauth.failed` · `source.deploy_token.checkout` · `source.deploy_token.commit` · `source.data_sync.enabled` · `source.relationships.read` · `source.relationship_refresh.set` * **Apps** — An app going live, being rolled back, having its source replaced, or being widened to everyone in the instance. The audience change is the one to watch: it exposes an app, and everything it renders, to every user here.\ `app.deployed` · `app.rolled_back` · `app.shared` · `app.previewed` · `app.promoted` · `app.preview_discarded` · `app.enabled` · `app.disabled` · `app.deleted` · `app.source_pushed` * **Platform releases** — A release deployed, pushed or rolled back, with the backend and the versions it moved between.\ `release.deployed` · `release.pushed` · `release.rolled_back` * **Agent rules** — The standing rules injected into every matching agent turn. A delete carries the retracted text, because the delete itself is permanent — it is what makes retracting a rule immediate. Recorded the same way whether the rule was armed from the Skills page or from the command line.\ `skill.published` · `skill.unpublished` · `skill.rolled_back` · `skill.deleted` · `skill.updated` * **Knowledge base** — Pages published, retracted, rolled back, deleted or rewritten, with the release each act cut. Recorded the same way whether the act came from the knowledge-base editor or from the command line.\ `kb.published` · `kb.unpublished` · `kb.rolled_back` · `kb.deleted` · `kb.updated` * **Extensions, conduits and config** — Deploys, tests, requests for the assistant to draft one, long-lived API token mints, permission changes, rotations, revocations and permanent deletions, authoring tool calls, binding repairs, and whole-manifest applies — each stamped to the acting administrator. A rotation names the token it retired and the one that replaced it; a permission change carries the token’s permissions both before and after, because the token itself only holds the new set; a deletion is recorded here precisely because the token’s own row is gone, so this page stays the answer to what became of it.\ `extension.deployed` · `extension.test` · `extension.authored` · `extension.token.minted` · `extension.token.revoked` · `extension.token.rotated` · `extension.token.deleted` · `extension.token.scopes_changed` · `extension.mcp.call` · `extension.mcp.deploy` · `conduit.binding.retried` · `config.applied` * **Business-logic hooks and conduits** — A hook proposed by the healing agent, and an administrator confirming, rejecting, disabling, enabling or removing it. The same five verbs cover a conduit, and a removal is recorded here precisely because the definition’s own row is gone: this page stays the answer to what became of it, and to who decided.\ `hook.proposed` · `hook.confirmed` · `hook.rejected` · `hook.disabled` · `hook.enabled` · `hook.removed` * **Background processes** — An administrator switching a deployed process off, which stops it running and stops every schedule queuing it, or back on. The row carries the state before and after and names who did it.\ `process.enabled` · `process.disabled` * **Failed-record decisions** — What an operator decided about a record the source refused, including an edited retry, which carries the whole payload before and after.\ `failure.muted` · `failure.retried` · `failure.payload_edited` * **Sync** — Writes pushed back to the source, a row someone linked to the record the source had already created, and whether a sync run started or was declined.\ `sync.write.succeeded` · `sync.write.failed` · `sync.write.adopted` · `sync.run.started` · `sync.run.declined` * **The in-product agent** — Records the chat agent created or edited on a person’s behalf, configuration and transform writes it made, the Slack actions it took once a person confirmed them (and the ones it refused), its crashes and slow turns, and consent granted to a connected MCP client. The last two are an administrator reading this trail itself through a connected assistant — searching it, or asking for one record’s whole timeline — because who read who changed what is as answerable as the change.\ `chat.insert` · `chat.update` · `chat.config_write` · `chat.transform` · `chat.slack_action` · `chat.agent_crash` · `chat.tool_call_observation` · `chat.tool_call_long_running` · `chat.agent_slow_response` · `mcp.oauth.consent` · `mcp.audit_log.read` · `mcp.record_history.read` * **The healing agent’s own decisions** — What the Mechanic did about a rejected record on its own initiative — deferred it, quarantined it, retried it, escalated it — and when a pattern was promoted to apply automatically.\ `governance.defer` · `field_validation.quarantine` · `record_locked.retry` · `record_locked.quarantine` · `record_locked.workflow_quarantine` · `record_locked.escalate` · `unreadable_record.retry` · `unreadable_record.auto_ignore` · `permission.apply` · `permission.apply_failed` · `permission.reject` · `tier.promote` · `tier.promote.reject` · `setup.tenant.created` ## Recorded as it happens [Section titled “Recorded as it happens”](#recorded-as-it-happens) Five families name their event only at the moment it is recorded, so they are not in the generated list above. They are on the same trail, on the same page, and each is one of the toolbar’s event families. * **Record changes** — `record.insert`, `record.update` and `record.delete`, one row for every change to a mirrored record, written inside the database rather than by any part of the application. **This includes the changes your connected source brings in**: a change a person makes here and a change the connector projects in from your connected source are both recorded, and the actor on the row is what tells them apart — a person’s sign-in address, or `sync_service` for the connector. The row carries the record before and after, which is what the panel’s field-level comparison is built from. The one thing it does not carry is `raw`, the untouched payload your source sent: it is kept with the sync record itself, it can run to megabytes on a single transaction, and it would bury the fields that actually moved. Every column your instance has promoted out of it is recorded in full, and each row names the omission in its own context. * **Boundary bypass** — `data.boundary_bypass.insert`, `data.boundary_bypass.update` and `data.boundary_bypass.delete`, written inside the database rather than by any part of the application. This is the one that catches a write nobody meant to make: it is recorded when a signed-in session writes to a mirrored table directly, without going through the write path that runs your instance’s business logic first. * **Guardrail configuration** — `guardrail.config.created`, `guardrail.config.updated`, `guardrail.config.activated`, `guardrail.config.deactivated`, `guardrail.config.deleted`, `guardrail.config.limit_changed`, `guardrail.config.posture_changed` and `guardrail.config.usage_reset`. Every control over how much your instance may write back to a connected source — the rules, the rate caps, the allow-or-deny posture, and a reset of the usage counters. The exact event is decided from which setting moved, so it is recorded by the database at the moment of the change. * **Install phase** — `phase.promoted`, recorded when an instance is moved out of learning mode into steady state, whoever moved it. * **Adapter operations** — `adapter..`, one row per privileged operation an adapter performs on a connected source: creating or deleting a custom field, deploying a bundle, and so on. The event name carries the source and the operation, so the set is as wide as what that source lets an adapter do, and each row carries the guardrail decision that let it through. ## How long it is kept [Section titled “How long it is kept”](#how-long-it-is-kept) Your instance keeps at least one year of audit history — the financial-record floor — and removes rows older than that on a rolling basis. One year is the shortest it can ever be: the retention window can be lengthened for an instance, never shortened below the floor. Export before you need it if you have to hand a period to an auditor after that window closes. ## Attribution and durability [Section titled “Attribution and durability”](#attribution-and-durability) Every action that an authenticated identity takes is attributable to that identity, and the identity is taken from the verified session, never from what a request claims — a signed-in session cannot write an audit entry directly, only through the product’s own recorded actions. A staff [impersonation](/tenant/auth-levels/) session is stamped with the staff member’s identity, so a support action is traced back to a real person rather than to the borrowed user. Recording an audit event never blocks the action it’s recording — an audit write that fails is logged but never breaks the operation. **Preview:** every action listed above is recorded and visible today, data changes included. What is still narrower than it sounds is where you read a single record’s history: it is reachable by filtering this page to that record’s table and id, and there is no history tab on the record’s own screen yet. ## Related [Section titled “Related”](#related) * [Auth levels](/tenant/auth-levels/) * [Users and permissions](/tenant/users-and-permissions/) # Auth levels > The ways an identity authenticates to your instance and the capabilities each session carries. Shipped Turbobase supports several ways to authenticate to your instance, each producing a session that carries the same kind of capability and scope information so that [row-level security](/tenant/data-model/) enforces access uniformly no matter how you got in. ## Ways to sign in [Section titled “Ways to sign in”](#ways-to-sign-in) * **OAuth (hosted sign-in)** — the normal way a person signs in. They authenticate against the instance’s hosted sign-in, are matched to an invited user by verified email, and get a session. Access is [invite-only](/tenant/users-and-permissions/): an authenticated identity that was never invited gets no session. * **Email and password** — the built-in sign-in form, with an optional second factor (see below). * **Passkey** — a user enrolled with a passkey can sign in directly with it. * **Personal access token** — a script or the CLI presents a [PAT](/tenant/access-tokens/) as a bearer; it resolves to the owning admin and that admin’s live role. A token carrying the `mcp:data` permission reaches the data MCP too, under that same admin’s row-level permissions; reading is all it may do there unless it also carries `mcp:write`, which an administrator grants alongside the read permission and never instead of it. A token carrying `rest:read` reaches the instance’s rows over the ordinary web interface as well — the batched read at `POST /v1/batch` and an attachment’s bytes — and `rest:write` beside it reaches the write routes, each request running as the owning admin for the length of that request. The two pairs are independent: neither implies the other. * **App exchange** — a Turbobase app exchanges a handoff for a session so it can act on the user’s behalf. * **MCP** — an [MCP client](/tenant/mcp-server/) presents either a per-user OAuth access token or a PAT carrying `mcp:data`. Either is bridged to a short-lived session so the same RLS fires for the client as for the app; the session is minted inside the request and never handed back. Changing data through it needs the instance’s MCP write setting on, and a second permission that depends on the credential: a token needs `mcp:write`, and a person’s own connected client needs the write permission its owner gave it on the approval screen or from **Account → Connected apps**. Neither is granted by default. * **Native app (iOS, Android, desktop)** — an app your team builds signs a person in through your instance’s own OAuth server, the same sign-in they see in a browser. The app registers itself, sends the person to your instance to sign in and approve the app by name, and receives that person’s own session as a token. It then reads and writes exactly what they may — no more — and the token expires on its own. Nobody new can be signed up this way: access stays [invite-only](/tenant/users-and-permissions/), so a person the instance has never invited gets no session however they authenticate. Each person retracts an app from **/settings/account → Connected apps**, after which it cannot renew and must ask them again. See [building a native app](#building-a-native-app) below. * **Staff impersonation** — managed-service staff can act as a user in your instance for support. An impersonated session is short-lived and stamped with the staff member’s identity, so every action it takes is attributable back to a real person, not just to the borrowed user. * **Deploy** — extension deploys authenticate with a deploy-scoped PAT. ## Bring your own identity provider [Section titled “Bring your own identity provider”](#bring-your-own-identity-provider) By default people sign in against the instance’s hosted sign-in. Administrators can also wire your own identity provider under **Settings → Sign-in** — connect a **Google Workspace** domain or any generic **OIDC** provider, with a copy-paste redirect URL to register on the provider’s side — so your team signs in with the accounts they already have. Access stays [invite-only](/tenant/users-and-permissions/) regardless of provider: an identity that was never invited gets no session. ## Building a native app [Section titled “Building a native app”](#building-a-native-app) A mobile or desktop app your team builds signs people in against your instance directly — there is no separate sign-in to build and no shared key to ship inside the binary. **Register the app.** `POST /oauth/register` with the callback the operating system routes back to it. The callback must be an **https URL on a domain your app claims** — an iOS universal link or an Android app link — because that claim is what proves the callback belongs to your app. A custom scheme such as `com.example.app:/cb` is refused, and it is what most OAuth libraries reach for first, so set the redirect explicitly. A desktop or CLI client may instead register `http` on `127.0.0.1` or `localhost`, on any port. The registration takes no credential and returns a public client id; there is no client secret, and PKCE is required. **Claim a callback on your instance.** If the native app belongs with a hosted app on your instance, declare it in that app’s `public/turbobase.json` — `"native": { "ios": { "appIds": ["."] } }`, and/or `"android": { "packages": [{ "name": "", "sha256": [""] }] }` — and deploy. Declaring is not enough on its own: your instance names only the apps that are also on its **trusted native apps** list, which Turbobase sets for your instance on request and which no deploy or app can change. Ask for each iOS app id, and each Android package with its signing-certificate fingerprint, to be added. Once both are in place your instance serves `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json` naming the app, with its callback at `https:///oauth/app-callback/`. That URL is the callback to register. On iOS, add `applinks:` and `webcredentials:` to the app’s associated domains and target iOS 17.4 or later, the first release whose sign-in sheet catches an https callback. The claim follows the version that is live: a preview claims nothing until promoted. **Trust only apps you sign.** Only the iOS universal link is limited to that one callback path. `webcredentials` lets the app use your instance’s passkeys and saved passwords, and an Android app link lets the app open any link on your instance, including password-reset and invite emails. Removing an app from the trusted list takes effect on your instance within a minute, but Apple and Android devices cache the file, so a phone can keep honouring the old one for hours. **Register at run time, not at build time.** A registration nobody signs in on lapses after an hour, and one that is used lasts thirty days from its latest sign-in, so a client id baked into a build stops working. Register on first launch, keep the id on the device, and register again when `/oauth/authorize` or `/oauth/token` answers `unauthorized_client`. **Ask for the right thing.** Send `resource=https:///rest/v1` on `/oauth/authorize` and repeat it on `/oauth/token`. That is what says “this app wants the person’s data”, and it is what the token you get back is good for. Omit it and you get the MCP server instead. `/.well-known/oauth-authorization-server` lists under `resources_supported` whichever of the two your instance is actually serving — the data one always, the MCP one only while the MCP server is switched on — so a client that reads it never asks for something it would then be refused. **What the person sees.** Your instance’s own sign-in, then a card naming your app and what it is asking for. They approve it once. **What the app holds.** That person’s own session, as a bearer token. Send it to `/rest/v1` (as both `apikey` and `Authorization: Bearer`), to `POST /v1/data/:recordType`, to `POST /v1/batch`, to `POST /v1/stock` and to `GET /api/account/picture/:userId`, exactly as a browser app does. It carries their capabilities and scopes and nothing else, and it lasts eight hours. **Drawing people’s pictures.** `GET /api/account/picture/:userId` answers with the picture that person has set, so your app shows the same faces the web app does instead of initials. Anyone signed in to your instance can read anyone’s, which is what lets a list of people show a face beside each name. It carries an `ETag`, so send it back as `If-None-Match` and a re-draw costs you a `304` and no image. A person who has set no picture is a `404` — draw your own initials or glyph for that. Setting or removing a picture is still the web app’s **/settings/account → Profile picture**; the bearer reads, it does not write. **Renewing it.** The token response also carries a `refresh_token`. Keep it, and when the eight hours are up send it with `grant_type=refresh_token` and the same `client_id` — the person sees nothing. When a renewal hands back a new `refresh_token`, keep that one and discard the one you used; when it hands back none, the one you just sent is still the one to keep. Renewal works for **thirty days from the sign-in**, and using it does not extend that: on day thirty-one the renewal is refused with `invalid_grant`, and the app signs the person in again. Treat that refusal as the signal to start the flow at `/oauth/authorize`, not as an error to retry. **Turning it off.** The person retracts the app under **/settings/account → Connected apps**. It cannot renew the token it already holds — the next renewal is refused — and it has to ask them again before it can obtain a new one. The token in its hands is not cancelled at the click; like any session it runs out on its own, within the eight hours above. ## Passwords and second factors [Section titled “Passwords and second factors”](#passwords-and-second-factors) Password sign-in is email-first: you enter your email at **/login**, then your password on the next step. If your account has a second factor enrolled, the password alone isn’t enough — you land on **/login/mfa**, which shows whichever factors you have: a field for your authenticator (TOTP) code, a **Use a security key** button for a passkey, an **email me a code** option, or any combination. The email code doubles as a recovery path, so a lost authenticator or passkey doesn’t lock you out. Forgot your password? **/login/reset-password** emails you a reset link — the response is deliberately generic, so the form never reveals whether an address is on file. The link lands on **/login/set-password**, where you choose a new password against a live checklist of the policy: at least 12 characters, with an upper-case letter, a lower-case letter, a digit, and a symbol. ## Managing your sign-in methods [Section titled “Managing your sign-in methods”](#managing-your-sign-in-methods) Each user manages their own security surface at **/settings/account**, under four tabs — **Profile** (the picture on your avatar), **Account access** (how you sign in), **Connected accounts**, and **Connected apps**: * **Password** — change it; your current password is required. * **Passkeys** — enroll a passkey through the browser’s native prompt, see each enrolled passkey with when it was added and last used, and remove one. * **Authenticator (TOTP)** — enroll by scanning the generated secret into an authenticator app and confirming a code. * **Email code** — turn on receiving your second-factor code by email; it also serves as a recovery factor when an authenticator or passkey isn’t to hand. * **Connected apps** — on the page’s **Connected apps** tab: see everything you have connected to your account — a phone app your team built, an MCP client, or both — and revoke any of them. Each row says which it is. The list is your own connections only; you can never see or revoke another user’s. ## What a session carries [Section titled “What a session carries”](#what-a-session-carries) Every session encodes: * The user’s **role** (admin or standard) — administrators bypass RLS. * The user’s **capabilities** — the verb × record-type operations they may perform. * The user’s **scopes** — the dimensional restrictions limiting which rows they can see and touch. A restriction is a write boundary as well as a read filter: creating or editing a record that names a dimension outside the session’s own scope is refused before the statement runs, so nobody commits a row they would then be unable to find. * The user’s **content grants** — the exact message-history channels the role they are acting as may read. Because these are carried on the session itself, the database decides most of what a request may touch from the session alone, and the same decision holds for the app, the PostgREST API, and MCP alike. Two things are checked live on every statement instead: * **Whether the session is still alive.** A person who signed out, was suspended, or had the role they are acting as edited stops reading and changing your instance’s records straight away, from every client — not at their next sign-in and not when their session runs out. See [Users and permissions](/tenant/users-and-permissions/) for what each of those does, and [App auth wiring](/build-apps/auth/) for what an app sees when it happens. * **Content grants.** Each request checks them against the role as it stands now, so a channel removed from a role, or a role taken away, stops being readable immediately. Sessions are time-limited and expire. ## How long people stay signed in [Section titled “How long people stay signed in”](#how-long-people-stay-signed-in) A browser session lasts **1 day** unless an administrator changes it, and it slides — an active person is kept signed in, so the length is really the stretch of inactivity that ends the session. An administrator can set it in two places, in whole days, and the longest a session may last either way is **30 days**: * **Instance-wide** — **Settings → Sign-in → Sessions**, under *Keep users signed in for*. This is the default everybody gets. * **Per role** — the session-length field on the [role editor](/tenant/users-and-permissions/). A role’s own value wins for its holders, whether it is longer or shorter than the instance default. Leaving it empty means the role inherits the default. Both are in seconds, and neither accepts anything below 1 hour or above 30 days. Changing either reaches people who are already signed in: the new length is applied the next time their session renews, so shortening a role takes effect without anybody having to sign out. Weigh a long session against where it is used — a browser left open on a shared machine stays signed in for exactly as long as you set. Lengthening it suits a role whose people only read. Two things never follow this setting: a staff impersonation session and a *view as* session always last ten minutes, however long an instance’s own sessions are. A native app’s token has its own lifetime too, described above, and so does an identity provider you have connected — if your provider ends its own session sooner, your people will be asked to sign in again at that point regardless of what is set here. ## Realm isolation [Section titled “Realm isolation”](#realm-isolation) Instance sessions are issued and verified only for your instance. The managed-service staff and customer-console identities are entirely separate realms — they never share a session, secret, or cookie with your instance. Staff impersonation is the one bridge, and it’s deliberately short-lived and fully attributed. ## Related [Section titled “Related”](#related) * [Users and permissions](/tenant/users-and-permissions/) * [Access tokens](/tenant/access-tokens/) * [Audit log](/tenant/audit-log/) — every sign-in and session event is recorded # Channels > Connect your Slack workspace, ask Pilot questions from any channel, and link your own Slack account. Shipped Channels connect your Turbobase instance to Slack, so it can reach your team where they already work. Slack is set up in one place — **Settings → Slack** — and is not one of the tiles in the Sources catalog. ## Connecting your Slack workspace [Section titled “Connecting your Slack workspace”](#connecting-your-slack-workspace) An administrator opens **Settings → Slack** and clicks **Add to Slack**. Slack asks the workspace for every permission Turbobase uses in one consent screen; there are no extra permissions to grant later for individual features. Once the install finishes, the same page shows the connected workspace, whether Slack events are reaching your instance (with a retry if they are not), and when the last one arrived. Turbobase works in the channels its bot has been invited to. Invite it with `/invite @TurboBase` in each channel you want it in. It keeps those channels — messages, thread replies, edits, deletions and members — up to date in real time, and **Settings → Slack** lists every channel with its sync status, a switch to stop syncing one, and **Sync now**. **Messages** is how many of that channel’s messages are in Turbobase right now, so it moves as messages arrive. **Last synced** is when the channel’s last complete pass through its history finished; the first pass can take days on a busy workspace, and until it finishes the column shows when the channel’s newest message landed instead — messages are already searchable long before the history is fully in. **Connected before this release? Click Add to Slack again.** Turbobase now requests all of its Slack permissions at once, and Slack only grants new permissions when a workspace consents again. Until an administrator reinstalls, **Settings → Slack** shows a banner asking for it, and anything that needs a permission your workspace has not granted fails — nothing is switched off for you. Reinstalling keeps every setting on the page. **Disconnecting** on **Settings → Slack** removes the bot token and every Slack setting from your instance. The Slack workspace stays registered to this Turbobase instance, so connecting the same workspace to a different Turbobase instance needs Turbobase support. ## Asking Pilot from Slack [Section titled “Asking Pilot from Slack”](#asking-pilot-from-slack) @-mention `@TurboBase` in any channel it has been invited to and your message goes to [Pilot](/tenant/pilot/), your AI assistant, which answers in place. In a thread, Pilot reads the whole thread first, so a follow-up question does not need to repeat what was already said. Every question runs as *you*: the answer is scoped to your own role and row-level security. If you have not linked your Slack account yet, Turbobase replies privately with a pointer to the **Account** page — your account menu, or `/settings/account` — instead of answering. Asking from Slack works whether or not the web chat is switched on — the pilot-chat setting decides whether Pilot appears in the Turbobase app, not whether it answers here. While the web chat is on, the reply links to the conversation in Turbobase for administrators, and everyone else sees a note that viewing it there is admin-only at the moment. While it is off, the reply says neither: there is no conversation to open. **Direct messages are off unless an administrator turns them on** on **Settings → Slack**. Until then, a direct message to the bot gets no reply; @-mentions in a channel work either way. ## Making changes from Slack [Section titled “Making changes from Slack”](#making-changes-from-slack) When a question leads to a change — posting a message, creating or renaming a channel, updating a canvas, changing a record — Pilot posts what it proposes to do and waits. The person who asked confirms by reacting with ✅ (or replying `confirm`) and cancels with ❌ (or `cancel`). A reaction from anyone else does nothing, and a proposal nobody answers expires. Administrators choose which kinds of Slack action Pilot may take — posting, reactions, channel management, canvases and files — on **Settings → Slack**. Channel management is admin-only whatever the setting. ## Who can see what [Section titled “Who can see what”](#who-can-see-what) Pilot only uses Slack content the person asking is allowed to see. | Who is asking | Slack content Pilot can use | | ---------------------------------------------- | ---------------------------------------------------------------------------------------- | | Someone who has not linked their Slack account | Public channels only | | Someone who has linked their Slack account | Public channels, plus the private channels and group DMs their Slack user is a member of | | An administrator | Every channel Turbobase syncs | An answer posted in a Slack channel is visible to everyone in that channel, so it never draws on a *different* private channel — only public channels and the channel you asked in. In a direct message with the bot, your normal access applies. ## Linking your Slack account [Section titled “Linking your Slack account”](#linking-your-slack-account) Every user links their own Slack account on the **Account** page — your account menu, or `/settings/account` — under **Connected accounts**. Linking is what lets Pilot answer your questions from Slack, lets a notification @-mention you, and lets you see the private channels you belong to. An administrator can also onboard a teammate channel-first: a channel invite from **/admin/users/invite** carries a one-shot token, and redeeming it creates the Turbobase account and binds the chat identity in a single step. ## Outbound notifications [Section titled “Outbound notifications”](#outbound-notifications) Your instance can post a notification into the Slack channel an administrator picks on **Settings → Slack** and @-mention the right person — for example when a scheduled task has something for someone to look at. Messages render in Slack’s native format (mrkdwn). ## Related [Section titled “Related”](#related) * [Users and permissions](/tenant/users-and-permissions/) — the accounts a chat identity binds to * [MCP server](/tenant/mcp-server/) — chat-driven access to your data from an MCP client # Custom fields > Promote custom fields discovered in a connected source into first-class, queryable columns. Shipped Source systems let you define custom fields: a custom column on an Oracle NetSuite record, a HubSpot property, a Salesforce `__c` field. Turbobase discovers these and **promotes** them into real, typed columns on your [canonical data model](/tenant/data-model/), so a source-specific field becomes something you can query, filter, and join on directly, instead of digging it out of the raw payload. ## How promotion works [Section titled “How promotion works”](#how-promotion-works) A discovered custom field is promoted into a column named `cf__`, where `` is a short, fixed two-character code for the source — `cf_ns_` for NetSuite, `cf_sf_` for Salesforce, `cf_st_` for Stripe, `cf_hs_` for HubSpot, and so on. The prefix keeps two sources’ fields from colliding even when they share the same underlying field name, and tells you at a glance which source a column came from. Standard canonical columns never carry the `cf_` prefix. The mapping from a source field to its column is deterministic and stable: the same field always promotes to the same column name across migrations, even when a very long field id has to be truncated to fit Postgres’s identifier limit. If you hold two connections of the same source, each connection’s custom fields land in their own distinct columns so they never overwrite each other. ## Read-only and computed fields [Section titled “Read-only and computed fields”](#read-only-and-computed-fields) Some source fields are computed by the source and cannot be written back. A Salesforce formula field and a Salesforce auto-number field are the two you are most likely to promote: both become ordinary columns you can query, filter and join on, and neither accepts an edit — the source rejects the whole record when one is sent, so Turbobase leaves them out of every write. The field-selection drawer does not yet say which of the fields it offers are computed, so check the field in Salesforce Setup if you need a column you can write to. A formula field has a second limit worth knowing about, because it is invisible until something looks wrong. Salesforce recalculates a formula without changing the record’s last-modified timestamp, and every sync after the first reads only the records whose timestamp has moved — so a formula column holds whatever the value was when the row was last read for some other reason. There is no schedule that refreshes it on its own. To bring one up to date, start a full re-scan of that source from **/data/sync/history**. A promoted formula column that lags behind what you see in Salesforce is expected, not a fault. ## Managing customizations [Section titled “Managing customizations”](#managing-customizations) Administrators pick which discovered custom records, lists, and fields to surface from the Records page at **/data/sync/records**. Expand a row and each per-source line inside it opens a field-selection drawer listing the discovered fields for that source; the row’s own “+ Add fields” goes straight there when the row has one place to put a field, and asks which line you mean when it has several. Once promoted, a custom field reads and (where the record type is bidirectional) writes just like any other column. ## Backfilling history [Section titled “Backfilling history”](#backfilling-history) A promoted column is not just populated going forward — Turbobase backfills the history for you, in two passes: 1. Every row whose stored payload already carries the field is filled straight from that snapshot. No calls to your source, so it costs nothing against your API budget. 2. Rows that predate the field existing in your source are re-fetched, paged, in the background and through the same per-source call gate that protects the rest of your sync. There is no cap on how many rows this covers. The backfill starts on its own when you approve the customization and runs asynchronously — approving does not block on it. Progress shows inline on the record’s row under **/data/sync/records**. ## Related [Section titled “Related”](#related) * [Custom tables](/tenant/custom-tables/) — author brand-new native tables * [Data model](/tenant/data-model/) — the raw and promoted tiers # Custom tables > Author brand-new native tables, alongside your mirrored canonical data. Shipped Beyond the records mirrored from your connected sources, Turbobase lets you author your own **native tables** — tables with no external source, defined from scratch — that live right alongside your canonical data and get the same treatment: row-level security, realtime, the AI catalog, and a write path. ## Authoring a table [Section titled “Authoring a table”](#authoring-a-table) Administrators create a table from the Records page at **/data/sync/records** — the “New custom table” builder names the table and defines its fields — or by asking [Pilot](/tenant/pilot/), your AI assistant, to create one in chat. Today’s field types are text, number, date, boolean, and a free-form select. The table is created as a `cr_` (record) or `cl_` (list) table; because there’s no external source to review against, a table you author is auto-approved on creation — you are the authority for it. Definitions are validated up front: a reserved, duplicate, empty, or invalid table or field name is rejected before any change is made, so a bad definition can never leave a half-created table. Custom fields on these tables promote into `cf_sa_*` columns, reusing the same promotion machinery as source-discovered [custom fields](/tenant/custom-fields/). A table is never finished: use **+ New field** on its row in the Records page to add a field later, and existing rows keep their data. ## From the CLI, or an app’s manifest [Section titled “From the CLI, or an app’s manifest”](#from-the-cli-or-an-apps-manifest) The builder is not the only door. The same tables can be authored from the [CLI](/cli/overview/), which is how an app developer or an AI agent works: ```bash turbobase tables list # what exists, and who claims it turbobase tables create tasks -f title:text -f due:date # one table, right now turbobase tables plan # preview; writes nothing turbobase tables apply # apply this project's declarations ``` An app can also declare the tables it needs in its own `public/turbobase.json`, so deploying the app creates them: ```json { "tables": [ { "slug": "task", "displayName": "Tasks", "fields": [ { "name": "title", "type": "text", "required": true }, { "name": "due", "type": "date" } ] } ] } ``` ## Writing to a table you authored [Section titled “Writing to a table you authored”](#writing-to-a-table-you-authored) A write goes through the same path as any mirrored record — `POST /v1/data/{recordType}`, or the app client’s write helper — and the record type is the table’s slug (`task`) or the `customrecord_`-prefixed spelling of it (`customrecord_task`). Both name the same rows, so an app can use either — and both wait for the same thing: a table is writable once its declaration has been reconciled, so a write made before that, or after a reconcile that failed on it, is refused `record type "…" is not writable` rather than attempted against a table that may be half-built. Either way, **a row in a table you authored stays on the instance.** Turbobase reads which system owns the table off the definition, not off the system the signed-in session happens to be connected through, so the row is marked as belonging to the instance and is never queued out to a connected source — there is nothing upstream to send it to. A write that names a connected source anyway, on a table that source does not have, is refused as it is made and says so, instead of being accepted and failing against that source hours later. ## When two apps want the same table [Section titled “When two apps want the same table”](#when-two-apps-want-the-same-table) Several apps on one instance can legitimately want a `task` table, so a declaration is a **claim**, not an instruction. The table you get is the merge of every claim on it: a field appears if any app asked for it, and two apps asking for the same field is agreement rather than a collision. The one case that fails is two claimants declaring the same field name at genuinely different types — one app’s `due` as a date and another’s as text. A column can only have one type, and silently picking one would break the other app’s reads, so the deploy is refused with a report naming both apps and both types. Rename your field, or use the type that is already there. `turbobase tables plan` reports the same thing without changing anything, so a conflict is discoverable before it is a failed deploy. The Records page shows **Claimed by** on every shared table, so you can see an app depends on one before you drop it. Releasing a claim — an app that stops declaring a field, or is deleted — shrinks the definition but **never drops a column or its data**. Your data outlives the app that introduced it; only an explicit, confirm-gated `turbobase tables drop` removes anything. ## Permissions [Section titled “Permissions”](#permissions) You grant per-role read/write/delete on a custom table and can toggle a row-level “restrict to owner” flag, so the same [capability and scope](/tenant/users-and-permissions/) model that protects your canonical data also protects the tables you author. **Not yet shipped:** Richer field types and lookup relationships between tables are still in progress. Today’s tables support the basic field types listed above; typed lookups to other tables and the broader rich-field-type set are not yet available. ## Related [Section titled “Related”](#related) * [Custom fields](/tenant/custom-fields/) — promote source-discovered fields into columns * [MCP server](/tenant/mcp-server/) — custom tables are discoverable and addressable over MCP * [Data model](/tenant/data-model/) # Data model > The canonical data model your instance exposes over Postgres and PostgREST, with bidirectional and read-only record types. Shipped Every Turbobase instance gives you a single, canonical data model that mirrors the records from each connected source. You read it over a normal Postgres connection or the auto-generated PostgREST API — the same surface your Turbobase app, [Pilot](/tenant/pilot/) (your AI assistant), and the [MCP server](/tenant/mcp-server/) all read through — and write it through a single validated write boundary that checks each change before it lands. ## One canonical shape across sources [Section titled “One canonical shape across sources”](#one-canonical-shape-across-sources) Records flow in from each connected source (Oracle NetSuite, Stripe, QuickBooks, Shopify, and others) and are normalised into a shared, source-agnostic shape. A `customer` looks the same whether it arrived from NetSuite or Stripe, so you query one table rather than learning each source’s schema. Each row carries the source it came from and the source-side identifier, so you can always trace a canonical row back to its origin. When the same real-world entity appears in more than one source, [Atlas](/tenant/atlas/) consolidates them — and your apps can read that consolidated identity directly, asking by any one of the source records and getting back every record behind it, a page at a time. See [reading the identity graph from an app](/tenant/atlas/#reading-the-identity-graph-from-an-app). ## Raw and promoted tiers [Section titled “Raw and promoted tiers”](#raw-and-promoted-tiers) Each record is stored in two complementary tiers: * **Raw** — the full source payload is preserved on every row, so nothing the source sent is ever lost, even fields Turbobase doesn’t model. * **Promoted** — the fields Turbobase canonicalises are surfaced as first-class typed columns you can query, filter, and join on directly. [Custom fields](/tenant/custom-fields/) discovered in a source are promoted on top of this into their own `cf_*` columns, so a source-specific field becomes a real, queryable column rather than something you have to dig out of the raw payload. ## Bidirectional vs read-only [Section titled “Bidirectional vs read-only”](#bidirectional-vs-read-only) Record types fall into two classes: * **Bidirectional** — record types you can create and edit from your instance. Writes never hit the tables directly: a raw PostgREST `INSERT` / `UPDATE` / `DELETE` as an ordinary user is refused, and every change goes through a single validated **write boundary** that checks it first. Whether those writes are pushed back to the originating source is gated per source — write-back ships **off** and is turned on for a source on your instance when you’re ready (Stripe is the exception, always on). Until it’s on, a bidirectional type is effectively read-only upstream: your edits live in your canonical mirror but aren’t sent to the source. Once it’s on, a write you make is written back; if the source rejects it, you see the rejection as a [sync failure](/tenant/source-health/) rather than a silent drop. * **Read-only** — record types that mirror inbound source data without a write-back path. ## Row-level security [Section titled “Row-level security”](#row-level-security) Access is enforced in the database by row-level security (RLS), not just in the app. RLS works on two axes, and one grant that runs the other way: * **Capabilities** — a verb × record-type vocabulary like `read:sales_order` or `write:invoice` that gates which operations a user may perform on which record types. * **Scopes** — generic dimensional restrictions (for example, by subsidiary or location) that limit which rows a user can see and touch. * **Content grants** — the reverse of a scope, for message history: a role reads a channel’s history only when it names that channel exactly (see [Content access](/tenant/users-and-permissions/)). Administrators bypass these checks; everyone else sees and writes only what their [role and permissions](/tenant/users-and-permissions/) allow. The same RLS fires no matter how the request arrives — the app, PostgREST, or MCP — so there is no privileged back door around it. ## Where to start [Section titled “Where to start”](#where-to-start) * [Provision your instance](/getting-started/provision-your-instance/) * [Connect your first source](/getting-started/connect-your-first-source/) # Development > The pipeline for changing your instance: proposals, Studio authoring, solutions, tracked releases, templates, standards, and processes. Shipped Development, at **/admin/development** in your Turbobase app (administrators only), is where changes to your instance are proposed, designed, shipped, and watched. It is one pipeline behind a shared tab strip — **Proposals · Solutions · Releases · App Management · Processes** — so an idea moves from raw input to a monitored deploy without leaving the surface. Opening the bare `/admin/development` URL lands you on Proposals, the pipeline’s first stage. ## Proposals [Section titled “Proposals”](#proposals) Every piece of development work starts as a proposal. The Proposals list shows each one (newest first) with its evidence count and lifecycle status — `draft` → `complete` → `realized` → `documented` — plus labels and epics for grouping, and live badges: **Working** when the AI is actively processing a proposal in the background, **Active** once work has started on a draft. Archiving a proposal is a reversible hide, not a lifecycle change — unarchive it any time from the Archived view. Raw inputs feed the pipeline through the **Inputs** drawer, opened from an icon with a count badge in the Proposals header. It collects everything worth reacting to — free-text notes you drop in, uploaded documents, data query results, and signals the platform raises on its own — for triage: dismiss what’s noise, archive what can wait, and select what matters. Pressing **Start** composes the selected inputs into a fresh draft proposal and opens it in Studio; anything you typed in the request box becomes your first chat message there. The same composer and Inputs drawer also lead your instance’s home dashboard, so you can start a proposal the moment you open the app — it stays disabled until the initial sync finishes. ## Create a proposal by email [Section titled “Create a proposal by email”](#create-a-proposal-by-email) You don’t have to be in the app to start a proposal. Forward or CC an email to your instance’s proposal address — or grab your personal create-address from the email icon in the Proposals header — and the message, with its attachments, opens a fresh proposal. Reply to a proposal’s own `proposal+@…` address and the reply is attached to that proposal as evidence, redrafting it if it’s still open; once a proposal is marked complete it’s frozen, so a later reply is recorded against it but changes nothing. Each address carries a token that authorizes it, so there’s no open catch-all inbox — mail to an unrecognised address is dropped, not turned into a proposal. Every inbound message is triaged to one of three outcomes: attached to an existing proposal, used to create a new one, or ignored. ## Studio [Section titled “Studio”](#studio) Studio is the chat-first authoring canvas for one draft proposal — a conversation with the AI, with everything it is working from on the left and everything it is producing on the right: * **Left — Inputs.** The signals, evidence (in fact and interpretation lanes), notes, documents, and data attached to this draft. Each lane can add and remove inputs. * **Center — the conversation.** You shape the proposal by talking to the AI; its replies stream in as it works, and the composer sits at the bottom. Earlier revisions live behind a **History** control in the header. * **Right — living artifacts.** The proposal draft, re-rendered as the AI writes it, alongside a portfolio overview and the solutions it plans to mint. **Mark complete** is the freeze point: it moves the proposal to `complete` and mints its planned solutions into the Solutions stage. After that its evidence and body are locked — further edits go through an explicit change request, so what was approved stays what was approved. ## Solutions [Section titled “Solutions”](#solutions) A completed proposal mints solutions — the concrete deliverables that realize it. Kinds include hooks, source changes, conduits, configuration, documentation, apps, data manipulation (a bounded, previewed, idempotent data fix applied as a one-shot job, never a deploy), and native (work your instance already covers out of the box). A documentation solution is authored *about* its sibling change — the doc explains the very change shipping alongside it. The Solutions tab is the standalone pipeline stage listing every solution across all proposals, each with its origin proposal, target record type, current status, source system (where it lands on one), and the release that ships it. ## Releases [Section titled “Releases”](#releases) Every deploy through Turbobase is a tracked release: gated, audited, monitored, reversible. The Releases tab lists each release with the solution it delivers, its target source, and its lifecycle status (pending, deploying, live, rolled back, failed, or superseded by a newer release). Each row also carries git provenance — the repository, commit, and pull request the deployed artifact was built from. Turbobase holds no repository credential and clones nothing; provenance is links only. Every release gets a per-release **monitor**: a pre-release health baseline compared against live samples taken after the deploy, with per-object error accounting. The verdict is deliberately fail-closed — a release only shows healthy when the watch has explicitly verified it against a captured baseline; a missing or unreadable baseline shows as unknown, never healthy. A detected regression files a signal back into Inputs, and rollback is available from the monitor page. ## Templates [Section titled “Templates”](#templates) The **Templates** icon in the Proposals header opens a gallery of starting points. Picking one creates a proposal seeded from the template and drops you straight into Studio, where you refine the brief conversationally before anything is generated. Templates come in three categories: * **Apps** — e.g. order management, expense management, a customer grid. The proposal’s brief drives generation of a hosted app served inside your instance at `/a/`; see [Building apps](/build-apps/). * **Documentation** — e.g. documenting a process as an SOP. * **Source changes** — e.g. adding a validation rule in a connected source. ## Standards [Section titled “Standards”](#standards) The **Standards** icon in the Proposals header opens your house-standards library: free-text notes and uploaded documents that record how your business wants things built — requirements, SOPs, policy. A standard you add lands as an unpublished page under the **House standards** node in the [knowledge base](/tenant/knowledge-base/#house-standards); publishing it is what puts it into effect. From then on it grounds proposal drafting, so the AI’s drafts and generated deliverables take it into account automatically. ## Apps [Section titled “Apps”](#apps) The Apps tab manages the hosted apps your instance serves: enable or disable an app, share it with specific users or roles, or delete it. New apps are best started from an app template (above); the tab also offers a blank “Create app” for CLI-first workflows, where you ship builds with `turbobase apps deploy`. ### Which apps can be changed from a user’s request [Section titled “Which apps can be changed from a user’s request”](#which-apps-can-be-changed-from-a-users-request) Every app Turbobase serves carries a floating **Suggest a change** button, so whoever is using it can describe what they want and file it as a proposal without finding you first. Working one of those automatically means editing the app’s own source, so each row on this tab states whether this instance holds that source: * **`rev N on the instance`** — the source is here, with the date it arrived and whether it was pushed by a person or generated by the proposal engine. A change request against this app can be applied automatically. * **`Not on the instance`** — the app was uploaded already built. A built bundle cannot be turned back into a project, so a change request against it is still filed and still worth having, but somebody has to make the change by hand. The row names the one command that fixes it for good: `turbobase apps push `, run once from the project directory by whoever has it. After that the app behaves like any other. Somebody who files a request against an app in the **second** state is told so in the widget as soon as they send it — so nobody is left waiting on something that was never going to happen on its own. Against an app in the first the widget says nothing beyond confirming the request, because nothing on the instance applies a change on its own yet. Their request is filed either way, and it may still be answered as a configuration, script or documentation change rather than an app change. A `turbobase apps deploy` run from a source directory records that source itself, so an app shipped that way is never in the second state. See [Building apps](/build-apps/). ## Automations that react to a change request [Section titled “Automations that react to a change request”](#automations-that-react-to-a-change-request) A request somebody files from an app’s **Suggest a change** button is a record on your instance, so an automation can react to it. A conduit — the platform’s cross-system bridge — can now watch for one: it fires when a request is filed, again when the planner works out what kind of change it is, and again on every status change, and the run is told which of the three it is so you can react to one moment rather than all of them. Each run sees the request’s title, its text, which app it was filed against, who filed it and its current status, and nothing of the working state behind it. The same bridge runs the other way. A request arriving somewhere else — a message in a chat channel your instance is connected to, for instance — can be turned into a change request automatically; it lands as a draft for somebody to read, exactly as a person’s filing does, and carries a note of where it came from. An automation can only ever create a draft: approving, shipping or editing a request stays a person’s decision. More broadly, a conduit can now react to **any** record saved on your instance, whoever saved it — your app, a background process, the AI assistant, Studio, or another automation — not just to records arriving from a connected system. Each run is told whether the save created or changed the record and which fields moved, so an automation can fire on one transition instead of on every edit. ## Processes [Section titled “Processes”](#processes) **/admin/development/processes** is the background work your apps have deployed: what exists, how each one is triggered — on a schedule, or only when somebody asks — whether it is switched on, and how its last runs went. It is the one place where somebody who did not deploy a process can find out that it exists at all, which is why it sits at the tail of the same strip. Switch a process off here and it stays deployed and stops running. ## Scheduled tasks [Section titled “Scheduled tasks”](#scheduled-tasks) **/admin/development/scheduled-tasks** lists the recurring and one-shot tasks created by asking [Pilot](/tenant/pilot/), your AI assistant, to schedule something (its schedule-task tool). Each task shows its schedule, status, next and last run, and its recent run history; you can pause, resume, or delete a task from the page. The list covers tasks you own plus tasks shared with you — the platform’s own internal jobs never appear here. This page is no longer a tab on the Development strip: go to the address directly, or ask Pilot to open it. ## Related [Section titled “Related”](#related) * [Analyzer](/tenant/analyzer/) — where customization risk and usage findings originate * [Building apps](/build-apps/) — the playbook generated apps are built against # Guardrails > Circuit breakers that keep a failing sync from looping, rate caps that stop a mass mutation reaching your source, and a policy surface where you author and activate your own rules. Shipped Guardrails are the safety mechanisms that keep your instance’s sync stable when something goes wrong, so a single bad record can’t spiral into a runaway loop, bury you in noise, or mass-mutate your source — plus the policy rules you define yourself about which operations need a human sign-off. ## Stopping the sync loop [Section titled “Stopping the sync loop”](#stopping-the-sync-loop) A write back to a source can fail terminally — the source rejects it and will keep rejecting it. Without a guardrail, stamping that failure onto the record could look like a fresh edit, re-arm the write-back, fail identically, and repeat about once a second forever. Turbobase prevents this by distinguishing a real edit from sync bookkeeping. When the only thing that changed on a record is the sync machinery’s own status stamping (marking a terminal failure, recording completion, backfilling a source id), that is **not** treated as a user edit and does **not** re-arm the write-back. A genuine data change still does. The loop is broken at the source. ## Deduplicating failures [Section titled “Deduplicating failures”](#deduplicating-failures) When a record really does fail repeatedly, the failures don’t pile up as near-identical rows. Turbobase keeps a single open failure row per record with an occurrence counter, so you approve, reject, or ignore it once rather than N times. Resolving it frees the slot, so a genuinely new failure after resolution opens a fresh row. This is the row you see and act on in the [sync-failure triage](/tenant/source-health/) view. ## Write-back rate caps [Section titled “Write-back rate caps”](#write-back-rate-caps) Deduplication stops one record failing loudly. Rate caps stop *many* records changing at once: they are the circuit-breaker against an app — buggy, or badly prompted — mass-mutating your live source data. Turbobase counts committed write-backs per source and refuses to push past a hard cap. A write is denied if it would exceed the cap for **any** window: the minute cap stops a burst, the hour and day caps stop a slow drip a minute cap never sees. Excess writes are denied and written to the [audit log](/tenant/audit-log/); nothing reaches the source. Creates/updates and deletes are counted separately, and deletes carry a much tighter default — an update re-converges on the next reconcile, a source delete does not. | Op class | Per minute | Per hour | Per day | | ------------------------ | ---------- | -------- | ------- | | Create / update → source | 600 | 10,000 | 100,000 | | Delete → source | 60 | 1,000 | 5,000 | Administrators change the caps under **Settings → Write guardrails**, one tab per window. `0` disables a cap — which removes that protection entirely, so prefer raising a cap to switching it off. Set them for your real edit volume: too low and legitimate bursts are denied (they re-converge on the next reconcile). Current usage is on the Guardrails page itself — **Write-back rate usage**, per source and operation, with today’s count against the day cap. Open a row’s graph for the detail, or to reset the counters. If the figures can’t be shown, a card takes the table’s place and says which of two things happened: they could not be authorised — sign in again, and ask another administrator to check your role if they stay missing — or they could not be loaded, which is worth retrying in a few minutes. Either way the rules below are unaffected, and the panel is never left rendering empty, because an empty table reads as “no usage”, which is a different answer. ## Deny by default [Section titled “Deny by default”](#deny-by-default) By default an operation that matches no rule is **allowed with an audit record**, and you carve out exceptions with DENY rules — a deny-list. Administrators can invert that under **Settings → Write guardrails**: with **Deny write-back by default** on, any write matching no rule is denied at every enforcement phase, and you open records with ALLOW rules instead — an allow-list. Turning it on with no ALLOW rules freezes **all** write-back. Author the allow rules for the records you intend to keep open first, and check them with `turbobase guardrails test` before you flip it. This is the global posture. It is distinct from the per-source write-back entitlement and from the individual rules below. ## Your own guardrail rules [Section titled “Your own guardrail rules”](#your-own-guardrail-rules) The circuit breakers above run automatically. On top of them, the **Guardrails** page in your Turbobase app (administrator role required) is where you manage policy rules — which operations against a source are allowed outright, allowed with an audit record, held for confirmation, or denied. The page shows the per-risk defaults, then your rules in three lifecycle buckets: * **Proposed** — staged, awaiting review. Rules land here when you author one through [Pilot](/tenant/pilot/), your AI assistant: describe the intent in chat (“block fulfillments over $10,000”, “require approval for journal entries”), confirm the staged rule, and it appears here as proposed. A proposed rule is **not** enforced until an administrator clicks **Activate**. * **Active** — enforced. A rule matches an operation by source, operation, target, risk level, and actor, with optional payload conditions such as an amount threshold, and decides the outcome. A small set of bootstrap rules ships active out of the box. * **Inactive** — switched off with **Deactivate** but kept for later re-enabling. Hidden by default; a toggle reveals them. Each rule authored through Pilot keeps the chat prompt that recreates it, surfaced by the help icon on its row. Open a rule’s usage to see how often it actually fired — counts over the last 24 hours and 7 days, broken down by the decision it produced. A rule that never fires is usually a rule that doesn’t match what you thought it matched. ## Deletes are addressed separately [Section titled “Deletes are addressed separately”](#deletes-are-addressed-separately) A write back to a source and a delete are **different operations** in the rule vocabulary: `.write` for a create or update, `.delete` for a delete, which carries the `destructive` risk level. Opening `.write` never opens `.delete`, and the reverse holds too — a role or rule that can change an invoice cannot thereby remove one. Under the default posture an unruled delete is **held for confirmation** rather than pushed, and under deny-by-default it is denied outright. Deletes reach a source only from a soft-delete on a writable, already-synced record; read-only and never-synced records are skipped, and no app can hard-delete a mirrored row at all. ## Pending approvals [Section titled “Pending approvals”](#pending-approvals) When an operation hits a require-confirmation decision it doesn’t run — it queues at the top of the Guardrails page with who requested it, how many approvals it needs, and when it expires. **Review** opens the confirmation so an approver can decide it; recent decisions — approved, denied, or expired — stay listed on the page. ## Related [Section titled “Related”](#related) * [Sync health](/tenant/source-health/) — where deduplicated failures surface for triage * [Data model](/tenant/data-model/) — bidirectional write-back, where failures originate # Knowledge base > Your instance's living business memory — a process-first page tree with drafts, published releases, and comments. Shipped The knowledge base is your instance’s living memory of how the business actually runs — processes, approval rules, thresholds, conventions, and how your team uses each connected source. It is a tree of markdown pages at `/knowledge-base`, organised process-first rather than system-first, and it feeds [Pilot](/tenant/pilot/), your AI assistant: when someone asks “what’s our month-end process”, Pilot answers from these pages. ## Editors and readers [Section titled “Editors and readers”](#editors-and-readers) What you see at `/knowledge-base` depends on whether you hold the `write:document` capability (administrators hold it implicitly — see [Users and permissions](/tenant/users-and-permissions/)): * **Editors** see the full live tree, drafts included, and can create, edit, move, publish, and delete pages. * **Everyone else** sees the *current published release* — a snapshot, never the live drafts. Nobody gets a 403; readers of an instance with nothing published yet simply see an empty state. Any member can comment on the pages they can see; threads anchor to a text selection and can be resolved. A new comment emails the page’s editors and the other people on the thread, so a question doesn’t sit unseen. While a page has unpublished work, it belongs to the editor who started it — others see “in progress by” and are blocked from overwriting. The owner (or an admin) can discard the draft, resetting the page to what readers currently see. Editors can also **import a folder of markdown** at once: each file lands as a draft page under a parent you choose, so you can seed the tree from existing docs in one step. Imports are per-file — a bad file is reported and skipped rather than failing the whole batch. ## Publishing and releases [Section titled “Publishing and releases”](#publishing-and-releases) Publishing is what moves content from the editors’ tree to readers. Publishing a page makes it reader-visible **and** cuts a new immutable release snapshotting the whole published tree — so it also captures any other pages with pending edits, which the per-page “Unpublished” state makes visible beforehand. Unpublishing works the same way in reverse. Administrators additionally get the release history panel: every version with its changelog (pages added, edited, removed), rollback to an earlier release, and export of a release — or the full live tree — to a single self-contained HTML file. ## Sharing pages outside your team [Section titled “Sharing pages outside your team”](#sharing-pages-outside-your-team) The knowledge base is for people with an account on your instance. There is no external-guest access: sharing a page means giving someone a seat, or exporting what you want them to have. Administrators can **export** a release — or the full live tree — to a single self-contained HTML file (see [Publishing and releases](#publishing-and-releases) above) and send that. It is a snapshot, so it does not update when the page does, and it carries no session, no code and no link back into the instance. ## House standards [Section titled “House standards”](#house-standards) Requirements documents, SOPs and house rules you upload for the whole instance land here as pages under a **House standards** node. You add them from the **Standards** icon in the Proposals header — see [Development → Standards](/tenant/development/#standards) for the fuller picture of how they fit the proposal pipeline. They are **drafts when they land**, like every other page here, and they ground proposal drafting **once published** — so a document nobody has reviewed is not quietly steering every proposal, and publishing one is what puts it into effect. Being KB pages, they are also *retrieved* rather than injected: a standard grounds a proposal when the proposal is about it. If you need a rule that holds every time regardless of the subject, that is a skill, not a standard — see [Knowledge base or skills?](#knowledge-base-or-skills) below. ## Knowledge base or skills? [Section titled “Knowledge base or skills?”](#knowledge-base-or-skills) The knowledge base is **retrieved**: when someone asks a question, the most related pages are pulled in. That is right for how your business works — processes, thresholds, conventions — and wrong for a rule that has to hold every time, because a page only applies when the question lands near it. For those, use [agent skills](/tenant/skills/), which are injected into every relevant answer whether or not the question mentions them. The test: *would the AI get this wrong if nobody reminded it?* Then it belongs in a skill. ## Related [Section titled “Related”](#related) * [Agent skills](/tenant/skills/) — standing rules injected into every answer * [Development → Standards](/tenant/development/#standards) — the Standards library in the Proposals header, and where House standards pages come from * [Pilot](/tenant/pilot/) — answers “how do we do X” from published pages * [Users and permissions](/tenant/users-and-permissions/) — capabilities, including `write:document` * [Connect Claude Code](/build-apps/claude-code/) — how app developers feed what they learned back here, as unpublished draft pages an editor reviews (`turbobase kb push`) # MCP server > Connect an MCP client to your instance for RLS-bridged access to your canonical data — read-only by default, optionally read-write — with prompts and resources that ground the client. Shipped Your Turbobase instance can expose a Model Context Protocol (MCP) endpoint, so an MCP client — Claude Desktop, Cursor, or anything else that speaks MCP — can inspect your canonical data without needing Postgres credentials. ## Enabling it [Section titled “Enabling it”](#enabling-it) The MCP server is off until an administrator turns it on at **/admin/settings**. While disabled, the endpoint responds as disabled to every caller, so a probe can’t tell “disabled” from “wrong credentials.” ## Authentication and RLS [Section titled “Authentication and RLS”](#authentication-and-rls) **Two credentials reach this endpoint, and there is no shared, all-access token for either.** * **A per-user OAuth connection.** A person connects their own client and signs in; the validated token is bridged to a Turbobase session for that caller. Reads run as them. * **A personal access token carrying `mcp:data`.** A script, an agent or anything else unattended presents the token as a bearer. Reads run as the administrator who minted it, and it can change data as well if it also carries `mcp:write`. A token carrying either permission lasts **7 days** by default rather than the 90 an ordinary PAT gets — long enough for a job that spans a working week, short enough that one left in a project file does not stay useful for a quarter. Set a longer lifetime at mint if you need one. See [access tokens](/tenant/access-tokens/). Whichever arrives, the endpoint bridges it to the same short-lived Turbobase session, which carries that person’s [role, capabilities, and scopes](/tenant/users-and-permissions/). That session is minted inside the request and never leaves it — no response body carries it, nothing sets a cookie, and it expires within minutes however long the credential behind it lives. Every read and every write then goes through the same [row-level security](/tenant/data-model/) the app uses: an administrator gets the full canonical set, a standard user gets only their in-scope rows, and an out-of-scope record reads as simply “not found” — no existence leak. There is no path that bypasses RLS. Read and write permissions are separate, and both are the *user’s*, not the client’s: a client connected by someone who can view orders but not edit them can read orders and nothing more. **Both credentials can change data, and a token needs one more permission to.** A person’s own connection carries their whole authority, so what it may change is decided by their own permissions and by your instance’s write setting. A token carries only what an administrator ticked for it: reading takes `mcp:data`, and changing anything takes `mcp:write` as well — a second box, granted alongside the read one and never implied by it. Without it the write tools below are not offered to the token and refuse it if named anyway, and turning on your instance’s write setting does not change that. **Your instance’s setting still outranks the token.** `mcp:write` says this credential is allowed to change data; **Allow MCP clients to change data** says this instance is. Both have to be true, and the two refusals stay distinguishable — so a client that cannot write is told which of the two to go and fix. **These two permissions are about this endpoint and nothing else.** A token may also carry `rest:read` and `rest:write`, which let a program reach your data over the ordinary web interface — see [access tokens](/tenant/access-tokens/). Neither pair implies the other: a token connected here is not thereby allowed on that interface, and a token allowed there is still refused here without `mcp:data`. A token that lacks a permission it needs is refused with a plain message naming that permission — not a sign-in challenge. Its credential is fine; the permission is missing, and an administrator can add it on the token’s own row at **/settings/tokens** without re-issuing it, so nothing has to be re-pasted wherever the token is stored. ## Connecting a client [Section titled “Connecting a client”](#connecting-a-client) Clients register themselves automatically — there is no OAuth Client ID to copy or paste. Each Turbobase instance fronts its sign-in with a per-instance OAuth bridge that implements **Dynamic Client Registration (RFC 7591)** and **Client ID Metadata Documents (CIMD)**, so any MCP client — Claude, Cursor, VS Code, a raw MCP SDK, or an autonomous agent — registers its own callback (a loopback port, or an HTTPS callback — plaintext `http://` on anything but `localhost` is refused) on first use and opens the sign-in page. Add the endpoint shown on the **/admin/settings** Integrations card to your client and connect; sign-in then happens on your own Turbobase sign-in page — the same credentials you use in the app. No operator action is needed to onboard a new client type. **You approve each client once.** Because registration is open by design, signing in is not on its own an authorization: the first time a given client asks, Turbobase shows you an approval screen naming the client, the exact address your authorization would be sent to, and what it is asking for. Nothing is issued until you approve it. **That screen is also where you decide whether the client may change anything.** It carries one permission — *let this client create, change and delete records* — and it starts unticked, so approving without touching it connects a client that can read your records and nothing more. Reading is granted either way. You can change your mind at any time from **Account → Connected apps**, which says of every connection whether it may change records and lets you grant or take that back one client at a time; the next call the client makes sees the new answer, with nothing to reconnect. The permission is yours and applies to your connection only — the same client connected by a colleague is a separate decision of theirs. After that the same client connects silently, and every client you have approved is listed under **Account → Connected apps**, where you can revoke any one of them individually. That tab is not only MCP: the same OAuth server signs people in to [native apps your team builds](/tenant/auth-levels/#building-a-native-app), and those appear beside your MCP clients, each row saying which it is. A revoked client cannot renew the access it already holds — its next token refresh is refused — and it faces the approval screen again before it can obtain a new token. Every token request a client makes names the client it is for (its `client_id`), which is what ties the request to your approval, and a refresh token is usable only by the client it was issued to — presented under any other client’s id, it is refused. A client’s registration is provisional until someone signs in through it: one that never completes a sign-in lapses within the hour, and simply registers again the next time it connects. Approval is per person — a colleague’s approval of the same client does not carry over to you. **A connection lasts thirty days.** Once you have signed in, the client renews itself quietly in the background and you are not asked again until the thirty days are up — however often, or however little, you use it. Then you sign in once more. Revoking the client under **Account → Connected apps** ends it sooner, as above. If an approval screen appears when you did not just connect a client — particularly one flagged as never seen on this instance before — close the page and tell your administrator. That is what a link sent by someone else looks like. ## What a client can do [Section titled “What a client can do”](#what-a-client-can-do) Every instance exposes these read tools: * **list\_record\_types** — every record type your instance knows about, including your [custom tables](/tenant/custom-tables/), with source attribution *and what may be done with each one*: whether it is read-only or writable, whether write-back is switched on for that system right now, which operations are actually reachable, one line on what the record is for, and the name of the prompt holding the full account of it. That last group is what tells two records apart when their names do not: on a Slack-connected instance `message` is the channel history your instance has read and `channel_message` is the message it posts. Metadata only, no row data. * **list\_records** — list rows of a record type (capped at 100 per call), through the caller’s RLS. * **search\_fields** — find which record type and column holds a piece of data, by concept: “exchange rate”, “credit limit”, “due date”. Search this first when you know what you want but not where it lives — the other tools all assume you already know the record type’s name. Schema metadata only, never row data. * **get\_record** — fetch a single canonical record by record type and source-side id. * **get\_write\_state** — what became of a change to one record. A write through this server is saved here and *queued*, so this is the tool that says where it got to: `synced` once your source system took it, along with the id that system minted for it; `pending` while it is still on its way; `needs_input` or `blocked` if something refused it, with the reason written down at the minute it happened — the field that was missing, the source’s own sentence, the list of fields to fix. Address the record by the `pg_row_id` a write returned, which is the only key a change that has not reached the source yet has. Pass `wait_ms` and the call holds for up to twenty seconds waiting for the verdict, so a client can ask once instead of checking on a timer; if the wait runs out the answer still comes back, saying so. It is offered whether or not writes are switched on, because a stuck record outlives the setting that produced it, and it answers only for records the caller could already read. * **search\_documentation** — search your published [knowledge base](/tenant/knowledge-base/) — processes, policies, playbooks. Returns matching documents with a snippet. Questions about how your *organisation* works route here, as opposed to `list_records`, which reads transactional business records. * **read\_document** — read one published knowledge-base document in full by its slug. The published release only: unpublished drafts are never returned. A slug from a `search_turbobase_docs` hit belongs to `read_turbobase_doc` instead — this tool reads what *your organisation* wrote. * **search\_turbobase\_docs** — search Turbobase’s own documentation: how hooks, conduits, apps, the source sync, the CLI and this server work. Questions about the *platform* route here — “how do I read the mirror from an app”, “what does a hook receive” — as opposed to `search_documentation`, which searches your own written processes and policies. The pages are the ones your instance’s release shipped, so the answer always describes the version you are actually running, and it says which version that is. * **read\_turbobase\_doc** — read one page of Turbobase’s documentation in full by its slug, from a `search_turbobase_docs` hit. A slug that is not one of our pages comes back as `found: false` rather than as an error, and a slug from a `search_documentation` hit belongs to `read_document` instead. * **batch\_read** — run up to ten of the above in one call and get the results back in order. Useful when a client already knows the handful of things it needs and would otherwise make ten round-trips. One item failing leaves the others intact. ### One name, several systems — and the combined list [Section titled “One name, several systems — and the combined list”](#one-name-several-systems--and-the-combined-list) Two things to know before reading a record type whose kind of row more than one of your systems holds. **Your systems each have their own word for a customer, and asking for one of those words gets that system’s list.** `customer`, `zoho_crm:account` and `getsales:company` are three names for the same kind of row, and a plain `list_records { "record_type": "customer" }` answers with whichever connected system publishes that exact name. That is now said in the answer: `applied.source_filter` names the system the read narrowed to, and `warnings` names your other systems’ words for the same thing. Nothing is hidden and nothing is refused — the count is just one system’s, which is the part that used to be invisible. Where several of your systems spell a record type the *same* way — `contact`, say — one read already covers all of them: `applied.source_filter` is `null`, the rows come back from every one of them, and the answer says which systems it spanned. There is nothing missing from that read, so it carries no “other words” warning. **For one list across all of them, read the identity record type.** Turbobase already consolidates these rows into a single identity per real-world thing ([Atlas](/tenant/atlas/)), and that identity is a record type of its own: ```plaintext list_records { "record_type": "turbobase:identity_customer", "page_size": 100 } ``` There is one per bucket — `turbobase:identity_customer`, `…_vendor`, `…_contact`, `…_employee`, `…_partner`, `…_lead`, `…_item` — each read under the capability for that bucket, each pageable with `page.next_cursor` like any other record type, and each read-only, because Atlas derives the rows. `get_record` does not take them: an identity is addressed by its own `id` rather than by a source-side id, so read it with `list_records` and match on `id`. Whenever the warning above fires it names the right one for what you asked for, so a client does not have to know the list in advance. ### Nine more, for administrators only [Section titled “Nine more, for administrators only”](#nine-more-for-administrators-only) These read across your whole instance — your connected account, or the change history of every record in it — rather than one person’s rows, so there is nothing to narrow person by person, and that is why they are administrator-only. **A client signed in as anyone else is not offered them at all**: they are absent from its tool list, not present and refusing, so a non-admin’s assistant never suggests something it cannot do. * **search\_catalog** — search your customization catalog by free text: scripts, custom records, fields, lists, workflows, roles, saved searches, installed bundles. It matches ids, names *and* the plain-English explanation Turbobase generates for each one, so you can ask what you actually want to know — “which script handles rebates?” — rather than needing the id first. Each hit says whether the object is yours to change. * **explain\_object** — one catalog object in depth: its captured definition or script source, what it is for in plain English, and whether this account can change it. Address it with the `kind` and `externalId` a `search_catalog` hit reports. * **source\_script\_log** — your source account’s own script execution log, for the minute a write failed in. Reach for it when a write is refused for a reason your payload does not explain. Pass `until` to read an earlier hour — up to about 30 days back — such as the minutes around a save that did not reach your instance in real time. * **source\_inbound\_deliveries** — what one of your connected systems actually sent to Turbobase, and what Turbobase did with each delivery. Reach for it when a change you made in a connected system has not appeared here: it is the only place that tells “that system never sent it” apart from “we received it and turned it away” apart from “we dropped it on purpose”. Ask for a `source`, and optionally one `connection_id`, one `record_type`, a `since_minutes` window (default 1440 — twenty-four hours — and clamped rather than refused) and a `limit` (default 100, maximum 200). The answer has three parts, and the second two are the point: * `entries` — one per delivery, newest first: when it arrived, which connection and record type it was about, which record (`record_ref`), and its `status` — `accepted`, `failed`, `ignored` or `refused`. `failed` means the delivery got in and the work it started then failed, so a delivery that did not land is never reported as accepted. `reason` says exactly which check ended it. * `registration` — whether that system is **still subscribed to send you anything** (`registered`, which is `true`, `false`, or `null` when Turbobase cannot tell), the address Turbobase expects it to post to, and the last delivery seen *at all*, outside your window. An empty list with `registered: false` is a subscription to re-create; an empty list with a delivery an hour ago is a different problem entirely. * `window` — what was actually read. **Read `truncated` before concluding anything**: when it is true you are looking at a capped slice, so “nothing here” is not “nothing happened” — narrow by record type or shorten the window and ask again. No value from a delivery’s contents is kept, so this tells you *whether* a change arrived and never what was in it. * **source\_late\_arrivals** — the changes from a connected system that reached Turbobase **late**: picked up by the scheduled catch-up because the real-time path never delivered them. It reads the same record as the Late arrivals screen. Reach for it to find *which* records real time misses — one record type, one connection, or one record by `external_id` — and how late they were. Ask for a `source`, and optionally `connection_id`, `record_type`, `external_id`, `path` (`scheduled`, the default, lists the late ones; `all` lists both), a `since_minutes` window (default 1440, at most 90 days) and a `limit` (default 100, maximum 500). The answer has three parts: * `entries` — the late changes, newest first: when each was applied, which record, what the change did, the system’s own modified time and `lag_seconds`. An empty `lag_seconds` means the system gave no time to measure from — a reconciled deletion, or a record that carries only a date — not that it arrived on time. * `summary` — per record type, how many changes arrived in real time and how many late over the same window. Read it first: it shows whether the gap is one record type or everything. * `window` — what was actually read. **Read `truncated` before concluding anything**, exactly as for the delivery tool. To see *why* one record was missed, read it with `get_record` (who last changed it, and on which form) and check `source_inbound_deliveries` for whether a delivery for it ever arrived. * **source\_relationships** — the **linked record pairs** your instance has found: which table holds the lines, which holds the parent, and the field on the line that points at it — together with which of the two “re-read” behaviours is switched on for each pair, and when it was switched on. Optionally ask for one `source`; omit it to list every connected system. Each pair carries both markers as a time or as nothing: nothing means that re-read is off, and a time says when it was switched on, which is the answer to “did my change take effect”. The app does not list the pairs: a record on **Data → Records** carries a re-read mark when one of its pairs has a re-read switched on, and this tool is where the three names and the time live. * **source\_relationship\_refresh** — switch **one** of a pair’s two re-reads on or off. Address the pair with `child_table`, `link_key` and `parent_table`, exactly as `source_relationships` lists them, then say which behaviour (`marker: "children"` or `marker: "parent"`) and which way (`enabled: true` or `false`). A pair your instance has not found is refused, and the refusal names what *is* declared on that line table — nothing is ever created here. The answer says `changed: false` when the pair was already in that state, and the original time is left alone rather than re-stamped. The two behaviours are independent, and which one you want depends on which record is arriving late: * `children` — **re-read the lines when their parent is saved.** Some systems commit a parent’s lines as part of the parent’s own save and fire no event on the lines, so the parent arrives in seconds and its lines wait for the scheduled catch-up. * `parent` — **re-read the parent when one of its lines is saved.** The mirror image: saving a line makes the system rewrite totals on the parent, silently. **Each switched-on pair has a running cost** — one query to the connected system per parent save, plus one re-read per line — so switch on the pairs that are actually arriving late (`source_late_arrivals` says which) rather than all of them. * **list\_audit\_events** — search your [audit log](/tenant/audit-log/) for **who changed a record, when, and which values moved**. Reach for it whenever the question is “who did this”: a change Turbobase pushes back reaches your source system as the one integration account, so that system’s own history names nobody and this is where the person’s identity is. Filter by `actor` — an email works, because the trail snapshots it beside the raw id — by `record_type` plus `external_id` or `pg_row_id` for one record, by `action` (a trailing dot means the whole family, so `record.` is every row change), by `changed_field`, and by a `since` / `until` window. Each row carries the changed fields only, with the previous and new value of each, never the two whole records. Pages newest first, 25 a page and at most 100; pass `page.next_cursor` back as `cursor` for the next one. `changed_field` is matched *after* the read rather than by an index, so three things follow: it needs one of the other filters beside it, a page can come back short, and **`page.post_filtered` is how you tell a short page from the end of the set**. There is one other way a page is not the end. A bulk write stamps every row it touches with the same instant, and a band of more than 500 rows at one instant is deeper than a single read reaches — when that happens the last page carries **`page.seek_capped`**, which means “narrow the window to see the rest”, never “that is everything”. * **get\_record\_history** — one record’s timeline, **newest first**, with **what became of each change** beside it. Each entry is an audited change — who, when, which fields moved — plus `sync`: the outbound event it produced, its state, and the moment it reached your source system. So an entry reads “this person set status Pending → Delivered at 18:09, and it reached the source at 18:09:23”. A change made in Turbobase that produced **no** outbound event is still an entry and says so in `sync_note`, because that gap is usually the thing being looked for; a change that arrived *from* your source system is marked `inbound`. It returns the newest `limit` changes (100 by default); when the record has more, `page.next_cursor` pages back through the older ones, as far as the trail goes — at least a year. `since` and `until` narrow it to a stretch of time. Address the record by `record_type` plus `external_id`, or by `pg_row_id` for a row your source has never minted an id for. Reading the trail is itself recorded on your audit log, under `mcp.audit_log.read` and `mcp.record_history.read` — who read who changed what is as answerable as the change. Catalog search is also on the [authoring endpoint](/build-apps/claude-code/), reached with a personal access token. Both doors answer the same question the same way; an administrator connected here needs no token to ask it. ### Your proposals, for administrators only [Section titled “Your proposals, for administrators only”](#your-proposals-for-administrators-only) The change requests on **Development → Proposals** can be worked through from a client too, so an administrator can read one, discuss it and act on it without opening the app. Like the Proposals page itself, these are administrator-only and absent from anyone else’s tool list. Two tools read: * **list\_proposals** — the proposals in one of the page’s three tabs: `active` (still in flight, the default), `completed` (signed off or beyond) or `archived`. Each hit carries its short number, title, status, labels, epics and evidence count, and can be narrowed by label, epic, status or title. * **get\_proposal** — one proposal in full, addressed by its id or its short number: the text, the evidence, the timeline of every comment and change with who made it, and the revision history. It also lists which of the actions below are allowed on it right now. Eight more act on a proposal. They change data, so they are offered only where [client changes are switched on](#letting-a-client-change-data) and, for an access token, only when it carries `mcp:write`. Each writes the same timeline entry the page’s own button writes, under your name: * **comment\_proposal** — add a comment to its timeline. * **rename\_proposal** — change its title. * **revise\_proposal** — write a new version of its text; the old one is kept in the history. * **sign\_off\_proposal** — mark a draft complete. This freezes it and, when it carries a target, creates its Solution for review. Nothing is applied. * **archive\_proposal** — hide it from the list, or bring it back. * **delete\_proposal** — delete an archived proposal permanently, only with an explicit confirmation, and never one whose data-manipulation run wrote to your source. * **label\_proposal** — add or remove a label, or create a new one and add it. * **group\_proposal** — group it under an epic, ungroup it, or create a new epic for it. A signed-off proposal is frozen here exactly as it is on the page: renaming or revising it is refused, and the change goes to its Solution instead. ## Letting a client change data [Section titled “Letting a client change data”](#letting-a-client-change-data) **Two things have to be true, and they belong to two different people.** The first is the instance’s: a new instance’s MCP server is read-only, and stays that way until an administrator turns on **Allow MCP clients to change data** on the same **/admin/settings** card as the server itself. It is a deliberately separate switch from the server itself: whether an external LLM may *read* your mirror and whether it may *change your ERP* are different decisions, and wanting the first shouldn’t commit you to the second. It is a ceiling — while it is off, nothing can change data, whatever anybody has granted. The second is yours, on the connection the request arrives on. Each client you connect carries its own permission to change records, granted by you on the approval screen or afterwards from **Account → Connected apps**, and **no connection has it by default**. So turning the instance setting on does not arm every client everybody has ever connected: it only makes it possible for a person to grant one of their own. An access token is the same question one layer along, answered on the token’s own row — a token writes if it carries `mcp:write`, and the per-connection permission does not apply to it. The two refusals read differently on purpose, because they are fixed by different people: one names the instance setting and sends you to an administrator, the other names the client’s own missing permission and sends you to your account page. While either is missing, the write tools don’t merely refuse — they are not offered at all, so a connected client never sees them. Once both hold, the caller gets two more tools: * **write\_record** — create or update one record. Whether it creates or updates is decided by the keys: pass a record’s id and it edits that record; pass none and it creates a new one. Records with line items (a sales order and its lines) are written together in one call. * **delete\_record** — delete one record, addressed by its id. There is no delete-by-filter and no bulk delete, deliberately: a client has to find the record first and name it. Where the delete goes is decided by the record’s own system, not by the one the client signed in against. ### What a client write actually goes through [Section titled “What a client write actually goes through”](#what-a-client-write-actually-goes-through) A write from an MCP client is not a side door. It runs the identical path a write from your app runs, in the same order: 1. **Your business rules.** The same [hooks](/extending/writing-hooks/) that validate an in-app write run here, synchronously. A rejection stops the write and comes back to the client as a readable explanation it can act on. 2. **Your write guardrails.** The same rules on your [Guardrails](/tenant/guardrails/) page apply, including the fact that deleting is its own permission — allowing a record type to be edited never implies allowing it to be deleted. 3. **The caller’s own permissions.** Row-level security decides, per row, exactly as it does for that person in the app. A client can only change what its signed-in user could already change; being able to *see* a record does not grant changing it. A write arriving over an access token runs as the administrator who minted that token, so it can never exceed what that person could do in the browser either. 4. **The normal sync queue.** The change is saved in Turbobase and queued for your source system, the same as any other change made in Turbobase. **A write or a delete may name any connected system.** On an instance with several systems connected, `write_record` takes a source-qualified record type — `record_type: "zoho_crm:account"` — and the change is routed to that system whichever one the client’s session happens to have signed in against. `delete_record` is no longer limited to the session’s own system either: a delete is routed by **the record’s own system**, read off the record rather than off the sign-in. So deleting a record belonging to a system whose upstream delete is not implemented yet is refused at once, naming that system — and a record that *can* be deleted is deleted whichever system the client signed in against. When the id you pass matches records in more than one connected system (or in two accounts of one system), the delete is refused rather than applied to one of them: address the one you mean by its `pg_row_id`. And where two connected systems spell one record type the same way — `account` means a GL account in NetSuite and a customer in Salesforce and in Zoho CRM — name the system, either as `record_type: "netsuite:account"` or with `source_system`, and the delete resolves. On a delete that name is a qualifier rather than an instruction: it says which record is meant, and the record’s own system still decides where the delete is sent. That last point is worth repeating to anyone reading a client’s summary: **a successful write is not yet in Oracle NetSuite.** It is in Turbobase and on its way. The record’s `write_status` tells you where it got to: `pending` while queued, `synced` once the source accepted it, `needs_input` or `blocked` if something refused it, and `awaiting_approval` if your own guardrails are holding it for somebody to approve before it is sent. The tool descriptions tell the client this too, so a well-behaved one will check before reporting back — and **get\_write\_state** is how it checks, on the `pg_row_id` the write itself returned. It answers for *every* one of those states, success included, and it takes a `wait_ms` so the client can ask once and be told the outcome rather than reporting a queued write as a finished one. **A write that is missing something mandatory is refused on the call, not afterwards.** Where a connector can decide the question from the payload alone, `list_record_types` publishes what a create needs and the write boundary refuses on exactly that list — so a client is told what to send in the same answer that tells it the write is allowed, and an incomplete one comes straight back naming the field rather than being accepted and parked. ### Everything is audited [Section titled “Everything is audited”](#everything-is-audited) Every mutation attempt writes a row to your audit log — the successes *and* the refusals, including the ones that stop before any write is attempted: a write on an instance where MCP writes are off, a record type that is read-only or unknown, a client with no signed-in identity. So “a guardrail stopped it”, “the instance is read-only” and “nobody tried” are three different things in the log. Each row records who (the signed-in user, not “an MCP client”), which tool, which record type and record, what the outcome was, and which fields were touched *by name*. It also records **which credential** arrived — a person’s own connection, or an access token — and **which one**: for a token, which token; for a connection, which of that person’s connected clients. So an unattended change is distinguishable from a human one, and whichever credential made a surprising change can be found and taken back without disturbing the others that person uses. The *who* is unchanged either way: it is the person, which is what keeps your guardrail rules matching. Field values are not copied into the log; they are already on the record itself. Filter for them on the **Compliance → Audit log** page with the **MCP client changes** filter. If a call fails for a reason that has nothing to do with the request — an internal error on the instance — the client is told only that, together with a correlation id. The cause is in the instance log under that id for an administrator to look up; it is never echoed to the client. ## How many requests a client may make [Section titled “How many requests a client may make”](#how-many-requests-a-client-may-make) Your instance meters this endpoint itself, on top of the [edge rate limit](/build-apps/reading-data/#keep-concurrency-low) that fronts everything else it serves. The allowance is **2,000 requests per minute**, which is sized so a bulk load — well over a thousand rows, one row per call — finishes inside a single window rather than being paced against a guess. Four things decide how that budget behaves: * **Reads and writes come out of the same budget.** One counter covers the whole endpoint, so the `list_record_types` and `list_records` calls a client makes before an import spend from the same allowance the import’s writes do. * **It is counted per instance process, not per connected client.** A client does not have the budget to itself — every caller reaching the same process shares it — so pace against the numbers each response returns rather than assuming the full allowance is yours. * **One call is one request, whatever it carries.** A `batch_read` bundling ten reads costs one, and a `write_record` saving a sales order together with its lines costs one. Bundling is the cheapest way to fit a lot of work into a window. * **The window does not re-arm when you are refused.** Retrying while over budget is neither punished nor useful; it simply fails again until the window turns over. **Every response tells you where you are.** Each reply carries the budget, so a client can pace against live numbers instead of a figure copied out of this page: ```plaintext X-RateLimit-Limit: 2000 X-RateLimit-Remaining: 418 X-RateLimit-Reset: 37 ``` `X-RateLimit-Limit` is the allowance, `X-RateLimit-Remaining` is what is left in the window you are in, and `X-RateLimit-Reset` is the whole seconds until it refills. **What a refusal looks like.** Go past the allowance and the endpoint answers `429`. That response carries `Retry-After`, in whole seconds and always equal to `X-RateLimit-Reset`, *and* repeats the same numbers in the body — because a client talking to this endpoint through an MCP library is handed a message rather than an HTTP exchange, and never sees a response header at all: ```json { "jsonrpc": "2.0", "id": null, "error": { "code": -32000, "message": "Rate limit exceeded: this endpoint allows 2000 requests per minute, shared by reads and writes and counted per instance process. Retry in 34 seconds — retrying sooner does not extend the window.", "data": { "limit": 2000, "remaining": 0, "reset_seconds": 34 } } } ``` `error.code` is `-32000` for being throttled and nothing else, so a client can tell it apart from the endpoint being switched off without reading the message. Wait `reset_seconds` and carry on. Nothing before the refusal is rolled back: the calls that were answered are saved, so a resumed import should pick up from the last row that succeeded rather than starting again. ## When your instance is momentarily busy or being updated [Section titled “When your instance is momentarily busy or being updated”](#when-your-instance-is-momentarily-busy-or-being-updated) A read can also be declined for a reason the caller did nothing to cause. There are two of those, and they answer the same way: * **Your instance’s data layer had no spare connection free** at that instant. * **Your instance is being updated** — a short maintenance window, or the service answering reads restarting, which happens a few times a day and lasts a moment. Your instance absorbs the brief version of either by itself: it waits a moment and asks again, and the caller gets its rows with nothing to see. A momentary restart therefore never reaches you at all. If the condition lasts longer than that, the call comes back as a refusal of its own: ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32001, "message": "the instance was momentarily too busy to serve this read — nothing was refused and nothing changed. The call is safe to repeat: wait a moment and send it again. If it keeps failing, give an administrator correlation id 3f0a9c21b7d4e85a.", "data": { "retryable": true } } } ``` `error.code` is `-32001` for those two conditions and nothing else; the message says which one you met. **The call is safe to repeat** either way — nothing was refused, nothing was written, and no partial result was returned; wait a moment and send the same call again. That is what separates it from `-32603`, the generic internal error, which means something inside the instance went wrong and a repeat will not help: take the correlation id out of that message and give it to an administrator instead. Inside a `batch_read`, an item declined this way carries the same `code` on its own in-band error, so the rest of the batch still answers and only that item needs repeating. ## When a call runs out of time [Section titled “When a call runs out of time”](#when-a-call-runs-out-of-time) A tool call gets eight seconds. Past that your instance stops waiting for its own handler and answers, rather than holding the connection open until the front door closes it with nothing on it: ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32002, "message": "`list_records` timed out after 8s — nothing was refused and nothing changed. The instance is answering slowly: wait a moment and send it again, or ask for less in one call. If it keeps failing, give an administrator correlation id 3f0a9c21b7d4e85a.", "data": { "retryable": true } } } ``` `error.code` is `-32002` for this and nothing else, and it is deliberately not `-32001`: the remedy is wider. Repeating the same call is one answer, and **asking for less is the other** — a smaller `page_size`, a tighter filter, fewer items in one `batch_read`. Nothing was written and no partial result was returned. ## Prompts (skills) [Section titled “Prompts (skills)”](#prompts-skills) The same operator-curated skills that ground Pilot, your AI assistant, and Turbobase’s other AI surfaces are exposed to MCP clients as prompts: `prompts/list` shows what’s available, `prompts/get` pulls one into the client’s context. Two kinds: * **Connector skills** ship with each source’s connector (record anatomy, domain playbooks) and appear automatically — but only while their source is connected. Disconnect the source and the prompt disappears. A skill can opt out of a surface with `surfaces:` frontmatter (e.g. `surfaces: pilot` keeps a Pilot-only skill off MCP entirely). * **Admin-authored skills** (managed at **/admin/skills**) are opt-in: tick **MCP server (external clients)** on a skill to expose it. The default is off — authoring a skill for Pilot never silently shows it to external clients. See [Pilot](/tenant/pilot/) for the wider skills surface these per-skill toggles control. A skill’s audience still applies through the caller’s bridged session: an admin-only skill is invisible to a standard user’s client — not merely unlisted but unfetchable by name, so there’s no existence leak. Toggles take effect immediately; no restart. ## Resources [Section titled “Resources”](#resources) Three resources give a client the lay of the land without burning tool calls: * **`catalog:///`** — the record catalog: every record type the caller can read, with backing table and column metadata. Capability-filtered per caller, like everything else on this server. * **`recipes:///`** — intent recipes (the columns, stats, and actions that matter for a record type + intent), filtered to your connected sources. A recipe for a source you don’t have never appears, and a surviving recipe’s column/action projections drop the cross-source parts you can’t use. * **`docs:///`** — a link map of documentation URLs. Links only — no doc bodies are proxied here, because the text itself is served by the `search_turbobase_docs` and `read_turbobase_doc` tools above, which need no web access at all. ## Instructions [Section titled “Instructions”](#instructions) At `initialize` the server hands the client an instructions block built for that caller: the connected sources, the record types their role can actually read, the available prompts and recipe intents, and links into these docs. The `list_records` and `get_record` tool descriptions likewise enumerate the live record types, so a client knows what it can ask for before its first call. Two callers with different roles get different instructions — the grounding is as RLS-scoped as the data. **What may be changed is one answer, not two.** The block’s “Records you can CHANGE” line names exactly the pairs `list_record_types` reports `writable_now: true` for — one derivation serving both, so the handshake and the listing cannot contradict each other about the same instance. The line is what can be changed *at all*; whether a system named on it is connected is the “Connected sources” line above it, and a write to one that is not has nothing to land in and is refused. **And when a read behind the block does not answer, it says so.** If the lookup of your connected systems fails, both lines report that the posture could not be established rather than stating that nothing is connected and nothing is writable. Those are claims about your instance, and they must not be made on the strength of a failed read. ## Related [Section titled “Related”](#related) * [Connect Claude Code](/build-apps/claude-code/) — step-by-step setup for a coding agent on your own machine, including the authoring endpoint * [Data model](/tenant/data-model/) — the canonical tables these tools read * [Custom tables](/tenant/custom-tables/) — discoverable and addressable over MCP * [Access tokens](/tenant/access-tokens/) — the `mcp:data` permission, which is what lets a token you mint read rows through this endpoint **Planned:** Streaming responses. Every call is request/response today; a tool that needs to stream would layer that on later. # Pilot > Pilot, the AI assistant built into your Turbobase app — query data, stage writes, schedule tasks, shape your sync, and manage its skills. Shipped Pilot, your AI assistant, lives in the left sidecar of every page in your Turbobase app. Open it from the rail toggle or with ⌘/Ctrl+K, or use the full-page view at `/chat`. The pilot-chat setting decides whether Pilot appears in the web app, and it is off by default: until an administrator turns it on, the whole chat surface stays hidden. Asking Pilot from [Slack](/tenant/channels/) works either way, as long as you have linked your account. Pilot reads and writes through the same surfaces your apps do — the PostgREST API and the write boundary — so your [role and permissions](/tenant/users-and-permissions/) apply to everything it does. There is no privileged back door. ## What you can ask [Section titled “What you can ask”](#what-you-can-ask) **Look things up.** “Show me open invoices”, “count customers added this month”, “find past-due sales orders.” Pilot queries the canonical [data model](/tenant/data-model/) directly, and picks up per-intent recipes (curated column lists, stats, and row actions) for common views. It can also open a record or page for you — “open customer 42” navigates there. **Ask how *your* business does things.** Pilot searches your [knowledge base](/tenant/knowledge-base/) (published pages only) for questions like “what’s our approval limit” or “how do we handle returns”, and can recall from semantic memory — ingested meeting transcripts, chat history, email, and documents — for “what did the customer say about X.” **Check on the system.** “Is sync healthy?”, “what’s broken?”, “how many records do we have?” Pilot summarizes sync health, record counts per source, and recent [sync failures](/tenant/source-health/), and reads configured settings. Ask “what can you do” for its own capability list. **Schedule recurring work.** “Every Monday at 9am, send me open sales orders.” Pilot turns natural-language schedules into a scheduled task that re-runs a chat prompt on a timer, delivered in-app or to a bound [channel](/tenant/channels/) (Slack). ## Writes are always staged [Section titled “Writes are always staged”](#writes-are-always-staged) Pilot never mutates data on its own say-so. When you ask it to create or update a record, it builds a **write proposal** — a card in the chat showing exactly what would change, with Confirm and Cancel buttons. Nothing happens until you click Confirm; only then does the write run through the same write boundary every app uses, with row-level security and hooks enforced. If the originating source rejects the write, you see it as a sync failure rather than a silent drop. The same staged-confirm pattern covers configuration: * **Add records to the sync** — “also sync customrecord\_widget”, “pull phone calls.” Pilot proposes the subscription; custom tables and custom lists additionally wait for an admin to approve the pending row on the **Data → Records** page before anything is created. * **Sync more custom fields** — “also sync the PO number field on sales orders.” Same flow: a pending proposal you confirm, then admin approval before the columns are added and backfilled. * **Stage guardrail rules** — “block fulfillments over $10,000.” Pilot translates the intent into a structured rule and stages it as *proposed*; it is not enforced until an admin activates it on the [Guardrails](/tenant/guardrails/) page. Pilot can only add new rules — editing, activating, or deleting existing ones happens on that page. ## Skills and MCP servers [Section titled “Skills and MCP servers”](#skills-and-mcp-servers) Administrators tune what Pilot knows at `/admin/skills`, which manages two things: * **Skills** — markdown instruction packs that shape how Pilot approaches specific tasks. They come from three sources: **bundled** seeds that ship with Turbobase (read-only, with “Fork to edit” to copy one into your own editable set), **admin-authored** skills you write yourself, and **registry** imports from agentskills.io or skillsmp.com, which you can disable, fork to edit, or pin to a version. Each skill can target the whole instance or a specific role. Connector-owned skills for your connected sources appear in the same list automatically and are read-only. * **MCP servers** — an admin-managed list of remote MCP endpoints. Today Pilot surfaces them as reference resources in its context; runtime tool-calling against them is not yet wired in. ## Related [Section titled “Related”](#related) * [Data model](/tenant/data-model/) — the canonical tables Pilot queries and writes * [Guardrails](/tenant/guardrails/) — where staged guardrail rules are activated * [Knowledge base](/tenant/knowledge-base/) — the pages behind “how do we do X” answers * [Agent skills](/tenant/skills/) — the standing rules Pilot applies on every turn * [Channels](/tenant/channels/) — deliver scheduled tasks to Slack # Agent skills > Standing rules your instance injects into every AI answer — drafted, reviewed, published, and versioned like the knowledge base. Shipped Skills are the standing rules your instance gives its AI. A skill is a short instruction — “always filter the entity table by entity type”, “anything over $5,000 needs CFO approval before it is proposed” — that is placed into the assistant’s instructions on **every** relevant turn, whether or not the question mentions it. They live at `/admin/skills`, alongside the MCP servers tab. ## Skills or knowledge base? [Section titled “Skills or knowledge base?”](#skills-or-knowledge-base) Your instance has two places to write down what it knows, and they behave differently. Putting something in the wrong one is the most common reason a rule “doesn’t stick”. | | [Knowledge base](/tenant/knowledge-base/) | Skills | | ---------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------- | | How the AI sees it | **Retrieved** — the pages most related to the question are pulled in | **Injected** — placed in the instructions every matching turn | | Right for | How your business works: processes, thresholds, conventions, context | Rules that must hold every time, whether or not anyone asks | | If it is long | Fine — only the relevant part is retrieved | Costly — it is in every prompt, so keep it short | | When it does not apply | The question was about something else | Never; that is the point | **The test:** *would the AI get this wrong if nobody reminded it?* Then it is a skill. *Would a person only need it when they went looking?* Then it is a knowledge-base page. A hard constraint written as a knowledge-base page will be followed when someone asks a question near it, and quietly ignored the rest of the time. That is the failure this split exists to prevent. ## Drafts and publishing [Section titled “Drafts and publishing”](#drafts-and-publishing) A skill’s text goes into your AI’s instructions word for word, so it is treated like a change you deploy rather than a note you jot down. There are two states, exactly as for a knowledge-base page: 1. **Draft.** Every new skill starts here, and a draft is injected **nowhere**. This is where skills written by your app developers (`turbobase skills push`) and skills proposed in the Studio arrive. 2. **Published.** The skill is live — its text is in the instructions on every matching turn. **Publish takes effect straight away.** There is no separate “now release it” step: publishing a skill records a release containing that skill and puts it in force. Publishing one rule never ships someone else’s half-finished one, because the release is scoped to the skill you published. Retracting is just as immediate, because you should never have to wait to stop something going wrong: * **Unpublish** it — move it back to draft — and it stops being injected on the next turn. Its text and history are kept, so publishing it again is one click. * **Delete** it and it is gone for good; earlier releases keep their own copy. Draft and published are the only two states. If you remember a separate on/off switch beside them, it is gone — it answered the same question publishing already answers, and unpublishing does everything it did, just as fast. Editing the *text* of a live skill is the one thing that is not immediate: your edit is saved but the AI keeps reading the published version until you publish again. So you can rewrite a rule that is currently in force without it changing under you mid-edit. ## History and rollback [Section titled “History and rollback”](#history-and-rollback) Every publish and unpublish is recorded as a numbered release, showing exactly which rules were live and what changed. Administrators can roll back to an earlier one when a rule turns out to make answers worse. A rollback restores the **text**. It will not re-publish a skill you unpublished — something taken down for a reason stays down. ## Where a skill applies [Section titled “Where a skill applies”](#where-a-skill-applies) Each skill lists the surfaces it reaches. Every skill reaches Pilot; the rest are opt-in: * **Pilot** — your in-product assistant, and the chat channels that route through it. Always on. * **External MCP clients** — tools connected to your instance’s [MCP server](/tenant/mcp-server/). * **Authoring sessions** — the CLI and Claude Code, when a developer is [building an app](/build-apps/claude-code/) against your instance. * **Proposal Studio** — solution drafting, solutioning and app generation. Turning on the last two is usually what you want for engineering rules: they are what stop an app developer’s AI session rediscovering the same constraint every time. A skill reaches every surface it is exposed to, on every turn. Connector skills additionally require their source to be connected. ## Where skills come from [Section titled “Where skills come from”](#where-skills-come-from) * **Written by an administrator** in the app. * **Pushed by an app developer** — `turbobase skills push` takes the rules recorded under `## Agent rules` in a project’s `INSTANCE-LEARNINGS.md` and files them as drafts. The rules grouped under one `###` sub-heading arrive as a single skill named after that heading, so a session that recorded twenty rules gives you a handful of reviewable entries rather than twenty. See [Connect Claude Code](/build-apps/claude-code/). * **Proposed in the Studio** — a proposal can carry a *skill* solution when the right answer is not to change a system but to change what the AI always knows. * **Bundled** — first-party skills that ship with the platform. These are read-only; editing one forks it into a skill of your own. Every route lands a **draft**. Writing a skill and publishing one are always separate acts — nothing an AI proposes reaches your instructions on its own. Publishing is normally something an administrator does in the app. It can also be delegated to a developer’s access token, but only deliberately: the token needs the **Publish skills** capability, which is never granted by default and has to be ticked when the token is minted. It can be revoked on its own without withdrawing the developer’s ability to deploy apps, every publish records who did it, and anything published this way can be rolled back or deleted with one command. A token that can deploy an app cannot, by itself, change what your AI is told. ## Related [Section titled “Related”](#related) * [Knowledge base](/tenant/knowledge-base/) — the retrieved half of the same job * [Pilot](/tenant/pilot/) — where skills are applied * [MCP server](/tenant/mcp-server/) — exposing skills to external clients * [Connect Claude Code](/build-apps/claude-code/) — how app developers push rules back # Source health > Monitor sync and service health, throttle outbound source calls, and triage sync failures in your instance. Shipped Your Turbobase instance gives you a clear view of whether the sync is running and whether your connected sources are healthy — and the controls to keep a misbehaving source from overwhelming itself. ## Service health [Section titled “Service health”](#service-health) Each of your instance’s background services (the sync worker, the realtime listener, and the scheduled-task worker) exposes a health endpoint (`/healthz` / `/readyz`) reporting whether it’s up and how long it’s been running. The admin dashboard reads these and surfaces an agents widget marking each service up or down, so a crashed background process is visible rather than silent. **Record types a new release adds switch themselves on.** When a release starts syncing a kind of record your connection wasn’t syncing before, your instance adds it to the list your connected system pushes from — by itself, within the hour, on every connection you have. There is nothing to press and nothing to reconnect, and a record type you added to that list yourself is never removed. The exception is a list you have deliberately taken over: tick **Lock Enabled Record Types** on the Turbobase configuration record in your connected system and your instance stops changing the list at all. The Enabled record types check on **/data/sync/health** then names that checkbox rather than asking you to add the types, and its **Update** button is how you take the new types on when you want them — on a list that isn’t locked, you shouldn’t need it. ## Outbound call throttling [Section titled “Outbound call throttling”](#outbound-call-throttling) Every source’s API has a finite concurrency budget. Turbobase puts a per-source call gate in front of outbound requests — a shared queue that caps how many calls to a given source are in flight at once, so your instance never trips a source’s rate limit by fanning out. The cap is configurable per source. ## The Data area [Section titled “The Data area”](#the-data-area) Day-to-day sync monitoring lives in your Turbobase app under **/data**, a set of tabs that share one spine: * **Overview** (**/data/sync**) — the at-a-glance page: per-source sync throughput, recent call-gate samples showing how busy each source’s gate is, the most recent failures, and a “Mechanic at a glance” summary of what the healing agent is doing (failures awaiting your review, recently fixed, and failures it prevented this week). * **Records** (**/data/sync/records**) — manage which record types sync from each source your instance is mirroring. That is every connected source, plus any source that mirrors on its own schedule without holding a connection — a source with rows in a table its own catalog declares is listed here whether or not it appears under **Sources**. Rows are grouped by what each record **maps to** in Turbobase, not by what its source calls it, and the “Maps to” column names that target: the businesses you mirror sit together on the `customer` row whether your system calls them Accounts, Companies, Organizations or Customers, and an accounting chart of accounts keeps a row of its own. Each source’s own name for the record stays on its chip — `Zoho CRM · account` on the customer row — so you can still see which record type an app or the write API asks for, and a row’s Rows and Size only count rows that live in that one place. This is also where pending customizations await review and where an in-progress [custom-field](/tenant/custom-fields/) backfill shows its progress inline on the record’s row. * **Activity** (**/data/sync/history**) — the recent sync events (source, direction, record type, state; the source column sorts and filters like the rest) and the history of full sync runs, with a manual “Sync & detect now” trigger for administrators. A source that mirrors on its own schedule writes no sync run, so the Runs view carries a short panel naming those sources, how far each has got, and a link through to the page that owns its detail — for Slack, **/settings/slack**. The state column says where each event has got to: `pending` means it is queued and has not been picked up yet, `in_flight` means your instance is sending it to the connected system right now and is waiting for the system to answer, `committed` means it landed, and `failed` means it did not and has a row under **/data/sync/failures**. Some writes take the better part of a minute on the wire — a stock receipt is one — so an event sitting on `in_flight` is a write in progress rather than a queue that has stopped. * **Late arrivals** (**/data/sync/late-arrivals**) — the records the scheduled catch-up delivered first, so the realtime push missed them. See below. * **Health** (**/data/sync/health**) — a per-source set of health checks, each reporting pass, warn, or fail, rolled up into an overall status. * **Sources** (**/data/sources**) — your connected sources, with per-source pages for credential status, secret rotation, a connection test, and reconnect. Connecting a new source happens through the “+ Add a connection” picker, which hands off to the console’s connect flow. ## When data sync is off [Section titled “When data sync is off”](#when-data-sync-is-off) An instance can be connected to a source and deliberately **not** mirror its records — the customizations-only posture chosen in the connect wizard’s Advanced settings (see [Connect your first source](/getting-started/connect-your-first-source/)). Customization discovery, schema reconcile and the [Analyzer](/tenant/analyzer/) all run; record data does not land. **/data/sync** says so at the top rather than showing an empty dashboard: the throughput and failure figures below read as nothing *by design*, and this is not treated as an outage — it raises no alert. When only some of your sources are in that posture, the banner names them. To start mirroring, open the source’s page under **/data/sources** and use **Enable data sync**. The historical backfill starts on its own and runs in the background; the record tables already exist and are empty, so the backfill lands in columns that already match. Enabling asks you to confirm, because **once enabled it can’t be disabled**. ## When a connection is paused [Section titled “When a connection is paused”](#when-a-connection-is-paused) A **paused** connection is not a failing one, and your instance says so rather than leaving you to work it out from a flat graph. Pausing is done from the console (see [Add sources](/console/add-sources/)) and acts on one connection: if you hold two NetSuite accounts you can pause one and leave the other syncing. A pause stops the traffic **in both directions**: * **No new records are brought in.** The connection drops out of the scheduled sync’s targets and its incoming webhooks are accepted and discarded. * **Nothing you change here is sent back to it.** An edit or a delete you make against a record mirrored from a paused connection is refused where you make it, with a message saying the connection is paused — never accepted and then quietly not sent, which would leave you believing the change had landed in your account. **Everything already mirrored is untouched**, so your apps, queries and reports carry on reading it. Where you see it: * **/data/sources** marks the connection **Paused** on its row, with a link through to the console page that resumes it. A source holding two connections badges only the paused one. * **Health** (**/data/sync/health**) reports it as a pass with a note, not a warning. A paused connection has an idle sync chain by design, and a health surface that is permanently amber is one nobody reads. * The sync run’s own output names it — `this connection is paused — resume it to sync it again` — so a quiet run says why it was quiet. Resuming clears the pause and the connection is back in the next run’s targets. It picks up from wherever the source is then; nothing is replayed from the paused window beyond what the source’s own change feed still carries. This is a different switch from [data sync](#when-data-sync-is-off) above: that one is per source, chosen once, and only goes on. Pause is per connection and always reversible. ## When a source refuses a connection [Section titled “When a source refuses a connection”](#when-a-source-refuses-a-connection) A pause is your decision. A **refusal** is the source’s, and it usually arrives without anyone deciding anything: someone revokes the app’s access, or the connected user’s role changes, and the source starts turning down one kind of record. Your instance notices after a few refusals and **stops sending changes of that kind on that connection**. It keeps going for everything else — a grant that lost one kind of record kept the rest, and a connection that lost nothing is untouched. If you hold two accounts of the same source, only the refused one is held back. **Nothing is lost while a connection is refused.** Every record you create or change is accepted here and held, exactly as it is when a write fails for any other reason. What stops is the pointless part: your instance no longer asks the source a question it has already answered, so your source’s rate limit isn’t being spent on calls that cannot succeed. Where you see it: * **Health** (**/data/sync/health**) carries one row naming the source and the kinds of record it is refusing, with reconnection as the fix. One row for the problem, not one per record held. * **/data/sync/failures** lists the held records themselves, as it does for every other kind of write failure. To clear it, **reconnect the source** from its page under **/data/sources**. If the refusal is a permission rather than an expired sign-in, widen the connected user’s access in the source first, then reconnect. The held records go through by themselves afterwards — there is nothing to re-queue and nothing to retry by hand. **It also clears itself.** Every so often your instance lets one change through to see whether the source will take it. If it does, the connection resumes immediately and the records waiting on it go through the same way; if not, it stays held and tries again later. So a grant somebody fixes without telling you starts working again on its own. ## Late arrivals [Section titled “Late arrivals”](#late-arrivals) Changes reach your instance two ways: a **realtime push** the moment a record is saved in the source, and a **scheduled catch-up** that sweeps for anything the push missed. The push is what makes your data feel live; the catch-up is a safety net. A **late arrival** is a change the catch-up delivered first — so no realtime push arrived for it. Something in the source system changed that record without telling your instance at the time: a script, a batch job, an import with server scripts switched off, or another integration writing through a path the realtime trigger does not cover. **/data/sync/late-arrivals** lists them, so you can see where the gap is and fix it at the source. **What counts, and what does not.** A change counts as a late arrival only if the catch-up actually applied it. When the catch-up re-reads a record the realtime push had already delivered, it finds nothing changed and nothing is recorded — a re-read that matched is not a late arrival. The same holds for a deletion already recorded, and for a record your instance does not mirror. **Nor does the first copy of your data.** When you connect a source, your instance mirrors what that source already holds. None of it is a late arrival: those records were not changed while your instance was watching, so there was no realtime push to miss. The tab counts from the moment that first mirror finishes. **That holds per record type, not just per connection.** A record type can get its first copy long after you connected — you approve a custom record or a custom module, or a Turbobase release starts mirroring something new. The first pull of its existing rows is a copy of what was already there, so none of it is counted, whichever source it comes from. Changes to that record type from then on are counted as they are for everything else. **And it holds however long that copy takes.** A record type with a lot of history can need several sweeps to copy — each sweep works for a bounded window and the next one carries on where it stopped. Your instance records when a record type’s copy starts and when it finishes, so every sweep in between is part of the copy and none of it appears here. A record type that stops part-way, or whose source is briefly unreachable, is continued rather than counted. **Two cadences, so “late” means different things.** Creates and edits catch up on the **four-hourly** sweep, deletions on an **hourly** one. A record edited by a script can therefore sit up to four hours behind, while a record deleted the same way is at most an hour behind. The tab groups by connection, record type, and whether the change was a create, an edit or a deletion — because the fix differs for each, and a gap on one of two connected accounts is a different gap from one on both. Beside each count is that record type’s **realtime rate**: how many of its changes arrived in realtime rather than through the catch-up. A count alone is unreadable — forty late arrivals is excellent on a connection handling forty thousand changes and alarming on one handling forty. Open a row to see the individual records with the source’s own modified time, when your instance caught up, and the delay between the two. Times are shown in UTC. **Click a record to see what actually changed.** A panel opens beside the list with one line per field that moved, the value before and the value after, and everything that stayed the same behind a disclosure. It is read from the audit trail at the moment you click — the list itself never reads it, so the tab stays as quick as it is. That answers a question the count on its own cannot. “Customer updated” can mean a real edit at the source, or movement in fields your instance does not mirror at all — and those are not the same finding. When nothing your instance records moved, the panel says so in words rather than showing an empty list, and tells you why: the edit was in source fields that are not mirrored, or in the raw payload that is not kept. A create shows the values it started with and no “before” side; a deletion shows the last values recorded and no “after”. A source-reported deletion marks the record as deleted rather than removing it, and that marking is not itself a change your instance records — so what you see is the last thing the source wrote to the record before it went. On a record your instance no longer holds at all, the panel says there are no last values to show, rather than offering an earlier change as the deletion’s. If the trail cannot be read, the panel says that too — never that nothing changed. **The fix is at the source, not here.** Shortening the catch-up interval would narrow the delay without closing the gap. What closes it is at the source — but which thing differs from one connection to the next, so your instance works that out rather than assuming it. **Press “How to fix” for which gap it actually is.** The button appears beside the filters whenever something is late on a source that can push, and opens a panel on the page — one section per affected connection, saying which of two different faults that connection has. Either **realtime delivery is not arriving at all**, because it was never set up for that account or the deliveries are being turned away, which is fixed by connecting the source; or **the deliveries are arriving and something keeps saving certain records without announcing it**, which is fixed in the script doing the saving. A 0% realtime rate is equally consistent with both, so your instance checks its own delivery record rather than guessing — and when it cannot read that record, the section says the delivery state could not be checked rather than naming a fault nothing confirmed. For NetSuite the second section names the record types still arriving on the catch-up and shows the two-line notify call that closes the gap, and both link to [Real-time sync from your own NetSuite scripts](/extending/netsuite-realtime-sync/), which covers the four cases NetSuite does not report. A poll-only connection is named as arriving on the schedule by design, with nothing to fix. **Unless the source has no realtime path at all.** Some systems offer no way to tell your instance about a change as it happens — nothing to subscribe to, or nothing that identifies the notification as genuinely theirs. For those, the scheduled catch-up is how their data arrives, by design. Such a connection is marked **Poll-only** on the tab, with no realtime rate against it: there is no trigger at the source to fire and nothing for you to fix. Its changes are still listed, because you may well want to see them; they are simply not a missed delivery. The two figures at the top of the tab say how much of the count came from those connections and how many of them the realtime rate leaves out, so neither number reads as a fault that is not there. An empty tab is good news: every change in the window arrived in realtime. The list counts from when this feature reached your instance forward — there is no history before that. Administrators only, like the rest of the Data area. ## Sync failures [Section titled “Sync failures”](#sync-failures) When a record can’t be written back to its source, it doesn’t disappear and it doesn’t retry forever. The failure is recorded as a single deduplicated row — repeated failures of the same record collapse into one row with an occurrence count, rather than flooding you with near-identical entries. Administrators triage these at **/data/sync/failures**, where each failure can be approved, rejected, ignored, muted, retried, or edited-and-retried. Every such decision is written to the [audit log](/tenant/audit-log/). This pairs with the [guardrails](/tenant/guardrails/) that stop a terminal write failure from re-enqueuing itself in a tight loop. ## Related [Section titled “Related”](#related) * [Data model](/tenant/data-model/) — bidirectional writes and where failures come from * [Guardrails](/tenant/guardrails/) — the sync-loop circuit breakers # Users and permissions > Instance users, the built-in and custom roles they hold, and the capabilities and record restrictions that gate their access. Shipped Every Turbobase instance has its own user directory and its own permission model. Who can sign in, what they can do, and which rows they can see are all decided inside your instance — not borrowed from a source system. ## Roles [Section titled “Roles”](#roles) A role is a **named set of permissions**. Permissions are not edited person by person: you author roles, then assign them. Three roles are built in and ship with every instance: * **Admin** — everything, including settings, sync, the Analyzer and user management. Administrators bypass capability checks entirely, manage other users, and mint [access tokens](/tenant/access-tokens/). * **Data admin** — full read, write and delete on every record type, including record types added later. No admin functions. * **Standard** — the default grant for an invited user: read and write across the order-to-cash, procurement and work-management records, read-only on the GL and HR surface. Built-in roles cannot be edited or deleted. To start from one, use **Copy to a new role** — you get an editable role with the same permissions. (Admin is the exception and cannot be copied: it bypasses capability checks, so a copy would either be a second unrestricted administrator or a standard role that only looked like one.) The one exception is **session length**, below: a built-in carries its own, set from a card on its otherwise read-only page. Nothing it grants can be changed. ## Custom roles [Section titled “Custom roles”](#custom-roles) Administrators author their own roles at **/admin/roles** — a warehouse clerk, an AP specialist, a read-only auditor. The role editor has four sections: * **Record permissions** — the records this role may work with. Add a record, then tick what the role may do with it. The list starts empty and holds only the records you added, so a role never grants more than you chose; **Add record** offers every record type your instance carries — printers and print jobs as much as sales orders — and your own custom tables beside them, marked with the source they came from. Removing a record revokes every verb it held. Record types added later inherit nothing unless you grant a wildcard. * **Action capabilities** — permissions that are not tied to a record type. * **Record restrictions** — limit which records the role can see by field value: pick a field, list the allowed values. A record with an empty value on that field is always visible. Use `{{user.}}` (for example `{{user.id}}`) to match the signed-in user’s own value instead of a fixed list, so “only this user’s own records” follows whoever is signed in. * **Content access** — the Slack channels whose message history the role may read, one channel at a time, chosen from the public and private channels your instance mirrors. Holders read a granted channel’s history in Turbobase even if they are not members of it in Slack, or not Slack users at all. Granting nothing reads nothing — the opposite of a restriction, where an empty value means unrestricted. Tick **Post into these channels** to let holders post into the granted channels from an app as well, member or not — and nothing else: no other channel, no edits, no other activity. The message goes out as your workspace’s Slack app, and holders still can’t edit or delete messages in a private channel they aren’t in. Pilot still follows Slack membership, and direct messages can never be granted. The role also needs **Read granted message history**, which the editor ticks for you with the first channel; at most 16 channels per role. Adding a channel your instance hasn’t mirrored as a public or private channel is refused when you save, and the message names the channel — a grant like that would read nothing and post nothing. A channel already on the role that has since left your workspace keeps saving, so you can still rename or re-permission that role and remove the channel when you’re ready. Saving a role re-issues permissions for everyone holding it on their next sign-in, and the page tells you how many holders that is. Removing a channel from **Content access** takes effect sooner: a holder loses that channel’s history on their very next request. A role with holders can’t be deleted. Role creation and edits are written to the [audit log](/tenant/audit-log/), and the whole grant as it stood before and after the edit is on the audit row’s own before/after columns — so **Show diff** on that row reconstructs a capability you removed from a role thirty people hold, without needing a backup. ## A user holds a set of roles [Section titled “A user holds a set of roles”](#a-user-holds-a-set-of-roles) A user may hold **several** roles and switch between them; exactly one is their **default**. The default is the role a fresh sign-in mints. A user switches freely inside their own set but cannot re-point their own default — that’s an administrator’s call. Switching reloads the app under the new role. A user can’t be left holding no roles. To take away their named grant entirely, detach them from roles instead; their permissions then come from their own stored bundle. Users provisioned from a connected source are managed by that source — Turbobase roles do not apply to them. ## How long a role’s people stay signed in [Section titled “How long a role’s people stay signed in”](#how-long-a-roles-people-stay-signed-in) The role editor carries one more field beside the name and description: **Keep this role’s users signed in for**, in whole days. Leave it empty and the role’s people get the instance-wide default from **Settings → Sign-in → Sessions**. Fill it in and that value wins for them instead — in both directions, so a role may be longer than the default or shorter than it. The limits are 1 day and 30 days, and the editor refuses a fraction or anything outside them before it saves. Built-in roles carry it too, from a card of their own on the read-only page. It is the only thing about a built-in you can change. **When a user holds several roles, the one they are acting as decides.** A fresh sign-in mints their default role’s length; switching role re-issues the session with the new role’s length, so a person who moves from a 1-day role to a 30-day one gets 30 days from the moment they switch and not before. Shortening a role reaches the people already holding it — their next session renewal is minted at the shorter length, without anybody having to sign out. See [Authentication levels](/tenant/auth-levels/) for how the instance-wide default is set and what never follows it. ## Editing a role interrupts the people holding it [Section titled “Editing a role interrupts the people holding it”](#editing-a-role-interrupts-the-people-holding-it) **Changing what a role may do reaches its holders’ records immediately, through every client.** It used to reach them only when their session next renewed, which could be up to half their session length away — fifteen days on a 30-day role — so somebody moved to a narrower role went on reading what the wider one allowed. Their data is now refused the moment you save. What a holder sees depends on where they are: * **In an app** — reads and writes start being refused, and a well-built app sends them to the sign-in page, which hands them a fresh session carrying their new access without asking for a password. * **In the console** — their next page asks them to sign in again. They are not locked out and nothing is lost; signing in gives them the role you just saved. So **edit a role when an interruption is acceptable**, the same way you would time any other change that touches who can see what. Adding a capability interrupts in exactly the same way as removing one — the instance does not try to tell a widening apart from a narrowing, because the holder’s session no longer matches their access either way. ## Capabilities and scopes [Section titled “Capabilities and scopes”](#capabilities-and-scopes) Permissions are enforced in the database by row-level security, so they hold no matter how a request arrives — the app, the PostgREST API, or the [MCP server](/tenant/mcp-server/). Two axes combine, and one grant runs the other way: * **Capabilities** — a verb × record-type vocabulary (for example `read:sales_order`, `write:invoice`, `read:entity:customer`) that gates which operations a role may perform on which record types. A write grant does **not** imply delete. * **Scopes** — the dimensional restrictions authored as *Record restrictions* above (for example by subsidiary or location) that limit which rows the role can see and touch. An empty scope on a dimension means unrestricted on that dimension. * **Content grants** — the channels listed under *Content access* above. They work the other way round: message history is readable only for a channel the role names exactly, and only while the user is acting as that role. A person holding several roles reads a granted channel only while acting as the role that grants it. ### Roles an app declares [Section titled “Roles an app declares”](#roles-an-app-declares) An app can declare the roles its screens need, and deploying it creates them here as ordinary roles — with no holders until you assign them. An app may also declare a role’s restrictions and content access. Those values are marked **declared by** the app in the editor, and your edits win: a channel you remove is not re-added by the app’s deploys for as long as the app keeps declaring it, and a restriction you change keeps your value — the editor says so beside it. The same holds for a capability you remove from a role an app declared: the app’s deploys never add it back — nor a wildcard that would grant it — and report it as kept removed until you grant it again here. A channel you add is never removed by the app — even if the app later declares the same channel, because an app removes only what its own deploys put on the role. The same holds for a restriction you set: if an app later declares exactly that value, it shows **declared by** the app, but the app can never clear or change it. An app that stops declaring a channel it added removes it on its next deploy, unless another app on the same role still declares it. ## Access is invite-only [Section titled “Access is invite-only”](#access-is-invite-only) A person can sign in to your instance only if they’ve been invited. An authenticated identity that was never invited gets no session — there is no open self-signup into an instance. Invited users are matched on their verified email, and suspended users are refused on every request. **Suspending somebody reaches their data, not just their next page.** Their reads and writes of your instance’s records stop at once, from every client — the console, an app, the API and the live data stream alike — whether or not they have a browser tab open and however long their session had left to run. Reactivating them restores it just as directly: they sign in again and carry on. The same holds when somebody signs out: the session they signed out of stops working there and then, and their other sessions on other devices are left alone. **Somebody you invite holds the role you gave them from their first sign-in** — its permissions, and nothing else. If you picked a custom role on the invite, that is the role their very first session is minted under; you do not have to assign it again once they arrive. The same holds for somebody who joins through a chat invite link — Telegram, Slack or Discord — who lands on the built-in role the invite named. ### Repeated attempts are slowed down [Section titled “Repeated attempts are slowed down”](#repeated-attempts-are-slowed-down) Sign-in and password-reset attempts are limited, so nobody can sit and guess at a password, a one-time code or a reset code. Two limits apply at once: one on how fast any single network address may try, and one on how many attempts may be made against **one account** in ten minutes — so trying the same account from many machines is no faster than trying it from one. Somebody who trips a limit sees **“Too many attempts. Wait a moment and try again.”** on whichever screen they were on. Nothing is locked and nobody has to be let back in: waiting clears it, and the person signs in as normal. Asking twice in a row for a reset email sends one email, not two. The screen says the same thing either way, and the second request is still recorded in the [audit log](/tenant/audit-log/) — only the duplicate email is held back, so a fresh link cannot be used to bury the one already sitting in somebody’s inbox. ## Managing users [Section titled “Managing users”](#managing-users) Administrators manage users at **/admin/users** — inviting people, assigning roles, and suspending or reactivating accounts. Each identity in the list carries its own profile picture beside it, or the person outline where nobody has set one; a picture is set by the person themselves, under **Profile** on their own account page. Every such change is written to the [audit log](/tenant/audit-log/), with the person’s access before and after the change on the row’s own before/after columns. A **password change** and a **password reset** are two different events, and they filter separately. A change is made by someone already signed in who knows the old password; a reset is made through the emailed link, by someone who may have lost control of the account. Reading them as one event hides exactly the case worth looking at. ## View as a user [Section titled “View as a user”](#view-as-a-user) “The Warehouse role can’t see fulfilments” is a question you can answer by looking, rather than by reading the role editor and guessing. From a user’s page, **View as this user** opens a read-only session that reproduces exactly what that user sees. It lasts 10 minutes, cannot write anything, and every action is still attributed to you; your own session resumes when you exit from the banner. Inside it, every button, form and menu item that would change something is shown disabled and says why — the session is read-only — so what you see is what the session can do, not a control that fails only when clicked. Because a user holds a set of roles, the control sits on each row of their assigned-roles table — you look through **one** role at a time, and the banner names which. An administrator may view any user, but only under a role that user actually holds. Two cases refuse: a suspended user (a session for them would be the one way to get a live one while they are suspended), and starting a second view-as from inside one — view-as sessions do not chain. Exit the first from the banner. ## Related [Section titled “Related”](#related) * [Auth levels](/tenant/auth-levels/) — how sessions and capabilities are minted * [Audit log](/tenant/audit-log/) — where role, user and view-as activity is recorded * [Data model](/tenant/data-model/) — the RLS-protected tables these permissions gate