Releases and adopt notes
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”-
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 same504an aborted read answers, plusnotRun: true— present only on a sub-query that was never sent, absent on every other slot, the waytruncatedalready behaves. Itsbody.errorsays 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 nosource,recordTypeorrecordIdappended — 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”-
Offer
resumeProcessRun(runId)instead of your own “finish what the run left” screens. A process step that declaresawaitSourcewaits a bounded time for the connected system’s verdict; past that budget the run endscommittedwith the steps it never reached counted inoutstanding. 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.resumeProcessRunposts toPOST /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 whosewhendeclined 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. Answers202 { runId, state: 'running', queued: true }— watch the run until it readscommittedwithoutstanding: 0, exactly as you do after an invoke. -
Branch on
409 process_run_not_resumablerather than offering the button on every stopped run. Only a run that endedcommittedwith work outstanding can be resumed. A run that isqueuedorrunninghas not stopped, afailedorrolled_backone needs its refused step re-keyed rather than re-offered, acancelledone was stopped by a person, and acommittedrun with nothing outstanding has finished — each answers409naming the state on the body, with nothing queued. A process declaredeachRowanswers409 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 same404 process_run_not_foundthe run read gives them. Two resumes in quick succession start one continuation — the second answers409. -
Run
turbobase processes resume <runId>when nobody has built a screen for it yet. The same door from a terminal, on anextensions:deploytoken owned by an administrator, besideprocesses cancelandprocesses 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 theadditionalProperties: falsebeside 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 intodefinitionsand pointed at it with$refpurely 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
typeunion, a nesteditemsor ananyOfmember went missing, and refusing correct ones where apropertiesdid. Flatten the shape, or declare the repeated part once indefinitionsand reference it with$ref.
v1.48.0
Section titled “v1.48.0”-
Read
line_numberonorder_fulfillment_lineanditem_receipt_lineas 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 byline_numberif you do already: the order is stable, but it is not guaranteed to be the order NetSuite displays them in. Join tosystem_note.line_indexon it as documented. -
Read
line_index, notline_number, onorder_fulfillment_lineandjournal_entry_line— onlyitem_receipt_line.line_numberis 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 theline_ordermigration called all threeline_numbercolumns 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 typesstill namesline_numberas the paging key for them inSEEK_KEYS, becauseline_indexarrived nullable — paging throughSEEK_KEYSkeeps working while the column exists. Order and display lines byline_index. -
Return a Promise from an update step’s
pgRowId,externalIdorexpectedSyncVersionwhen the value needs a read. A deployed process has always awaited them, but the@turbobase/extensionstype said they were synchronous, so the natural code — read the row’s currentsync_versionthroughctx.query, then hand it over — did not type-check, and authors passed values across fromwhenby hand. The type now allows a Promise, and the engine awaits it on every path. The order is unchanged:when, then the address functions, thenbuild. -
Read
SOURCE_APPLIED_UNLINKEDon a process step, and link the record instead of telling the user to clean it up by hand. When anawaitSourcestep’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 recordedSOURCE_REFUSED— and a rollback then deleted the row, which had noexternal_id, so the record stayed in the connected system with nothing on your instance pointing at it. The step is nowfailedwithrefusal_code: 'SOURCE_APPLIED_UNLINKED', the source’s id on the step’sexternal_idand the same message as before, and no rollback — automatic orPOST /v1/processes/runs/:runId/rollback— deletes its row. The run still stops there. OfferPOST /v1/data/:recordType/settlewith the step’spg_row_idto link it. Code that matched the message rather than the code keeps working; code that branched onSOURCE_REFUSEDfor this case should branch on the new code. -
Add
turbobase project skillsto.claude/hooks/session-start-refresh-types.shonce, so the platform’s skills stay current in this project. The skills the scaffold writes that are platform knowledge —write-a-processandbusiness-rule-to-hook— used to be written atinitand never again, so nothing the platform learned afterwards reached your sessions. They now carry aplatform-maintainedline, andturbobase project skillsrewrites them from the installed CLI. Paste this at the end of the hook, after theproject adoptline:Terminal window if turbobase project --help 2>&1 | grep -qE '^ +skills( |$)'; thenturbobase project skills >/dev/null \|| echo "turbobase: \`turbobase project skills\` failed — the platform skills were NOT refreshed." >&2fiThe 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 toINSTANCE-LEARNINGS.mdand delete it to take the platform’s. Your own skills are never read. New projects get the hook line frominit. -
Stop probing the session before calling an
/api/account/*route, and send aturbobase:session_deaduser to/loginas the docs already say. A role edit or a role-changing deploy ends every signed-in session at the data plane withturbobase:session_dead, and the instance’s own pages used to answer that by deleting the session cookie — so the next console call, or/loginitself, 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/loginis now what happens. -
Refresh
.claude/skills/write-a-process/SKILL.mdfrom 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_UNLINKEDand how to link its record; async update addresses; delete-onlyonRefusedand bare scope refusals; and that a preview cannot run a new or changed process. Runturbobase project initin an empty directory and copy that one file over yours — never--forceover your project, which overwrites everything.
v1.47.0
Section titled “v1.47.0”- Keep the
line_indexfallback when you sort a NetSuite document’s lines byline_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 withline_orderthe 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 keptline_ordernull. 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 byline_orderwith nulls last, thenline_index. PostgREST:purchase_order_line.order=line_order.asc.nullslast,line_index.asc.
v1.46.0
Section titled “v1.46.0”-
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 throughPOST /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 onturbobase 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 stillafter_canonicalize_inbound, so a synced record never fires twice. -
Leave
idempotencyKeyoff anafter_saveconduit. 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, soctx.externalIdis its own surrogate key and is the same string for the record’s create and every later edit. The conventional'<id>:${ctx.externalId}'therefore dedups the second save into the first and the conduit fires once in that record’s lifetime.turbobase ext lintasks forctx.externalIdin 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 fromctx.changein it. -
Read
ctx.changein anafter_saveconduit’sfrom.wheninstead of firing on every edit. It is{ kind: 'insert' | 'update', fields: [...] }, andfieldsnames the columns the writer actually supplied — soctx.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.savedByto 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 firesafter_saveunder the pair itstonames, so a conduit that listens where it writes feeds itself. Gate onctx.savedBy.id !== '<your conduit 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.turbobaseis a reserved source for the platform’s own records — it is not a connector and never will be, so do not use it as asource_systemanywhere 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
toatturbobase:proposalto 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 namesid,statusor 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’ssolution_target, and the filing itself reaches anafter_saveconduit assavedBy: { 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
tonames a system this instance does not have to be refused at deploy time.turbobase ext deploypreviously accepted a target under any unknown source — including a mistyped connector id andturbobase:<anything>— and the conduit then failed on its first run withunresolved target. It now 400s withconduit_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_saveHOOKS on records your app writes and they now run.POST /v1/data/:recordTyperanbefore_saveand then nothing once the save committed, so anafter_savehook 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 shapeafter_deletealready uses at the same boundary: it reportsdirection: 'standalone' | 'outbound'andorigin: '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, andctx.payloadis the record’s LAST STATE, which is the one thing a deletion reaction cannot read back for itself. A delete your guardrails or abefore_deletehook refused fires nothing, and a repeat delete of a record that is already gone — still a200, because a delete here is idempotent — fires nothing either.ctx.changeis absent on this trigger, as it is on the two inbound ones. -
Read
kindsandoriginon aturbobase:proposalconduit. The change- request projection wasid, title, body, app_slug, filed_by, status; it is now those six pluskinds— the solutioning kinds the request has been classified as,[]until somebody classifies it — andorigin, which says how the request arrived.origin.channelis one ofwidget,conduitor the request’s own creator (authored,request, …); a widget request also carriesorigin.route, the screen it was filed from, and a conduit-filed one carriesorigin.conduit_id,origin.from_source,origin.from_record_typeandorigin.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 firesafter_saveonturbobase:proposalwithkindsnamed as what changed — before this, the only run a request ever produced was its filing, wherekindsis empty by construction, so a conduit keyed on classification could not be written. A classification reportskindsand nothing else: it does not reportstatus, so a conduit watching for a decision on a request no longer fires when somebody merely works out what the request is. -
Set
app_slugandfiled_byin acreate-draftpatch 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. Anapp_slugyour instance does not have is REFUSED, naming the slug, and nothing is created.filed_byis recorded as REPORTED by the conduit and never as a signed-in user, so a request’screated_bystill only ever names somebody who authenticated. -
Address a Slack
channel_messageto a person by email to send a direct message.raw.channeltook 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 withim:writenamed — 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
toto start it:to: { source: 'turbobase', recordType: 'process', process: '<process id>' }. 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 atturbobase ext deploy, naming it. -
React to a release and to a published knowledge page. Two new reserved records publish into the same
after_saveseam:turbobase:app_release(id,app_slug,app_name,version,deployed_by,deployed_at), which fires on a PRODUCTION deploy and not on a preview; andturbobase: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 itstois refused at deploy. -
turbobase ext linthas new advice about anafter_saveorafter_deleteidempotency key, and it reverses what it used to say on those triggers. A declared key that carries nothing about the save now warnslifetime-idempotency-key: on a save triggerctx.externalIdis 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 fromctx.change. The advice forafter_canonicalize_inboundandafter_sync_success_outboundis unchanged — therectx.externalIdis 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_targetwith 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 fieldfiled_byis read from, so a classified request could read as anonymous. The planner’s hints are now merged onto what the request already carried, andsetProposalTargetmerges 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
filerecord onsource_system: 'slack'. It takes two requests: POST the bytes to/v1/data/file/uploadswithcontent-type: application/octet-streamand keep theupload.upload_idit answers, then create the record with that handle onraw.upload_id, the destination onraw.channel(a channel name, a channel id, or a person’s email address) and the file’s name onpath.raw.thread_tsputs it in a thread, andraw.initial_comment,raw.titleandraw.alt_txtare optional. The row settleswrite_status: 'synced'with Slack’s own file id as itsexternal_id, so read the status rather than treating the create’s 200 as a file in the channel.list_record_typespublishes the required paths asrequired_inputs— read them rather than hard-coding the list. -
Do not edit or delete a Slack
filerow. Both are refused — the create with 422 at the boundary, a privileged enqueue or conduit withcode: '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_typespublishesops: ['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 inGET /v1/catalog/records/fileandGET /v1/catalog/columns?types=fileresolves to Slack rather than Google Workspace, which changes the table and the read capability it describes. Pass?source=google_workspace(orgithub,azure_blob,slack) and nothing collapses. The foursource:recordTypecouples are unchanged. -
Read the number out of an over-size upload refusal.
POST /v1/data/:recordType/uploadsone byte over the 8 MiB ceiling used to answer Fastify’s ownRequest body is too large, which named no limit; it now answers 413 withrejection.code: 'UPLOAD_TOO_LARGE',field: 'body'and the byte limit inrejection.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:writeon the Slack app. A workspace connected today already grants it. One connected before file sharing existed does not, and a share then endswrite_status: 'blocked'withreason_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
labelon a FeedHive instance to get the workspace’s labels. A new read-only record type,feedhive:label, lands on the sharedlabelrelation with each label’s name:/rest/v1/label?source_system=eq.feedhive, orlist_recordsonlabel. It needsread:label, which is the capabilitygithub:labelandasana:asanaTagalready use, and it appears in/v1/catalog/recordsand inturbobase project types. It refreshes on every sync, so a label added or renamed in FeedHive arrives on the next one. -
Set
raw.labelson a FeedHivesocial_postto file a post under a label. Accepted on the create and on the update, as an array of label ids — read them off thelabelrelation above; the name is not accepted. A mirrored post now also carries its own label ids inraw.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:labelsREPLACES 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 offrecord_write_staterather 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’sraw.labelsand 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’sraw. Every line-bearing Xero record — invoice, bill, credit note, supplier credit note, purchase order, spend money and manual journal — now projects each line’sTracking[]entry into the line table’s existing dimension columns, so an invoice line tagged with a “Region” option readsclass_name = 'West'andclass_id = 'xero:{TrackingOptionID}'. When the source sends an entry with no option id the_idcolumn stays NULL and the_nameis still filled, so match on the name when the id is absent rather than treating the line as untagged. The wholeTracking[]array is still kept in the line’sraw, so nothing you read today stops working. -
Select a source’s cost-tracking axes from
/rest/v1/segmentand their values from/rest/v1/segment_value. These are new and they are the canonical store for every source’s dimension values: onesegmentrow per AXIS (a Xero tracking category, a Business Central dimension, a NetSuite custom segment, and NetSuite’s own class / department / location), and onesegment_valuerow per value on it. A value carriessegment_pg_row_id,canonical_segment,name,parent_id/parent_name,is_inactiveandraw. Count one axis’s values withsegment_value?select=*&segment_pg_row_id=eq.{id}andPrefer: count=exact. An archived value is present withis_inactive = truerather than missing, so filter on that column if you only want the ones a user can still assign. -
/rest/v1/classification,/rest/v1/departmentand/rest/v1/locationkeep working unchanged, and are the ones to stop writing to. They now hold the subset ofsegment_valuewhose axis fills that typed slot, kept in step by the database, with the samepg_row_idon both sides — so every list read, filter andpg_row_idreference 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 ontosegment_value(setsegment_pg_row_id; the slot is derived). Reads need no change then either. -
Replace an embed of a dimension —
order?select=*,location(*)— withorder?select=*,segment_value(*). PostgREST resolves an embed through a foreign key, and every<dim>_pg_row_idlink now referencessegment_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), carryingcanonical_segmentand 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 — changingcanonical_segmenton an existing row is not supported in this release: the values move slot but theclassification/department/locationmirror 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, andsegment_mappingsays 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 byline_tableandline_pg_row_id. It is read-only in this release: the values beyond the typed columns still reach the source through the line’sraw. 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_linecascades 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 testsclass_idanddepartment_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_iddoes not reach Xero: the writer rebuildsTrackingfrom the line’s inboundraw. The mapping row says so —direction = 'inbound'and a NULLsource_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.messagefor 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.statusis stillblocked,reason_codeis unchanged on all three, andfieldsis 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
summaryoffGET /v1/eventsinstead 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. Forsync.failure_recordedthe 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 — andturbobase logs --jsonnow prints it. The rendered one-line form is unchanged. -
Stop treating
500 write_failedon a retriedPOST /v1/data/:recordTypeas “it may or may not have landed”. A retry carrying anidempotencyKeythe caller itself committed on or after this release is now answered200 { ok: true, replayed: true }with the first attempt’spg_row_idandexternal_id, for every caller — including the three that used to get the failure back every time, forever, for that key: a role holdingwrite:<table>withoutread:<table>, 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 forbiddenfromPOST /v1/data/:recordTypewhen 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, oninvoice,order,purchase_order,item_receipt,item,time_bill,vendor_billand seventeen more. Nothing changes for an unrestricted session, for anapp_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_actorcolumn, written besideidempotency_keyat 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 carryingidempotency_key, your instance’s owncr_*/cl_*/ext_*record types included, and is rejected on a request body likeexternal_idandoriginating_system; regenerate withturbobase project typesif you select*into a fixed-shape type. -
Read a line’s
UNKNOWN_COLUMNrefusal fromlines[n].<field>, not from the bare column name. A write toPOST /v1/data/:recordTypewhoselines[n]names a field the line table has no column for is still refused422withcode: "UNKNOWN_COLUMN"and a message naming the line table, butfieldnow carries the path —lines[0].descriptionwhere it used to bedescription. Nothing changes for a refusal on the record’s own fields, which still carry the bare column name. If your app mapsfieldonto a form input by exact string, split on the last.; if it only shows the message or branches oncode, 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: truewhen you create a row in your own table.POST /v1/data/:recordTypeon a table your app (or the console) declares now refuses a create that omits a required field, or sends it asnull, with422andcode: "REQUIRED_FIELD_MISSING",fieldnaming the column (cf_sa_<name>), and stores nothing. An edit that leaves the field out is still accepted and keeps the stored value; an edit that sets it tonullis 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 abefore_savehook, or droprequiredfrom the manifest. Tables mirrored from a connected system are unchanged. -
Put
turbobase ext lint --ciin your deploy lane, including on a lane that ships a process.ext lintnow raises each rule only on the kind it is about. The four conduit-wiring rules — a weakidempotencyKey, a missingcreatedFrom, an extremecycleBudget, 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 oneweak-idempotency-keywarning whose fix namedctx.externalIdandctx.fromSource, which a process has no access to, and--ciexited 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--cifrom a lane because of this, you can put it back; if you suppressed the warning in a process module by namingctx.externalIdin its idempotency key, you can go back to keying on the row. -
Add
connection_idto any PostgREST upsert whoseon_conflict=namesexternal_id. 66 more tables —journal_entry,check,subsidiary,accounting_period,label,project,subscription, every procurement line table and the rest — now carryconnection_id text not null default 'default', and their unique and primary keys read(connection_id, …, external_id). A request still sending?on_conflict=external_idfails42P10: 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 readsdefault, but two reads move with it: the subsidiary / posting-period embed resolves through a new column (two entries below), andexternal_idon its own stops identifying one row (three entries below). -
Drop the
!…_fkeydisambiguator when embedding the lines of anitem_receipt,return_authorization,vendor_return_authorization,journal_entry,checkortransfer_order. Those six header/line pairs carried two foreign keys — the old source-id one besideparent_pg_row_id— so a bare?select=*,item_receipt_line(*)returned300/PGRST201. The source-id constraint is gone and the bare embed now resolves. The explicititem_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_fkeyand their four siblings) has no relationship left to resolve, so drop the disambiguator or point it atparent_pg_row_id. -
Embed a transaction’s subsidiary or posting period through the record, not the id column.
order?select=*,subsidiary(*)andcash_sale?select=*,accounting_period(*)now resolve through newsubsidiary_pg_row_id/posting_period_pg_row_idcompanion columns rather than throughsubsidiary_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 embedsnullinstead of failing. The companions start empty, so a row written BEFORE the upgrade embedsnulluntil the nightlysystem.dimension_ref_backfillsweep (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 thesubsidiary_id/posting_period_idtext columns — and the*_namedenorm where the table carries one — which keep every value they had. -
Filter on
connection_idas well asexternal_idwherever you need exactly one row.external_idis no longer unique by itself on any of these tables, sosubsidiary?external_id=eq.1can 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 thepg_row_idsurrogate each row already carried, sopg_row_idis the single-column handle to one row. -
Both of the above apply to your instance’s OWN record types too — the
cr_<scriptid>,cl_<scriptid>andext_<recordType>tables. Every one a tenant holds gained the sameconnection_idcolumn and the same(connection_id, external_id)key at upgrade, socr_widget?on_conflict=external_idfails42P10andcr_widget?external_id=eq.CR-1can match two rows. Send?on_conflict=connection_id,external_id, and filter onconnection_id— or reach forpg_row_id— when you want one row. The table names are the tenant’s, so they are not listed here;GET /v1/catalog/recordshas the set for the instance you are building against. -
Hold
read:activityinstead ofread:zendesk_ticket_comment,read:intercom_conversation_partorread:notion_comment, and read help-desk ticket comments, messaging transcript parts and document comments from/rest/v1/activity. The three tableszendesk_ticket_comment,intercom_conversation_partandnotion_commentreceive no new rows: every comment now lands on the shared conversation satellite, told apart byactivity_type(ticket_comment,message,comment) andsource_system. Existing rows were carried across. An app that posts a reply on a help-desk ticket inserts anactivityrow withactivity_type = 'ticket_comment'and the ticket inwork_item_external_id. -
Hold
read:work_iteminstead ofread:intercom_conversation. The conversation header folded onto thework_itemrow it described:channel,snoozed_until,first_response_timeandresolution_timeare columns on that row, andintercom_conversationhas no row of its own any more. -
Hold
read:boardinstead ofread:cr_jira_board,read:cr_jira_projectorread:ap_project, and read boards and projects from/rest/v1/board. Jira boards and projects and Asana projects land on the sharedboardtable beside GitHub’s;board.kind(board/project) tells them apart andboard.project_keycarries a project’s short key. An app that creates an Asana project inserts aboardrow withkind = 'project',titleanddescription—boardaccepts its first write, underwrite:board. -
Hold
read:board_fieldinstead ofread:ap_section. A project’s sections are ONEboard_fieldrow per board (external_id = 'sections:<project id>') whoseoptionsarray is the sections in order; a task’ssection_idis still the section’s own id, which is the option’sid. -
Hold
read:entity:employeeinstead ofread:worker, and read HR workers from/rest/v1/entity?entity_type=eq.employee.hire_date,termination_dateandworker_typeare columns on the employee row;employment,compensationandtime_offcarryentity_id(andemployment.manager_entity_id) naming that row, beside theworker_idcolumns they already had. -
Hold
read:memoryinstead ofread:notion_documentorread:notion_block. A page is amemory_documentof kinddocand its blocks are the page’sbody_text; a block has no row of its own.memory_documentnow carriesconnection_id, so a filter onsource_system+external_idcan match one row per connection. -
Hold
read:time_entryandwrite:time_entryinstead ofread:time_billandwrite:time_bill, and read or write NetSuite time from/rest/v1/time_entry. Every source’s time is on one table.duration_minutesreplaceshoursand is sixty times larger;author_idis the employee,descriptionthe memo,entered_onthe date andwork_item_external_idthe task the time was logged against. APOST /v1/data/timeBilltakes those column names and goes out to NetSuite in hours. -
Hold
read:credit_card_chargeinstead ofread:card_transactionorread:credit_card_refund. Ramp card swipes and NetSuite card refunds land oncredit_card_chargebeside NetSuite’s charges;record_kind(charge/refund) andsource_systemtell them apart, refund lines are oncredit_card_charge_line, andmerchant_name,merchant_category_code,receipt_ref,transacted_at,settled_atandcurrency_codeare columns. -
Stop selecting
tran_internal_idoncredit_card_charge_line,credit_card_refund_lineandinventory_status_change_line. The column is gone from those three line tables; join a line to its header throughparent_pg_row_id, which every line carries.order_lineandpurchase_order_linekeep it. -
Re-point every label read at
label_defandlabel_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_defandproposal_epic_def— stop being maintained the moment this lands and are removed one release later.label_defholds the names (id,scope,name,color,description,managed_key,created_by,created_at) andlabel_linkrecords 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
scopewhere you used to pick a table.app_label_defbecomeslabel_def?scope=eq.app,catalog_label_defbecomeslabel_def?scope=eq.catalog,proposal_epic_defbecomeslabel_def?scope=eq.epic, and the proposal vocabulary — which proposals and solutions share — islabel_def?scope=eq.proposal. Omit the filter and you get all four vocabularies in one list. -
Filter on
target_typewhere you used to pick a join.app_labelbecomeslabel_link?target_type=eq.app, and the thing’s identity moves from a typed column (app_id,object_id,proposal_id,solution_id) totarget_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. -
scopeandtarget_typeare different axes — do not match them up. An epic on a proposal is ascope = 'epic'name on atarget_type = 'proposal'link, so a read that asks only for a proposal’s links gets its labels AND its epics. Joinlabel_defand filter onscopeto tell them apart. -
Embed an app’s or a proposal’s labels through a named relationship, not the join.
label_link.target_idcarries no foreign key, soapp?select=*,label_link(*)does not resolve. Useapp?select=*,labels_on_app(label:label_def(id,name,color)), and on a proposallabels_on_proposal(...)orepics_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_atforinserted_atonaccount,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_typeandunits_type_line;currency_codeforcurrencyoncard_transaction,chargeandsubscription;is_archivedforarchivedonnotion_document;memoforcommentsonproject;namefortitleonboard,board_card,opportunityandprint_job; andline_indexforline_numberonitem_receipt_line,journal_entry_lineandorder_fulfillment_line.item_receipt_line.line_numberis 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 av_-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_summaryandtransaction_rollup. Both names work today; the bare names are dropped a release from now.customizationis the one of the sixteen you also write to —v_customizationaccepts 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,employeeandcontact— keep their names and are not affected. -
Stop reading
customer_unpacked,vendor_unpacked,contact_unpackedandemployee_unpacked; read the promoted columns onentity, or thecustomer,vendor,contactandemployeeviews. Those four views spreadentity.attrsinto one column per field, which the promoted columns onentitynow 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_atandsync_versionare 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, issrc/sync/mirrored-tables.ts. -
connection_idis 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 aresrc/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 ofturbobase project types, which reads that document from a signed-in browser session.GET /v1/catalog/records,/columnsand/fieldsare unaffected, and so is the in-product agent: theirdescriptioncomes 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.mdis 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 andPOST /v1/dataall 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. -
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. Readingdetailsis what lets you bounce the first user to/loginand show the second a permissions message. Readmessageto 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/batchsub-result’s own status, not the envelope’s. The batch stays200and the refusal lands one level down, asresults[i].ok === falsewithresults[i].status === 403and the samedetails. Code that readsresults[i].bodyafter 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 renamedp_tokenand the function hashes it internally, so what the database stores inchannel_invite.token_hashis no longer something a holder can redeem.anonno longer holdsEXECUTE, so an unauthenticatedPOST /rest/v1/rpc/redeem_channel_inviteis refused. An app still passingp_token_hashgetsinvite not foundwith the invite left open. -
Stop calling
adapter.confirm_request,adapter.confirm_decideandadapter.confirm_expireunder a viewer’s session.authenticatedlosesEXECUTEon all three andUSAGEon theadapterschema; they wereSECURITY DEFINERwith no authorization check of their own. The gatedturbobase.confirm_decidewrapper is unchanged and is the admin door. -
Open the CLI sign-in link as given, fragment and all.
open-dashboardnow builds…/auth/admin/cli#token=<jwt>rather than?token=<jwt>, 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/authoringnow 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 withRATE_LIMIT_MCP_AUTHORING_MAX. -
Give an
external_apiconnection a base URL on the public internet.POSTandPATCH /admin/sources/external_api/connectionsnow 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_savehooks 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 noconduit_eventrow. 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
rawwithout stepping overattributes. Every SOQL row carries anattributes: { 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_atpolicy. A record your app held an id for may now read as absent. -
Order Salesforce and HubSpot lines by
line_order, not byline_index. Onopportunity_line,estimate_lineandorder_line,line_indexis 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 nullableline_ordercarries the position a person sees, counting from 1. It is null for a source that embeds its lines in the header (NetSuite, Zoho), whereline_indexalready is the order, soorder by line_order nulls last, line_indexis the portable form. Regenerate types to pick the column up. -
Regenerate types: generated interfaces no longer include adapters this instance is not connected to.
renderCatalogTypesandSEEK_KEYSnow skip a record whose source the instance has no connection to. A NetSuite-only project that importedSalesforceOrderLine— which was the only interface describingorder_line, and described the wrong column names — must switch toNetsuiteOrderLine, emitted per source and per line table. If you worked around this by selectingorder_linecolumns by hand, drop the workaround. -
Read
opportunity.currency_codebefore 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.estimateandorderalready had it. -
An embed off a Salesforce opportunity or quote now returns the account.
opportunity?select=*,entity(*)andestimate?select=*,entity(*)returned null on every Salesforce tenant becauseentity_typewas unset and the composite key is MATCH SIMPLE. If your app worked around that by readingraw->>'AccountId'and joining in the client, drop the workaround — and note thatentity_nameis 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_idandactivity.author_nameare populated on Salesforce rows, and Salesforce users and queues are mirrored asentityrows withentity_type='employee'(a queue carriesattrs.isQueue). An app renderingowner_idcan render the name instead; one filtering a staff list onentity_type='employee'will now see queues, so exclude them withattrs->>'isQueue'where that matters. -
Expect fewer
work_itemrows for a non-admin reader on a Salesforce tenant.work_item’s read policy admits a row whoseowner_user_idis null, and no adapter filled that column until now — so every Salesforce case was readable by anyone holdingread: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_idnow carries a SalesforceWhatIdonly when it names a Case; an Opportunity or AccountWhatIdmoved toactivity.entity_external_id, which also carriesWhoId— the person the call was with. An Event’screated_atis 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 inraw->'__salesforce'->>'startsAt'. -
Stop sending
lineson a Salesforce opportunity toPOST /v1/data/opportunity(and MCPwrite_record). It is now refused with 422 andcode: '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_nameon a Salesforce contact create,last_nameandcompany_nameon a lead, andtitle,statusandexpected_close_dateon an opportunity. A create missing one is now refused at the boundary with 422 andcode: 'MISSING_REQUIRED_FIELD', naming the field, rather than being saved and refused by Salesforce minutes later ontorecord_write_state. An account still needs something to name it —display_name,company_name, orlast_namefor a person account.list_record_typespublishes the declaration asrequired_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 MCPwrite_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, readrecord_write_stateto know the write has landed rather than waiting for the mirrored value to move. -
Read
reason_codeon a refused Salesforce write — it is no longer alwayswrite_failed_unexpectedly. A refusal now arrives asneeds_inputcarrying the row’s own column infieldsand Salesforce’s sentence inmessage, after one attempt rather than six. New values an app can see:source_value_not_accepted,source_duplicate_record,CANNOT_UPDATE_CONVERTED_LEAD, andoutcome_unknown/source_response_lostfor 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-DDsent 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 astrandate, on a customcustbody_*/custrecord_*date field, and the same day written the account’s own way as6/24/2026, all store 24 June, whatever time zone the account and the user behind the connection are set to. Adatetimefield 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_typeto 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,lotNumberedAssemblyItemorserializedAssemblyItem— 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 productinventoryItemorassemblyItemand nothing came along to fix it, so an app that filtered on the tracked type was silently missing those products and one that trustedinventoryItemto mean “needs no lot number” sent receipts NetSuite refused. Nothing is renamed and no column moves, so a query namingitem_typekeeps working; what changes is the value on the affected rows. If you filter the item list onitem_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/:recordTypeandDELETE /v1/data/:recordType. Both resolve a row byexternal_idANDitem_type, so an edit or a delete sent toinventoryItemorassemblyItemfor 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. SendlotNumberedInventoryItem,serializedInventoryItem,lotNumberedAssemblyItemorserializedAssemblyIteminstead — the value the item row now carries is the one to address it by. Reads are unaffected:read:itemcovers every sub-type and the row-level policy onitemdoes not look atitem_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 asblockedwithsource_response_lost. Treat it as build-apps/skills now describes: suppress any “try again” affordance, rendermessage, and do not offer a correct-and-save prompt —fieldsis 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 fromwrite_settle_conflict. -
record_write_state.messageon 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
qualifiedRecordTypewhen you have the qualified name. Every entryGET /v1/catalog/records(and/v1/catalog/records/:type) returns now also carriesqualifiedRecordType—"netsuite:invoice"— the same stringturbobase project typeswrites at the top of each generated interface.recordType("invoice") andsource("netsuite") are unchanged, so nothing you read today moves. Do not filterrecordTypeon asource:prefix: it never carries one. Whether a record can be written right now iswriteCapability !== nullhere — the same answer MCPlist_record_typesgives aswritable_now; the catalog route has nowritable_nowordirectionkey. -
Read
turbobase.activity.directionto tell a conversation apart from a comment, andturbobase.activity.delivery_statusto tell whether a message you sent arrived. Both are new, nullable canonical columns onactivity, and both are NULL on every row that was already there — so nothing you read today changes value.directionisinboundwhen the other party sent the event andoutboundwhen 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_statusis the source’s own answer about the recipient —sent,delivered,readorfailed— and is set only on outbound messages. Neither is a second spelling ofwrite_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 anactivityfeed, adddirectionto the select and style the two sides differently; if you were inferring direction fromauthor_id, stop. -
Two relations are new and your generated types will grow:
turbobase.whatsapp_phone_numberandturbobase.whatsapp_message_template. Re-runturbobase project typesto 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(orowner_role_id) throughPOST /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 needswrite: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
activityrow is now filtered per business number for every non-administrator, including through a write’sRETURNING. 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 onactivity,memory_documentandmemory_chunkare unaffected and keep exactly their current semantics. -
Send a WhatsApp message by creating a
messagerecord, and put the two addresses underraw.POST /v1/data/messagetakesbodyas a column and the addressing inraw, which is the delivery’s own shape:raw.metadata .phone_number_id(orraw.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 — andraw.to(orraw.wa_id) for the customer’swa_id. Neither is a column ofturbobase.activity, which a dozen sources share, so a top-levelphone_number_idis refusedUNKNOWN_COLUMN; a create naming neither address is refusedMISSING_REQUIRED_INPUTon the call. For a template, setraw.type: 'template'andraw.template.name, with the variables underraw.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 thewhatsapp_message_templaterow, each scoped to the connection — soraw.last_inbound_atandraw.template.statusin a payload are ignored, and so is amessageyou create yourself withdirection: '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 anexternal_idis refused as append-only, and a delete is not propagated. Sending is also held behind the per-instancesync.bidirectional.whatsappentitlement, 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_recordanddelete_recordto be absent from an MCP connection’stools/listafter 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 carriesmcp:write— and a native app your team builds on the same OAuth server’sdataresource 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.
-
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_nameandreporter_nameon awork_itemrow whoseitem_typeisticketwere 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 fillsstatusas well asstatus_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/<recordType>andPOST /v1/batchstill reject it with SQLSTATE23503— but it now comes from a check that names the column instead of a constraint, so the message readsno subsidiary matches subsidiary_id "123"and no constraint name appears in it. Match on the code and onsubsidiary_id/posting_period_id, not on<table>_subsidiary_fk. -
Expect
403 forbiddenfromPOST /v1/data/timeBillwhen 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 ontime_entryanywhere on the instance; those writes, and the matching deletes, are now refused before the statement runs — the same refusaltime_billgave before the consolidation moved the rows, and the same oneinvoice,orderandpurchase_orderalready give. Nothing changes for an unrestricted session, for anapp_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_entryfor 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 answers200with 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 holdsread:time_entry. -
Point a conduit’s
toatturbobase:build_requestto ask for a change to be built in a code repository your instance owns. The patch carriesrepository(asowner/name),app_slugandrequest— the instruction, verbatim — and all three are required;request_idis 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.turbobasestays 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 --conduitas usual, the build id the platform answered with is on the run’sto_external_id— which that line prints asto_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:proposalfiles a draft change request,turbobase:build_requestspends build budget — and which one runs is fixed by the record named into. 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 agetsalescontactthroughPOST /v1/data/contactnow carriesraw.linkedin(the nickname after/in/) orraw.linkedin_id(a profile URL or any other LinkedIn reference) to the workspace; before this they were accepted, dropped, and the write still readsynced. Any other LinkedIn key —raw.linkedin_url,raw.linkedin_profileand the rest — is now refused withUNKNOWN_LINKEDIN_FIELDnaming the key you used, immediately at the write boundary and aswrite_statusneeds_inputon 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 butraw.linkedinis no longer refused as incomplete. -
Add
@turbobase/code-standardsto your app’sdevDependencies(npm install -D @turbobase/code-standards). It is the lint, format, dead-code and duplicate check a new app fromturbobase apps initnow 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.jsonat 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/**"] }. Keepsrc/types.tsignored:turbobase project typesregenerates it every session, so a finding in it is one you cannot fix.src/lib/turbobase.tsis the platform’s client, replaced by these notes rather than edited by you. -
Add
oxfmt.config.tsat your app’s root containingexport { 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.jsonat your app’s root with{ "units": ["."], "ignore": ["src/types.ts", "src/lib/turbobase.ts", "dist/**", ".claude/**", "node_modules/**"] }. The key isignore, 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.jsonat 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": "."fromcompilerOptionsin your app’stsconfig.jsonand keeppathsas it is. The type-aware lint reportsbaseUrlas an error, andpathsresolve without it. -
Add
"check": "code-standards check"and"fix": "code-standards fix"to your app’spackage.jsonscripts. If you already have acheckscript, keep yours and add these under another name (for examplestandardsandstandards:fix). Runnpm run fixonce and commit that as its own commit. Then runnpx code-standards baselineand commit thecode-standards.baseline.jsonit writes: it records the findings your app already has sonpm run checkpasses today and fails only on new ones, andcheckrefuses to run without it. Runnpm run checkbefore every push. -
Add the format hook to
.claude/settings.jsonso Claude Code formats each file it edits: runturbobase apps init <slug>in a scratch directory and copy.claude/hooks/format-edited-file.shand.claude/hooks/format-edited-file.mjsinto your project, then add{ "type": "command", "command": "bash .claude/hooks/format-edited-file.sh" }to thehookslist of yourPostToolUseentry whosematcherisEdit|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. APOST /v1/data/location— ordepartment,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 reacheswrite_status = 'synced'on its own and no longer needsPOST /v1/data/<recordType>/settlerun 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 with200where it answered409 SETTLE_TWIN_NOT_ADOPTABLEfor the same reason the automatic settle refused. Nothing changes for a record type whose table keeps two rows apart on the connection alone, such asitem. -
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_notesand into the route’s409message now ends with the rows it refused to choose between —… refusing to choose one to discard: <pg_row_id>, <pg_row_id>— 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 still55000and the rejection code is stillSETTLE_TWIN_NOT_ADOPTABLE. -
Order a NetSuite document’s lines by
line_order, not byline_index.line_orderis new on twenty line tables —order_line,invoice_line,purchase_order_line,vendor_bill_line,check_lineand the rest — and it holds the position a person sees in NetSuite, counting from 1.line_indexis 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 online_indexshows the lines scrambled.line_orderis null on a document not yet re-read since this release, so sort on it and fall back toline_indexwhile 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’spo_line_idand an audit row’ssystem_note.line_indexhave 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. Soitem_receipt_line.po_line_id = purchase_order_line.line_indexmatched 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_indexVALUES change. Every NetSuite line last written by the poll or by a realtime save gets a newline_indexwhen 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_lineandcredit_card_charge_linefan their lines across NetSuite’sitemandexpensesublists, and an expense line’sline_indexwas 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 ofline_index=gte.1000no longer selects the expense lines. Useline_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 sameline_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”- Expand
caps_by_verbwhen you decodetb_jwt— re-copysessionClaims()fromsrc/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:capabilitiesholds only the marker"#packed", andcaps_by_verbmaps each verb to its space-separated remainders, so{ "read": "order invoice" }isread:orderandread: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 everyhasCapability()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”-
Expect
turbobase apps deployto record your source now. Run against a directory holding apackage.json, it cuts the version exactly as before and then records that directory as the app’s newest source revision — the same thingturbobase apps pushhas 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 beginningsource 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 oneturbobase apps push <app>. -
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.mdon your disk. The deploy now says which revision that is (learning ingest queued … — reading source rev R); the warning telling you to runturbobase apps pushfirst is left for the deploys that really do record nothing —--no-source, a prebuilt bundle, a failed collection. -
Pass
--no-sourcewhere 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-buildhas 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.<anything>and.mcp.jsonare never uploaded —turbobase apps initwrites a live token into.mcp.json— while.env.exampleis 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_docswith 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 besidesearch_documentation, which still searches the tenant’s own knowledge base; platform questions go to the new one. -
Follow a hit with
read_turbobase_docto get the whole page, passing theslugthe 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 abatch_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=<words>when you are not going through a tool, with&limit=<n>up to 25 (default 8). It needs only thecatalog:readscope your personal access token already carries for/v1/catalog/adopt-notes— no new scope, and no re-issue. An emptyqis refused with400 {"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 answers404carrying{found: false, slug, currentVersion}, and an instance whose build shipped no corpus answers200 {count: 0, results: [], note}on the search andfound: falsehere 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/<slug>/source-fields. Sendfields: [{ name, type, label, record }], whererecordis 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 refused400 field_unresolvablenaming the field and what to change; a field the account already manages is refused409 field_existsnaming it, because this creates fields and never edits them. At most 25 fields per request — a longer batch is refused400 {"error":"too_many_fields","limit":25}, so split a large mapping run rather than sending it in one call. -
Store the
scriptIdthat comes back, not the name you sent. The answer fromPOST /v1/apps/<slug>/source-fieldscarries a solution id and one entry per field, each with the MINTED id the field will actually deploy under — a barelegacy_refon a customer comes back ascustentity_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:createin your manifest’srolesand have an admin assign it. A signed-in session without it answers403 {"error":"forbidden","capability":"source_field:create"}, which the shared client raises as a permission error. A Data admin’swrite:*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/<slug>/source-fields/releases({ solutionId, target: 'auto' }), then pollGET /v1/apps/<slug>/source-fields/<solutionId>. Each field readsdrafted,queued,validating,deployedorfailed; a failed one carries areasonyou can show the person who asked. One release per request — a second ask answers409 release_existsnaming the release the request already has. -
deployedis not yet selectable — wait forpromotionto saypromoted to a column. Adeployedfield exists in the accounting system and still has no Postgres column, so selecting it returns nothing. Each deployed entry fromGET /v1/apps/<slug>/source-fields/<solutionId>carriespromotion, 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 readsregistered 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 atdeployed. Aregistration for column promotion failedmeans the field is created upstream but its column never will be without an administrator. -
targetis your app’s own choice, and onlyautocan succeed from here.POST /v1/apps/<slug>/source-fields/releasestakestarget: 'auto'— the capability an admin assigned you is the authorisation, and the deploy is enqueued — ortarget: 'approved', which an app can never satisfy on its own: it answers403 approval_requiredand marks that release failed, because an app may not name its own approver. So ask forautoin a one-click flow, and if you offerapprovedat all, showapproval_requiredas “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_canonicalrow, with both source records underentity_source_link. A paged read of the identity graph returns fewer rows than it did andPrefer: count=exactreturns 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— notentity_type— to learn what KIND of thing an identity is. A person identity carries theentity_typeof whichever member arrived first,leadorcontact, so dispatching on it tells you about that member rather than about the identity. The newmatch_scopecolumn is the stable answer (personfor 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, andturbobase project typespicks it up on the next run. A member’s ownentity_type, underentity_source_link, still says what that member is. -
Filter on
match_scope, notentity_type, when you list PEOPLE.turbobase:identity_leadandturbobase:identity_contactare two lists over one scope now, and theentity_type=eq.<kind>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. Sendmatch_scope=eq.person(orentity_type=in.(lead,contact)) to list people in one pass. Every other kind is a scope of its own, soentity_type=eq.customerand the rest are unchanged. -
Delete the “no counterpart exists” branch a conduit keeps for a person.
atlas.resolvenow 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
domainwas 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 thepersonscope no longer carry adomainat 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 ownattrs, where it is unchanged. -
A caller holding only
read:entity:contactnow 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_approvalas a heldwrite_status. It is a new value on every mirror table’swrite_statuscolumn and onrecord_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, andrecord_write_state.messagesays so. Code matching on an exhaustive list of statuses will now meet a value it has not seen. -
Stop reading
pendingas “a write is on its way to the connected system”. A write held for approval used to sit atpendingindefinitely — 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 readawaiting_approval. The record’sreason_codecarries which guardrail rule held it. -
Expect one
guardrail_hold_expiredfailure 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 leftblocked. 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, notblocked. A write NetSuite refuses because another record already holds the name — a GL account, an entity, an item — used to landblockedwith 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 readneeds_inputand carry NetSuite’s own sentence inrecord_write_state.message, with an emptyfieldsarray because the refusal names no column.reason_codeis unchanged, so a branch on that keeps working; a branch onstatusalone will see a row move between the two held values. -
Handle two new refusals on
DELETE /v1/data/:recordType. Both arrive as422with{ error: 'validation_failed', rejection: { code, message } }— the shape a guardrail refusal and abefore_deleterejection 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 isrejection.code === 'DELETE_BLOCKED_BY_DEPENDENTS'on the422, and the late one is stillreasonCode === '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
2xxfrom 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 answers422on 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 back422 { code: 'ENTITY_NOT_FOUND', field: 'entity_id' }rather than500 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 whoseattrsomitsisCustomer/isVendor, namingattrs.isCustomer),INVALID_ATTRS(anattrsthat is not an object), andUNSTORABLE_TEXT(a field, or a key insideraw, carrying a NUL or an unpaired half of a surrogate pair). Each names the field. A retry loop that treated the old500as a transient fault should treat all four as final. -
Send
entity_pg_row_idwhen you create an entity and a transaction together.entity_idis 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 theentity_pg_row_idsibling to thepg_row_idthe 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) answers422 { code: 'ATOMIC_WRITE_REQUIRED', field: 'lines' }instead of200. 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 carryingcreated_from_idis exempt, as it always was, and an UPDATE that omitslinesstill preserves them. -
Stop creating
github:pull_requestrecords. A create answers422 { code: 'EDIT_ONLY' }, the same waygithub:labelalready 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 the200was followed by a write-back failure. Editing a mirrored pull request’s title, body and state is unchanged. -
Create journal entries.
POST /v1/data/journalEntrynow works; every create was refused by the database before, becausejournal_entry.hashwasNOT NULLand no caller could supply a value for it. Xero’smanualJournalwrites the same table and is fixed with it. -
Update an app-owned table through the MCP
write_recordtool. An update addressed bypg_row_idto one of your own manifest-declared tables now succeeds, instead of answering400 record type "…" is not writablefor a tablelist_record_typeshad just published aswritable_now: true. Rows created before 2026-10-06 are the ones that were affected. -
Expect a
linesrefusal to talk about YOUR connected system. TheLINES_NOT_SUPPORTEDmessage 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, thefieldand the remedy — write the parent, take itsexternal_id, post each child to its own record type — are unchanged. -
Handle
-32002from atools/callas “repeat it, or ask for less”. A tool call that outruns the instance’s own eight-second deadline now answers that code withdata.retryable: trueand 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-32001because the remedy differs:-32001means send the same call again,-32002means send it again or narrow it — a smallerpage_size, a tighter filter, fewer items in onebatch_read. -
Widen any check that reads
-32001as “the connection pool was busy”. An upgrade window now answers-32001too, withretryable: trueand 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 largeapps pushand this is only ever reached by a request nobody is still sending. The answer carriesstatusCode,error,messageandRetry-After. -
Expect an unexpected failure under
/v1/*to answer a redacted 5xx carrying acorrelationId. 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, therecord_write_state.reason_codeyour app reads is nowsource_duplicate_recordinstead of the source’s generic deterministic code (NS_ERRORon NetSuite). The status staysneeds_inputand 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
customercreate to link rather than duplicate. APOST /v1/data/customerwith noexternal_idnow 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 setsexternal_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 withreason_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 reacheswrite_status = 'synced'on its own and no longer needsPOST /v1/data/:recordType/settlerun 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
expirationdateon an inventory assignment — so a lot you receive withraw.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_idon any item record and have it reach the connected system.item_idon an item is that item’s own code there, and it was only sent for four of the twelve item record types — on anassemblyItem,discountItem,nonInventorySaleItem,otherChargeSaleItem,lotNumberedInventoryItem,serializedInventoryItem,lotNumberedAssemblyItemorserializedAssemblyItemit 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 DIFFERENTitem_idon one of those eight was silently having it dropped and will now rename the item. -
Send a payable line coded by
category_idalone and have it accepted. A line on acheck,vendorBill,purchaseOrderorvendorCreditcarrying a category and neitheraccount_idnoritem_idnow 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 carriesitem_id, or that declares its ownline_kind, is unchanged. -
Delete records from a process with a
deletestep. Declare one as{ id, recordType, delete: { externalId, sourceSystem } }— orpgRowIdinstead ofexternalIdwhere you hold our row id. Both address keys are functions of the step context ((ctx) => ctx.input.externalId), the same shapeupdatetakes, 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
buildon 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 adeleteblock and abuildis refused at deploy time naming the field to remove, and so is one declaredrollback: 'undo': a delete cannot be undone, so a delete step is alwaysrollback: '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,pgRowIdandexternalIdon each step entry ofGET /v1/processes/runs/:runId, exactly where a write step’s outcome already lands. Those are the route’s names; through PostgREST the same rows areprocess_run_step, whose own columns arerefusal_code,refusal_field,refusal_message,pg_row_idandexternal_id. A removed record readsconfirmed; a refused one readsfailedcarrying the refusal’s own code and sentence, whether the refusing party was your guardrail, abefore_deletehook 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
refusalon aconfirmeddelete 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 — readsconfirmedwithrefusal: { code: 'SOURCE_AWAITING_CONFIRMATION', … }: the record is hidden in Turbobase and queued, and the connected system has not been asked yet. Every otherconfirmedleavesrefusalnull. The run does not wait for the approval and does not fail; if your cleanup is meant to run unattended, add anallow_with_auditguardrail 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 --prunenow 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 fromturbobase/schedules.jsonin 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/applyused 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 exportto bring your schedules back.GET /v1/config/exportnow 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 keepingturbobase/schedules.jsonby 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.jsonyou exported BEFORE this release, before your next deploy. The old export wrote them intoturbobase/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 fromturbobase/bundle.json(orturbobase/schedules.json); a copy already written is a bundle schedule, so removing the entry and re-running with--prunedeletes it. -
Stop setting
TURBOBASE_DEBUGto read the end of a refusal — and widen any script that matches on one. A refusal the instance wrote ({ error, reason }—invalid_bundlefromext deploy/ext promote,extension_invalidfrom 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
namestarts withsystem.. 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 containssystem.is fine. -
Restart your local instance after adding a recipe or an anatomy file.
/v1/catalog/recordsand/v1/catalog/records/:typeused 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 asources/<source>/recipes/<record>/<intent>.jsonor asources/<source>/skills/anatomy/<record>.mdyou 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/commentused to be refused422 APPEND_ONLY; it is now accepted and corrects the reply in place, on an issue, a pull request or a discussion alike. Address it byexternalId(orpgRowId) and send the new wording inbody; 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 GitHubissue,pull_requestorlabeldelete is still refused, and the refusal now names what to do instead — close the issue, or remove the label in GitHub — in a422 DELETE_UNSUPPORTEDrejection, where it used to be a400saying 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_messagewith anexternalIdset. An addressed Slack write used to be accepted and POSTED as a second message; it now edits the message in place throughchat.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 anexternalIdinstead. An id Turbobase did not mint (aconduit:…placeholder, say) is now refused rather than posted. -
Expect
deleteongithub:commentfromlist_record_types. Theopsa 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
tablesarray — 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 with422 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”-
Make the first
id: '…'literal in every hook, conduit and process file that extension’s own id.turbobase ext deploy(and--to <env>) now refuses a file whose first literal names something else — a step, a constant, a comment — withextension_id_mismatch, and stores nothing;turbobase ext lintflags it first. A copy an older instance stored under the wrong id still exists: find it withturbobase ext plan --to <env>and delete it withturbobase ext remove <id>. -
Expect an
eachRowprocess to act on every visit to a row, and keep youroutstandingpredicate 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 runswhen/buildagain. Create steps still run once per rowkey, and a crash re-delivery still resumes without writing twice.GET /v1/processes/runs/:runIdstep entries now carryrowKeyandslice, with one set of entries per visit. -
Add the
write-a-processskill to an existing project: re-runturbobase apps init <app> .in it — it writes the missing.claude/skills/write-a-process/SKILL.mdand 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_conflictrecord fromrecord_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 setexternal_id, which the write boundary refuses. -
Send the
POST /v1/processes/:idbody bare — it IS the input. A body wrapped as{ "input": { … } }reachesctx.inputandeachRow.outstandingwrapped. -
Start a process from a terminal or a script with
turbobase processes invoke <process-id> --input '<json>'(or--input-file <path>; add--syncto wait for the outcome). It needs a personal access token carryingextensions:deploywhose 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/:idnow accepts that same token as a Bearer. A token owned by a non-administrator is refused403 admin_required, and one withoutextensions:deployis refused403naming the scope — branch on those instead of the old401 access_token_not_accepted. -
Make the
inputof every{ "kind": "process" }entry inturbobase/schedules.jsona JSON object that passes the process’sinputschema. Scheduled runs now validate it the wayPOST /v1/processes/:iddoes: 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(orturbobase processes rollback <runId>). It deletes, newest first, only the records that run’s ledger shows it created — never one anupdatestep changed — and answers202. A run stillqueuedorrunninganswers409; one alreadyrolled_backanswers200withqueued: false. PollGET /v1/processes/runs/:runIduntil it readsrolled_backorfailed. -
Stop sizing
guardrails.max_deletes_per_*for a process rollback. A run’s own rollback deletes now count againstguardrails.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_backrun’sreasonbefore assuming the source is clean. It names the source deletes still being sent; a delete the source refuses restores its record and turns the runfailedwith the refusal named. -
Link a
write_settle_conflictrow withPOST /v1/data/<recordType>/settle(body{ "pgRowId": "…" }), orturbobase data settle <recordType> <pgRowId>from a terminal, instead of asking support. It links the row to the id on itsrecord_write_state.external_id— never one you send — and answers200 { pgRowId, externalId, adopted: true, discarded }. Where your app shows a blocked row withreason_code = 'write_settle_conflict', offer this in place of “try again”, and still never re-save the row. -
Handle the two new
409s from that route:NOT_SETTLE_CONFLICT(the row is not in that state — nothing changed) andSETTLE_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”- Drop any fixed delay between saving a file and sharing its link, and keep a
retry on
404 content_unavailableinstead. A file created throughPOST /v1/data/fileis readable atGET /v1/data/file/{external_id}/contentas 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_unavailableas “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”-
Branch on
write_status: 'needs_input'for an e-signature request the connected system refused over its own content, where you only handledblocked. 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 settleblockedwith 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 settlesneeds_inputwithreason_code: 'SOURCE_REJECTED'andrecord_write_state.messagecarrying what DocuSeal asked for, and itssync_failure.categorymoves fromsource_blockedtosource_validationwith it. Render a correct-and-save prompt from the message rather than a support notice — and do not gate that prompt on arecord_write_state.fieldsentry, because this refusal names no column and the sheet is empty by design. Every other connector’s fieldless refusal still keepsblockedand the generic hold. -
Add
queryRowsto a replay fixture to exercise a conduit that reads. A conduit declaringreadscan now be replayed offline: put aqueryRowsmap in the fixture JSON, keyed verbatim by the PostgREST path-and-query string your transform passes toctx.query, andturbobase ext replay --fixtureserves those rows to the isolate. A key you did not supply answers[], and--traceprints one line per read — the string asked for, whether the fixture served it, and the row count — so run it once with noqueryRows, 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:--tracenow prints the whole walk on a failed replay, with the error as its last line, where it used to print the error alone. Thefrom.whengate gets the read too. Rows are served only to a conduit that declaredreads; one that declared none still has noctx.query, offline exactly as deployed. Replay does not mirror the declaration refusals (an undeclared resource or column, a filter outsidereads[].by, the per-run budget) — those remain the engine’s. -
Read a GetSales custom field at
raw.gscf_<field id>oncontactandcompany. Each value the workspace holds now arrives with the record and lands at its own top-level key ofraw, soentity?select=external_id,raw->>gscf_<field id>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_fieldscarries 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_fieldsto change one, and expect a 422 for a field the workspace does not have. A write toPOST /v1/data/contactor/v1/data/companysends 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 withUNKNOWN_CUSTOM_FIELDnaming 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 bysync.bidirectional.getsales, which still ships OFF. -
Redeploy any process that
turbobase ext deployrefused with400 process_step_not_writableon a record typelist_record_typesreportswritable_now: true.POST /v1/extensionsasked 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 barerecordType('account', not'netsuite:account'); to pin the system, returnsource_system: '<system>'in the step’sparent. 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”-
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 ownsource_systemrather 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
pgRowIdwherever anexternalIdis not unique to one connection.DELETE /v1/data/{recordType}?externalId=…now answers422 SOURCE_AMBIGUOUSwhen 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_idis 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 back404. -
Send
sourceSystemon a delete when two of your connected systems spell one record type the same way.DELETE /v1/data/{recordType}takes a new optionalsourceSystemquery parameter (source_systemon the MCPdelete_recordtool), and it is the answer to422 SOURCE_AMBIGUOUSon the RECORD TYPE — today onlyaccount, 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 answers404rather 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_CONNECTEDrather than being queued to the system the session belongs to; and a record whose own system cannot be read at that moment answers422 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_failureyou 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_loston a workflow-dispatch row as “a build may already be running”. APOST …/dispatchesthat GitHub answers with a5xxor a429now parksrecord_write_stateatblockedunder that reason code on the FIRST attempt, with a message naming the repository, the workflow and the ref. It used to staypendingthrough six retries — each one a second real build in the customer’s repository — and then park underwrite_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_<status>, not onwrite_failed_unexpectedly. A GitHub5xxor429on any write-back now filessync_failure.classificationnamed 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 a5xxor a429now endsrecord_write_stateatblockedunderHTTP_503/HTTP_429instead, once the retries are spent, and filessync_failure.category = 'source_blocked'rather thansource_errororrate_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. Readmessagerather 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_codevaluesource_record_type_unknownonrecord_write_state. A write to a record type the connected system has no record of now landsstatus: '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_erroron NetSuite) with “Syncing is held — your team has been notified.” An app that branches onreason_codeshould treat it like the other blocked codes and show the row’smessage: nothing on the record releases it, andfieldsis always empty. -
A write for a record type the connected system does not have is now REFUSED BEFORE IT IS SENT.
POST /v1/data/:recordTypeand 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 acustomrecord_*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
inlinefromGET /v1/data/file/<external id>/contentfor images, video, audio and PDF, and add?download=1wherever you meant a download. The route used to answercontent-disposition: attachmentfor 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)fromsrc/lib/turbobase.tsto build a file’s URL, rather than composing the path. It takes the mirrored-file ROW and adds?connection=<id>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
<img>or<video>source to it directly, and seek withRange. A ranged request answers206withcontent-rangeandaccept-ranges: bytesand is fetched from the source as a window, so a seek into a long video no longer moves the whole file; a range past the end answers416. Responses carry the file’setag, so a revisit withIf-None-Matchanswers304without re-reading the connected account.cache-controlisprivate, no-cache, so nothing is shared between users. -
Keep sending a non-HTML
Accepton your ownfetchcalls. A request with no session and an HTMLAcceptis now302-redirected to/loginand back to the file, for a person clicking a stored link from outside Turbobase. A program asking forapplication/json,*/*or an image type still gets the unchanged401envelope, so nothing that parses that envelope changes. -
Declare a record capability on any record
/v1/catalog/recordspublishes — an administrator can now grant it. The role editor offered eleven record types out of the eighty-nine the catalogue gates on a read or write capability, so a manifest declaringread:print_job— or any of the other seventy-eight — installed cleanly, was enforced by RLS, and could not be granted: the read failed and there was no row on the role editor to tick. The editor derives its list from the same catalogue your app reads now, and shows a capability your manifest declares even when it is not a catalogued record. If you told customers to grant a wildcard (read:*) to work around this, you can stop; ask for the record itself. -
Put a process on a schedule instead of pressing it — add a third body kind to
schedules.json. An entry whose body is{ kind: 'process', process: '<id>', input? }queues a run exactly asPOST /v1/processes/<id>does, and the scheduler hands off immediately rather than holding its lane for the length of the run.turbobase ext deployrefuses an entry naming a process it cannot find, so a typo is a deploy error rather than a schedule that silently does nothing. Writing one needs the administrator role: the instance refuses aprocessschedule from anyone else, in the database, the same way it already refuses asystem_job. -
Stop re-invoking a long
eachRowprocess — it carries on by itself. A slice that ends with rows outstanding queues the next one, whichever trigger started the run, until nothing is outstanding or the run reaches its slice cap. A run that stops at the cap ends with a reason naming it, so “stopped early” and “failed” are no longer the same ledger row to read. -
Ask the instance’s own model from your app with
ai.complete({ prompt, tier })out ofsrc/lib/turbobase.ts— the same call is exported flat asaskModel. The call is a passthrough — none of the platform’s skills, grounding or system prompt is added on the way out — and you choose a TIER rather than a model id, so the instance decides what it pays for.askModelsends your app’s slug for you (from the page’s/a/<slug>/path) because the allowance and the usage row are both per app; against the raw route,POST /v1/ai/completerequiresappin the body and answers400 app_requiredwithout it, and404 ai_app_not_foundfor a slug that is not an app deployed on the instance. The spend lands in the instance’s usage underapp:<your-slug>— a preview spends its own app’s. Every call must also fit one instance-wide window shared by all apps (four times the per-app numbers); a429from that one carriesscope: 'instance'. A view-as session is refused with403 view_as_session: it is read-only. -
Re-copy
src/lib/turbobase.ts, or add the new members by hand.turbobase apps initnow writesai.complete(andaskModel),invokeProcess,processRun,cancelProcessRunandappSlug, plus aTurbobaseAiLimitErrorfor a refusal on a model limit. An app project scaffolded before this release has none of them — nothing you already use changed, so a copy is additive. -
Mint a token carrying
ai:invokeif a PROGRAM is calling the model. Your app’s own screens need nothing new — they call under the session cookie the user already holds.ai:invokeis opt-in and is in no token kind’s defaults, so a token you minted before this release cannot call it and answers403 insufficient_scopenaming the permission. -
Start and watch a process from your app with
invokeProcess(id, input),processRun(runId)andcancelProcessRun(runId). Invoking answers a run id rather than an outcome, so closing the tab does not stop the work; the run read carries the state, the reason it ended and what it did not get to. A cancel is a STOP and not an undo — the run finishes the step it is on and what already went upstream stays. -
Tell your customer WHICH of their people spent the tokens — read
actoronllm_usage, notuser_id. Every AI call your app makes now records who asked, as plain text:user:<their source user id>for somebody signed in from NetSuite,cli:<id>for a token,user:<uuid>for a native session.user_idis a uuid and is still null for anyone signed in through the source bridge, which is most of your users — so a screen attributing spend byuser_idalone showed all of them as unattributed.actoranduser_labelridev_llm_usage_by_operationandllm_usage_timeseriestoo, the admin-only reads behind the usage panel. -
Expect
429from the model proxy, and readretryAfterMsoff it — or catchTurbobaseAiLimitError, whichaskModelthrows for the same thing withlimitandretryAfterMson it. The instance limits tokens per minute, tokens per call and tokens per day, and refuses a tier it does not allow.retryAfterMsis null when waiting would not help: a prompt over the per-call ceiling, or a disallowed tier. The limits are checked before the model is asked, so a refused call costs nothing — and an unconfigured or paused model still answers the503 llm_unavailable/503 ai_budget_pausedenvelopes you already handle. -
Declare your app’s own, lower model ceiling in
public/turbobase.jsonunderai: { tokensPerMinute, maxTokensPerCall, tokensPerDay, tiers }— and expect it to BIND.turbobase apps planand the deploy refuse a number above the instance’s, naming the limit, and report the EFFECTIVE one; every call then enforces the lower of the two, read from your deployed manifest, so a number you lower takes effect on the next deploy rather than only being reported. Declaring nothing is unchanged — every app deployed today keeps working. -
Ask the model from inside a process with an
ai: { tier, prompt }step, and read the answer out ofctx.steps. The ENGINE makes the call, underprocess:<id>, and records it on the run’s ledger with its tokens; the step’s ownbuildstays pure and has no way to reach a model or the network. A later step reads the answer asctx.steps['<stepId>'].answer, which is a new member beside the{ pgRowId, externalId, disposition }an earlier step already carried — so code that switched exhaustively on that triple now meets a fourth key. -
See and stop your processes from a terminal:
turbobase processes list,processes runs <id>,processes run <runId>andprocesses cancel <runId>.GET /v1/processesis the read behind them — every deployed process with its trigger, its cron and timezone where it has one, whether it is switched on, and its last five runs, scoped by your own row-level security. Follow a run withturbobase logs --process-run <runId> --follow, on the one log verb rather than a second one, and it exits when the run reaches a terminal state. -
A run can now be stopped or capped by the instance, so handle a run that ends short. An administrator can switch one process off or all of them off; a run also stops at its next step boundary when it passes its maximum runtime or its maximum writes. Every one of those endings carries a reason naming the limit or the switch, and an invoke refused by a thrown switch answers
409naming it. How many runs one instance may have going at once is now an instance setting rather than a deployment’s env var — a run over the limit waits and shows as queued, and is never refused or dropped. -
Stop driving a multi-document sequence from the browser — describe it once and let a process run it. Four additions to the authored shape in
@turbobase/extensionscover the sequence that needed a tab open until now: a step that CHANGES a record, a group of steps that repeats, a step that declines, and a step that says a failure at it must not undo the chain. They are all optional, so a process you deployed today parses, deploys and runs unchanged. -
Declare
update: { step | pgRowId | externalId, expectedSyncVersion }on a process step to CHANGE a record instead of creating one.stepaddresses what an earlier step of the same run landed, by its id;pgRowIdandexternalIdaddress a row the run did not create, and both take a function of the step context. The write goes through the same boundary as a create, so your hooks, guardrails and write RLS all apply and the refusal you get back on the ledger row is the boundary’s own — a404for a row the invoking user cannot see, a409whenexpectedSyncVersionhas moved. -
Repeat a group of steps per input element with
forEach: { input: '<property>' }and nestedsteps. Inside the group,ctx.itemis the element andctx.indexits position; each iteration’s ledger rows are named<groupId>:<index>:<stepId>(leg:0:doc), and that is the spelling a later step uses to read one out ofctx.stepsor to address one withupdate: { step: 'leg:2:doc' }. One run may repeat a group at mostDEFAULT_PROCESS_FOR_EACH_MAX_ITEMS(100) times — over that, the run is refused before the first write, so move the work ontoeachRow, which the platform paces a slice at a time. -
Let a step decline with
when: (ctx) => boolean, or by returningnullfrombuild. Either way the step is recordedskippedand the run carries on and endscommitted. Abuildreturningnullused to fail the step withSTEP_BUILD_FAILEDand abandon the rest of the run — if you worked around that by returning a throwaway payload, you can stop. Awhenthat THROWS still fails the step: a broken condition is not afalse. -
Read
skippedonprocess_run_stepas “not attempted”, for either reason. It now covers a step the run never reached AND one that declined throughwhenor anullbuild. No new disposition value, so nothing that switches on the column breaks — but a viewer that showedskippedas “abandoned after a failure” is now telling your user the wrong thing. -
Put
rollback: 'keep'on a closing step so a failure there does not delete the chain it was closing. The default is'undo'— today’s behaviour, and nothing changes without the declaration. It is the FAILING step’s setting that decides whether the run rolls back at all, andkeepalso spares that step’s own record from a rollback some other step caused. -
A rollback no longer deletes a record an
updatestep merely changed. UnderonFailure: 'rollback'the run undoes only what it CREATED; an update step’s ledger row staysconfirmedand the record it addressed is left exactly as the step left it. The prior values are not restored — the ledger is what says which steps changed what. -
A process you DEPLOYED can finally read
ctx.row. A migration whose step read the source row out ofctx.row— the waywriting-processes.mddocuments — raisedSTEP_BUILD_FAILED: Cannot read properties of undefinedon every run, because the engine never passed the row into the isolate. If you worked around it by reading the row out ofctx.input,ctx.rownow carries the whole row theeachRow.outstandingread returned. -
Derive a received quantity from the item receipts, not from the purchase-order line. There is no
purchase_order_line.quantity_receivedand there will not be one: the source computes it, gives it no setter, and does not reliably refresh it when a receipt is posted, voided or edited without the order being touched, so a mirrored copy would be stale before we ever read it. Sum the receipt lines instead —item_receipt.created_from_idis the order’sexternal_idanditem_receipt_line.po_line_idis the order line’sline_index(textagainstinteger, so cast one side). Take magnitudes on both sides, and read0receipt rows as “none arrived”. The same holds for billed and fulfilled quantities, which are sums overvendor_billanditem_fulfillment. -
Name the constraint when you embed
item_receipt_line.item_receipt?select=*,item_receipt_line(*)fails withPGRST201and HTTP300, because that line table reaches its header through two foreign keys. Writeitem_receipt_line!item_receipt_line_parent_pg_row_id_fkey(...)— always theparent_pg_row_idone, since the source-id column is nullable and silently drops lines that have not synced.purchase_order_lineneeds no hint. -
Stop guessing a column name off a sibling relation. Every literal PostgREST read written inside the instance is now resolved against the migrated schema when we build, so a read naming a column no database has cannot ship from here. Your own app’s reads are not covered by that check —
purchase_order_line.sync_versionandcustomization_snapshot.run_iddo not exist and never have (the concurrency token is on the HEADER, and that snapshot is keyedsnapshot_id), and PostgREST fails the whole select on one unknown column, so such a read returns nothing at all rather than the rest of the row.GET /v1/catalog/recordsis the list of what you can read.
v1.37.0
Section titled “v1.37.0”- Reach your instance from a program with an access token: mint one carrying
rest:read, andrest:writetoo if it has to change anything. An access token presented asAuthorization: Beareris now accepted on the data routes, and the request runs as the administrator who owns the token — their row-level permissions, their capabilities, your hooks, your guardrails and the onward sync, exactly as for a change made in the app. Nothing an existing token can do has changed: neither permission is granted by default, so every token minted before this release carries neither. - Read with
POST /v1/batch, not/rest/v1. The batched read takes the token and gives a program the same PostgREST query grammar an app’s own client uses —{"queries":["print_job?select=external_id&status=eq.queued"]}— returning precisely the rows the token’s owner would see./rest/v1is served straight by PostgREST, which takes a browser session only and does not accept a token; nothing about that changed. - Fetch an attachment with
GET /v1/data/<recordType>/<externalId>/contentandrest:read— notrest:write. It is the one route under/v1/datathe read permission opens, so a download-only program needs no write permission; the owner’s file capability and row-level permissions still decide what comes back. - Write with
POST /v1/data/<recordType>, plus the upload route beside it andDELETE /v1/data/<recordType>.expectedSyncVersionstill decides the write, so two programs claiming the same row cannot both commit — the second gets a409 conflict. - Expect
403with{"error":"insufficient_scope","reason":"missing_scope", "required_scope":"rest:write"}when a permission is missing, and401when the credential is not accepted at all. Branch on the two: the first is “tick another box on the token”, the second is “this route is not open to a token, or the token is expired, revoked or unknown”. - Mint the pair together.
rest:writeon its own is refusedwrite_requires_read, and the mint dialog now shuts the write box until the read box is ticked (the MCP pair behaves the same way, from the same rule). A token minted withrest:readdefaults to a 7-day lifetime rather than 90, asmcp:dataalready did; type a longer one at mint if a job needs it. - Nothing else opened. Which handlers a token may reach is a committed list
(
src/auth/rest-register.ts) with a fence over it in both directions, so the console’s own endpoints, every credential and session route, and the surfaces that already have a permission of their own are all still closed to these two.
v1.36.0
Section titled “v1.36.0”- Register the printers your own print agent serves: write
printerwithsource_system: 'standalone'(POST /v1/data/printer, orwrite_recordover the data MCP). Sendname,description,state,computer_name,is_default, the newqueueit serves, and what it can print —document_formats(any ofpdf,raw,png,jpeg,text) andraw_languages(zpl,epl, …). Do not sendexternal_id; it is refused. The writer needswrite:printer(delete:printerto retire one), which no built-in role but Admin and Data admin carries — grant it in a custom role. Printers a print service mirrors in stay read-only. - Name a standalone printer on a job by its
pg_row_id: put the id the printer create returned intoprint_job.printer_external_id. A mirrored printer is still named by itsexternal_id. - Read a job with its printer in one request:
print_job?select=*,printer(*)(to-one;nullwhen the job names no known printer), and a printer with its jobs withprinter?select=*,print_jobs(*). The link stays soft — a job outlives its printer being removed.
v1.35.0
Section titled “v1.35.0”- Rename the
printJobcolumns you write:content_typebecomesdocument_format(+raw_language), andprinter_idbecomesprinter_external_id.document_formatis what the document is —pdf,raw,png,jpegortext— andcontentis always base64, so the encoding is no longer part of the name:pdf_base64→pdf,raw_base64→rawwithraw_language: 'zpl'(orepl,escpos, …). PrintNode printspdfandraw; the other three are refused on the row withformat_not_supported. The create’srequired_inputsonprintnode:printJobare nowprinter_external_id,document_formatandcontent, and a missing format answers422 NO_DOCUMENT_FORMAT(wasNO_CONTENT_TYPE). - Read
status_messageandretry_afteron a print job, notupstream_messageandwindow_resets_at. Same facts, renamed for any print service.reasoncontent_not_encodedis nowcontent_invalid, andformat_not_supportedandagent_errorare new. - Show a PrintNode job as “sent to the printer” while
statusissent. An accepted job used to sayqueued; it now sayssentuntil the poll settles itsucceededorfailed, andcompleted_atis stamped when it does.queuednow means “nobody has taken it yet”. - Use
copies,optionsandqueueinstead of putting them inraw.copies(1–999) becomes PrintNode’sqty;optionsis passed to the print service as given;queueis the routing key an external agent claims by. - To have your own program print, create the job with
source_system: 'standalone'and claim it withexpected_sync_version. A standalone job is never sent by Turbobase — it is a pull queue. Namesource_system: 'standalone'on EVERY write, updates included, and never use theprintnode:printJobname for one (that makes a PrintNode job). Over MCP this needs a PAT withmcp:dataandmcp:write. An agent listsqueuedjobs on itsqueue(andclaimedones whoselease_expires_athas passed), claims one by writingstatus: 'claimed',claimed_by,claimed_at,lease_expires_atandattemptswith thesync_versionit read asexpected_sync_version— a409/ conflict means another agent won, so move on — then writessucceededorfailedwithcompleted_at. Aclaimedrow withoutclaimed_byandlease_expires_atis refused by the database. The protocol is in the print job anatomy; it needswrite:print_job, which is still outside the standard bundle (turbobase-dev#3461).
v1.34.0
Section titled “v1.34.0”-
Declare a role’s scopes in
public/turbobase.json—roles[].scopeswas a deploy error until this release and is now accepted:{ "name": "Receiver", "capabilities": ["read:item_receipt", "read:memory"], "scopes": { "content": ["slack:C0PUBLIC"], "location": ["{{user.location}}"] } }.contentis a GRANT — the exact containers ofmemory_documentthe role may read. Every other key is a row RESTRICTION, the same thing an admin authors in Admin → Roles:subsidiary,location,department,class, or any name matching^[a-z0-9_]{1,63}$, with a whole-value{{user.<field>}}token narrowing each holder to their own value. A restriction on a dimension no table on the instance maps is legal and restricts nothing, andturbobase apps planwarns about it.appRoleis still refused. Runturbobase apps plan . --app <slug>after addingscopes— with the slug it prints what each role would gain or lose and what a deploy would retire ("roles": []included), writes nothing, and exits non-zero on a conflict. -
Pair
contentwithread:memoryon the same role, or it reads nothing. A non-admin reads amemory_documentrow only when the role they are ACTING AS holdsread:memoryAND lists that row’s container exactly incontent.read:memoryalone reads no rows, andcontentwithoutread:memorygrants nothing — the deploy warns on either half missing rather than failing, because neither can widen anything. Someone holding several roles reads a granted channel only while acting as the role that grants it, so make that role their default. A granted Slack channel’s history is readable whether or not the holder is a member of the channel, or a Slack user at all, private channels included; Pilot is unchanged. Gate your screen onhasCapability('read:memory')and expect an empty list, not an error, when the role carries no value for the channel you query. -
Add
write:activity:message— notwrite:activity— to the same role to let it post into its granted channel, and sendraw.channelas the channel ID inPOST /v1/data/channel_message. A role holdingwrite:activity:messageandslack:<channel id>incontentcreates achannel_messagein that channel whether or not the holder is a member of it or a Slack user at all, private channels included; it goes out as the workspace’s Slack app, like every post, and still needssync.bidirectional.slackswitched on for the instance.write:activity:messageadmits that one act and nothing else: no channel the role is not granted (a public one included), no direct message or group DM, no channel NAME (a grant is an id — 403), no other kind of Slack row, no other source’sactivityrow, and no edit or delete of anything. It does not satisfywrite:activity, andwrite:activitydoes not satisfy it:write:activityis every write on the sharedactivitytable — Zoho CRM tasks, calls and meetings and FeedHive posts, all written back upstream, edits of any existing row, and a post into every public channel — so never give it to a role just so it can chat, and expect a role holding onlywrite:activityto post exactly as before (where its user may see the channel) and never through a content grant. In a private channel they are not in, the holder cannot edit or delete a message and reads none of the channel’schannel_messagerows — not even their own post — so keep your own copy of what the app sent, and expect a retried create whoseidempotencyKeyalready landed to come back500 write_failedfor them rather than replayed (nothing is posted twice). Members and admins post exactly as before;write:*(Data admin) satisfieswrite:activity:messageas it satisfies every write. A role declaringwrite:activity:messagewith noslack:value incontentposts nowhere, and the deploy andturbobase apps planwarn about it. -
Read
author_user_idon a Slackchannel_messageyour app posted as the app user who posted it — the instance now sets it, and ignores a value in your payload. Every Slackactivityrow created throughPOST /v1/datais stamped with the posting session’s user id, whateverparent.author_user_idsaid; rows synced from Slack still carry none. -
Expect a capability an admin removed from your declared role to stay removed — a redeploy no longer puts it back. A deploy still adds every declared capability the role is missing, except one an admin took off the role (in Admin → Roles, or anywhere else) and has not granted back, and except a
<verb>:*wildcard that would grant such a capability again. The deploy reports each askeptRemovedCapabilitieson the role’s verdict, and the CLI printskept (admin removed): …under the role, on every deploy that declares it — not only the first — until an admin grants it back. If your app needs it, ask the admin; editing the manifest does not change the outcome. -
Write each
contentvalue asslack:<channel id>— Slack is the only source whose containers can be granted, at most 16 values per role, with no wildcard (*) and no{{user.…}}token. Any other source prefix (granola:,google_workspace:,standalone:…) or a malformed value fails the deploy naming the grantable sources. Direct messages and group DMs can never be granted, and a channel the workspace sync has not mirrored grants nothing until it is — the deploy warns. Values are de-duplicated and sorted, so their order in the manifest means nothing. -
Make a new source grantable in TWO places in the platform, or it grants nothing: its line in
GRANTABLE_CONTENT_SOURCES(src/auth/role-grammar.ts) AND a new migration that re-createsturbobase.grantable_content_sources()with the same list. The database re-checks every storedcontentvalue against its own list on every read, so the TypeScript line alone lets a manifest and the role editor save the value while it reads no rows;src/test/grant-dimensions.fence.test.tsfails until the two lists agree. Until then a manifest can only grantslack:values. -
Know what a redeploy does to scopes before you trim a manifest — unlike capabilities, your own scopes can be taken back. Omitting
scopes, or one dimension inside it, changes nothing on the role. Forcontent, a redeploy adds the values that are new since your last deploy and removes the ones you stopped declaring — only values your app ADDED, and not while another app on the same role still declares them;"content": []retires every value your app added. A value an admin added — one your app did not put there — is never touched (see the next bullet). A value an admin removed is never re-added by a deploy — not while your app keeps declaring it, and not after it stopped ("content": [], the role dropped fromrolesfor a version, the app deleted and reinstalled): the instance remembers the removal on the role, and the deploy reports the value as kept removed until an admin puts it back in Admin → Roles. Dropping a role fromroles, shipping"roles": [], or deleting the app retires thecontentvalues your app added on that role and leaves its restrictions exactly as they are. A restriction belongs to your app only when your deploy wrote it — on a role it created, or on one that was unrestricted on that dimension, which narrows what the role’s current holders see. Your app may narrow, widen or clear ([]) a restriction it wrote, as long as no admin has changed it since. Once one has, the admin’s value stays and the deploy says so — and if the admin’s value is wider than the one you declare, a deploy that ADDS capabilities to that role is refused, because they would reach rows your declaration keeps them from. Declaring a restriction your app never wrote on a role that already carries a different one, or one another app declares differently on the same role, is a CONFLICT: it refuses the whole deploy, capabilities included, naming the role and the dimension, because shipping the capabilities without the restriction would widen the role. Set the value in Admin → Roles (the next deploy adopts it) or give the app its own role. Preview deploys report these changes without applying them; promoting applies them.turbobase apps plan . --app <slug>reports them too, the values"roles": []retires included. On a role that already has holders, the report says how many people each added channel and each restriction it sets reaches. A removedcontentvalue stops applying on the holder’s next query; anything the role gained reaches a holder when they sign in again. -
Treat a value your deploy ADOPTED as the admin’s, not your app’s — removing it from the manifest later will not take it off the role. A deploy that finds a declared
contentvalue or restriction already on the role records the declaration but does not own the value: nothing is written, the report prints it asadopted(once, on the deploy that first finds it), and no later manifest of yours can retire thatcontentvalue, or clear ([]leaves it) or change (a different value is a conflict) that restriction. Only an admin can, in Admin → Roles. Your app owns only what its own deploy put on the role. -
Never commit a customer’s channel id in an app that more than one account deploys — add it to the manifest in that account’s own deploy instead. A shared app ships its roles with
read:memoryand nocontent(or noscopesat all, which changes nothing on redeploy), and each account’s deploy joins its ownslack:<channel id>intoroles[].scopes.contentfrom configuration it keeps in its own repository. An app built for exactly one account may name that account’s channels directly. -
Pass the app’s slug to every plan —
turbobase apps plan . --app <slug>, or"app": "<slug>"in aPOST /v1/apps/planbody — and add--app "${{ vars.TURBOBASE_APP_SLUG }}"to theturbobase apps plan .step in.github/workflows/turbobase-app-deploy.yml. New scaffolds carry it, and nothing rewrites a workflow you already have. The slug is how the plan tells your app’s earlier role declarations from another app’s, as the deploy (which always knows the app) 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 (ownership of scope "…" was not evaluated), the deploy decides it and may still refuse, and nothing the deploy would retire is listed. Every other dimension is planned exactly, slug or not. With it, a plan body that carries"roles": []also lists what that retires. The CLI sendsrolesonly when your manifest has the key;--appand--as app:<slug>must name the same app. -
Upgrade the
turbobaseCLI before you deploy, preview, promote or plan a manifest whose roles carryscopes— an older CLI prints a scopes-only change asok … already up to date. That is how it reads the newupdate_scopesverdict, including one that REMOVES acontentvalue from a role with live holders, and it prints no adopted values, no warnings and no retirements. The current CLI prints all of them on all four verbs, and prints any verdict action it does not recognise aschange … (upgrade the CLI to see details), never as up to date. -
Send
expectedUpdatedAt— the role’supdatedAtfrom your last read — when a scriptPATCHes/auth/admin/roles/:idwith a whole grant, and on409 role_changedre-read the role and apply your change again. A deploy can now change a role’sscopes(it retires acontentvalue your app stopped declaring). A script that read the role before such a deploy and wrote its wholescopesback after it would restore the retired value — recorded as an admin’s, so no deploy retires it again. WithexpectedUpdatedAtthe PATCH is refused instead and nothing is written; without it the route stays last-writer-wins on the fields the body names. The admin role editor sends it. -
Rename any scope dimension called
__proto__,constructororprototype— the roles API (POST/PATCH /auth/admin/roles) and the manifest’sroles[].scopesnow refuse them withbad dimension: <name> (reserved …). They match the dimension pattern but are properties every JavaScript object has, so a map keyed by one silently dropped or misread the dimension. A role that already carries one keeps it; saving that role’s scopes again means removing it. -
Work the
## v1.22.0to## v1.33.0sections your nextturbobase project adoptadds toADOPT.md, even though they are older than itsadopted-through=. Those releases shipped their adopt notes without writing them into the release log, so no project was ever served them. They are in the log now, dated as published by this release, and an instance on this release serves them once to every project stamped below it — no flag and no CLI upgrade needed. They merge intoADOPT.mdunder their own versions: anything the file already holds keeps its tick, and a project this release has stamped is not served them again. -
Read a mirrored file’s bytes from
GET /v1/data/:recordType/:externalId/content, and URL-encode the external id. This is the only surface that carries a file’s contents — asource_filerow never has and never will — and it serves a copied file and one still sitting in the customer’s own account identically, so an app never learns which storage mode a connection uses. The external id carries slashes (<container>:<folder>/<name>for Azure Blob Storage), soencodeURIComponentit. The answer is an attachment with the row’s ownmime_type,private, no-storeandnosniff; it needs the sameread:source_filecapability the row does. A file whose contents have not arrived yet answers404 content_unavailable, and one that is there and can no longer be fetched — a credential withdrawn, a blob moved in the source — answers409 content_unavailablewith a sentence saying which. Treat those two differently: a 409 read as “deleted” is how a customer concludes their files are gone. -
Upload a file’s bytes to
POST /v1/data/:recordType/uploadsBEFORE creating the file, and name the handle you get back. Send the bytes as the request body withcontent-type: application/octet-stream— not base64 inside JSON, and not a column. The answer is201 {"ok":true,"upload":{"upload_id":…, "size_bytes":…}}, and the create then carries that id underraw.upload_id. The ceiling is 8 MiB; above it the route answers413withUPLOAD_TOO_LARGE, and an empty body answers422withEMPTY_UPLOAD. The route asks for the create’s ownwrite:source_filecapability, because an upload is half of a create. This is not the knowledge-base upload path and does not share its limits. -
Send
raw.upload_idandpathon every file create, or the write boundary refuses it immediately.POST /v1/data/filewith neither answers422and names the first one missing —NO_FILE_CONTENTSfor the bytes,NO_TARGET_PATHfor the destination. Both are published onazure_blob:file’srequired_inputs, so an assistant readinglist_record_typesis told what a create needs rather than discovering it from a refusal.pathis the full path inside the connected container, folders and all (legal/2026/addendum.md); folders need no creating. Neither has a default and neither is getting one. -
Expect
source_filerows for a connected Azure Blob Storage container, where only GitHub wrote them before.azure_blobnow appears in/v1/catalog/recordswith afilerecord on the same shared table, so a surface already readingsource_filegains the container’s blobs and should filter onsource_systemif it meant GitHub’s files specifically. Read the text from the knowledge document, not from the row:content_textis always NULL on anazure_blobrow — a container is a whole document library and the column would be a second copy of it in the database — so the extracted text lands on the file’smemory_document, which is what the assistant grounds on. A surface that readscontent_textto show what a file says gets nothing for these rows; GitHub’s are unchanged and still carry it. -
Expect a file create to be refused until staff turn writing on, and handle the three codes.
sync.bidirectional.azure_blobships off per tenant: until it is on, every create answersBIDIRECTIONAL_DISABLED. After that a write addressed outside the one container a connection names answersCONTAINER_OUT_OF_SCOPE— connect the other container as its own connection — and a write that would overwrite existing bytes in an account with neither blob versioning nor blob soft delete answersCONTAINER_NOT_RECOVERABLE. Creating a file at a path that does not exist destroys nothing and is not held to the recoverability rule. -
Do not build a remove-a-file flow yet. Deleting a blob is built and switched off:
azure_blobis deliberately outside the delete-capable set until one verification against a real storage account, so a pg→source delete is refused before the adapter is reached. Adding and overwriting are available; removing is not. -
Live stock (
POST /v1/stock) now answers for items that hold stock on a bin-managed NetSuite account. It used to answer503 source_unreachablewith NetSuite’sUNEXPECTED_ERRORfor every stocked item, and200only for items at zero. A location’squantityCommittedis now the account’s committed figure for that whole location. Per bin and per lot it isnull, because the account doesn’t report a committed figure at that level. -
Group Jira issues by
project_idand showproject_nameandstatus_namefrom the columns — stop readingraw->fields->projectandraw->fields->status. Three columns onwork_itemthat were NULL on every Jira row now carry the values the payload always had:project_id(the project’s numeric Jira id),project_name, andstatus_name(the site’s own stage word —To Do,In Review, the same valuestatusalready carried, not Jira’s three-waystatusCategoryrollup).project_idaddressescr_jira_project.external_id, so a read that wants anything else about the project embeds across that join instead of digging into the stored payload. Nothing breaks if you leave arawread in place — these columns read NULL before this release, so no app can have been relying on a value — but an app that grouped by arawpath was getting a different answer from the next app that guessed the path differently, and a screen that grouped byproject_idwas correctly reporting “no work linked to a project” on an account where every issue IS linked.status_namematters beyond display: the catalog pairs an<x>_idcolumn with its<x>_nametwin, so the in-product agent andturbobase project typeswere already telling a generated app to renderstatus_namein place ofstatus_id, and it had nothing in it. -
Read a Jira project’s short code from
cr_jira_project.project_key, not by splitting an issue key’s prefix. New nullable column carrying the project key (PROJ) — the identifier a project picker, a grouping header or a label shows. Before this the key reached no column at all and the documented route was to splitPROJ-123on its last hyphen; drop that derivation. Both halves fill in place on the next sync of an account that is already mirrored, with no re-import and no cursor reset, so a project or issue synced before this release gains the values without any action from you. -
Stop sending
source_systemon a write to a table your app authored — the table’s own definition now decides, and naming a connected system is422 SOURCE_MISMATCH. A custom table (one declared in yourpublic/turbobase.json, or created withturbobase tables create) can be addressed by its bare slug,task, or by thecustomrecord_-prefixed spelling of it,customrecord_task; both name the same rows, and until now only the bare one was resolved from the table’s definition. The prefixed spelling was resolved from the NAME alone, soPOST /v1/data/customrecord_taskstamped the row with whichever system your instance happens to be connected through and queued it out to that system — which has no such record type — and the only sign was a blocked push some hours later. Two things change for an app using that spelling. A create with nosource_systemnow landssource_system: "standalone"andwrite_status: "synced"instead of (for example)"netsuite"and"pending", so a screen that reads either column back, or branches on them, sees a different value — and the row is correctly never queued anywhere. And a create that names a connected system on such a table is refused422 { "rejection": { "code": "SOURCE_MISMATCH" } }naming both systems, where it used to be accepted. If your app sendssource_systemon its own tables, drop the field; if it must send one, send"standalone". -
Expect
400 record type "…" is not writablefor acustomrecord_name this instance has no ACTIVE table definition for, and422 RECORD_TYPE_AMBIGUOUSwhen two definitions claim one table. Such a write used to resolve a table from the name and attempt an insert against it, which surfaced as a database error or as rows in a table nothing reads. Both answers now come before anything is written: the first names the record type you sent, the second names it and the systems claiming it, and neither is retryable without an instance-side change. “Active” is part of the first one, and it is the rule the bare spelling has always had: a definition whose last reconcile failed keeps its table but is not advertised, so both spellings of it are refused until an operator clears the failure — check the table’s status in the console rather than retrying. On the same door, a table whose owning system has since been DISCONNECTED is now422 SOURCE_NOT_CONNECTEDnaming that system, instead of a row saved with nowhere to send it; the rows already mirrored there are untouched and still readable. A custom record a connected system really owns is unaffected on both doors — it is still stamped with that system and still pushed to it, and it is now stamped with the system that OWNS the table rather than the one your session signed in against, which is the same value on a single-source instance and the correct one on any other. An UPDATE is unchanged in every case: it takes its system from the record, so rows written before this release stay editable exactly as they are. -
Print by creating a
printJobrow, not by calling anything.POST /v1/data/printJobwithprinter_id,content_typeandcontent, plus an optionaltitlethat shows in PrintNode’s own queue. All three are published onprintnode:printJob’srequired_inputs, so an assistant readinglist_record_typesis told what a create needs instead of discovering it from a refusal; without them the write boundary answers422naming the first one missing —NO_PRINTER,NO_CONTENT_TYPE,NO_CONTENT. It asks forwrite:print_job, which is not in the standard capability bundle: creating one spends the customer’s own PrintNode credential and makes a machine produce paper, so a tenant admin grants it per user. -
Carry the document on the row, already base64-encoded.
content_typeispdf_base64for a PDF orraw_base64for a label printer’s own language (ZPL and the like), and those two and no others — a URI content kind is refused with reasoncontent_not_encodedbefore any call is made. Turbobase never fetches a document, so whatever creates the job supplies the bytes. There is no size cap, and the bytes do sit in the tenant’s own database. -
Read the outcome off the row’s
status, and treat a refusal as a state rather than an error. The create answers immediately; the print is settled later, on the same row.statusis one ofqueued,succeeded,failed,refused, andreasonnames the cause from a closed list —printer_not_named,printer_not_found,content_not_encoded,auth_not_configured,auth_refused,rate_limited,rejected,network_error,print_error,print_expired,print_deleted.upstream_messagekeeps PrintNode’s own sentence. Onrate_limited,window_resets_atsays when the job may be tried again, so a surface can state the time instead of polling. None of these raises an error and none of them is a failed write. -
Do not tell anybody their document printed because the write succeeded. A job PrintNode has accepted is
queuedwith itsexternal_idset — accepting is not printing, and the computer can be asleep, the client can be out of date, the printer can be out of media, the job can expire unprinted. Turbobase pollsGET /printjobs/{id}/statesand settles the row tosucceededorfailedafterwards;statecarries PrintNode’s own word for where it got to. Show “sent to the printer” untilstatusis terminal, and wait for the row to change rather than for the create to return. -
To print the same document twice, create a second row. A
printJobthat already carries anexternal_idhas been handed to PrintNode, and editing it will never print again — the write path refuses a job it has already sent, and the row keeps whatever outcome it had. So a “Print again” control creates a new row rather than re-saving the old one, and a retry after a refusal is the same: a refused job has noexternal_id, so fixingprinter_idon it and saving does send it. -
Expect every print to be refused until staff turn writing on.
sync.bidirectional.printnodeships off per tenant: until it is on, a create is refused withBIDIRECTIONAL_DISABLEDand nothing reaches PrintNode. -
List the printers to let somebody choose one; never write to them.
printnode:printeris in/v1/catalog/recordsas a read-only mirror behindread:printer, one row per device on the connected account, carrying itsname, itsstate(online/offline), and thecomputer_nameit is attached to — which is what tells four printers called “Zebra” apart. Itsexternal_idis the value aprintJobnames inprinter_id. An offline printer is a legitimate target, because PrintNode queues for it. A write addressed atprinteris refused as the record’s own direction, and a tenant holding two PrintNode accounts has two connections, so filter onconnection_idwhen it matters — printer 42 in one is not printer 42 in the other. -
Do not build a cancel-a-print flow. A document that has been printed cannot be un-printed, so
printnodeis deliberately outside the delete-capable set and a pg→source delete is refused before the adapter is reached.
v1.33.0
Section titled “v1.33.0”-
Send a line’s inventory detail as an ARRAY of assignments on
inventory_detail.[{ "binnumber": "<bin internal id>", "quantity": -1 }]for an item at a location that uses bins,[{ "inventorynumber": "<lot or serial>", "quantity": 1 }]for a lot- or serial-numbered item, and both keys on one assignment when the item is both. The quantity carries the line’s sign. A location that uses bins needs this on an UNTRACKED item too — NetSuite refuses the record otherwise, withreason_code = 'inventory_detail_required'onrecord_write_state. -
Expect
422 INVALID_INVENTORY_DETAILfromPOST /v1/data/:recordTypefor any other spelling. An object where the array goes, an{ inventoryassignment: [...] }wrapper,raw.inventory_detail, or an assignment key the write path does not send used to be answered200and dropped, so the account then refused the record for the detail you thought you had sent.rejection.fieldnames the offending key (lines[0].raw.inventorydetail,lines[0].inventory_detail[0].bin, …) andrejection.messagequotes the supported shape. A mirrored line echoed back as read is not affected. -
Take bundle 2.12.2 on the NetSuite account to post a bin-only assignment. Older bundles refuse every assignment without an
inventorynumber, so an untracked item cannot be adjusted at a bin-managed location until the account is on 2.12.2. The same bundle writes a NEGATIVE adjustment’s lot or serial toissueinventorynumber, so a write-off of a numbered item issues the existing number rather than receiving a new one. -
Read
record_write_state.messageforinventory_detail_requiredas “bin and/or lot/serial”, not “this item is tracked”. The sentence no longer claims the item is lot- or serial-tracked, because the source refuses an untracked item at a bin location with the same words;fieldsis unchanged (inventory_detail). -
Read a line’s lots and bins from
inventory_detail(orv_inventory_assignment) on every line table that has the column, not out ofraw. Only item receipts and item fulfilments used to fill it. Invoice, cash sale, credit memo, transfer order, return authorization, vendor return authorization, vendor credit and inventory adjustment lines now do too, on the next sync of each record. A bin-only assignment (an untracked item at a location that uses bins) is now kept, so expectv_inventory_assignmentrows withinventory_number = nulland abin_number. A query that filters oninventory_numberis unaffected; one that counts rows per line will now count those bins too. -
Order a “recently touched” list by
updated_at, and useupdated_sinceagain — the column now means when the row last changed, not when it was first imported. On every mirrored table aBEFORE UPDATEtrigger stampsupdated_atwhenever the row actually changes, whether the change arrived from the connected source or from your ownPOST /v1/data/{recordType}edit. Before this it was written once on import and moved only when a particular connector happened to set it, soupdated_sinceon a catalog or MCP read answered nothing for records that had changed that morning, and the most recently touched rows sorted to the bottom. Two things to check in an existing project: a list sorted byupdated_atwill now come back in change order rather than import order, so if you were relying on it as a stable proxy for import order switch that read tocreated_at; and a screen that gave up onupdated_sincebecause it returned empty pages can start using it. The catalog description for the column changed to match, soturbobase project typesand the in-product agent now describe it as the changed-at column instead of telling you ordering by it gives sync batch order. -
Nothing moves it but a real change. A bookkeeping write — a write-back completing, a freshness confirm, a source-id backfill, a terminal write status — leaves
updated_atalone, and so does a platform maintenance backfill, so the value does not drift on its own and a four-hourly sweep that finds a record unchanged does not make it look edited. Rows mirrored before this release were repaired once from the platform’s own change clock, so a record nobody has touched since does not read as freshly imported forever.
v1.32.0
Section titled “v1.32.0”-
Show how fresh a row is: read the new
source_confirmed_atcolumn on every mirrored table, and treatNULLas “never confirmed”, not as “just confirmed”. It is atimestamptzcarrying when the connected system was last asked about that row and agreed with it — stamped on every inbound confirm, whether or not a value moved — so it answers “is this figure current” in the same request that returns the row (select=*has it; so doesturbobase project typesonce you regenerate). It is NOTlast_modified_pg, which is when the row last CHANGED: a row re-read thirty seconds ago and found identical has a four-hour-oldlast_modified_pgand a thirty-second-oldsource_confirmed_at, and the second one is what a staleness badge should read. It is NOTsynced_ateither, which is still the other direction — when one of your writes last reached the connected system. Rows that existed before this shipped readNULLuntil the next confirm touches them, so renderNULLas “not confirmed yet” rather than as age zero. -
Stop treating a refused write as a validation error when the status is
503— send the same body again instead.POST /v1/data/:recordTypeandDELETE /v1/data/:recordTypenow answer503 { error: 'hook_unavailable', rejection: { code: 'HOOK_UNAVAILABLE', message } }with aRetry-Afterheader (seconds) when one of the tenant’s safety checks could not finish in time. This case used to arrive as422 { error: 'validation_failed', rejection: { code: 'HOOK_FAILED_CLOSED' } }, so an app that branches on422was showing the person a validation error they could do nothing about and losing the write. Nothing was wrong with the record and nothing was saved: retry the identical body, keeping the sameidempotencyKey, and show the person nothing to fix — therejection.messageis already written for them to read if you want to surface it.422is unchanged and still means a check returned a verdict about the data. -
Handle
HOOK_UNAVAILABLEfrom the MCPwrite_recordanddelete_recordtools as “send it again”, not as a bad request. The same case used to reach a model asINVALID_REQUEST, which reads as “your payload is malformed” and prompts a rewrite of a record that was correct. -
Keep sending the retry key as the body’s own
idempotencyKey, and drop any guard around a create that put it insideparentinstead. Both spellings are now recognised onPOST /v1/data/:recordType. A create whoseparentcarriedidempotency_keywith no top-levelidempotencyKeyalways wrote the column and was always deduped by the index, but only the top-level field was read when deciding whether a repeat was a retry — so the second submission came back as a generic failure for a record that was sitting in the table. It now answers with the row the first call created, in the same envelope a first-time write returns, exactly as the top-level field does. The documented contract is unchanged and the top-level field is still the one to use: it is the spelling the scaffolded client sends and the only one the body shape names. Nothing that works today changes. -
Handle
connection_refusedas areason_codeon ablockedrecord_write_staterow, and say the record is waiting on the connection rather than on the reader. When a connected system refuses write-back for one kind of record — a revoked grant, a narrowed role — Turbobase now stops sending that kind of record on that connection instead of offering each one and being refused. Your write is still accepted and the row is still held, so nothing your app does changes; what changes is why. There are nofieldsto fix and no edit that clears it, so an app that enumerates reason codes should route this one to the same place it routespermission_deniedrather than to a fix sheet. It clears by itself once the connection works again, and the held records are sent with no retry from your side. -
Stop branching on
502fromPOST /v1/stock— it now answers503 { error: 'source_unreachable', message }. That body never used to arrive: the edge in front of every tenant replaces an origin502with its own “Bad gateway” page and discards the JSON, so a failed stock read reached you as untyped HTML and themessagenaming what the source said was thrown away. Read503plus that code as “we could not ask the account”, and never as a zero. -
Read
knownbesidefoundon every itemPOST /v1/stockreturns.found: falsemeans “no stock reported in the scope you asked about” and used to be the only answer, so a mistyped item id and a sold-out item were byte-identical.found: false, known: trueis an empty shelf;found: false, known: falseis an id this instance has never heard of;known: nullmeans the answer cannot speak for the id (a truncated read).found: trueis alwaysknown: true. -
Read
locationKnownon the answer when you narrowPOST /v1/stockwithlocationId.truethe location exists,falsethis instance holds no such location — which previously came back as every item reporting nothing, so a screen passing a renamed or deleted location id was told the warehouse was empty.nullwhen you named no location. It is a field and not a refusal on purpose: the location list is a mirror that lags the account, and a stale mirror must not refuse a live read. -
Stop branching on
502fromPOST /v1/apps/receipt-extract— it now answers503 { error: 'extract_failed', message }. Themessageis the instruction to the person holding the receipt (“enter the report by hand”) and it reaches a caller for the first time with this change.llm_unavailablein the same endpoint is also503, so readerror, not the status:llm_unavailablemeans no provider is configured,extract_failedmeans the provider could not read the image. -
Stop branching on
502from/v1/extensions/proposals/:id/*and/v1/extensions/:id/remove— they now answer503 { error: 'provenance_unverified' }. The CLI has always had a sentence for that code and could never show it, because the body was replaced at the edge before it arrived. -
Expect
503rather than502from the tenant app’s own pages, endpoints and form actions when something upstream of them fails. 137 server sites moved, so an enhanced form submission (use:enhance) now receives the app’s own{ error }instead of HTML it cannot parse, and a page load renders the app’s error surface instead of the edge’s. An upstream answer that carries its own status still keeps it — a409is still a409. -
Stop branching on
502fromGET /v1/catalog/openapi.json— it now answers503 { error: 'postgrest_unreachable', status }. That body never used to arrive: the edge in front of every tenant replaces an origin502with its own HTML page and discards the JSON, so codegen failed with no cause to show. Read503plus that code as “the catalogue’s upstream is down”. -
Stop branching on
502fromPOST /v1/extensions/author-conduit— it now answers503 { error: 'author_failed' }. It shares a status with the503 { error: 'ai_budget_paused' }refusal, so readerror, not the status:ai_budget_pausedmeans the instance is doing what it was told,author_failedmeans the author genuinely failed. -
Stop branching on
502fromPOST /v1/apps/:id/release-event— it now answers503 { error: 'record_failed', message }. Themessagenames which part of the release ledger refused the write, and it reaches callers for the first time with this change; it was being discarded at the edge. -
Read
errorrather than the status onPOST /v1/processes/:id?mode=sync— the over-budget answer moved from504to202 { error: 'sync_budget_exceeded', runId, processId, state: 'queued', message }. The run was accepted and is still going, which is what202means. A504was replaced at the edge, so therunIdyou need in order to keep watching the run never arrived. In this mode a202is the one answer that does NOT mean finished — a200carries the settled run. -
Stop branching on
502from the MCP OAuth bridge’s/oauth/authorize— it now answers503 { error: 'temporarily_unavailable', error_description }. That is the status RFC 6749 pairs the code with, and the one an OAuth library can act on; a502arrived as an HTML page, which no library can read as an OAuth error at all. -
Read the signed-in person’s roles from
GET /api/account/rolesinstead of the root layout’s page data. An app that offers a role picker had no supported way to find out which roles the person holds: the session carries capabilities rather than role names, and the route that answers the question is on a different origin behind an HttpOnly cookie the page cannot read — so the only thing that worked was readinguser.rolesandacting_role_idout of the console’s own/__data.jsonpayload, a devalue-flattened internal shape that can change in any release and takes the picker down with it when it does. Stop reading it. The new route is same-origin, needs no token beyond the session cookie the browser already sends, and answers{ "roles": [...], "actingRoleId": "<id>|null" }— every role row whole (roleId,name,description,appRole,isBuiltin,builtinKey,isDefault,grantedAt), and the one in force, which is the role the session has switched to and only falls back to the default member when the session was minted before that was recorded. Switching is unchanged:POST /api/account/switch-role. -
Treat an empty
roleslist as a true answer and branch on the status, not on the list. A200carrying{ "roles": [], "actingRoleId": null }means the person holds no roles — render nothing rather than an error — while a failed read is503 {"ok":false,"error":"roles_read_failed"}and no session at all is401 {"ok":false,"error":"unauthenticated"}, both as JSON rather than a redirect or a sign-in page. An app that showed “no roles” on a503would hide a picker the person can actually use. The failure is503and never502, for the reason the502notes in this same release give: the edge in front of every tenant replaces an origin502with its own HTML page and throws the JSON away. -
Re-deploy your
processes/modules — until now none of them installed.turbobase ext deployandturbobase ext planread a module’s identity out of its text, and they only recognised a process whose step record types were written as quoted strings. A step that named its record type through a constant — which is how almost every process is written — was reported ascould not read id / recordType / eventand silently left out of the plan, so invoking it answered404 process_not_found. Runturbobase ext planto see them appear as adds, thenturbobase ext deploy --to <env>. Nothing in your module changes: a process now needs only a readable quotedid:, and the record types stay where they were, on the steps.
v1.31.0
Section titled “v1.31.0”-
Ask for current stock with
POST /v1/stockinstead of reading the mirrored quantity columns. SendconnectionId, anitemIdsarray (at most 200 ids), and optionallylocationIdto narrow to one location. The answer is read from the source during your request — not from a copy refreshed on a timer — and comes back asitems, one entry per id in the order you asked, each carryinglocationswithquantityOnHand/quantityAvailable/quantityCommittedand, where the account keeps them, abinsand alotsbreakdown. The body takes those three keys and nothing else: there is no way to name a table, a query or a filter of your own, and a request carrying one is refused with400 unknown_field. -
connectionIdis required and is never defaulted — send it even on a single-connection instance ("default"). A tenant may hold more than one connection of the same source, so omitting it would point your stock question at an account nobody chose; it is refused with400naming the field. A connection the instance does not hold answers404 connection_not_found, and one whose connector has no live stock surface answers501 live_stock_unsupported_for_source— never an empty list, which would read as “none in stock”. -
The answer is per location — join it to
locationwhen you need stock by subsidiary. Nothing on the response names a subsidiary and nothing on the request filters by one: a location determines its subsidiary, so group thelocationIdvalues you get back against the mirroredlocationtable, which is keyed on that same id and carriessubsidiary_idandsubsidiary_name. Readinglocationis scoped to the subsidiaries the session may see while the live figures are not, so alocationIdthat finds no row there is stock this session cannot place — show it on its own rather than folding it into a subsidiary total. -
Treat
nullas “the account does not keep this”, not as zero — and checkfoundbefore showing a figure. An item the source reported no stock for comes backfound: falsewithlocations: nullrather than an empty location list, which would read as “in stock nowhere”.binsandlotsarenullwhen the account keeps no bin or lot detail for that item, rather than[], which would claim the detail exists and is empty. -
found: falsemeans “none reported for the question you asked” — it is not “no such item”, so do not use it to validate an id. It is what you get for an item the account holds only at some other location when you sent alocationId, and whether an item sitting at zero appears at all is a property of the source’s own balance surface. Showing “no stock here” is right; showing “no such item — check the id” is not. If you need to tell a mistyped id from something that has sold out, read theknownfield beside it — you no longer have to resolve the id againstitemyourself. -
foundhas THREE values, andnullis notfalse— branch onfound === truerather than onif (item.found).truethe source reported stock for it,falsenone was reported for the question you asked — the bullet above, and never “no such item” —nullthis answer could not speak for it. The third arrives when the response istruncated: the surface stops at 1,000 source rows rather than paging further while you wait, and an item it did not reach is one you were told nothing about. Showing those as out-of-stock would be exactly the wrong answer, which is why they are notfalse. -
Read
itemsNotCovered, and ask again for what is in it. It lists the ids this answer could not speak for, in the order you asked, and is[]whenevertruncatedisfalse. It is the same set as thefound: nullentries, given to you in one place so a retry is a slice rather than a scan. Ask for those ids in a second request, or narrow withlocationId. Row count grows with an account’s own bin and lot detail — one row per item, location, bin and lot — so a lot-tracked account can truncate well inside the 200-id limit; no item count makes it unreachable. Every figure you ARE given is complete: an item whose rows were cut short is reportedfound: null, never as a partial quantity. -
Ask your administrator for
read:itemANDread:inventory_numberif your app reads live stock. Both are required and both are in the default standard bundle, so no ordinary user needs a change — but a hand-restricted role will be refused with403naming them. Every call reaches the customer’s own account and spends its API concurrency budget, so treat this as a deliberate read on a user action, not something to poll. -
The mirrored stock columns keep their current meaning — nothing you read today changes.
inventory_number’squantity_*columns andlocation_summaryare still there, still gated byread:inventory_number, still refreshed by sync. Reach for/v1/stockwhen the figure has to be current at the moment somebody looks at it; keep using the mirror for reporting over history, which is the one thing a live read cannot give you. -
Add
"suggestChanges": falseto your app’sturbobase.jsonto take the floating “Suggest a change” button off that app. Omit the key and nothing changes — the button stays, exactly as it does today — so this is only ever written to turn it off, for a kiosk display, a shop-floor terminal, or an app whose users are not the people who commission changes to it. The value must be a boolean:"false"in quotes,0or anything else fails the deploy naming the key, rather than quietly picking a side. The answer is recorded per version, so 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. -
Expect
403 {"error":"suggest_changes_disabled"}fromPOST /v1/apps/<slug>/proposalsonce an app declares the button off. A page a user left open before that deploy keeps its button until they reload, and the request it sends is refused — distinct from theforbiddena missingproposal:createcapability returns, because no permission grant puts this one back. -
Check
truncatedon eachbatch()sub-result, and page withoffsetwhen it is set. A sub-query now returns at most 1000 rows: ask for more (limit=5000, or nolimitat all) and you get the first 1000 withtruncated: trueon that result. The field is ABSENT rather thanfalseon a complete page, so the check isif (r.truncated), and a sub-query whose ownlimitis 1000 or less can never carry it — if you asked for 25 and got 25, nothing was cut. The cap applies to the page and never to the count, so{ query, count: 'exact' }still reports the whole filtered set and is how you know how far to page. One cut sub-query does not affect its siblings in the same batch. Until now an unbounded sub-query streamed the entire filtered set, which is what put every other read on the instance behind it in the queue for its database connections; the ceiling isBATCH_READ_MAX_ROWSon the instance. If a surface genuinely needs tens of thousands of rows, page them withselectPage(). Leave a process’sctx.queryalone — the cap is this endpoint’s and is not applied there, so a step still gets the whole result set and needs no paging added to it. -
Send
parent.source_systemwhen a record type means a different record in more than one connected system — it now decides which, on a create and on an edit.POST /v1/data/:recordTypeused to answer422 SOURCE_AMBIGUOUSfor such a record type and tell you to name the system, but naming it could not clear the refusal: the value was added to the candidates rather than used to pick one.accountis the record type this lands on — a customer in Zoho CRM and Salesforce, a ledger account everywhere else — so on an instance with either of those connected beside anything else, noaccountcould be created or edited at all. Naming the system is now what chooses the table, soPOST /v1/data/accountwithparent: { source_system: 'zoho_crm' }(or the MCP’szoho_crm:accountspelling) lands the row in the right place. Nothing that succeeds today changes. -
On one of those record types, expect the refusal you had coming rather than
422 SOURCE_AMBIGUOUS. Naming a system with no record of that kind is now400 record type "<type>" is not writable; naming one that is not connected is422 SOURCE_NOT_CONNECTED; and naming one the addressed record does not belong to, on an edit, is422 SOURCE_MISMATCHwhen that system keeps the record type in the same table and404 row not found or not permittedwhen it keeps it in a different one —source_systemis what chose the table the row was looked for in, so naming the wrong system means the row was looked for in the wrong place. All four were422 SOURCE_AMBIGUOUSbefore, because the ambiguity was raised a stage above them. Branching onSOURCE_AMBIGUOUSto retry with asource_systemis still right; branching on it for anything else is not, and on an edit a404for a row you can plainly read means thesource_systemyou sent is not the one that row belongs to. With nothing named you still get422 SOURCE_AMBIGUOUSon fieldsource_system, listing the connected systems to choose from. No answer moves on any other record type. -
Expect the identity lookup to answer for records held only in Turbobase, and read the member key as
pg:<pg_row_id>. A customer, contact, supplier, employee or partner created in Turbobase itself now joins the same identity as its copies in connected systems, soturbobase.entity_source_linkandturbobase.entity_canonical_viewlist it as a member and a conduit’sctx.atlas.resolve({ targetSource: 'standalone' })returns its key instead ofnull. That key is not a connected system’s id: it is the prefixpg:followed by the record’s ownpg_row_id, which is what you address the row by on/v1/dataand/rest/v1/*. If your app or conduit reads anullfrom that lookup as “this company is not held in Turbobase”, or assumes every member key is a connected system’s id, change it now — readsource_systemfirst, and only onstandalonestrip thepg:prefix to get a row id. Do not decide from the key’s shape alone: a connected system is free to mint an id that starts withpg:too, and a member that is notstandalonealways holds that system’s own id, which you pass through untouched. Records entered before this release are brought in by a one-off pass on upgrade. -
Name several systems on
parent.source_systemto create one record in all of them.POST /v1/data/:recordTypenow accepts a list as well as a string on a create:source_system: ['netsuite', 'quickbooks']makes ONE record in the first-named system, records that the others hold a copy, and sends every later edit to each copy on its own. You no longer write to each system separately and keep the copies in step by hand. Name'standalone'first to keep the master in Turbobase and push copies out. -
Read
row.sourceson a multi-system create, and only then. The response gainssources: [{ source_system, external_id }]naming every system the create was accepted for. It is ADDITIVE and absent when you name one system or none, so an app that does not ask for copies needs no change at all. Everyexternal_idin it isnull: the upstream writes are asynchronous, so the response says what was accepted, never what each system did with it. Check it rather than assuming it — if you named several systems andsourcesis absent, the record was created in the first-named system and no copy was recorded. -
Send an
idempotencyKeyon a multi-system create — 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 withsources, converging on the record you already have rather than making a second one. Without a key no call recovers them: a plain re-POST creates a second record and a list on an update is refused, so the record stays single-system until you add the copies in each system yourself. -
Handle four new refusals if you send a list. A named system that is not connected is
422 SOURCE_NOT_CONNECTED, one with no write path for the record type is422 SOURCE_NOT_WRITABLE, and one that means a different record by that name is422 SOURCE_AMBIGUOUS— each naming the system to fix. A list entry that is not a system name at all is422 SOURCE_INVALID, refused rather than skipped, because skipping it would create the record in fewer systems than the write asked for and say nothing about it. The whole write is refused and nothing is written, rather than creating the record in some of the systems you asked for. -
Keep sending one system on an update. A list there is
422 SOURCE_LIST_ON_UPDATE:source_systemis still verified against the record and never written onto it, so a record cannot be moved between systems, or given a copy in another one, by editing it. -
Poll
txn_source_linkover PostgREST to see what each copy did. Its read is no longer admin-only — anyone who can read the record can read its bindings.txn_source_link?master_table=eq.<table>&master_row_id=eq.<pg_row_id>returns each system’swrite_status(ok,pending,blocked,divergent,muted) and, for a refused copy, thelast_errorthat system gave. One copy being refused never costs you the others. -
Page
entity_canonicalon its newpg_row_idinstead of onid. The Atlas identity table now carries the same surrogate cursor every other paged relation on the platform does — NOT NULL, unique, added beside theidprimary key, which is unchanged — soturbobase project typespublishes a seek key for it and a/rest/v1/entity_canonicalwalk no longer has to fall back to ordering on the primary key. Re-runturbobase project typesto pick it up. Nothing was removed and no existing query changes behaviour. -
Read the combined identity list over MCP as
turbobase:identity_<bucket>.list_recordsnow takes the seven identity record types the record catalogue has always advertised —turbobase:identity_customer,…_vendor,…_contact,…_employee,…_partner,…_lead,…_item, and the bareidentity_<bucket>form — and pages them withpage.next_cursorlike any other record type. They used to be listed in the catalogue and then refused as an unknown record type. They are read-only, andget_recordrefuses them by name: an identity is addressed by its ownidrather than by a source-side id, so read it withlist_recordsand match onid. -
Check
warningson alist_recordsread of a record type your other connected systems hold under a different name. A plaincustomerread on an instance connected to several systems answers with the one that publishes that exact name — unchanged, andapplied.source_filterhas 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 combinedturbobase:identity_customer. Rows and status codes are unchanged; this is a new entry in an existing array. -
Treat the
initializeinstructions’ “Records you can CHANGE” line as exactly the pairslist_record_typesreportswritable_now: truefor. 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_canonicalto watch the combined identity list, and write aDELETEhandler for it. The Atlas identity table is now in thesupabase_realtimepublication, so apostgres_changeschannel on it delivers instead of reaching SUBSCRIBED and streaming nothing for ever — which is what a channel opened on the catalogue’stablevalue for aturbobase:identity_<bucket>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 nodeleted_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 aDELETEhandler, and with default replica identitypayload.oldcarries only the primary key (id) and noentity_type. Drop the row byid; 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
alsoCalledandcombinedRecordTypeoff a/v1/catalog/recordsentry instead of asking the AI connector. Each directory entry for a record that another connected system holds under its own name now carriesalsoCalled—["getsales:company", "zoho_crm:account"]on aquickbooks:customerentry — andcombinedRecordType, theturbobase:identity_<bucket>list that consolidates all of them into one row per real-world thing. They are the same answer the MCP envelope’swarningsalready 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 entryrather than for an empty array. Read them off the response rather than out oftypes.ts: likewriteCapability, they are resolved live per request from what this instance has connected, soturbobase 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.tagsin aPOST /v1/data/contactorPOST /v1/data/companyedit (undergetsales): 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 atagon the next sync. Atagstill cannot be created on its own. An edit that does not nameraw.tagsleaves the tags alone. -
Create a GetSales company with
POST /v1/data/companyundergetsales, sendingdisplay_name(andcompany_name, the same name) plusraw.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”-
Keep the
refresh_tokenyour native app now gets back from/oauth/token, and renew with it instead of signing the person in again. The token response forresource=https://<instance>/rest/v1has 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 freshrefresh_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_granton 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 itserror_description./.well-known/oauth-authorization-serverlistsoffline_accessunderscopes_supportedalongside therefresh_tokengrant it already advertised. -
Send only the six top-level keys
POST /v1/data/:recordTypereads —parent,lines,pgRowId,externalId,idempotencyKey,expectedSyncVersion. Any other top-level key is now refused422 UNKNOWN_BODY_KEYnaming it, where it used to be ignored: a body putting the record’s fields underfieldsordatawas read as an emptyparent, so the boundary created a row of NULLs and answered200 { 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 underparent.raw. -
Read
item.quantity_on_handinstead ofraw.totalquantityonhand. The total on hand is now a promoted, filterable, real-time-fresh column onturbobase.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_memoandestimateby their newcurrency_code. The portable ISO 4217 key is now on all three, besidecurrency_idandcurrency_name, resolved the same way as onorderandentity, and backfilled on upgrade.totalon these rows is the document’s own face value, so a per-currency total built oncurrency_idalone 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
entitywith the newis_placeholdercolumn. 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 ascoalesce(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_nameandlast_nameonentity. 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 inraw.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 intorawfor a customer’s name. -
Declare your native apps in
public/turbobase.json→nativeand runturbobase apps deploy— that is now the whole setup. The instance serves the enabled app’s current version in/.well-known/apple-app-site-associationand/.well-known/assetlinks.jsonwith 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”-
Ship an app’s own agent skills as files: put each one at
skills/<slug>/SKILL.md(frontmatternameanddescription, then the body) and runturbobase skills push --from skills --app <app slug>. Each file becomes the skill<slug>in the instance’s skill registry with its authored name and description — thedescriptionis what an MCP client reads inprompts/listto choose it — and its body is replaced whole when the file changes. It lands as a DRAFT. Add--publishto publish exactly the skills that push wrote (new or changed); that needs a token carryingskills:publishas well asextensions: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 youskills unpublish/skills deleteit. The route isPOST /v1/apps/:slug/skills; the app must already be deployed. -
Declare your app’s native twin under
nativeinpublic/turbobase.jsonto 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": ["<TeamID>.<bundle id>"] } }(and/or"android": { "packages": [{ "name": "<application id>", "sha256": ["<signing-cert fingerprint>"] }] }) 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-associationand/.well-known/assetlinks.jsonnaming that app, with its callback at/oauth/app-callback/<your slug>. Only the iOS universal link is limited to that path —webcredentialsreaches 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 atPOST /oauth/registerand addapplinks:<instance host>andwebcredentials:<instance host>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.
ASWebAuthenticationSessionmatches an https callback by host and path from 17.4; on an older iOS the redirect loads/oauth/app-callback/<slug>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_PAUSEDon every write — a customer can now pause one source connection, and your writes to records that came from it are refused.POST /v1/data/:recordTypeanswers 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 showingrejection.messageis 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/:recordTypeanswers409with a proseerrorand norejection. 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-readablerejectionobject — so there is noSYNC_CONNECTION_PAUSEDto check for on this path, and a check written for one would never match. Readerrorand show it; the prose is customer-ready and names the resume, exactly as the write’s does. An unreadable pause check answers403here rather than a retryable code, so a refused delete is never worth an automatic retry. -
Retry
422 SYNC_CONNECTION_STATE_UNKNOWNon 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
<source>_connection_pausedas async_failureclassification, under categorysource_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 (<source>_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 "<name>" is not writablefromPOST /v1/data/:recordTypewhen 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 stayedpendingwith 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/recordsfor 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_typesanswers 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 underentity_source_link, pages by canonical (so a page boundary never cuts an identity in half), and carries the exact total underPrefer: 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.<id>&select=canonical_id,entity_canonical(*,entity_source_link(*)). That nested embed is what saves the second request; drop it forentity_canonical(*)when the identity alone is all you want. Always filter onconnection_idas 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:leadorread:item. The graph was admin-only until now and is gated per bucket from here on, exactly asentityis: 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 a200and 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 owndisplay_name,primary_email,tax_idanddomainare 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 underentity_source_link(...)againstentity(oritem), where row-level security has already narrowed them. -
Re-generate your types (
turbobase project types) to pick upentity_canonicalandentity_source_link. Both now appear on/v1/catalog/records— one entry per bucket,identity_<bucket>, 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;GETlists them andDELETE /admin/atlas/decisions/<id>forgets one. Admin session only, and the same ledger the/data/atlasqueue 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 stayedpendingfor ever./v1/catalog/recordsalready published all three aswriteCapability: 'write:activity'and the write boundary already answered200, but nothing was ever sent — so the row committed, itsrecord_write_stateparkedpendingwith noevent_id, and there was no attempt to see and nothing to retry. The write now reaches Zoho and the row settleswrite_status: 'synced'carrying the Zoho id, with the sidecar cleared. Two consequences to handle: a value Zoho refuses now settlesneeds_inputwithreason_code: 'source_value_not_accepted', a message naming the value, and arecord_write_state.fieldsentry — a refusal these three records could not return before, so branch on it rather than waiting forpendingto clear; and a row already strandedpendingwith 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 bysync.bidirectional.zoho_crm, which defaults off, so expect a deterministic refusal until staff enable it. -
Read
write_statuson/rest/v1/automation_runto tell a run you started from one you are only watching — the table now carrieswrite_statusandidempotency_key, the two columns every writable table carries. A run you dispatched withPOST /v1/data/workflow_runreadspendinguntil it reaches GitHub andsyncedonce it has, carrying the run’s own id inexternal_id; one that went nowhere readsneeds_inputorblocked, andrecord_write_statesays why. A run mirrored inbound is alwayssynced. 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_statusisneeds_inputorblocked. 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 fromrecord_write_stateand let the reader start a fresh run instead. -
Call an outside web service by writing an
external_api:requestrow, not by fetching from your app./v1/catalog/recordsgainsexternal_api:request→ tablerequest,readCapability: 'read:request'. Create one withPOST /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 byconnection_idand do not assume'default'exists. -
Ask a tenant admin for
write:requestandread: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. Withoutwrite:requestthe create is refused 403 with the capability named in the body envelope; withoutread:requestthe row is invisible to/rest/v1. -
Poll the row’s
statusand branch onreason— a refusal is a state, never a thrown error or a dropped call.statusisqueued→succeeded|failed|refused;reasonisnullon success and otherwise one ofhost_not_allowed,timeout,cap_exceeded,non_2xx,network_error,connection_not_found,auth_not_configured. Oncap_exceededthe row carrieswindow_resets_at, so wait for that instant rather than retrying in a loop.elapsed_msis set on every attempt that left the process. -
Treat
response_bodyas bounded and checkresponse_truncatedbefore you parse it. A body is cut at 64 KiB and at most 32 response headers reachresponse_headers;set-cookieand the other credential-bearing headers are dropped entirely.request_headersmirrors 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.
pathresolves against the connection’s base URL — a relative value joins under its path prefix — and an absolute URL, a//hostauthority, userinfo or a backslash authority all landrefused/host_not_allowedwith no call made. A connection is confined to a HOST, not to a path prefix. -
Handle a new
422 SOURCE_AMBIGUOUSfromPOST /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 refused400 not writable— which is what lets a NetSuite session create arequest. When more than one could, you get422with codeSOURCE_AMBIGUOUS; sendsource_systeminparentto say which. No create that succeeds today changes behaviour. -
There is no
sync.bidirectional.external_apiflag 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/:recordTypenow 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,spendMoneyandreceiveMoney. The entitlement ships OFF, so an app that writes one of those today still gets the refusal it gets now, withBIDIRECTIONAL_DISABLEDnamingsync.bidirectional.xero. Branch on the refusal rather than assuming the write landed. -
Read the direction off
/v1/catalog/recordsrather than hard-coding read-only for Xero. Those thirteen of a Xero tenant’s fourteen record types publishdirection: "bidirectional"and awriteCapability.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
parentandlinesin the samePOST /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 inraw.POST /v1/data/receiveMoneywith alinesarray is refused 422LINES_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 ownrawasLineItems, 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/*orrpc/write_transactionfrom a browser session is refused42501at the grant layer, exactly as the build-apps skills page has said since the writer-role cutover:INSERT/UPDATE/DELETEare revoked fromauthenticatedon every canonical table and only the write boundary can reach the role that holds them.POST /v1/data/:recordTypeis the door. This is unchanged by anything above. -
Check
writeCapabilityoff the catalog before drawing an edit affordance — all thirteen now ride a capability the built-in Standard role carries.write:depositandwrite:vendor_creditwere 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,spendMoneyandpurchaseOrderlines carry it asaccount_name(Xero’sAccountCode) and it is sent as written.invoiceandcreditNotelines do NOT: their line table is projected without an account column, so put the code in the line’s ownrawasAccountCodeand it will reach Xero. A line that sends neither is coded by Xero to the organisation’s default account, silently — setitem_name(Xero’sItemCode) or one of the two above on every line. -
Keep a line’s
external_idwhen you re-send lines. It is Xero’sLineItemIDand 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_paidandamount_remainingare 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
statusofPAID,VOIDEDorDELETEDon a Xero record.PAIDis derived; the other two are Xero’s delete verb, which this release does not open — a delete against a Xero record is refusedUNSUPPORTED_OPERATION. Settable statuses areDRAFT/SUBMITTED/AUTHORISEDon documents,AUTHORISEDon payments and bank transactions, andDRAFT/POSTEDon manual journals. -
A Xero validation failure now names Xero’s own reason. The error message carries every
ValidationErrors[].MessageXero returned (for exampleAccount 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’sCode); a manual journal needs amemoand lines. -
Create a supplier credit with
POST /v1/data/vendorCredit, lines in the same call.vendor_creditwas read-only on NetSuite and is now writable end to end: the row persists against its ownpg_row_idwithexternal_idnull, reaches NetSuite through the write path and settles towrite_status = 'synced'carrying the id NetSuite assigned. Editing an already-mirrored credit works the same way, addressed bypgRowId. A CREATE must carry itslinesarray in the same request — NetSuite replaces a sublist in entirety and has no per-line endpoint, so a line-less create is refused withATOMIC_WRITE_REQUIREDbefore anything is enqueued. -
Set
line_kindon a credit line, or setaccount_idand leaveitem_idempty. 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. Setap_account_idto 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 newdeposit_linetable.depositis writable, and its lines are the “other deposits” — money banked that is not a customer payment. Each line takesentity_id,account_id,amountandmemo; 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 indeposit.payment_summary: sending them back would un-bank them, so that sublist is never written.cash_backis NetSuite’s own arithmetic and is dropped on the way out, whilecash_back_account_idtravels — posting a row you just read is safe. -
deposit_lineholds 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.depositnow keys onpg_row_id, andexternal_idis nullable. If your app addresses a deposit byexternal_id, it still resolves — the column keeps a UNIQUE carrying exactly what the primary key carried — but a deposit your app created hasexternal_id: nulluntil the write reaches NetSuite. Address it bypgRowIdin 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 changesaccount_type_idon an account that already exists upstream is refused with aFIELD_VALIDATIONerror 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_numberis 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:accountorread:depositif 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:depositis 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 matchingdelete:capabilities exist and are deliberately NOT in the default bundle — destructive operations stay with administrators.GET /v1/catalog/recordsnow reports all three records asbidirectionalwith awriteCapability, 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.accountis every ledger’s chart of accounts, andturbobase.depositandturbobase.vendor_creditare Xero’s as well as NetSuite’s — and writing them is a NetSuite capability, not a table capability. A row whosesource_systemis anything Turbobase only reads from is refused400 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/recordsanswers 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 carrieswriteCapability: 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_PERMISSIONuntil 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 withcash_refund?select=*,cash_refund_line(*)— the line table’s only foreign key to the header isparent_pg_row_id, so a bare embed resolves and needs no constraint hint. Needsread:cash_refund, which is in the default Standard bundle. -
Create and edit one with
POST /v1/data/cashRefund. Passcreated_from_idnaming 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,subtotalandtax_totalare computed by NetSuite and are stripped from the outbound payload; sending them changes nothing. Needswrite:cash_refund. -
Take bundle 2.11.0 on the NetSuite account before your first write. The integration role’s
TRAN_CASHRFNDgrant 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 withINSUFFICIENT_PERMISSIONafter 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.refundrows now carryrecord_type, naming the source’s own record type —refund(Stripe),refundReceipt(QuickBooks),transactionRefund(Shopify),customerRefundorcashRefund(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 withturbobase project types. -
GET /v1/catalog/recordslistsnetsuite:cashRefundas 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 ownpg_row_idwithexternal_idnull, reaches NetSuite through the write path and settles towrite_status = 'synced'carrying the internal id NetSuite assigned; an edit is addressed bypgRowIdthe same way. Name, email, phone, the inactive flag and the subsidiary are typed columns.title,department,classandlocationtravel through therawpassthrough — they are not queryable columns, so filter on them in your app rather than in the request. A supervisor isparent_entity_id, which now reaches NetSuite as the employee form’s ownsupervisorfield. -
Ask your administrator for
write:entity:employeeif 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:employeeis unchanged and every member still has it.GET /v1/catalog/recordsnow reportsnetsuite:employeeasbidirectionalwith awriteCapability, 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,sendemailandrequirepwdchangeare refused withFIELD_NOT_WRITABLEnaming the field, and so are pay (salary*,compensation*),birthdate,socialsecuritynumber,taxidnumberand 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,birthdateand the rest inraw— 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.entityholds QuickBooks employees, Intercom admins, HubSpot owners, Ramp employees, Workday workers and Zendesk agents — six other sources — under the sameentity_type = 'employee'. Writing one answers400 record type "employee" is not writablewhatever capabilities the caller holds, because the answer is decided by the row’s ownsource_system. Checksource_systemon a row before offering an edit control for it. -
Read NetSuite support cases at
/rest/v1/work_item?item_type=eq.ticket. Scope bysource_system=eq.netsuitewhen 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 useitem_type='ticket'. The case’s opening message isdescription, its subject issubject, and the customer it belongs to isentity_id/entity_namewithentity_type='customer'. Needsread:work_item, which is in the default Standard bundle. -
Read the conversation from
/rest/v1/activity, with a SECOND request — not an embed. Filterwork_item_external_id=eq.<case external_id>&order=created_at.asc.turbobase.activitycarries no foreign key towork_item, deliberately, sowork_item?select=*,activity(*)does not resolve and no constraint hint makes it. Each row’sactivity_typeiscase_message_incoming(the customer wrote it) orcase_message_outgoing(the business did) — that pair is how you draw the two sides of the thread. Needsread:activity. -
Raise a case with
POST /v1/data/supportCase, and always name the customer. Sendentity_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 aneeds_inputwrite state namingCompanyrather than as a failed request.subjectanddescriptionare the fields that carry the case’s title and opening message. Do not senditem_type; the route stampsticketfrom the registry. Needswrite:work_item. -
Answer a case with
POST /v1/data/caseMessage. Sendwork_item_external_idnaming the mirrored case andbodycarrying the reply. Do not sendactivity_type— the route stampscase_message_outgoing, the business answering. Needswrite:activity. The record type iscaseMessageand notmessage, which NetSuite calls it: Slack already publishesmessage, on a different table, so a request naming it would be ambiguous. -
Record the CUSTOMER’s own message by writing
turbobase.activitydirectly, withactivity_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 thanPOST /v1/data/caseMessage, which stamps the outgoing direction; the samewrite:activitycapability covers it. Before 1.1 such a row was sent and never acknowledged, so it keptwrite_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_idas a NetSuite internal id. NetSuite keeps case correspondence on two surfaces, and the reply is written to whichever one that case already uses — amessagerecord 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 external_id>:case-message:out— or:infor the customer’s own message, since the case form holds exactly one of each. Both read back through the sameactivityrows and both are stable across re-syncs, so useexternal_idto 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 theprioritycolumn. 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.priorityis therefore dropped from the outbound payload; sending it changes nothing, andraw.prioritytravels unchanged.item_key(the case number),statusandcreated_atare 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_CASEgrant atFULLships 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 withINSUFFICIENT_PERMISSIONafter the row has already been written here. That refusal now settles asblockedwith reasonsource_permission_missingand 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/recordslistsnetsuite:supportCaseandnetsuite:caseMessageas bidirectional. Regenerate your types withturbobase 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/activitywithsource_system=eq.getsales.getsales:activityis a new row in/v1/catalog/recordsagainst tableactivity, under the sameread:activitycapability Zoho CRM’s and Salesforce’s activity rows already use — so an app that already holds it needs no new grant.activity_typecarries the GetSales verb verbatim (email_sent,linkedin_message_replied,note_left,pipeline_stage_changed, …), not the cross-sourcetask/event/callvocabulary, andbodyis a one-line summary Turbobase renders — the full upstream payload is inraw. Read-only: a write against the couple is refusedREAD_ONLY. -
Join an activity to the person or organisation it is about through the new
activity.entity_external_id, and re-runturbobase project types. It is nullable and matchesentity.external_idwithin the sameconnection_id. Every GetSales activity hangs off a contact or a company and none off a work item, sowork_item_external_idis 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
activityon the next tick, up to four hours later, andcreated_atis 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/contactwith the list inraw.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 refused422 NO_TARGET_LISTnamingraw.list_uuidwhen it names none, and422 LIST_NOT_MIRROREDwhen 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 refused422 INCOMPLETE_CONTACT_CREATE. All three answer before anything is saved. Addraw.company_uuidto link the contact to the company they work at.list_record_typeslistsraw.list_uuidunderrequired_inputs, and it is still gated bysync.bidirectional.getsales, which ships OFF — readwritable_nowbefore offering the create.companyandtagremain edit-only. -
Re-pace any unattended
/mcpclient — 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 a429carryingRetry-Afterand the-32000JSON-RPC envelope with the numbers repeated inerror.data. Pace againstX-RateLimit-LimitandX-RateLimit-Remainingon 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”- Set a Zoho field Turbobase has no column for by writing
raw.<ApiName>on an UPDATE toPOST /v1/data/:recordType— and audit what your app already puts inrawon 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 arawkey address the same Zoho field, set the COLUMN — it wins. If your app was usingrawon 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
rawkey is NOT sent — a create carries no changed-field hint, so only the mapped columns (and a custom module’scf_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_<slug>) — updates and creates both. They were refusedrecord type "…" is not writable; a create now comes back with the id Zoho minted, stamped onto the row. Two new refusals to branch on, both422withrejection.code:CUSTOMIZATION_NOT_ACTIVEwhile the module’s table is still being built (retry after the next reconcile — it is not a bad request), andSOURCE_NOT_CONNECTEDwhen 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”-
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. Themessagenames the id — render it, and suppress any “try again” affordance your screen offers on ablockedrow 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.repositoryasowner/repoon every GitHub issue you create throughPOST /v1/data/issue, with the title insubject— for example{ "subject": "Widget spins backwards", "raw": { "repository": "acme/widgets" } }. A create missing either is now refused with422before anything is saved (NO_TARGET_REPOSITORYnamingraw.repository, orINCOMPLETE_ISSUE_CREATEnamingsubject), and a repository that is notowner/repois refused asINVALID_TARGET_REPOSITORY. Before, such a create answered200and the issue never opened. There is no default repository, and the one you name must be one the GitHub App installation selected.list_record_typeslists both underrequired_inputs. -
Read
writable_nowbefore writing one of the nine newly writable record types, and expectfalseuntil staff enable the source.zoho_crm‘scall,event,task,product,quoteandquote_line, andgetsales’contact,companyandtag, now publish a write posture to/v1/catalog/recordsand to MCPlist_record_types. Every one is gated bysync.bidirectional.<source>, 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, neverPOST /v1/data/quote_line. Zoho publishes no line module: the items rideProduct_Detailson the quote’s own call, soquotecommits header and lines in one atomic write and a direct line write is refused as not writable. Omitlinesto edit the header alone; an empty array does the same thing and is NOT how you clear a quote’s items. Zoho reads an emptyProduct_Detailsas “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/<recordType>to refuse a CREATE forgetsales’companyandtag, 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. Acontactis the exception and can be created — see the note aboutraw.list_uuid. -
Expect a Zoho CREATE of a quote, an event or a call to come back
needs_inputnaming a field you did not send. Each needs a value Turbobase has no column for — a quote’sSubject, an event’sEnd_DateTime, a call’sCall_DurationandCall_Type— so Zoho refuses it with its ownMANDATORY_NOT_FOUNDrather than Turbobase inventing one. Editing a mirrored record is unaffected. -
Re-read the record counts for
zoho_crm’scall,event,taskandproductif 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_adjustmentwas read-only and is now writable end to end: the row persists against its ownpg_row_idwithexternal_idnull, reaches NetSuite through the write path and settles towrite_status = 'synced'carrying the id NetSuite assigned. Editing an already-mirrored adjustment works the same way, addressed bypgRowId. A CREATE must carry itslinesarray in the same request — NetSuite replaces the sublist in entirety and has no per-line endpoint, so a line-less create is refused withATOMIC_WRITE_REQUIREDbefore 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) ornew_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, clearadjust_qty_byfirst.quantity_on_handandestimated_total_valueare NetSuite’s own readings and are never written back, so posting a row you just read is safe. -
Ask your administrator for
write:inventory_adjustmentif 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_adjustmentexists and is deliberately NOT in the default bundle — destructive operations stay with administrators.GET /v1/catalog/recordsnow reports this record asbidirectionalwith awriteCapability, 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”-
Pace an MCP client against the
X-RateLimit-*headers on/mcpinstead of a delay you fitted by trial and error. Every response from that endpoint carriesX-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(whole seconds), and a refusal addsRetry-Afterequal 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.mdcarries 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
/mcp429out oferror.data, not the old top-levelmessage— 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 readingstatusCodeoff the body, or parsing the seconds out of the prose, must move toerror.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 onerror.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 thatRetry-Afterwas 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 reportRetry-Afterand a live remaining count. The page now says which layer each statement is about. -
Branch on
422/UNKNOWN_COLUMNfromPOST /v1/data/:recordTypewhen a save names a field the record has no column for — not on500write_failed. That refusal was only ever reaching apps correctly for a handful of record types; for every other one — every custom record, andinvoice,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. Renderfieldagainst 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/:recordTypecreate with the sameidempotencyKeyand 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/registerand namingresource=https://<instance>/rest/v1on/oauth/authorizeand/oauth/token. That resource indicator is new and it decides which credential you are handed: name it and/oauth/tokenreturns the instance’s own session token — the same RS256 JWT a browser session carries — which/rest/v1(as bothapikeyandAuthorization: Bearer),POST /v1/data/:recordTypeandPOST /v1/batchall 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 asresources_supportedon/.well-known/oauth-authorization-server. The token lasts eight hours and renews withgrant_type=refresh_tokenfor 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:/cbis refused withinvalid_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 registershttpon127.0.0.1orlocalhost, on any port, unchanged. The refusal message now names both accepted shapes. -
Send the session token as
Authorization: Bearerfrom a native app onPOST /v1/data/:recordTypeandPOST /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-serverandPOST /oauth/registerto 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/authorizeor/oauth/tokennaming the MCP resource — or naming none, which means MCP — still refuses503 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.channelandbodyon a create ofslack:channel_message—POST /v1/data/channel_messagenow 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 answered200with apg_row_id, nothing was posted, and the refusal surfaced minutes later onrecord_write_stateas a held row that named no field to fix. It is now an immediate422carryingerror.code(NO_CHANNEL/NO_TEXT) anderror.field(raw.channel/body), no row is written, and nothing is queued.raw.channeltakes 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 throughwrite_recordon 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 the200must read the refusal on the call. Distinct from the existingCHANNEL_NOT_MIRROREDrefusal, which is a channel name the workspace directory does not hold. -
Read
required_inputsforslack:channel_messageonlist_record_typesinstead of hard-coding what a post needs. The entry now publishesraw.channelandbodybesideops, 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_inputrather 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_statenow readsNO_CHANNELwithraw.channelonfieldsand 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 matchingsync_failurerow moves from thesource_blockedbucket tosource_validation.
v1.24.0
Section titled “v1.24.0”- Declare
readson a conduit that has to look something up, and read it withctx.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 areselect=*, embeds and any operator outsideeq, neq, gt, gte, lt, lte, like, ilike, is, in.qis one PostgREST path-and-query, the same shapedefineProcess’sctx.querytakes. A conduit that declares nothing has noctx.queryat all, so nothing changes for one you have already written. - Cap a conduit’s reads with
maxQueriesPerRun(default 8, max 32) and a per-readmaxRows(default 50, max 500). The query budget belongs to the RUN, so asequenceshares 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 ofchannel_messageis narrowed toactivity_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_eventledger 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[].externalIdis populated for a replica binding once the write-back confirms it. It was alwaysnullbefore, because nothing called the stamp; branch on it rather than treating a bound source as unidentified.
v1.23.0
Section titled “v1.23.0”-
Stop treating
write_status = 'pending'as “still in flight” — a write that never reached the connected system now endsblocked. It used to staypendingfor ever once the retries were spent, so a screen that waited for it to clear waited on nothing. Handle the two newrecord_write_state.reason_codevalues and render the row’smessage:source_unreachable(the connected system could not be reached, so nothing was sent — the reader’s next save is what re-sends it) andwrite_failed_unexpectedly(the write failed in a way nothing recognised; the generic held text applies and the team has the failure). -
Expect a
blockedrow on an UPDATE and a DELETE whose write was lost, not only on a create.source_response_lostpreviously 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_CONNECTEDon 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 asource_systemonce 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 omitsource_systemto use the one your session signed in against. -
Stop treating
SOURCE_NOT_CONNECTEDon a Slackchannel_messageas “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 refusedSYNC_BIDIRECTIONAL_DISABLEDinstead, which names the real gate: writing back to Slack is a per-tenant entitlement our staff turn on. Both are422and neither writes a row, so an app that only surfaces the message needs no change. -
Match
record_write_state.reason_codeonsource_changed_since_read, notWRITE_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 asWRITE_CONFLICTwith 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_staterow 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, sayingneeds_inputwhere the row your app reads saidblocked. 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’spg_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 askind: '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_statusvalue 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
Stageto 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 withfailure_cause: source_value_not_acceptednamingStageand the value sent. -
Send
template_external_idandraw.submitterson a create ofesignSubmission, andbodyandraw.accountson a create ofsocial_post—POST /v1/data/:recordTypenow 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 answered200and failed minutes later onrecord_write_state. It is now an immediate422carryingerror.codeanderror.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 the200must read the refusal on the call. Same behaviour throughwrite_recordon the MCP data server. -
Read what a create needs from
required_inputsonlist_record_types, instead of hard-coding it. Every entry carries the field paths a create requires besideops—[]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_statewhen a write comes back parked. New read tool on the data MCP server: pass thepg_row_idwrite_recordreturned (or anexternal_idonce 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/esignSubmitteraddressed bypg_row_idorexternal_id.docuseal:esignSubmitteris now writable as an UPDATE —list_record_typesreportsops: ["update"]for it — and the edit reachesPUT /submitters/{id}. A create is still refused, now withEDIT_ONLYand the reason, where it previously came back400 "not writable": an app that branched on that status for this record type should readerror.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 asblockedwith 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 changingtemplate_external_id. It now settlesneeds_inputwithreason_code: 'source_value_not_accepted', a message naming the refused id, and arecord_write_state.fieldsentry carrying{ field: 'template_external_id', label: 'Template External Id', submitted: true, requested: '<the id sent>' }— so offer an edit and re-save rather than a support link. Every other e-signature refusal keepsblockedand the generic hold. -
Stop editing a sent signature request —
POST /v1/data/esignSubmissionaddressing an existing row now answers422 APPEND_ONLYwhere it answered200. It applies to both addressing keys,pgRowIdandexternalId, 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 ordinaryPOSTand is unchanged. Changing a party on the request that already went out is done in DocuSeal, not from your app —esign_submitteris granted read only andPOST /v1/data/esignSubmitteranswers400 record type "esignSubmitter" is not writable. If you branch onrejection.code, read this one too: on an instance where the DocuSeal write entitlement is off, that same addressed write used to answer422 SYNC_BIDIRECTIONAL_DISABLEDand now answers422 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 answersSYNC_BIDIRECTIONAL_DISABLEDwhile the entitlement is off. -
Expect
createalone inlist_record_typesfordocuseal:esignSubmission. The record-metadata surface publishedcreate, 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_filefor 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 carriescontent_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_filerow whosestorage_refis 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 readscontent_textright after a connect finds some of it null for that long. Design for the null instead of waiting it out: filter onstorage_ref is not nullwhen a surface needs the bytes. -
Do not read
content_textfor 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, withstorage_refandcontent_textnull, so a repository’s file count is honest without the mirror carrying its generated artefacts and binary assets.content_sha256may or may not be set on one — it is there when the file was read before the policy declined it — sostorage_ref is not nullis the predicate for “the bytes are here”, never the digest.
v1.22.0
Section titled “v1.22.0”-
Read the five new keys on
list_record_typesinstead of guessing what a record does. Every entry now carriesdirection(bidirectionalorread_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) andprompt(the record’s anatomy prompt, passable toprompts/getverbatim).writable_nowis the same answerGET /v1/catalog/recordsgives aswriteCapability, so the two can no longer disagree. Nothing was removed —record_type,source_system,list_pathandtableare unchanged. -
Name
source_systemon awrite_recordCREATE 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
initializeinstructions now name/mcp/authoringand what the two extension kinds are — hooks and conduits — so “every new note goes into that channel” resolves toauthor_conduitthere instead of a record write that does not exist. Nothing about the authoring server’s own surface changed. -
Add
?mode=synctoPOST /v1/processes/:idwhen your app wants the answer on the call. It returns200 { runId, processId, state, reason, outstanding, steps }once the run settles, or202 { error: 'sync_budget_exceeded', runId, … }when it outlasts the instance’s budget — which is not a failure, so watchGET /v1/processes/runs/:runIdfor the run that is still going. Branch onerror, not on the status: in this mode a202is the one answer that does not mean finished. Naming no mode is unchanged:202and a run id. -
Handle
cancelledwherever you read a process run’s state. It joinsPROCESS_RUN_STATESin@turbobase/extensions, so aswitchover 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
committedas “there is nothing left to do.” A run now carriesoutstanding; a run that took a slice of a migration endscommittedwith 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/queueinstead 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 withPOST /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 inoutstanding. -
Declare
eachRow: { outstanding, key }on a process that moves many records rather than invoking it once per record.outstandingreturns 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 — andkeynames the column identifying a row. Each step then reads its row fromctx.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
awaitSourcewait 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 endscommittedwith the steps it never reached inoutstandingand itsreasonnaming the step still waiting. If you branched onfailedto detect a slow source, branch onoutstanding > 0and the reason instead. -
Read the tracked item types your customer actually stocks.
turbobase.itemnow carries seven moreitem_typevalues —lotNumberedInventoryItem,serializedInventoryItem,lotNumberedAssemblyItem,serializedAssemblyItem,nonInventorySaleItem,otherChargeSaleItem,discountItem. A screen that filtereditem_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 ownpg_row_idwithexternal_idnull, reaches NetSuite through the write path and settles towrite_status = 'synced'carrying the id NetSuite assigned. Editing an already-mirrored row works the same way, addressed bypgRowId. -
Ask your administrator for
write:location/write:department/write:classificationif 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/recordscontinues to reportreadCapability: nullfor these three and now reports awriteCapabilitybeside 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 async_failurerather than as a refused save. -
Read FeedHive posts from
feedhive:social_postonactivity, not fromfeedhive:post./v1/catalog/recordsgainsfeedhive:social_post→ tableactivity,readCapability: 'read:activity'(/rest/v1/activity?source_system=eq.feedhive&activity_type=eq.social_post). It is the writable FeedHive record — what Turbobase scheduled — whilefeedhive:poststays the read-only knowledge row onmemory_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 bysync.bidirectional.feedhive, which defaults off, so expect a deterministic refusal until staff enable it.
v1.21.0
Section titled “v1.21.0”-
Start a build by POSTing a keyless
workflow_runto/v1/data/workflow_run. Sendref(the branch or tag), andrepositoryFullName(owner/repo) plusworkflowunderraw.workflowis the workflow’s FILE NAME (ci.yml) or its numeric id — never theworkflow_external_idyour mirror holds, which is a GraphQL node id for any run that arrived by webhook and is refused. Add the workflow’s own declaredinputsbeside them if it has any. The run appears inautomation_runand 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
writeCapabilityon/v1/catalog/recordsbefore rendering a “run this” control.github:workflow_runnow reportswrite:automation_runfor a tenant entitled to GitHub write-back andnullotherwise, so an app can hide the control instead of discovering the refusal at the first press. The caller also needs thewrite:automation_runcapability 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
errorstring instead of guessing.POST /v1/data/:recordTypeused to answer a permission failure with a bareforbiddenon 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 answers403withrecord type "X" needs the write:Y capability for Z, and this session does not hold it, for every writable record type rather than onlyworkflow_run. The capability it names is the samewriteCapabilitythat/v1/catalog/recordspublishes for that record, so an app can surface “ask your administrator forwrite:Y” verbatim. Nothing that used to succeed now fails — the check asks exactly what the database already asked, including honouring a Data admin’swrite:*; 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 sendsource_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 answer400 record type "…" is not writableon every write, delete and MCPwrite_record— the row could be read and not created. It now takes a create, an update bypg_row_idand aDELETE /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 inentity, a local GL account inaccount— and that is whatsource_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 astandalonerow is now refused422 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 sendsource_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
403and404fromPOST /v1/data/{recordType}where you used to get500 write_failed. An update whose key matches no row you may write now answers404 {"error":"row not found or not permitted"}, and a write your permissions refuse answers403 {"error":"forbidden"}— the same two answers the MCP tools already gave. Nothing that returned200,400,409or422changes. -
Re-read
writeCapabilityfrom/v1/catalog/recordsbefore you branch on it — twenty-two entries that published one now reportnull. Each declared a write its adapter cannot perform and thatPOST /v1/data/{recordType}already refused withrecord 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 arejira: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:fileanddocuseal:esignSubmitter. -
Write a Slack
channel_messagefrom an app or an outside AI client on any workspace-installed instance — the create is no longer refusedSOURCE_NOT_CONNECTED. Slack’s connection was never registered, so every generic question the platform asks — isslackconnected, may this instance create achannel_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 refusedSOURCE_NOT_CONNECTEDwith the connected list in the message — correct, and new. -
Put a channel NAME in
raw.channelif you want to, and expect a synchronous 422 when the name is unknown.raw.channelon achannel_messagenow takes#general,generalor 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 withrejection.field = "raw.channel"— where it previously returned 200 and failed asynchronously aschannel_not_foundinrecord_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/recordsgains three entries:slack:channelon the newchanneltable,slack:useronentity(entity_type = 'contact',read:entity:contact), andslack:reactionon the newreactiontable. A Slack person whose Slack profile email matches a contact mirrored from another system resolves to the sameentity_canonical, so your people views stop showing them twice.channelcarries no read capability andreactioncarriesread: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’sraw.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 thereaction_added/reaction_removedevents, 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/reactionwithsource_system: 'slack',raw.channel,raw.tsandraw.reactioncallsreactions.add. Deleting the row does NOT callreactions.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_removedarrives as an event and the row goes away, which is unchanged by this. Adding is behind the samesync.bidirectional.slackstaff switch (default OFF) and the same operator guardrails as a post, and refused the same deterministic way by both. Ask an administrator forread:reactionandwrite:reaction— neither is in the capability bundle a standard user is seeded with, so on that session/v1/catalog/recordsomitsslack:reactionaltogether and a write to the table is refused. An admin, or any role holdingread:*/write:*, already has both. Reading the rows through/rest/v1/reactionis unaffected either way: a Slack row answers to the channel rule above, not to a capability.
v1.20.0
Section titled “v1.20.0”- Ask an administrator for the new
mcp:writepermission when your script or unattended agent has to CHANGE data, not only read it. A personal access token carryingmcp:datareaches the data MCP’s read tools; addingmcp:writebeside it reacheswrite_recordanddelete_recordas well, and those run the same write boundaryPOST /v1/dataruns — 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 withoutmcp:data, and Edit on the token’s own row at/settings/tokensadds it to a token you already hold without changing its secret. Two things must both be true for a write to land: the token carriesmcp: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/v1is unaffected and still refuses a token outright. - Correct the sentence about tokens and writing in your project’s
CLAUDE.md. The scaffoldturbobase apps initwrites 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 freshinitor 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”-
Send
source_systeminparentwhen you CREATE a record on an instance with more than one system connected, and expect a 422SOURCE_NOT_CONNECTEDif you name one that is not.POST /v1/data/:recordTypeused 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 UPDATEsource_systemis VERIFIED against the addressed record and never written onto it: restating the value you read back still works, a different one is a 422SOURCE_MISMATCH, and a record cannot be moved between systems by editing it. Editing a record whose system has since been disconnected is refused withSOURCE_NOT_CONNECTEDnaming that system rather than silently saved. Omittingsource_systemis unchanged on a single-system instance. -
Name any connected system in the MCP
write_recordtool —record_type: "zoho_crm:account"from a session signed in against another system now reaches the write boundary instead of being refusedSOURCE_MISMATCH.delete_recordis 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:datapermission, or to add it to one you already hold — Edit on the token’s own row at/settings/tokenschanges 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 (<base-url>/mcp, theturbobaseserver in the.mcp.jsoninitwrites, 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 stillPOST /v1/dataas a signed-in person; and PostgREST still refuses a PAT outright (PGRST301), so/rest/v1is unaffected. The app CLAUDE.mdturbobase apps initwrites 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 freshinitor correct it in place. -
Edit a QuickBooks bill, customer payment or bill payment from your app with
POST /v1/data/bill,POST /v1/data/paymentandPOST /v1/data/billPayment— all three answered400 record type "bill" is not writablebefore 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_billforbill(entity_id/entity_nameis the vendor, plustran_id,tran_date,due_date,memoand itsvendor_bill_linerows),customer_paymentforpayment(entity_idis the customer,totalis the amount, andapplied_tonames the invoices it pays), andvendor_paymentforbillPayment(entity_idis the vendor,totalthe amount). Creating a bill payment from a row is still refusedMISSING_LINES—vendor_paymentcarries 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 Xerobillis refused exactly as before. -
POST /v1/data/itemis newly accepted on a QuickBooks-connected instance too, and it used to answer400 unknown record type "item"— a different refusal from the three above, becauseitemwas already open at the write boundary and the route could not work out which table it meant. Senditemcolumns (item_id,display_name,sales_description,base_price,is_inactive). On a create, setitem_typeyourself: QuickBooks keeps every item under the oneitemrecord type, so Turbobase cannot infer the sub-type for you, anditem.item_typeis NOT NULL — omitting it fails the insert rather than returning a field-level422. An update addressed bypgRowIdorexternalIdis 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 rowsyncedonce the write landed, sowrite_statussat unsettled and the QuickBooks id was never written back toexternal_id. If your app readswrite_statusto decide when an edit is done, or keys offexternal_idappearing, those now resolve on the same schedule as every other QuickBooks record. -
On ANY CRM tenant, read the businesses you sell to from
customerrather thancompany, and holdread: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 ontoturbobase.entityasentity_type='customer': they are served by/rest/v1/customerand read withread:entity:customer, and thecompanysurface they used to sit on is retired (see below)./v1/catalog/recordsreports each of them underread:entity:customerwith the predicateentity_type=eq.customer, so re-runningturbobase project adoptand regenerating types is enough for anything that reads the catalog. Rows already mirrored are moved by the upgrade, together with theentity_typehalf 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 ontoread:entity:customer(see the retirement note below). -
If your app EDITS a Zoho CRM or Salesforce account through
/rest/v1/*, it now needswrite:entity:customerinstead ofwrite:entity:company. The write policies onturbobase.entityare armed perentity_typethe same way the read policy is, so the capability the row answers to moved with its discriminator.write:entity:customeris in the default standard bundle andwrite:entity:companynever 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 loseswrite:entity:company, and an admin has to grant itwrite:entity:customerif it should keep editing those accounts. -
A Zendesk connection’s
readscope now grantsread:entity:customerin place ofread: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— thecompanyentity type is retired on every tenant, and re-runturbobase project types. Once the six connectors above moved, nothing wroteentity_type='company', so the value is removed rather than left as a surface nothing fills: theturbobase.companyview behind/rest/v1/company, theread:entity:company,write:entity:companyanddelete:entity:companycapabilities, and the RLS policies that checked them are gone. Every row still carrying the value — including rows your app wrote there itself — is re-labelledcustomerby 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 onread:entity:company,write:entity:companyordelete:entity:companyloses it and gains nothing in its place: if that role should keep seeing those accounts, an admin grants itread: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/companyor a SQL query namingturbobase.companynow fails instead of returning rows — point it atturbobase.customerand filter onsource_systemif you only want one connector’s rows. -
POST /v1/data/commenton a GitHub connection to reply to an issue, pull request or discussion. Sendwork_item_external_id(the parent’s node id) andbody, with nopgRowIdorexternalId— the reply lands onactivityaswrite_status = 'pending'and picks up the id GitHub mints when it is posted. Creates only: addressing an existing reply is refused422 APPEND_ONLY, andDELETE /v1/data/commentstays refused. The session needswrite:activity. -
Send a signature request with
POST /v1/data/esignSubmission, signers inraw.submitters. The route answered400 record type "esignSubmission" is not writablebefore this release. Sendtemplate_external_id(required) inparent, and the parties asparent.raw.submitters— a list of{ email, name, phone, role, source_external_ref }— becauseesign_submissionhas nosubmitterscolumn and a top-level key is still refused422 UNKNOWN_COLUMN. You get back apg_row_idwithexternal_idnull: watch that row forwrite_statuspending→syncedand the provider’s id filling in, then read each party’ssigning_urloffesign_submitter. The caller needswrite:esign_submission, which is deliberately NOT in the built-in Standard role — an admin grants it per user — and the whole capability stays behindsync.bidirectional.docuseal, which ships off and refuses withBIDIRECTIONAL_DISABLED.esignSubmitteris unchanged: still no standalone create. Full page: Embedding a signing page. -
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 samework_item_external_id, so a query that selected everything for an item gets them too. Theiractivity_typeis one ofstatus_change,assignment,labeling,planningorrename,bodyis a ready-to-render one-line summary (added the label bug,renamed to …), andraw.eventcarries GitHub’s own verb. -
Re-run
turbobase project types—/v1/catalog/recordsgainsgithub:issue_event, backed byactivityand read withread: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_filteron every MCPlist_records/get_recordanswer to know whose rows you were given. On a table several connectors publish onto —entity,memory_document,activity,work_itemand 34 others — a read the source DECIDED is now narrowed withsource_system=eq.<source>, and that key says so (nullwhen nothing narrowed). It applies when you pinned a source (source_system, or thesource:record_typeform) or when only one connected source publishes the name, solist_records {record_type: "post"}returns FeedHive posts and no longer the meeting notes sharingmemory_document. A BARE name that several connected sources publish is deliberately still unnarrowed: those answers now carry awarningsline naming the sources and the qualified forms that separate them — pin one wherever your app assumed a single connector was answering.get_record’sapplied.source_systemchanges meaning with it, from the source the NAME resolved to, to the source YOU passed (nullwhen you passed none), which is whatlist_recordshas always meant by that key. -
Drop any stored MCP
page.next_cursorand 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 refusednew 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/recordsadvertisedwriteCapability: 'write:entity:lead'throughout, so an app that trusted the catalog built an editor that could not save. Sendentitycolumns (display_name,first_name/last_name,company_name,email,phone,status_name) and leaveentity_typeto the route. Handle the refusal first: until an operator switches onsync.bidirectional.zoho_crm/sync.bidirectional.salesforcefor that instance — and both ship OFF — thePOSTitself is rejected,422 validation_failedcarryingrejection.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 needswrite:entity:lead, plusread:entity:leadto 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: arolesentry in yourpublic/turbobase.jsoncarryingread:entity:leadandwrite:entity:leadis created as an ordinary role when you deploy (see v1.5.1), and the admin assigns it in Admin → Users.DELETE /v1/data/leadis still refused400 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?”) andexplain_objectare now on both endpoints and answer identically. On/mcpthey need no personal access token — the OAuth sign-in your client already has is the credential — so a project whose.mcp.jsoncarries 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, itscatalog:readscope and the scaffoldturbobase project initwrites are all unaffected. -
Handle
source_value_not_acceptedas areason_codeon aneeds_inputrecord_write_staterow, and rendermessage— 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 CRMMAPPING_MISMATCHrefusal used to — the row is stillneeds_input, because changing the value is what makes the write land — and what moves is thatreason_codeis now the cause instead of the connected system’s own code, andmessageis 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.fieldscarries the same field withsubmittedandrequestedbeside it, so ordinary fix-sheet rendering works and an app can show what was sent without parsing the sentence. An app matching onreason_code === 'MAPPING_MISMATCH', or rendering the old message, sees neither after this release.
v1.18.0
Section titled “v1.18.0”- Read a customer’s signature requests from the new
esign_*tables, and re-runturbobase project types./v1/catalog/recordsgainsdocuseal:esignTemplate,docuseal:esignSubmissionanddocuseal:esignSubmitter, and/rest/v1/esign_template,esign_submissionandesign_submitterserve 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 fromraw. 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. Checkstatusand the parent submission’sexpires_atbefore mounting: an already-signed or expired request loads a page that quietly will not submit. Full page: Embedding a signing page. - Expect a write to
esign_submission/esign_submitterto be REFUSED until an operator turns it on. Both record types are bidirectional, and every write is held shut per-instance bysync.bidirectional.docuseal, which ships off — the refusal isBIDIRECTIONAL_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_templateis read-only in every case.
v1.17.0
Section titled “v1.17.0”-
Write a GitHub issue or pull request from your app with
POST /v1/data/issue/POST /v1/data/pull_request— both answered400 record type "issue" is not writablebefore this release, on a GitHub-connected instance. Send canonicalwork_itemcolumns:subjectretitles,status(open/closed) closes and re-opens,labelsis 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), andassignee_idassigns or, set tonull, unassigns. Sendingassignee_namebesideassignee_idsaves 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_staterow withreason_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 landsneeds_inputwith its own plain-languagemessage, instead of reporting success and being reverted by the next sync. Nothing on the item is changed when this lands: show themessageand 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.descriptionfor the body of a GitHub issue or pull request — it was alwaysNULLfor GitHub rows and is now projected on every delivery, so an app that fell back toraw->>'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
boardfamily instead of decoding card order and dates out of label names, and re-runturbobase project types./v1/catalog/recordsgainsgithub:projectandgithub:project_item, and/rest/v1/board,board_field,board_cardandboard_card_field_valueserve them under RLS to any standard user — the fourread: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 withboard?select=*,board_field(*),board_card(*,board_card_field_value(*))— no constraint hint needed. Sort a column withboard_card?status_option_id=eq.<id>&order=position:positionis 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 — joinboard_card.content_external_idtowork_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 fromboard_card_field_valuein the column matching the field’sdata_type(value_text,value_number,value_date,value_option_id/value_option_name, orvalue_jsonfor a multi-valued one);board_field.optionsis 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 reportsprojects_permissionwhen they have not. -
Post to Slack by writing a
channel_messagethroughPOST /v1/data, and re-runturbobase project types./v1/catalog/recordsgainsslack:channel_messageagainst theactivitytable (read:activity), and a row written there is posted into a Slack channel. Setsource_systemto'slack'on the row you write —turbobase.activityis 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 inraw.channel— required; that shared table carries no channel column — the message inbody, and, to reply inside an existing thread rather than start a new one, the parent message’stsinraw.thread_ts. Since v1.21.0raw.channeltakes 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 refusedSOURCE_NOT_CONNECTEDon every instance. Once Slack accepts the post,external_idon 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 tenantafter_synchook as__result.rewrittenFieldsand are not written to the row. Nothing reaches Slack until staff turn onsync.bidirectional.slackfor the tenant (default OFF), and the workspace’s bot token needs thechat:writescope the read-only install does not grant — the connection’s Health tab now says which. A write refused by either is a deterministic refusal inrecord_write_state, not a retry. Slack’s other record type,slack:message, is channel history inmemory_documentand is still read-only. -
Join a work item’s conversation on
activity.work_item_pg_row_id, not onlywork_item_external_id, and re-runturbobase project types./rest/v1/activitygains a nullablework_item_pg_row_idholding the parent’swork_item.pg_row_id. It is the ONLY pointer a card created in Turbobase has —work_item.external_idis 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 anactivityrow withactivity_type='comment'beside thememory_documentthe message already produced.
v1.16.0
Section titled “v1.16.0”-
Read GitHub Actions runs from the new
automation_runtable, and re-runturbobase project types./v1/catalog/recordsgainsgithub:workflow_run, and/rest/v1/automation_runserves it under RLS to any standard user —read:automation_runis 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: filterstatus=eq.in_progressfor what is running,conclusion=eq.failurefor what broke, and page onstarted_at. Both those columns are LOWER-CASE where GitHub’s API shouts them,conclusionis NULL while a run is still going, and a re-run bumpsrun_attempton the same row rather than adding one — so counting rows counts runs, not deliveries. Jobs, steps, logs and artifacts are not mirrored;permalink_urlis 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 answers202 {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 asPOST /v1/data, and the undo when a step fails all happen on the server and survive a worker restart. Refusals never carry arunId:404 {error: 'process_not_found'}for an id nothing deployed, and400 {error: 'invalid_input', rejection: {code, field, message}}when the body fails the process’s declared schema. PollGET /v1/processes/runs/{runId}forstate(queued→running→committed|failed|rolled_back) and one entry per step with itsdispositionand, on a failure, the refusing party’s owncode/field/message; the same rows are readable through PostgREST asprocess_runandprocess_run_step, scoped to the invoking user. -
Import
defineProcessfrom@turbobase/extensions(or the@turbobase/extensions/processsubpath) to author one, and scaffold it withturbobase ext new process --record <type> [--then <type>] [--on-failure rollback]. A process declares a JSON-Schemainputand orderedsteps, each a purebuild(ctx)returning the{parent, lines?}payload for one record type;ctx.query(q)reads as the invoking user exactly asPOST /v1/batchdoes.ext deployrefuses a module whosestepsare not a list, whose step has nobuild, 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/datastill 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 byext test,ext lint,ext deployand 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? }, …]ondefineConduit— instead ofto+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 latertransformreads the ones already landed offctx.materialized({ source, recordType, masterTable, masterRowId, externalId }, in order), andawaitSource: trueon an entry holds the next one until the source has minted that entry’s id soexternalIdis set rather thannull. Every target is checked for writability on its own atext deploy(conduit_target_read_onlynames the entry’s pair; anawaitSourceagainst a record type with no write trail isconduit_target_no_write_trail, and against a target of the firing record’s own type — which merges rather than mints — isconduit_target_await_same_type). A failing entry parks its own ledger row, skips the rest withearlier target failed, and undoes nothing already materialized — use a process withonFailure: 'rollback'when you need the undo.ext replayprints one transform block per target. A singleto+transformconduit is unchanged, andctx.materializedis[]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 readingturbobase.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), plusline_indexandline_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 thatitem_receipt_line,journal_entry_lineandorder_fulfillment_linespell the ordinalline_numberwhile every other line table spells itline_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-widesystem_notetable stays admin-only. -
Handle
422 {error: 'validation_failed', rejection: {code: 'INVALID_UUID', field: 'pgRowId', …}}fromDELETE /v1/data/{recordType}where a500 {error: 'delete_failed'}came back before. ApgRowIdthat 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 refusalPOST /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 asexternalId, which is free-form text and is never screened. -
Add
connection_id=eq.<id>to any read ofsystem_notethat filters onexternal_id, and re-runturbobase 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 numberssystemnoteper account. So on an instance holding two connections of one source,system_note?external_id=eq.1001returns two rows where it returned one. The table’s primary key also moves fromexternal_idtopg_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 permanentdefaultconnection id, so a single-connection instance sees no change beyond the new column.system_noteis gated at RLS on the admin / data-adminread:*wildcard, so this reaches your app only if it runs on such a principal. -
Add
source_system=eq.<source>to any read ofentity,invoice,vendor_bill,credit_memo,vendor_credit,customer_payment,vendor_payment,purchase_order,account,item,journal_entry,checkordepositthat assumed one accounting system — Xero is now a connectable source and writes into all thirteen./v1/catalog/recordsgains fourteenxero:*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-runturbobase project types. -
Read
statuson a Xeroinvoiceorvendor_billas XERO’s word, not as the NetSuite/QuickBooks vocabulary. Those rows carryAUTHORISED,PAID,DRAFT,SUBMITTED,VOIDEDorDELETEDverbatim, where a QuickBooks row on the same table carriesopen/partial/paid. An app that filtersstatus=eq.openreturns nothing for Xero — filter onamount_remaining=gt.0instead 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_lineandcheck_line, a Xero row leavesitem_idandaccount_idNULL and puts Xero’sItemCode/AccountCodeinitem_name/account_name; those strings join toitem.item_idandaccount.account_number. Xero’s API publishes no id on a line at all, so an embed keyed onitem_idreturns nothing for these rows.credit_memo_linehas noaccount_id/account_namepair at all — read a Xero credit-note line’sAccountCodeout of that line’sraw. -
Expect nothing writable from Xero. Every
xero:*catalog entry isread_only, soPOST /v1/data/:recordTyperefuses one; there is no flag that changes it.turbobase project typesmarks them accordingly. -
Handle
422 { error: 'validation_failed', rejection: { code: 'INVALID_UUID', field, message } }fromPOST /v1/data/:recordTypewherever your app sends apgRowIdor a<x>_pg_row_idreference. 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 as500 { error: 'write_failed' }; it is now refused before the write, andfieldnames the exact slot (pgRowId,entity_pg_row_id, orlines[<n>].<column>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 withexternalId, which stays free-form text. -
Stop building a return’s lines by hand — set
created_from_idand let the order supply them. AreturnAuthorizationorvendorReturnAuthorizationcreated throughPOST /v1/data/:recordTypewithcreated_from_idnaming 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 byitem, 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 noitem_idset 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 nocreated_from_idis 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”- Re-read the column
label,formatandcurrencyKeyyour 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’stable_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 Centralvendor_billentry 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-runturbobase project typesand 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,workdayandgetsales. Their/v1/catalog/recordsentries now carry a label and format on the columns their recipe names, and their intents appear in the MCPrecipes:///resource. Nothing was removed and no column changed; if your app renders its own headings, prefer the catalog’slabelwhere one is now present. - Handle the new
url_columnrow-action kind if your app renders the chat grammar’sactionsitself. A row action may now be{ kind: 'url_column', url_key: '<column>' }, which opens the absolutehttps://address that column holds on that row — as opposed toexternal_link, which composes one from a fixed prefix. Use it when the provider’s own URL is mirrored whole (GitHubpermalink_url; Slack, Granola, Fireflies and Google Workspaceurl); 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-grammaris the resolver, and it returnsnullfor anything that is nothttps://.
v1.14.0
Section titled “v1.14.0”- Add
source_system=eq.<source>to any read ofentity,work_item,opportunity,activity,item,estimate/estimate_line,order/order_lineormemory_documentthat assumed one CRM — Zoho CRM is now a connectable source and writes into all nine./v1/catalog/recordsgains fourteenzoho_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. CopysharedTable.hintfrom/v1/catalog/recordsrather than composing the predicate yourself —entitystill needsentity_typebeside it (lead/contact/company), andwork_itemneedsitem_type=eq.ticket. Re-runturbobase project types. An instance whose Zoho organisation has custom modules also gains onecr_<slug>catalog entry per module, withsource: 'zoho_crm'andorigin: 'admin', discovered per tenant — so enumerate/v1/catalog/recordsat run time rather than hard-coding the list. - Check
connectedbefore you offergranola:noteas a browsable table — it joins/v1/catalog/recordson every instance, carryingconnected: falseuntil a tenant connects Granola. It is a memory-class record: rows land inmemory_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.noteis a record-type name no connector registered before, so a barenotequery that used to resolve to nothing now resolves to Granola’s rows.
v1.13.0
Section titled “v1.13.0”-
Check
source_detail.envelope_presentbefore you read a sync failure’ssource_detailas a diagnosis. A NetSuite refusal that carried nothing readable now records{ "envelope_present": false, "raw_message": "…" }onturbobase.sync_failure.source_detailwhere it previously recorded nothing at all — so a non-nullsource_detailno longer implieserror_name/field_id/implicated_scriptare in it. Renderraw_message(the source’s own sentence, capped at 500 characters) when the marker isfalse, 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-chatnow refuses one they cannot. The record named increated_from_idis read under the caller’s own RLS before anything is queued, sowrite:order_fulfillmentno longer lets a user fulfil a sales order they are not permitted to see. The refusal is403 { error: 'forbidden' }with nothing queued, and it is byte-identical to the answer for acreated_from_idthat 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
connectedbefore offeringfeedhive:post, and stop reading a barepostrecord-type name as “no such record”./v1/catalog/recordsgains one entry,feedhive:post, againstmemory_documentwithreadCapability: 'read:memory'— carryingconnected: falseuntil a tenant connects FeedHive, exactly as the GetSales entries do.postis 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 (writeBackrejects) and carry no Atlas identity, so nothing consolidates intoentity_canonical. Regenerate your types if you enumerate record types from the catalog. -
Read a FeedHive post’s social accounts from
participants, notscope_ref. One post is ONE row however many accounts it was cross-posted to, soscope_ref— a singletextcolumn — is null on every FeedHive row and the account ids live inparticipantsinstead. A query filtering these rows byscope_refmatches nothing rather than erroring. -
Handle
inventory_detail_requiredas areason_codeon aneeds_inputrecord_write_staterow — these rows used to arriveblocked. It means the record moves stock for an item the connected system tracks by lot or serial number and those numbers were never supplied, sofieldsnamesinventory_detailand ordinary fix-sheet rendering works; write the assignment to that column on the line and save again. Themessageis 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 ablockedrow 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 byread: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), withpath,ref,commit_sha,content_sha256,size_bytesand — 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/filesendpoint to learn. It is read-only from your app:POST/PATCH /v1/data/github:fileanswers400and 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_refandcontent_textare 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 aresource_file_change, joined to the pull request throughwork_item_external_id. -
Expect pull requests and discussions in
work_item, under newitem_typevalues. A list built onwork_itemand filtered byitem_typewill 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— plusanswered_atandanswer_external_idfor 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
sprintand its linked issues fromwork_item_link— two shared tables that were Jira-only until now. A list built on either and NOT filtered bysource_systemwill start including GitHub rows; add the filter if you meant one source. Onsprint,nameis the milestone title,start_at/end_at/complete_atare its opened / due / closed dates (end_atis NULL for an undated milestone — an ordinary case, not a broken row), andboard_idcarries the repository. Onwork_item_linkthe row reads inwardlink_typeoutward — the pull requestclosesthe issue — with both*_work_item_external_idcolumns joining towork_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 awork_itemis not, and awork_itemfiled under a milestone carries it insprint_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_itemandactivityreads byscope_5if your tenant registers a custom segment. Both tables now carry the generic scope slots and their read policies consultscopes_match(). Nothing changes for a row whose slots are NULL — which is every row that exists today — but a session whosescopesclaim 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_rateoff the transaction row when you convert a figure, and stop reaching forturbobase.currency.exchange_rate. Every transaction table —order,invoice,cash_sale,credit_memo,estimate,vendor_bill,purchase_orderandjournal_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_rateis 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 intocurrency_rateyou 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_memoorestimatethroughcurrency_idnow —raw.currencyis no longer the only way. These four record types carrycurrency_idandcurrency_namecolumns, so the currency is filterable, embeddable and writable like every other ref instead of being reachable only insideraw. This closes the gap v1.4.x’s note left open (“open an issue if you need one”).currency_nameis the source’s own display name and is source-owned in a conflict. For a portable ISO key, readcurrency_code—invoice,credit_memoandestimategained it in a later release (this file is newest-first, so see the entry above); as of this one onlycash_salestill needs resolving throughturbobase.currency.
v1.12.0
Section titled “v1.12.0”-
Read
writeCapabilityon every/v1/catalog/recordsentry before you offer an edit, and drop any write-it-and-see probe you built to find out. It isreadCapability’s twin —write:orderbesideread:order— and it isnullwhenever 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; treatundefinedas “unknown”, not as read-only. -
Stop putting
ownerinsiderawwhen you write a standard record — it is now dropped before the request leaves. On a NetSuite record type that is not a custom record,owneris 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. Acustomrecord_*write is unchanged:owneris a genuine setter there and still travels. -
Filter shared canonical tables with the concrete predicate
/v1/catalog/recordsnow publishes, and drop anypg_row_id-and-check-each-row workaround you built for these record types.sharedTable.hintused to say the instance could not name a value for seventeen record types stored alongside a same-source sibling — Google Workspace’sdoc/email/file/sheet/slide, Salesforce’saccount/lead/task/event, Intercom’sadmin/company/conversation/ticket, HubSpot’scompany/owner, Zendesk’sorganizationand getsales’scompany. Each now publishes a real predicate (kind=eq.doc,entity_type=eq.company,activity_type=eq.task,item_type=eq.ticket), andselectTarget.discriminatorcarries 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’ssalesTaxItem/taxGroup. -
Expect a read of one of those seventeen record types to return only that record type. The MCP
list_records/get_recordpath applies the predicate above, so a read that previously came back with the source’s whole slice ofentity,work_item,activityormemory_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— alabelsarray 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/appsgrid.coloris one ofslate,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 planshows 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 reachesrecord_write_stateasblockedwithreason_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_idon everyPOST /v1/data/itemReceiptcreate — it is now required, and a create without it is refused422. The rejection code isCONVERSION_SOURCE_REQUIREDandrejection.fieldiscreated_from_id; set it to theexternal_idof 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 return200and 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_transformableas areason_codeon aneeds_inputrecord_write_staterow. 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.fieldsnamescreated_from_id, so ordinary fix-sheet rendering works; themessageis 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 resendsubsidiary,currencyorcustomformat 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 outsidePOST /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 fromraw. 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”- Handle
insufficient_stockas areason_codeon aneeds_inputrecord_write_staterow, and rendermessagerather than a fix sheet. It means the connected system does not have enough of the item for the quantity requested, and it arrives where afield_validationrefusal used to: the row is stillneeds_input, because lowering the quantity is what makes the write land. What changed is thatreason_codeis now the cause instead of the generic validation code, andmessageis plain language telling the user to reduce the quantity or add stock — the source’s own words never said which field.fieldsis empty (the refusal names none), so an app that renders a fix sheet and nothing else will show aneeds_inputrow with no instruction; showmessagefor 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”- Stop joining on a column that
/v1/catalog/columnsnow marksscopeDimension— it is a permission boundary, not a link.subsidiary_id,location_id,department_id,class_id,scope_5…scope_8and their_nametwins carry a newscopeDimensionfield (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 existingrole/idFor/displayForvalue is unchanged, and the same column on thelocation/department/classificationmasters, where it IS a real parent link, is not marked. - Send
client_idon everyPOST /oauth/tokenrequest to your instance’s MCP OAuth bridge — theauthorization_codegrant as well asrefresh_token. A request without it answers400 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 answers400 invalid_grantwith no token: re-run the authorization instead of retrying the refresh. Therefresh_tokenyour client receives is bound to theclient_idthat 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 ownclient_id. - Pass
approve: trueto every shipping tool on/mcp/authoring, not onlydeploy.apps_push,tables_apply,bundle_deploy,release_deploy,release_push,release_rollbackandinstrumentnow answer the sameapproval_requiredresultdeployalways 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/callfailure 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 ownmessage, and refusals of your input are worded as before. - Expect a header-only update through
POST /v1/data/:recordTypeto 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 insideraw) 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 asnetsuite_field_validationwithdetail.fieldIdnaming the field anddetail.explain.divergedcarrying sent-vs-committed; treat it exactly like any otherfield_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 insideraw, 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”-
Write
approval_statusas the source’s internal id ('2'), never the display label ('Approved'). The mirror STORES the label —approval_statusprojects the ref’s name with no id fallback onpurchase_orderandvendor_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 toneeds_inputnamingapproval_status. If you need to change an approval status, send the id. -
Expect
fieldson aneeds_inputwrite 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 withfields: []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 emptyfieldsas “this refusal has no field” should render the list instead. -
Re-read the row after
del(recordType, …)— a2xxfromDELETE /v1/data/:recordTypeis 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 carrieswrite_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 norecord_write_staterow, so branch on its absence rather than waiting for ablockedthat never arrives. If your UI removed the record from a list on the2xxand 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 crossingdeleted_atis 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_dependentsas areason_codeon ablockedrecord_write_staterow. It means the source will not release the record while other documents point at it, andmessagenames 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
outcomeon eachturbobase.record_write_state.fieldsentry before you re-prompt the user for aneeds_inputrow — the source now says what it did with each field it named. Every entry still carriesfieldandlabel, unchanged. When the source answered, the entry also carriessubmitted(whether your write sent that field at all),requestedandactual(what you sent and what the record held when it refused, each cut to 200 characters), andoutcome: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), orempty(you never sent it and the record requires it). A label-named entry (field: "Vendor") gainsfield_id(entity) so you can map it to the column your form edits. Branch onoutcomewhen 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 (submittedis only ever stated where the source proved it), still writes the two-key entry, at most 25 entries per row are enriched, andstatus,reason_codeandmessageare unchanged either way.
v1.8.0
Section titled “v1.8.0”- Convert a dated amount through
currency_rate, notturbobase.currency.exchange_rate.currency.exchange_rateis 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 pereffective_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.<document date>&order=effective_date.desc&limit=1. An amount inquote_currency_code×exchange_rateis the amount inbase_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.<id>— each mirrors its own rate table, solimit=1would 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/recordsnow listsnetsuite:currencyRateagainst tablecurrency_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_codeon ablockedrecord — a write our own policy stopped now reachesrecord_write_stateinstead of sitting onpending. Until now a policy-stopped write left the row onpendingforever, so an app polling for the terminal state never got one and showed the write as still in flight. Match on theguardrail_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, andguardrail_hold_expiredfor an approval nobody answered in time) and the set can grow. Treat them like every otherblockedcode: 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 stuckpendingas “still working”, that timeout branch can go. - Read the new
descriptionon astatus/*_statuscolumn 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’sanatomyMarkdownnames 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_statusas 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_idinsideparentwhen you CREATE a record throughPOST /v1/data/:recordType— it now comes back422withrejection.code = "PROTECTED_COLUMN". The same holds forpg_row_id,synced_at,sync_version,last_modified_pgandoriginating_system: those columns belong to the platform.external_idis the record’s link back to the source system and it is minted UPSTREAM — a row you create carriesnulluntil 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 async_failurethat cannot heal itself. Create the record without the field and read the id back off the row.connection_idandsource_systemare 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_idand all) needs no change; only a value that would CHANGE one is refused. One status moved with it: an update addressed by apgRowIdyour session cannot see, carrying a protected column, now answers the same422where it used to answer500 write_failed. - Read
sync_attempt.error_detail -> 'detail'as JSON — stopJSON.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 anderror_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 forscriptsOnRecordType/explain/fieldReadbackwhen a write is refused naming a field you demonstrably sent. - Name
subsidiary_id/currency_idin the patch when you mean to change one — settingsubsidiary/currencyunderrawno longer reaches the source. APATCH /v1/data/:recordTypeis narrowed to the columns you named, and that narrowing now drops those two keys out of therawpassthrough as well as out of the typed columns: restating either at its own unchanged value makes NetSuite re-source the transaction and CLEAR itsentity, 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 overrawexactly as before. On a record type with no promotedcurrency_idcolumn (invoice,cash_sale,estimate,credit_memo,item_fulfillment, …)raw.currencyis 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_idon any payable line your app cached — some values change with this release. Onvendor_bill_line,purchase_order_lineandcheck_line, rows written by the bulk backfill could carry a serialised ref object ({"id": "3", "refName": null}) incustomer_idinstead of the id, and others were leftnullwhen the customer was in fact known. A one-shot rewrites both. If you storedcustomer_idoutside the instance, or matched on it as a string, refresh from/rest/v1/rather than trusting the cached value. - Stop treating a
nullcustomer_nameon a rebillable payable line as the normal case. The v1.4.0 note told you to embed or joincustomerbecause the bulk path setcustomer_idwith no name beside it. The name is now resolved from the mirrored customer, and staysnullonly 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/customformyourself 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 mirroredraw, 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.customformno longer changes a form on an UPDATE — it is dropped with the rest. Creates are unaffected, so the v1.2.0 guidance (“Setraw.customformif 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_typefrom any filter you built out ofsharedTable.discriminatorson a transaction table, and re-copy thehintfrom/v1/catalog/records. It was named oninvoice,order,estimate,opportunity,credit_memo,customer_payment,expense_report,order_fulfillment,purchase_order,vendor_bill,vendor_paymentandwork_item— 50 entries — where it is a per-table CONSTANT carried for theentityforeign 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.<source>is unchanged and is now the whole answer on those tables;entity_type=eq.customeronentityanditem_type=eq.inventoryItemonitem/work_itemare unchanged too. - Handle an EMPTY
sharedTable.discriminators— the note no longer always names a predicate.tax_codeholds NetSuite’ssalesTaxItemANDtaxGroup, so the oldsource_system=eq.netsuiteexcluded nothing while reading as a complete answer. The array is now[]and thehintsays to match onpg_row_idand check the row you read — the same wording/v1/catalog/objects/:idalready used. - Re-read the
hintwhere a filter narrows only PARTLY — it now names what still matches. Filteringactivitybysource_system=eq.salesforceleavessalesforce:eventbehind, andentityfor 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.discriminatorsshrinks to['source_system']for fourteen more record types.dynamics-365-bc:item,hubspot:product,quickbooks:item,salesforce:product,shopify:productandstripe:producteach store exactly one record type onitem, sosource_system=eq.<source>returns precisely that record type; the note used to additem_type=eq.<the kind you want — this instance cannot name it…>beside it and still close “or you will read (and label) the wrong row”, so following it literally returned nothing. The same eight onentityandwork_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:companyonentity, becausehubspot:contactandhubspot:ownerrows carrysource_system=hubspottoo — 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.statusfor the anchored-line refusal — it now landsblocked, notneeds_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 arriveneeds_inputwith an EMPTYfieldsarray, so a fix sheet asked the user to correct nothing at all. It now arrivesstatus='blocked',reason_code='anchored_line', and amessagenaming the receipt, shipment or bill to amend. If your app rendersneeds_inputas a form andblockedas 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
blockedwrite from the user when itsreason_codeissource_not_transformable— showrecord_write_state.message. Every otherblockedrow carries the fixed “no action is needed from you” hold, so an app that suppressesblockedentirely 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 stillblocked(nothing to type,fieldsis empty), so surface the message rather than a fix sheet. - Add
source_system=eq.<source>to every read oflabel,customer_statusandlead_source— each of the three now holds rows from two connectors, and each carries asharedTablenote on/v1/catalog/recordswhere it carriednull. The GetSales connector projectstagontolabelbeside Asana’sasanaTag,pipelineStageontocustomer_statusbeside NetSuite’scustomerStatus, anddataSourceontolead_sourcebeside NetSuite’sleadSource. None of the three is connection-scoped — the key is a bareexternal_id— so both sources write one id space and an unfiltered read returns both connectors’ rows. Copy thehintfrom the catalog entry rather than composing the predicate yourself;discriminatorsis['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 barecontactorcompanyrecord-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, andgetsalesis registered afterhubspot,salesforceandintercom, 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/recordson every instance —getsales:contact,getsales:company,getsales:tag,getsales:pipelineStage,getsales:dataSource— carryingconnected: falseuntil a tenant connects GetSales, so keep checkingconnectedbefore you offer one as a browsable table.tag,pipelineStageanddataSourceare names no connector registered before: a bare query for one used to resolve to nothing.
v1.7.0
Section titled “v1.7.0”- Send
'', notnull, underrawwhen you mean to blank a field UPSTREAM. Anullorundefinedvalue underrawis 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;rawnow matches them. This does not change whatnulldoes to the MIRROR — under v1.5.5 it still removes the key from the storedrawcolumn, and that is unchanged. The two meanings are now distinct:nulledits your mirror,''edits the source. - Cross a finding out in
INSTANCE-LEARNINGS.mdwhen 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~~), writeWITHDRAWNorRETRACTEDon 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 §Nfor 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”- Change every money column from
currency.usdtocurrency.native, and give itcurrencyKey: 'currency_code'.currency.usdandcurrency.eurhard-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 newcurrency.nativereads the ISO code from the columncurrencyKeynames and renders in that currency; a row that records none renders a bare number rather than inventing a symbol.currency.usd/currency.eurstill 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 acurrency.nativecolumn 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 newcurrencyKey, and theFormatterunion carriescurrency.native. A project pinned to the old union will not type-check a recipe or grammar that uses it. - Read
currency_code, notcurrency_id, when you need to know a row’s currency.currency_idholds a NetSuite internal id on a NetSuite row and an ISO code on a QuickBooks / Shopify / Dynamics one, andcurrency_nameis account-configurable free text.currency_codeis onorder,vendor_bill,purchase_order,return_authorizationandentity, 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_rateis the CURRENT rate), so a consolidated total cannot be computed correctly from the mirror today. Group bycurrency_codeand label each figure.
v1.6.0
Section titled “v1.6.0”- Delete your
apps pushworktree workaround —.gitis skipped by name now, file or directory.turbobase apps push <app> .used to fail from a git worktree withpush rejected: invalid_path in .git, because a worktree writes.gitas 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.gitand pushing that, push.directly again.
v1.5.5
Section titled “v1.5.5”- A partial patch to a TRANSACTION no longer deletes the rest of
raw— and if you addedrawto a patch to work around that, take it out. v1.5.2 saidrawmerges on update, and v1.4.3’slinesentry repeats it (“this is the OPPOSITE ofraw, 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 thewrite_transactionRPC, which kept REPLACING the column: a patch namingrawat all deleted every key it did not mention, silently, with a200. Measured on a live tenant: three marker fields cut a purchase order’srawfrom 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 explicitnull, 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.linesis still the opposite and still needs care — omitted preserves,[]clears, a list replaces. - Reading a document’s lines out of
rawwas 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, andrawis poll-fresh for anything that is not a declared link or a promoted column.order?select=*,order_line(*)(and thepurchase_order_line/invoice_lineequivalents) 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— asitem_receipt_line!item_receipt_line_parent_pg_row_id_fkey(...).
v1.5.4
Section titled “v1.5.4”- 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’srawcapturedversion, NetSuite’s optimistic-lock token, andrawis 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 dropsversionand 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*,_csrfand the other NetSuite form keys out ofraw— 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 throughrawonPOST /v1/data/:recordTypeis 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
rawasYYYY-MM-DD, not as an ISO instant — drop anynew 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 as2026-03-22T07:00:00.000Zfrom one path and2026-03-22T00:00:00.000Zfrom another, and a::datein any other zone read the wrong day. A date-only field has no time and now says so. The promotedcf_ns_*column is unchanged in TYPE (timestamptz) and lands at UTC midnight, so a range predicate keeps working; only therawstring shape changed.datetime/datetimetzfields are untouched — an instant is still an instant.
v1.5.3
Section titled “v1.5.3”- 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
lineshas 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
linesjust to keep a line’s tax code. Outbound lines now carrytax_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”- Stop sending a
linesarray you did not change — omitting it now PRESERVES the lines instead of deleting them. OnPOST /v1/data/:recordType, an update that carries nolineskey used to run an unconditionalDELETE FROM <line_table>and return 200 over a document it had just emptied. Three states now, and they are distinct: omitlines(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 422INVALID_LINESrather 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: restatingsubsidiary_id/currency_idat 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 (getValuereturns whatever the form rendered), and capturing them made every later write fail:rawis 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 asskipped_display_only. - If you call the
turbobase.write_transactionRPC directly, note thatp_linesnow 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”-
Declare the roles your screens need in
public/turbobase.json, and gate on the same capability strings. A new optionalrolesblock —[{ "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 declareappRole(admin is assigned, never authored) orscopes(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. Runturbobase apps planto see the verdicts before shipping — it writes nothing and exits non-zero if a role cannot be created. -
Add
hasCapability()/requireCapability()/sessionClaims()and theTurbobasePermissionErrorclass to yoursrc/lib/turbobase.tsfrom 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 checkhasCapability('read:invoice')before rendering, or a permission problem is indistinguishable from “no data”. A denied write now throwsTurbobasePermissionError(not a genericError), socatch (e) { if (e instanceof TurbobasePermissionError) … }can name the role to ask for instead of showing a raw message.hasCapabilitymirrors the database’shas_capability()including the admin bypass — it is UX only; RLS remains the guarantee. -
Re-run
turbobase apps initin an existing app project to pick up the newroles-and-permissionsskill. 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_shareandkb_guest_access_codeover/rest/v1— both tables are dropped. Knowledge-base guest access (the external email-code reader) is removed; aselectagainst 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_commentis unchanged and stays readable, except thatauthor_kindis now constrained tomember/admin— drop anyguestbranch in your rendering, and any guest-authored rows your app had cached are gone. -
Send only real columns in the
payloadofPOST /v1/sync/events/from-chat, and make sure the signed-in user holds the record’swrite:<table>capability. The transform route now applies the same two gates asPOST /v1/data/:recordType: a caller without the capability gets403 { error: 'forbidden', capability }, and any key that is not a column of the record’s canonical table gets422 { error: 'validation_failed', rejection: { code: 'UNKNOWN_COLUMN', field, message } }with nothing queued.created_from_idis always accepted. Move custom fields intoraw(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”-
Check
dataSyncEnabledon every/v1/catalog/recordsentry 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: falseis 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/recordsand/v1/catalog/records/:type. -
For ONE refused write you no longer need that log — read
source_detailon 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), andmandatory_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 likeStatusonto something you can set). It is visible in the failures drawer and on the row. Readscripts_deployed_on_record_typeas “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 forsource_script_logwhen 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, thenexplain_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_objectreturns the captured definition or script source, the generated explanation, andmodifiability— 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”-
Remove any workaround you added for customer writes being rejected — and match the rejection’s
fieldin snake_case, not camelCase. Every write toPOST /v1/data/customercame back422 MANDATORY_FIELD_MISSINGnamingcompanyName, 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 onlyphone, say) no longer has to restate the name. If you branch on the rejection payload, thefieldis nowcompany_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_validationnames a field and never a cause (five unrelated bugs share it),netsuite_ns_errorcovers one retryable race and several that are not,netsuite_has_dependenciesis a fact about the document graph and never retryable, andguardrail_hold_expiredmeans the write never reached the source at all. Also: a climbingoccurrence_countmeans the same broken write is being resent, which onlyRecord has been changedwill ever recover from. -
Please choose an item to addmeans 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 haditem_id,quantityandrateall null, from a builder that emitted one row too many. Filter empty objects out oflinesbefore sending; nothing downstream ignores them. -
Three more write diagnostics, all per-request.
options.explainSourcingdiffs 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.sublistReadbackgives requested-vs-actual per LINE, including whether aninventorydetailsubrecord’s assignments actually landed.options.capturePayloadechoes 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.capturePayloadis 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 asblocked, 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=<uuid>), each linking to its catalog entry — one measured account runs 13 User Events onsalesOrder, all invisible from the app side. Pair it withoptions.diagnostics: trueto get a requested-vs-actual readback proving whether your value landed. New skill:ns-whats-deployed. -
connection_idis no longer sent to NetSuite. It was reaching the wire asconnectionidand 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 reachessync_attempt.error_detailso 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-fulfillmentskill before writing the code. A fulfillment is a TRANSFORM of a sales order, never a standalone create (created_from_id), and itsstatusis a MANDATORY setter — the opposite ofstatuson every other transaction, where it is derived. The value is a prefixed code:ItemShip:Apicked,:Bpacked,:Cshipped. OnlyItemShip:Cposts 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-lineinventory_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↔itemReceiptbehaves exactly likesalesOrder↔itemFulfillment: NetSuite refuses with “would leave the Item Receipt … unlinked”. Updating the RECEIPT’s own header fails too, even with nolinesin 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 <id>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 isPending Billon creation, so receiving it refuses. Check the source’s status before changing the payload. -
Record has been changedis 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 onsalesOrder). Measured live: committed, then refused 3s later, then committed again 4s after that. It lands asblockedwithreason_code = ns_error, so do NOT re-prompt the user — reapply your change to a fresh read and resend. -
Narrowing
rawis not narrowing the patch — cut the canonical columns first. A write body has two levels: the canonical columns (subsidiary_id,currency_id, …) andraw. The re-sourcing trigger lives in the COLUMNS, butrawis the level people trim first. Measured live: two attempts on the same order seconds apart, one withrawat 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_idNULL; the inbound echo of that same line arrives from the source carrying it, and for a window the mirror holds BOTH — measured live as threeorder_linerows 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’sraw->'item'->'items'(the source’s own copy), or wait untilrecord_write_stateiscommittedand 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 likeorder_line?tran_internal_id=eq.<id>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_idon 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 fourcustbody_*fields and nolinesat all is refused withPlease 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 thecustbody_*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, orGET /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: truemeans 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-exchangeand thedata:readscope are REMOVED. Any script, CI step or agent that traded a PAT for a session and then called/rest/v1/*now gets a404on 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 exposeslist_records/get_record/search_fieldsas 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 <session-jwt>fromPOST /v1/data/:recordType,POST /v1/batchand 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 typesno 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-onlyto make the intent explicit rather than relying on the degrade path. -
Confirm a write landed by reading
turbobase.record_write_state— a2xxfrom 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 onstatus:pending(in flight),needs_input(the source rejected the payload — fix and resend),blocked(anything the end user CANNOT fix — checkreason_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_inputis a field problem with plain-language text and afieldslist,blockedis everything else and stays on the admin plane — the writer defaults toblockedwhen 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 forturbobase.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_idyour insert returned — do not re-key on a row fetched byexternal_iduntil 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 theexternal_idback. The platform reconciles them and keeps your originating row, deleting the twin (write-back echo twin reconciledin the worker log). A row you re-fetched byexternal_idinside that window may be the twin, so anything stored against itspg_row_iddangles 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=transformnow take anoptionsobject besidefields:explain(default false) returns{applied, diverged}comparing what you sent against what the record actually holds — on success the committed record, and on failure underdetail.explain, which is the case you actually want it for. Checkphasebefore reading it:committedmeans the value is live;pre_savemeans the save threw, nothing committed, andappliedthere means only “assigned in memory” — NOT that NetSuite accepted the field. A fieldsetValueaccepted and NetSuite then cleared shows up indivergedinstead of silently succeeding, turningPlease enter value(s) for: Customerintoentity: 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 alongsidesublists, since dynamic records have nosetSublistValue.ignoreMandatoryFieldsis gated per connection — honoured only while that connection is in debug mode (source_install.debug_mode_expires_atin 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. Absentoptions, 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: Xis FIVE different bugs — do not start from the field name. The same sentence has producedCustomer,Tax Code,Ship FromandOrder Typefrom 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 restatesubsidiary/currency/customformeven 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 withPlease 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
lineson a transaction UPDATE — omitting them DELETES them.write_transaction’s update leg runs an unconditionalDELETEon 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 returns200 OKwith nothing logged. Re-sending the same lines is safe; sending a subset replaces the sublist with that subset. Note this is the OPPOSITE ofraw, 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 allsync_eventrowsfailedmeans nothing reached the source, so the source is untouched by definition; (2)op=readshows 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(orcurrency/customform) on a NetSuite transaction update unless the value actually changed — writing it clearsentity. Measured live: on a sales order already holdingsubsidiary=7andentity=35, a write of{entity, subsidiary}— subsidiary set to its OWN current value — came backPlease 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-settingentityin 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_IDinpg→source read-back failedright 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].relorfailed to clear links[0], stop sanitising your payload — the platform was re-introducing it.linksis 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’sraw(the read-back stored the REST envelope verbatim and the header mapper spreadsraw), 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, NOTsync_failure— the latter has no detail column.sync_failureis one row per root cause (deduped,occurrence_count);sync_attemptis one row per attempt, anderrorName/stack/foreignScriptErrors/scriptsOnRecordTypelive there. Queryingsync_failure.error_detailreturns “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). Usesync_failurefor the reason text and occurrence count;sync_attemptfor the evidence. -
A NetSuite mandatory-field error naming a field you DID send is a customization problem — read
scriptsOnRecordTypebefore re-checking your mapping.sync_failure.error_detailnow carries the script context onfield_validationas well asns_error(bundle 1.11.0). Read the three fields together:stacksays whether the value was ever set (thrown atsave= it was),foreignScriptErrorssays whether any third-party script crashed, andscriptsOnRecordTypelists every User Event deployed on that record type. An emptyforeignScriptErrorsdoes NOT mean no third-party involvement — abeforeSubmitthat clears a field without throwing logs nothing, and that is the common case. Measured live: asalesOrderupdate failingPlease enter value(s) for: Customerwhile sendingentity_idhad 16 User Event scripts on the record. -
Stop sending NetSuite REST envelope keys in a write payload — strip
linksbefore you post. If you build a write body by spreading a record you fetched from the NetSuite REST API, you are also sending its HATEOASlinksarray; NetSuite then fails the whole save withfailed to set links[0].rel. Rows read from Turbobase are already safe (the read path dropslinks) — this only bites when an app round-trips a raw source response.
v1.4.0
Section titled “v1.4.0”-
Set
line_kind: "expense"on account-coded lines when you write avendorBill,purchaseOrderorcheck. These three records hold their lines in two NetSuite sublists — item-coded and account-coded (a GL account plus an amount, no item) — andline_kindis 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 omititem_id/quantity/rate; a line withline_kindset to"item"still goes to the item sublist exactly as before, so existing item-only writes need no change. Reads are unchanged —line_kindhas always been on the row. (Later narrowed: a line that leavesline_kindabsent or null is no longer sent to the item sublist unconditionally — one naming anaccount_idwith noitem_idis routed to the expense sublist by its own shape. Settingline_kindexplicitly is still the only unambiguous form.) -
Treat
is_billableon a payable line as THREE-valued —nullmeans “not observed”, not “not billable”.vendor_bill_lineandpurchase_order_linegaincustomer_id/customer_name/is_billable(check_linealready had the columns), the pair that lets a pass-through cost be rebilled to a customer. If your app lists rebillable costs, filteris_billable=eq.truerather than treating falsy as not-billable: rows synced before this release readnulluntil the one-shot backfill and the next delta sync fill them.customer_namecan staynullon rows that came from the bulk backfill even oncecustomer_idis set — embed or joincustomerif the name must always render.
v1.3.0
Section titled “v1.3.0”- Read
source,recordTypeandrecordIdoff 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=<nsRecordType>&recordId=<nsInternalId>to whatever app path the NetSuite admin sets on the script deployment, so/a/orders/detailarrives 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, andrecordTypeis 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”-
Add
.github/workflows/turbobase-app-deploy.ymlto get previews on pull requests and production on merge. Re-runturbobase apps init <slug> .in your project (it never overwrites an existing file) or copy the template fromtemplates/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 variablesTURBOBASE_BASE_URLandTURBOBASE_APP_SLUGand the secretTURBOBASE_PAT(scopeextensions:deploy); untilTURBOBASE_BASE_URLis set every job skips rather than fails. -
Delete
.github/workflows/turbobase-tables-plan.ymlif you have it. Its job is now theturbobase 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"] }]—opsiswriteand/ordelete, defaulting to["write"], anddeleteis checked separately because opening a write never implies the right to delete. This is a REQUEST, not a grant:apps deployrefuses 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.jsonandturbobase/schedules.jsonare now the desired state —turbobase ext plan --to proddiffs the REPO against your instance andext deploy --to prodapplies it. Previously the desired state came fromturbobase/bundle.json, which onlyext exportwrote 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 runsext deploy --to prod --prune, and every apply prints what it declined to remove. Adopt an existing instance’s config into your repo withturbobase ext export, then move the extensions intohooks/. -
Get the preview URL for a PR with
turbobase apps preview list <slug> --json, and update a preview in place withturbobase apps deploy <slug> . --replace-label "<text>".--replace-labelopens 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.--jsonexists 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 initnow scaffolds the exact stack docs.turbobase.io/build-apps/design-system/ specifies: Tailwind v4 as a Vite plugin (notailwind.config.js— the theme issrc/styles.css), shadcn/ui New York on the neutral base with acomponents.jsonsonpx shadcn@latest add <component>works with no init step, six primitives undersrc/components/ui/, lucide-react, and Inter (latin subset only). A@/*path alias is declared in BOTHtsconfig.jsonandvite.config.ts— keep them in sync, or the editor and the build disagree.apps initnever overwrites, so an existing project keeps its current styling until you migrate: take thepackage.jsondeps,vite.config.ts,src/styles.cssandcomponents.jsonfrom 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 initnow writes.claude/output-styles/turbobase-consultant.mdand 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 theoutputStyleline, 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 undersrc/that both write through the client and read like a rejection, and points athooks/. It never blocks an edit. Delete both files to remove it. -
ADOPT.mdnow 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-runturbobase apps init <slug> .to pick up the session hook, or runturbobase project adoptyourself. -
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_idon every transaction) will not accept anything but a real source id. Set the<x>_pg_row_idsibling instead and leave the ref column NULL:POST /v1/data/salesOrder {"entity_pg_row_id":"<customer pg_row_id>"}. The platform fillsentity_idon the way out.entity_pg_row_idnow exists on all twelve entity-referencing tables —cash_sale,credit_memo,customer_deposit,estimate,opportunity,purchase_order,vendor_billandvendor_paymentgained it, joiningorder,invoice,order_fulfillmentandcustomer_payment. So vendor + purchase order in one pass works, not just customer + sales order. If a leg create was failing seconds later withPlease 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:<uuid>"}. It must bepg:plus a full UUID — anything else is treated as a literal source id and passed through untouched, so an existing id that happens to startpg: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 withreference_cyclenaming both rows. Write one side without the back-reference, then patch it in.
- A ref column with a foreign key (
-
Create a customer or vendor directly —
POST /v1/data/customerwith no key now works. It used to answer422 MISSING_KEY(“requires externalId, pgRowId, or an external_id in the payload”), on the since-obsolete grounds that the entity table had nopg_row_idand a NOT NULLexternal_id. Both changed: entities mint their ownpg_row_idandexternal_idis nullable until the record reaches the source.customerandvendorwere 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 readpg_row_idoff the response. -
Name the entity with
entity_pg_row_idand the required-entity check will accept it.ns:transactions:require-entityread onlyentity_id, so a transaction naming a not-yet-synced customer by its sibling was refused422 MANDATORY_FIELD_MISSING: entityIdat the boundary — before the outbound resolver ever saw the sibling. It now treats a validentity_pg_row_idas an entity reference, exactly as it already treatscreated_from_id. Still refused: a transaction naming no entity at all, or a sibling that is not a UUID. -
Stop sending a full
linesarray just to edit a header field. A header-only PATCH of a record that has already synced was being refused withATOMIC_WRITE_REQUIREDacross 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_detailon 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 withPlease configure the inventory detail in line 1no 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 keyinventorynumberin 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 parsinginventory_detailyourself. One row per assignment across every inventory-affecting line table, carryingrecord_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.<receipt>. Itspg_row_idis derived from (line, ordinal) and is read-only — to change an assignment, write the wholeinventory_detailarray online_pg_row_id. -
Re-read
created_from_idonorderandvendor_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 intorawunder 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 “nocreated_from_id” as “raised standalone” was wrong for backfilled rows and is now correct. Read “what was billed from this PO” asvendor_bill?created_from_id=eq.<po external_id>. -
Set
raw.customformif 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 withInvalid Field Value <id> for the following field: customform. Creates now omitcustomformunless you supply it, and NetSuite picks its own default. If a line comes backInvalid Field Value <itemId> for the following field: item, that is the default form’s item-options list not exposing the item — send the right form asraw.customform, or ask your administrator to setNS_CUSTOMFORM_<RECORDTYPE>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_idand 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 ascr_<child>?cf_ns_custrecord_<field>=eq.<parent external_id>, matching the parent’sexternal_id(the link is a bare id, never apg_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:<table>_linestill 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/:recordTypenow drops the fields NetSuite COMPUTES rather than forwarding them:status(andorder_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 withInvalid 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 insideraw.approval_statusis 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. Yourlinesarray is now applied as in-place overrides matched byitem_id, not by position, so you can set a per-linelocation_idthe 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 anitem_idthe document did not source is a422, not an insert. Previously anylineson a transform failed the write outright with “trying to add or remove lines from a static sublist”. -
Surface
rejection.messageon a422fromDELETE /v1/data/:recordType— a delete can now be refused by a business rule, not just by a guardrail. Hooks gainedbefore_delete/after_delete, so a tenant rule (“this order has a fulfillment”, “this customer has open invoices”) rejects a delete in the same structured envelopebefore_savealready uses for writes. Treating any non-200 as a generic delete failure now hides the one message that says why.before_deletecan 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 exactlycapthrough and the rest come back422 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_idused 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<table>_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 whosepg_row_idyou were given. -
Handle a new
sync_event.state:resolved_local. If you readsync_event(or use generated types with a state union), add it — aTypeScriptunion that omits it will fail to compile, and aswitchwithout a default will fall through. It means a delete that completed WITHOUT being sent upstream: the row had noexternal_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 forcommitted. -
Deletes now actually reach the source. Every
pg→sourcedelete previously failed inside the adapter before NetSuite was called (a stored auth context that could never resolve), so adel()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
linesarray in the SAMEPOST /v1/data/:recordTypecall — 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 andauthenticatedholds no write DML, so it 403s — and it 404s first, because PostgREST matches functions by argument name). TheATOMIC_WRITE_REQUIREDrejection used to name that RPC; it now names this call. -
For a CUSTOM record, do not send
linesat all — its children are separate records. A native transaction (salesOrder,purchaseOrder,invoice, …) carries a NetSuite sublist that has no per-line endpoint, which is whatlinesis for. A custom record’s children are their own record type pointing at a parent, so write the parent, takeexternal_idoff the response, then POST each child to its own record type. Sendinglinesto a custom record is a422 LINES_NOT_SUPPORTED. -
Expect a
422rate-cap rejection fromDELETE /v1/data/:recordTypeeven for a row that never synced upstream. A delete of a row with noexternal_idstill completes locally and still returnsenqueued: false, but it is now recorded and COUNTS againstguardrails.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 asSYNC_GUARDRAIL_BLOCKEDwith the cap named inrejection.message. -
Replace your read-only
src/App.tsxwith the new starter — it now demonstrateswrite()anddel()alongsideselect(). It reads with a projected column list rather thanselect=*, renders the{ deleted, enqueued }shape verbatim, and distinguishes the loud delete modes: a guardrail deny, rate cap or parked source throws;require_confirmreturnsenqueued: truebut is held for an operator before it reaches the source; a source refusal lands as async_failurerather than a throw. -
Move validation out of
src/App.tsxintohooks/—turbobase apps initnow scaffoldshooks/and itssamples/fixtures. Your app cannot bypassPOST /v1/dataand its mandatorybefore_save(theauthenticatedrole holds no write DML), so a rule written in the app binds only that app. Run the scaffolded example withturbobase ext test hooks/<file>.ts. -
Re-run
turbobase apps initin an existing project to pick uphooks/,samples/and the newCLAUDE.mdboundary section. It is non-destructive and SKIPS files that already exist — rename your currentCLAUDE.mdandsrc/App.tsxfirst if you want the new text. -
Promote and retract hook proposals from CI with
turbobase ext proposals list|promote|retract|reject. Bulk promotion ispromote --auto, a dry run by default — add--apply, plus--allif you passed no filter. Needs a PAT withextensions:deploywhose owner is a live admin. Aprovenance='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 typesnow 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 asJOIN <txn>_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 onitem_receipt_line/journal_entry_line/order_fulfillment_linethe line column is spelledline_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’ssortKeynow 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_KEYSin 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 withturbobase project types. Line ordinals areline_number, notline_index, onorder_fulfillment_line/item_receipt_line/journal_entry_line. -
Discard any
nextCursoryour 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
enabledflag — publish/unpublish is the whole gate. The column is gone fromturbobase.skill, andGET /v1/skillsno longer returns it.turbobase skills listreportsdraft,unreleasedorlive; thedisabledstatus it used to print does not exist. Rules pushed byturbobase skills pushstill 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.mdfinding 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.mdto 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 fromTURBOBASE-FEEDBACK.mdwith instance name, user names, ids and source-system identifiers masked;"learnings": { "share": "off" }in a project’sturbobase.jsonstill narrows it for that project.turbobase auth loginnow prints all of this and where to turn it off. -
Read rows headlessly: mint a PAT with the
data:readscope and POST it to/auth/pat-exchange. The endpoint returns a 15-minute session JWT — send it as bothAuthorization: Bearerandapikeyfor/rest/v1/*, and asAuthorization: Bearerfor 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 typesreported it as an older instance and silently degraded to--records-only.data:readis 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.mdaside before you runturbobase 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<version>section) is now refused rather than overwritten — pass-o <file>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 withgit show HEAD:ADOPT.mdonly if you commit the file, so commit it if the checklist is a record you want to keep. -
Add a
pretestto your app’s package.json:"pretest": "turbobase project types -o src/types.ts || …"(the scaffold now writes it).src/types.tsis 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 bareENOENTthat 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 initwrites it into new projects; existing projects should copy the line across (the scaffold never overwrites your package.json). -
Delete
2>&1from.claude/hooks/session-start-refresh-types.shin projects scaffolded before this release. The hook sent BOTH streams to/dev/null, so when the v1.0.82 verb rename broketurbobase types, the refresh failed silently for weeks and every session ran on a staletypes.ts. The scaffold now mutes stdout only and its fallback line names the command that failed instead of guessing “not signed in?”. Re-runturbobase apps initin 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/<slug>/<name>URL on every deploy, and until this release that URL was servedCache-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/<slug>/…asprivate, 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 inpublic/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/<build-id>/…), which keepsmax-age=31536000, immutableand re-mints its URL on every deploy. Apublic/passthrough file is served from the mount path, which now revalidates: expect a conditional request answered304from the newETagrather than a cache hit, so the bytes still only move when they change. No code change — this is why apublic/file no longer behaves like a fingerprinted one. -
Run
turbobase auth statusfirst 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.jsonpresent, 0600, holding a well-formed bearer, and every tool silently absent. It prints no token material. Add--no-probefor the old purely-local listing. -
After any
turbobase auth login, re-runturbobase project initto 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/authoringnow answers a rejected token with the reason on both theWWW-Authenticatechallenge 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-runas the contract — its plan is now exactly what the push executes. A page whose unpublished work is YOUR OWN is no longer reported asblocked … this page will be SKIPPED(it was, and then the push appended to it anyway); a page genuinely held by another editor now readsaction SKIP — nothing is written, gets no write-set diff, and is excluded from the plan’sN page(s) / N appendedcounts and from the confirmation prompt’s numbers. If you scriptkb push, those counts changed meaning: they now count only pages that will actually be written. -
Project a PATH into
rawinstead of the whole column: MCPlist_recordsselectnow takeslines:raw->item->items/tag:raw->>key, andfilters[].fieldtakesraw->>key. A custom field that was never promoted lives only inraw, 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, whereis_nullalso matches the JSONnullliteral. The accepted shape is deliberately narrow: bare-word keys (letters/digits/underscore),->>only as the last arrow, a rename allowed inselectbut not infilters, and no other PostgREST select syntax inside a path. -
Name a promoted custom field the way the record does: MCP
list_recordsselect/filtersnow accept the source-side id (custbody_x) as well as thecf_<code>_column. The record, the anatomy docs and the source UI all use one name andselectaccepted only the other, so an agent that had been told the right name was still refused. Responses andapplied.selectecho thecf_column, so the canonical name is what you learn back. -
Drop the
{ sortKey: … }override you were passing toselectPagefor 56 mirrored tables and regenerate types —pg_row_idnow exists on all of them, and it is whatselectPagedefaults to.selectPage’s default seek key has always beenpg_row_id, and on these tables the column did not exist, so a plainselectPage('subsidiary?select=*')errored and the workaround was anexternal_idoverride — 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 readSEEK_KEYSinstead 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_idtopg_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 raisesTurbobaseCursorErrorrather 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_stateordata_manipulation_snapshotonpg_row_id— useid. All three have a column calledpg_row_idthat points at ANOTHER table’s row and is nullable (ondata_manipulation_snapshotit istext, notuuid). They are keyedid, andSEEK_KEYSdoes not offer them apg_row_identry. -
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’spg_row_idis a fresh uuid afterwards — fine within one paging scan, wrong as a stored reference or a saved cursor.SEEK_KEYS.<x>_linetherefore still returns['parent_pg_row_id', 'line_index'](line_numberonorder_fulfillment_line/item_receipt_line/journal_entry_line), unchanged by this release — if you readSEEK_KEYSrather than hard-coding, nothing on your side changes. -
Check any code that positionally destructures an entity-view row —
pg_row_idis appended LAST oncustomer,vendor,contact,employee, their_unpackedvariants,v_current_compensationandv_current_employment. ASELECT *against those views gains a trailing column; selecting by name is unaffected. Onentity_canonical_viewthe 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 pushif 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 newestturbobase apps pushofpublic/turbobase.jsonon 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 — runturbobase apps pushand check the file is valid JSON if you expected findings to arrive and none did. -
Drop any
entity_type/item_typefilter you were adding to MCPlist_records— it now narrows to the record type you asked for, and a persistednext_cursorfrom before this release is refused.customer,vendor,contact,employeeandpartnerall live inentity, and the tool issued its query with no type predicate at all, solist_records {record_type:"customer"}returned every entity sub-type your capabilities let RLS through (the same held forinventoryItemand friends onitem). 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 askingcustomerforentity_type in (customer,vendor)returns customers only; ask forvendorseparately. The response echoes what was applied asapplied.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 MCPget_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 oneexternal_id), andget_recordmatched onexternal_idalone 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 returnsfound:false, ambiguous:truewith the column that separates them, instead of guessing. List those rows withlist_recordsfiltered onexternal_idand pick byconnection_id. -
Copy
sharedTable.hintfrom/v1/catalog/recordsverbatim — it now names the type VALUE, and it names one onitem/work_itemwhere it never did. The hint saidentity_type=eq.<the kind you want>and, on the item tables, offered onlysource_system— which does not separateinventoryItemfromkitItem, so a “filter by source_system” reader still got every item type back. It now readsentity_type=eq.customer,item_type=eq.inventoryItem, andsharedTable.discriminatorslists 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
nullselectTarget.discriminatorfrom/v1/catalog/objects/:id— it is now null when no single predicate narrows the target’s table. It always returned a string, falling back tosource_system=eq.<source>even where that does not discriminate (tax_codeholds NetSuite’ssalesTaxItemANDtaxGroup, so filtering by source narrows nothing), which read as a complete answer and was not one. Thenotenow says what to do instead — match onpg_row_idand check the row you read. Where a type predicate does exist you get it (item_type=eq.inventoryItemin place of the oldsource_system=eq.netsuite), and it is byte-identical to what MCPlist_records/get_recordapply for the same record type. -
Read and write QuickBooks estimates:
quickbooks:estimateis now in/v1/catalog/records, projecting ontoestimate+estimate_line. Re-runturbobase project typesto pick it up. Embed lines withestimate?select=*,estimate_line(*)— a plain embed works, no constraint hint needed. Reading a quote out ofrawon a QBO tenant now has a typed alternative, andread:estimateis the capability that gates it. -
Do not read
estimate.created_from_idto 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 ownLinkedTxnon an estimate points forward at its child. Join from the invoice side. -
Delete your
on DELETE → remove from the listRealtime 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 stampsdeleted_at, and the restrictivedeleted_at is nullSELECT 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 throughPOST /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
contentRangeas a total — the v1.0.77 note promising one was wrong. That note told every project “each batch result’scontentRangecarries the total”;POST /v1/batchnever sent aPrefer: count=header, so every sub-response came back0-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 readpage.count— the rows matching your filter ignoringlimit/offset. Bare strings behave exactly as before and still pay for no count; theircountisnull, androws.lengthis the page, never the total. -
Branch on
result.ok, never onresult.status === 200, for anyPOST /v1/batchsub-result. A counted sub-query comes back206— 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=== 200check turns every counted page into a phantom failure. -
Ask for exact counts only on the pagers you actually render — four per
POST /v1/batchrequest, or the whole request is a400.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, soBATCH_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 refreshedbatch()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/batchyourself: keep counted sub-queries to four per request. Leave the lookup reads as plain strings. -
Refresh
src/lib/turbobase.tsfrom the current scaffold.batch()now takesBatchQuery[](a new export — an existingstring[]call site still compiles unchanged),BatchResultgainedcountandcontentRange, 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 a400(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 thatdel()emits no Realtime frame. Scaffold into a scratch directory (turbobase apps init <slug> /tmp/tb-scaffold) and copy that one file across — don’t--forceover your project. -
Re-run
turbobase project typesafter a custom field is promoted — a NEWLY promotedcf_<code>_<scriptId>column now carries the source field’s real type, orjsonbwhen the source reported none. It used to betextfor 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 landsnumeric, a Date or Date/Timetimestamptz, a Check Boxboolean, a Multiple Selectjsonb. So/rest/v1/vendor_bill?select=cf_ns_custbody_acme_inbound_shipmentreturns a JSON number or boolean where it returned a string, and aPOST /v1/data/:recordTypewrite to that column must send the matching JSON type. Read ajsonbcf_*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 storesjsonb— which holds the value but cannot beSUM()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 istextand staystext. 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 fromrawon 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 landsjsonbagain. -
Stop pattern-matching the
POST /v1/batch400text 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" }. Surfaceerroras it comes back rather than matching on it. -
Replace
turbobase ext logs <id>withturbobase logs --extension <id>, andturbobase ext logs <id> --followwithturbobase logs --conduit <id> --follow.ext logsis 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/eventsto 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 sharedcorrelation_id. Filters:subsystem,severity(a THRESHOLD —warnincludes errors),code,since(10m/2h/3d/ ISO),correlationId,extension,conduit,limit(max 500), andafteras a poll cursor. Sameextensions:deployscope your deploy PAT already carries. -
Send
x-request-idon a write and you get it back as thecorrelation_idon every row that write causes — the guardrail verdict, each hook outcome, the sync event.turbobase logs --correlation-id <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
<redacted:…>markers, and the row’sredactionarray names which rule groups fired. Your hook’sctx.reject()message, your guardrail rule’s reason, your record types, your external ids and yourcustbody_*/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
occurrencescount rather than written per row, andinfo/debugevents are only collected when the instance setsPLATFORM_EVENT_LOG. Retention is 90 days forerror, 30 forwarn, 7 forinfo, 1 fordebug. 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 newDELETE /v1/data/:recordTypeboundary. Copy the refreshedsrc/lib/turbobase.ts; the client gainsdel()alongsidewrite(). 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 SQLDELETEagainst a table — apps hold no DELETE grant and it would not propagate. Returns{ deleted, enqueued }(enqueuedis false for a row that never synced upstream). -
Open a record’s delete with a
<record>.deleteguardrail allow rule — a.writeallow 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 assync_failurerows, not retries; supply the reason or remove the dependents. -
Run
turbobase apps push— notapps 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 pushnow 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 printsno markdown found, nothing you wrote has reached the instance. -
Read the line
skills push/kb pushprints 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 knownmeans your edit landed and every finding in it was already dealt with — which is the expected answer after a regroup. -
Use
turbobase skills push --reapplyto land a corrected###grouping. Re-filing a rule under a new heading is deliberately the same finding, so it queues nothing;--reapplyre-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:publishscope 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 pushis unchanged and still runs onextensions:deploy. -
kb publish <slug>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 listreports whether each page is actually in the current release, whichvisibilityalone does not tell you.kb deleteis SOFT — restorable from the editor’s trash, unlikeskills delete. -
Rename
guardrails enable/disabletoactivate/deactivatein 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 createnow takes--decision, and defaults todeny. A rule canallow,allow_with_audit,require_confirm(hold the write for operator approval) ordeny.--messageand--noteare replaced by a single--reason, which is both what a blocked writer is shown and whatguardrails listprints. -
Rewrite any
--when 'recordType not_in a,b'condition as two rules. Envelope fields (recordType,source,origin) are no longer part of the--whengrammar — they are flags (--record,--source,--actor).--whennow expresses only a test against the row being written (total gt 10000). “Everything except X” is an allow rule at a low--priorityplus a deny rule at a higher one; first match wins. -
Expect
guardrails testto report a decision, not just blocked/clear. The JSON gainsdecision,phaseanddegraded;undecidedis gone. It also no longer lowercases the record you pass, so a camelCase type likesalesOrderreports 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 fromturbobase.skillandturbobase.mcp_server, along with the RLS clause that read them. A skill’s reach is now decided by its surfaces plusvisibilityalone. If you had a skill scoped to a single role, it is now visible to everyone on the instance. -
Group your
## Agent rulesunder###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 safelyabove the rules it covers.turbobase skills push --no-groupkeeps the old one-rule-per-skill behaviour. -
Expect
turbobase skills pushto report fewer rules than findings. It now printsN 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:publishscope 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 pushis unchanged and still runs onextensions:deploy. -
skills publish <slug>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 separatereleaseverb. Undo withskills unpublish <slug>;skills rollback <version>restores an earlier set;skills delete <slug>is permanent.
v1.1.19
Section titled “v1.1.19”- Re-deploy onto an existing preview with
turbobase apps deploy <app> --replace [preview-id].--previewon 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.--replacekeeps 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”- 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
itemon 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
statusto 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 overturbobase.itemwas 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, blankingtran_id,entity_id,statusortotalon the row. If you added defensive?? previousValuehandling for that, it is no longer needed — and it was masking real blanks. - Name the concrete NetSuite item type; there is no
itemrecord type./v1/catalog/records/inventoryItem,serviceSaleItem,nonInventoryResaleItem,kitItem,assemblyItem. All five project into the oneturbobase.itemtable — filter onitem_type, do not expect five tables.?source=still matters here:itemIS 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”- Pin
?source=on any/v1/catalog/records/:typeor/v1/catalog/columns?types=call foraccount,projectororganization. Those three names are published by several connectors onto DIFFERENT tables —accountis NetSuite’saccountand Salesforce’sentity;organizationis Zendesk’sentityand Workday’sdepartment. 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_orderdescribessalesOrder. The response still reports the registry’s own spelling inrecordType; 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 havepg_row_id. A seek key must be NOT NULL and UNIQUE.external_idstopped being both onvendor_credit,credit_card_charge,credit_card_refund,inventory_adjustmentandinventory_status_changewhen those five gained apg_row_idprimary key and theirexternal_idbecame nullable, unique only per connection (v1.0.82). An override written before that is now a silent row-dropper:<key>=gt.<last>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 withpg_row_iddeclaresexternal_id?: string | null, one without declaresexternal_id: string. Keep{ sortKey: 'external_id' }only for the latter. - Read the seek key from
SEEK_KEYSin your regenerated types instead of hard-coding it.turbobase project typesnow emitsexport const SEEK_KEYS = { <table>: '<column>' }, derived from live column nullability, soselectPage('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 returnnextCursor: nullthere, indistinguishable from the end of the list. And when you pass asortKeyother thanpg_row_idon a table whose rows come back carryingpg_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 <app> .beforeapps deploywhen you have editedINSTANCE-LEARNINGS.mdorTURBOBASE-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 lastapps pushrecorded. Deploy alone queues a job that succeeds against the previous revision and writes nothing, after whichturbobase skills push/kb pushreport an empty queue.apps deploynow 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 nextapps deploy”); Connect Claude Code and the CLI reference now state the dependency.
v1.1.12
Section titled “v1.1.12”-
Handle a new write rejection code
SYNC_GUARDRAIL_BLOCKEDwherever you already handleSYNC_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
customerwritable andsalesorderblocked 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.jsonunder a newtableskey —{"tables":[{"slug":"task","fields":[{"name":"title","type":"text"}]}]}— andturbobase apps deploycreates them. Field types aretext/number/date/bool/select, not Postgres types. Read the columns ascf_sa_<name>, not<name>; regenerate withturbobase project typesto pick them up. Omitting the key changes nothing, so an existing project needs no edit until it wants a table. -
Expect a 409
table_conflicton 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. Runturbobase tables planin CI to catch it before the deploy. -
Remove a field from your
tablesblock 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 onlyturbobase tables drop(confirm-gated, irreversible) removes anything. -
Commit the new
.github/workflows/turbobase-tables-plan.ymlthatturbobase apps initnow scaffolds, and add the two repo settings it needs: a secretTURBOBASE_PAT(anextensions:deploytoken — the same scopeapps deployuses) and a variableTURBOBASE_BASE_URL. It runsturbobase tables planon 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 fromtemplates/ci/turbobase-tables-plan.yml; delete it if your app declares no tables. -
Use
turbobase tables listto 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 rulesheading inINSTANCE-LEARNINGS.md, and push them withturbobase skills push. Use it for constraints that must hold on EVERY turn (“read lines through theparent_pg_row_idFK, never headerraw”); 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.mdafter upgrading. The brief now leads with your instance’s standing rules, so a session starts with them instead of rediscovering them. -
Add
data-tb-idto 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 barediv > spanplus 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”- Run
chmod 600 .mcp.jsonin every existing project. That file holds a live personal access token and the scaffold wrote it0644— readable by every account on the machine. New projects get0600. - Add
.mcp.jsonto your.gitignoreif it is not already there. The scaffold now writes the rule (and merges it into a.gitignoreyou 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.jsonif the project was ever pushed to a remote — mint a new one in Settings › Access tokens and delete the old one. Checkgit log --all -- .mcp.jsonfirst; an empty result means it never landed. - Rename
--versionto--pkg-versionin anyturbobase ext publishcommand 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 publishcould not be run at all. - Ask Turbobase support for a registry token if you use
ext install/ext publish, and set bothTURBOBASE_REGISTRY_URLandTURBOBASE_REGISTRY_TOKENfrom it, with the URLhttps://(loopback excepted). The marketplace now takes its own credential: the CLI no longer falls back toPROVISIONING_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. - Drop
--no-browserand--namefromturbobase auth logininvocations. Both only configured a browser sign-in leg that never completed;auth loginnow takes--base-urland--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”-
Read the item subtype on receipt, fulfillment, transfer-order and check lines from the new
item_typecolumn instead ofraw ->> '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) andtransfer_order_line(department_id/_name,class_id/_name,location_id/_name). Existing rows backfill automatically on upgrade. -
Read
opportunity_line.tax_amountas a column — it now exists, matching every other line table. Apps that summed tax fromrawcan drop that branch. -
Expect NULL, not absence, for four line fields that NetSuite does not have:
rate,amountandtax_amountonorder_fulfillment_line(a fulfillment does not price) andtax_amountonitem_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_linethrough 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 <slug> --preview --label "what changed". It builds and uploads exactly asdeploydoes, then serves the result at its own URL and leaves/a/<slug>untouched. The URL opens for anyone who can already open the app, so sending it grants no new access. -
Promote with
turbobase apps promote <slug> <preview-id>, or close it withturbobase apps preview discard <slug> <preview-id>.turbobase apps preview list <slug>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
--previewwith--no-build. A bundle this CLI did not build has its asset paths pinned to/a/<slug>/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 <slug>with no flags behaves exactly as before. -
Update
vite.config.tsonly if you replaced the scaffolded one. Newly scaffolded projects read the base from the environment:const ASSET_BASE = process.env.TURBOBASE_ASSET_BASE || '/a/<slug>/';thenbase: ASSET_BASE. A config that hard-codesbase: '/a/<slug>/'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, soapps pullkeeps 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/proposalthat was answering403 permission denied for sequence proposal_number_seq. Thenumbercolumn 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 returns201. No app change is needed — the request body is unchanged.
v1.0.83
Section titled “v1.0.83”- Key rows in
account,activity,currency,expense_category,refund,time_entry,work_item_link,location,departmentandclassificationby the newpg_row_iduuid.external_idis 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.<source>to everyexternal_idfilter on those ten tables that must hit exactly one row. Each is written by two or more connectors (accountby eight,locationby five), so a bareexternal_idmatch 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.<id>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 allconnection_id=default. - Send
pg_row_id, notexternal_id, as theon_conflictcolumn when POSTing to those ten tables withPrefer: resolution=merge-duplicates. Anon_conflict=external_idrequest now fails42P10— there is no single-column unique to conflict on. - Embed
accountfromcash_sale/customer_depositthrough the newaccount_pg_row_idcompanion: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 onconnection_id, which they never do, so it rejected every write from a non-defaultconnection with23503. It has been dropped.account_idis unchanged and still carries the value. - Switch every
location/department/classificationembed to the new companion column. The old text foreign keys are gone, so PostgREST no longer resolvesorder?select=*,location(*)— useorder?select=*,location!order_location_pg_row_id_fkey(*). The hint is always!<table>_<base>_pg_row_id_fkey, where<base>is the old column minus_id(class_id→class,from_location_id→from_location). - Expect
<base>_pg_row_idto 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 aBEFORE INSERT/UPDATEtrigger as they land, andsystem.dimension_ref_backfillhas dropped to a nightly backstop for pre-upgrade rows and for transactions that synced ahead of their dimension master. The<base>_idtext columns are unchanged throughout. - Re-subscribe with
pg_row_idif you filter realtime DELETEs. These tables’ replica identity follows their primary key, which moved fromexternal_idtopg_row_id(and onvendor_credit_line,credit_card_charge_line,credit_card_refund_line,inventory_adjustment_lineandinventory_status_change_linefrom(tran_internal_id, line_index)to(parent_pg_row_id, line_index)). A DELETE event’sold_recordnow carries the new key columns, so a subscription filtering deletes onexternal_idstops 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 gainedpg_row_id/connection_id, theirexternal_idchanged fromstringtostring | null, and every table referencing a dimension gained a nullable<base>_pg_row_id. - Scope line writes by
connection_idif you write lines directly.order_line,invoice_line,cash_sale_line,credit_memo_line,estimate_line,opportunity_lineandorder_fulfillment_linenow key on(connection_id, <parent id>, <line ordinal>);connection_iddefaults todefault, so single-connection apps need no change.
v1.0.82
Section titled “v1.0.82”- Stop selecting secret columns from
/rest/v1/*.source_install.session_private_key,source_install.source_signing_secret,channel_config.bot_token,user_email_alias.tokenandinbound_email.matched_alias_tokenare no longer readable by the session role — aselect=*or an explicit select naming one of them now returnspermission deniedfor the whole request. Name the columns you need instead ofselect=*. - Read channel config from
/rest/v1/channel_config_admin, not/rest/v1/channel_config. The view exposesbot_token_set(boolean) andbot_token_hint(last 4, masked) in place ofbot_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_adjustmentandinventory_status_changeinstead of reading them out ofraw:vendor_credit?select=*,vendor_credit_line(*)now resolves throughparent_pg_row_id, and no!<constraint>hint is needed — each pair has exactly one foreign key. - Key those five headers by
pg_row_id, notexternal_id.external_idis now nullable and unique only per connection ((connection_id, external_id)), so?external_id=eq.<id>can return more than one row on a multi-connection instance. Their*_linerows are identified by(parent_pg_row_id, line_index)—tran_internal_idis 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 installandext publishare unchanged. - Replace
turbobase tail <id>withturbobase ext logs <id> --follow. Thetailalias is removed. - Rename the knowledge-base push:
turbobase learnings push-kb→turbobase kb push. Same flags, same confirmation, same plan output. - Leave
turbobase mcpalone — it did NOT move. The.mcp.jsonin your project spawns it by path and keeps working untouched. - Re-run
turbobase project ciif you scaffolded the GitHub Action before this release: the committed workflow callsturbobase login, which no longer exists. The regenerated file usesturbobase auth login. (project ciwill not clobber your existing file — delete it first, or edit the one line.) - Edit
.claude/hooks/session-start-refresh-types.shin projects scaffolded before this release. It callsturbobase typesandturbobase grounding, both of which now fail — and the hook discards their output, sotypes.tsandINSTANCE.mdjust quietly stop refreshing at the start of each session. Change the two lines toturbobase project typesandturbobase project grounding.
v1.0.81
Section titled “v1.0.81”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 readsns_account_idnow getsundefined. Asource_user_idclaim ships alongside it, carrying the same value assub. - 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 returns400. If you were already filtering onsource_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(); useturbobase.jwt_source_tenant_id(), which no longer coalesces to the old claim. - Repoint any
postgres_changeschannel onsales_ordertoorder, and any channel oncustomer/vendor/employee/contacttoentitywithfilter: 'entity_type=eq.<type>'. Those four are views, and Postgres logical replication never fires on a view — the channel reachesSUBSCRIBEDand then streams nothing, silently. - Match Realtime deltas on
pg_row_idfor transaction tables and onexternal_idfor entity sub-type rows.external_idis null on a row your app just created and stays null until write-back commits it, so anexternal_idmatch drops every app-created row. - Replace any PostgREST read of
sales_orderwithorder—.from(),/rest/v1/, and?select=all42P01on a live instance. - Key rows in
work_item,payout,fee,worker,employment,compensation,time_off,pay_runandpay_statementby the newpg_row_iduuid, and add&source_system=eq.<source>to anyexternal_idfilter on them that must hit exactly one row — two sources can now hold the same id. - Send
pg_row_id, notexternal_id, as theon_conflictcolumn when POSTing to those nine tables withPrefer: resolution=merge-duplicates. - Read the customer/vendor/employee/contact name as
display_name, nevernameorentity_name; query the class dimension asclassification, notclass; order transaction lists bytran_date, notcreated_at. One unknown column42703s the wholeselect=. - 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 gainedpg_row_id/connection_id,work_itemgainedwrite_status, and theirexternal_idchanged fromstringtostring | null. - Send
source_systemon every canonical INSERT through/rest/v1/*. The column no longer defaults to'netsuite'(105 columns, plus every per-installcr_/cl_/ext_custom-record mirror) and is stillNOT NULL, so an insert that omits it now fails23502instead of silently landing as a NetSuite row. Updates are unaffected, andPOST /v1/data/:recordTypesets it for you from your session. - Pass
source_systeminp_parentwhen callingPOST /rest/v1/rpc/write_transaction, or call it with a session. It resolvesp_parent->>'source_system', then the session’ssource_systemclaim, 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_systemclaim.POST /v1/data/:recordTypenow returns400 {"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'toturbobase.scheduled_taskover/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_audienceand re-runturbobase types— the table is dropped. - Re-read
source_systemon 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
sourceSystemin the body ofPOST /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 — thesales_order→orderrename repointed it at the new table while its column-gate lookups still passed the literal record typesales_order. Read/rest/v1/orderand select the columns you need. - Stop reading
/rest/v1/user_scope,/rest/v1/field_restriction,/rest/v1/scope_dimensionand/rest/v1/skill_audit. All four tables are dropped. Each carriedGRANT SELECT … TO authenticated, so each was reachable even though nothing in the platform read them. Row-level scoping is nowturbobase.scope_slotplus thescopesJWT claim, resolved server-side byscopes_match(); there is no client-readable restriction registry replacingfield_restriction. - Drop
user_scope.*andfield_restriction.*actions from any/rest/v1/rpc/record_user_eventcall — the RPC now accepts only theuser.*namespace and raisesunsupported action: <x>otherwise. Its signature and admin-only gate are unchanged. - Stop reading
oauthClientIdfromGET /admin/proactive-learning/account. The field is removed from both the single-account andaccounts[]shapes; it had been unconditionallynullsince the different-production-account wizard was retired. - Optional: read
/rest/v1/v_sync_attempt_recentfor 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 issecurity_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 nowfailure_resolution_failure_id_fkey. Re-runturbobase types. - Pin the connector with
?source=when a record type has more than one.GET /v1/catalog/records/:type?source=stripeandGET /v1/catalog/columns?types=customer,invoice&source=stripenow 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,organizationandaccountonGET /v1/catalog/columns— the answer changed. The route used to collapse a duplicated type to the LAST registered connector while/v1/catalog/records/:typecollapsed to the FIRST; both now resolve first-in-registry-order.projectwas jira’scr_jira_projectand is now netsuite’sproject;organizationwas workday’sdepartmentand is now zendesk’sentity;accountwas ramp’s and is now netsuite’s. Pass?source=jira/?source=workday/?source=rampfor the old answer. - Read
resolvedSourcesonGET /v1/catalog/columns— a new{ "<recordType>": "<source>" }map. An intersection is only meaningful once you know whichcustomerwas intersected. - Handle
availableSourceson a/v1/catalog/records/:type404. It lists the connectors publishing that type that your session can already read, plusrequestedSourcewhen 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 newsearch_fieldstool) 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_typesand/v1/catalog/recordsdid not mention it. Both enumerate record TYPES, so any field not named after its record was unfindable — exchange rates are oncurrency.exchange_rate, an invoice’s open amount isinvoice.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 (entitybacks 14 of them), so a hit names the record type to query plus analso_onlist of the others pointing at the same column. - Pass
source_systemto MCPget_record— it is declared in the tool’sinputSchemanow. The handler always read it, butadditionalProperties: falsehad forbidden conforming clients from sending it. - Key your MCP record-type cache on
(source_system, record_type).list_record_typesadapter rows now carrysource_system;customerpreviously appeared six identical times. - Read
entity_typeoncash_sale,credit_memoandcustomer_deposit— newNOT NULL DEFAULT 'customer'columns. Omit it on writes and the default fills it in. Use it to disambiguateentity_id, which is ambiguous alone (customer 91 and vendor 91 share an id space). - Read
entity_typeonestimateandopportunity— new nullable columns. Treat NULL as “the source didn’t say”, not as'customer'; a filter written asentity_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!constrainthint (unlikeorder,invoice,order_fulfillmentandcustomer_payment, which carry a second FK toentityand return300/PGRST201on a bare embed). - Stop reading
raw.__hubspot.entityTypeonopportunity— the value is now theentity_typecolumn. Therawcopy is still written but is poll-bounded, not fence-fresh. - Expect foreign-key errors on writes to those five tables carrying an
entity_idthat doesn’t resolve, or that resolves under the wrongentity_type. Onestimate/opportunityyou may still sendentity_idwith a NULLentity_type; MATCH SIMPLE skips the check. - Filter reads by
connection_id, notexternal_idalone. Every Layer-A table (entity,order,invoice,sync_record, …) is keyed(connection_id, …, external_id), so/rest/v1/entity?external_id=eq.92can return more than one row on a multi-connection instance. Add&connection_id=eq.<id>wherever you assumed one row per source id, or handle the array. - Pass
?connection=<id>(or thex-turbobase-connectionheader) onPOST /:source/:recordTypeto 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 gainedconnection_id(text, NOT NULL, default'default') andlabel(text, nullable). Both are non-secret and readable through the existing admin-only policy;labelis the human-readable name a connection picker should render. - Do not join canonical identity through
connection_id.entity_canonicaldeliberately has no such column — Atlas dedup stays connection-agnostic, so the same real customer under two connections resolves to one canonical entity with twoentity_source_linkrows. - Pass
--connection <id>tosync:sourceto 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”-
Read item-fulfillment lines with the FK embed
order_fulfillment?select=*,order_fulfillment_line(*)— no constraint hint needed,parent_pg_row_idis 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_linehas existed since v0.1 with RLS, theparent_pg_row_idFK, and both sync directions. The catalog and the data MCP’slist_record_typesnow 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 = 0you 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.itemon a fulfillment is a REST link stub ({"links":[…]}) on any row synced before this version, soraw->item->itemsreads 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 stayedjsonb, 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 fromraw. 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”- Rename
turbobase learnings propose-kbtoturbobase learnings push-kbin any script or CLAUDE.md that calls it. There is no alias — the old name is gone, and so isPOST /v1/learnings/propose-kb(nowPOST /v1/learnings/push-kb).GET /v1/learnings/pendingkeeps its path. - Expect
learnings push-kbto 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 developersheading (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-01append once, and several findings naming the same unwritten page create it once.--dry-runprints the exact markdown each page receives;--diffadds it to the confirmation prompt. - Re-run
push-kblater 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”- Read the write-set that
turbobase learnings propose-kb --dry-runnow prints under each finding before you apply: the proposal body verbatim, plus the target page’svisibilitychange when a correction withdraws it from readers. Pass--diffto get the same block on the confirmation prompt, or--no-diffto 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-kbonly ever moves a pagepublished → 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/<slug>. Turbobase now injects/_tb/widget.jsinto 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<meta http-equiv="Content-Security-Policy">, keepscript-src 'self'andimg-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 — headerrawreads 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. Writeitem_receipt_line!item_receipt_line_parent_pg_row_id_fkey(…); the hint is always!<line_table>_parent_pg_row_id_fkey. Without it PostgREST returns300/PGRST201and no rows, and nothing retries it for you — the client’s fallback keys on400/PGRST200, so an unhinted embed is a hard error in your app. Use theparent_pg_row_idconstraint 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*_linetable plus an in-memory join ontran_external_idwith 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
jsonblink withselect=*,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.jsonunderrelationships(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()andselectPage()serve a declared embed either way: natively once the fast path is armed, resolved by the instance through/v1/batchuntil 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 a501naming the alias, rather than quietly returning different rows. Nested embeds and!innerwork 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], neverrow.created_from_po.name). A plain FK embed still follows the FK’s cardinality, soitem_receipt_lineis an array butcurrency: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 withselectPage()— a keyset seek, notoffset. An unboundedselect()that comes back at exactly the cap now logs aTurbobaseTruncationErrornaming 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 nopg_row_id, and mint a fresh cursor whenever the filter or select changes — replaying a stale one throwsTurbobaseCursorError. - Refresh
src/lib/turbobase.tsfrom the current scaffold if this project predates it:selectPage(),TurbobaseTruncationErrorandTurbobaseCursorErrorare new exports. Scaffold into a scratch directory (turbobase apps init <slug> /tmp/tb-scaffold) and copy that one file across — don’t--forceover your project.
v1.0.77
Section titled “v1.0.77”- Replace
select=*with the columns you actually render in anybatch()/select()call against a synced table —*drags the wholerawJSONB blob per row.POST /v1/batchnow 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
limitsized for a table scan rather than a screen, and page instead — each batch result’scontentRangecarries the total. - Read
error(notmessage) on a failed batch sub-result if you surface it in the UI; the client now preferserrorand falls back tomessage. - 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”- Keep
INSTANCE-LEARNINGS.mdandTURBOBASE-FEEDBACK.mdup to date as you work:turbobase apps initnow 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 byturbobase apps push. Anything the instance must have goes in tracked project files, not in your agent config. - Add a
learningsblock topublic/turbobase.jsonif 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’stelemetry.share_learningssetting, never widen it. - Run
turbobase adoptat the start of a session to refreshADOPT.mdwith anything newer than the version you last adopted, and work the list before starting new feature work.