Skip to content

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.

  • Handle a partial answer from POST /v1/batch: the whole call now has its own deadline. Until now a batch either came back complete or the front door gave up on it and your request failed with nothing in the body. A large fan-out of slow reads now answers in time with whatever finished, so check each slot before you render it — a batch that met the deadline comes back partial rather than failing.

  • Tell a sub-query that never ran from one that was tried: read results[i].notRun. A slot the deadline refused answers the same 504 an aborted read answers, plus notRun: true — present only on a sub-query that was never sent, absent on every other slot, the way truncated already behaves. Its body.error says so in words if you log it. Nothing changes for a call that finishes inside the deadline: no slot gains a field.

  • Ask again for the slots that did not run — not the whole batch. Re-sending all of it re-runs the sub-queries that already came back and re-enters the same queue that ran out of time. Collect the indexes carrying notRun, send those queries on their own, and merge them into what you have. Splitting a wide first paint into two narrower batches is the better fix if you meet the deadline often.

  • Give your app a page to open from a NetSuite create form, if you want one. The “TB Open in Turbobase” button can now also appear while a user is creating a new record: the NetSuite admin sets “Turbobase path on new records” on the script deployment (e.g. /a/orders/new), and the button opens that path exactly as set, signed in as usual, with no source, recordType or recordId appended — the record has not been saved yet. Nothing changes for a deployment that leaves the parameter empty, and the VIEW button still appends all three.

  • Offer resumeProcessRun(runId) instead of your own “finish what the run left” screens. A process step that declares awaitSource waits a bounded time for the connected system’s verdict; past that budget the run ends committed with the steps it never reached counted in outstanding. Until now nothing could carry it on, and invoking the process again started a NEW run whose step keys were its own — so the records the first run made were created a second time. resumeProcessRun posts to POST /v1/processes/runs/{runId}/resume, which puts the SAME run back on the queue at the step it stopped on: the steps its ledger already recorded are passed over — a step whose when declined as well as one that wrote, because the run reached it and decided — the one whose verdict never arrived is waited for again, and the rest of the chain runs. Only the steps the run never reached are performed, so nothing is written twice and nothing the run chose not to do happens behind you. Answers 202 { runId, state: 'running', queued: true } — watch the run until it reads committed with outstanding: 0, exactly as you do after an invoke.

  • Branch on 409 process_run_not_resumable rather than offering the button on every stopped run. Only a run that ended committed with work outstanding can be resumed. A run that is queued or running has not stopped, a failed or rolled_back one needs its refused step re-keyed rather than re-offered, a cancelled one was stopped by a person, and a committed run with nothing outstanding has finished — each answers 409 naming the state on the body, with nothing queued. A process declared eachRow answers 409 process_is_a_migration: its writes are keyed per source row, so invoke it again to carry one on. The resume is open to whoever invoked the run and to an administrator, and anyone else gets the same 404 process_run_not_found the run read gives them. Two resumes in quick succession start one continuation — the second answers 409.

  • Run turbobase processes resume <runId> when nobody has built a screen for it yet. The same door from a terminal, on an extensions:deploy token owned by an administrator, beside processes cancel and processes rollback.

  • Stop telling readers to roll a stopped run back and start over. The guidance in Author processes said to roll back a run that stopped on a wait once that step’s record had reached the source; resuming it is now the answer, and a rollback is for undoing work you do not want. A record the connected system applied but could not link back is still kept by every rollback (SOURCE_APPLIED_UNLINKED), and a resume onto one of those now records it the same way — so a resumed run no longer deletes a record that exists upstream.

  • Undo the shallow-schema workaround in a process input, if you added one. A process (or hook, or conduit) deployed from an app is read into the platform by a walk that used to stop at six levels and drop everything below in silence. A JSON Schema spends five of those on its own nesting — input → properties → a field → items → properties — so every per-field schema inside an array’s items was lost while the additionalProperties: false beside them survived, and the process then refused every non-empty array before it ran (targets.0 must NOT have additional properties). The walk now goes 64 levels, so an input that hoisted its item schema into definitions and pointed at it with $ref purely to stay shallow can be written inline again. Nothing is required of you: a hoisted schema keeps working.

  • Expect a deploy to be refused, by name, where it used to be accepted and then misbehave. Past 64 levels of nesting, or more than 256 keys in one object or entries in one array, the deploy now fails with an error naming the path that hit the limit instead of registering a schema with the deep part cut off. A definition that was over either limit was already validating the wrong shape — accepting bodies it should refuse where a type union, a nested items or an anyOf member went missing, and refusing correct ones where a properties did. Flatten the shape, or declare the repeated part once in definitions and reference it with $ref.

  • Read line_number on order_fulfillment_line and item_receipt_line as the line’s own NetSuite id, not as a position. It always meant that on rows a REST read wrote. Rows written by the scheduled poll held a 1-based position and rows written in realtime a 0-based one, so the value changed whenever the record was re-read. Each row takes the id the next time its record syncs. Keep ordering lines by line_number if you do already: the order is stable, but it is not guaranteed to be the order NetSuite displays them in. Join to system_note.line_index on it as documented.

  • Read line_index, not line_number, on order_fulfillment_line and journal_entry_line — only item_receipt_line.line_number is kept for good. Two v1.46.0 texts disagreed: the adopt note said those two columns are dropped a release later, while the header of the line_order migration called all three line_number columns frozen. The adopt note is right; the migration header overstated it, and migrations are not edited once shipped, so this is the correction. Until the two are dropped, project types still names line_number as the paging key for them in SEEK_KEYS, because line_index arrived nullable — paging through SEEK_KEYS keeps working while the column exists. Order and display lines by line_index.

  • Return a Promise from an update step’s pgRowId, externalId or expectedSyncVersion when the value needs a read. A deployed process has always awaited them, but the @turbobase/extensions type said they were synchronous, so the natural code — read the row’s current sync_version through ctx.query, then hand it over — did not type-check, and authors passed values across from when by hand. The type now allows a Promise, and the engine awaits it on every path. The order is unchanged: when, then the address functions, then build.

  • Read SOURCE_APPLIED_UNLINKED on a process step, and link the record instead of telling the user to clean it up by hand. When an awaitSource step’s create was APPLIED by the connected system but linking the new record back to its row failed (write_settle_conflict), the step used to be recorded SOURCE_REFUSED — and a rollback then deleted the row, which had no external_id, so the record stayed in the connected system with nothing on your instance pointing at it. The step is now failed with refusal_code: 'SOURCE_APPLIED_UNLINKED', the source’s id on the step’s external_id and the same message as before, and no rollback — automatic or POST /v1/processes/runs/:runId/rollback — deletes its row. The run still stops there. Offer POST /v1/data/:recordType/settle with the step’s pg_row_id to link it. Code that matched the message rather than the code keeps working; code that branched on SOURCE_REFUSED for this case should branch on the new code.

  • Add turbobase project skills to .claude/hooks/session-start-refresh-types.sh once, so the platform’s skills stay current in this project. The skills the scaffold writes that are platform knowledge — write-a-process and business-rule-to-hook — used to be written at init and never again, so nothing the platform learned afterwards reached your sessions. They now carry a platform-maintained line, and turbobase project skills rewrites them from the installed CLI. Paste this at the end of the hook, after the project adopt line:

    Terminal window
    if turbobase project --help 2>&1 | grep -qE '^ +skills( |$)'; then
    turbobase project skills >/dev/null \
    || echo "turbobase: \`turbobase project skills\` failed — the platform skills were NOT refreshed." >&2
    fi

    The first run replaces an older copy you never edited and saves it as SKILL.md.bak; a copy you DID edit is left alone and named, so move your notes to INSTANCE-LEARNINGS.md and delete it to take the platform’s. Your own skills are never read. New projects get the hook line from init.

  • Stop probing the session before calling an /api/account/* route, and send a turbobase:session_dead user to /login as the docs already say. A role edit or a role-changing deploy ends every signed-in session at the data plane with turbobase:session_dead, and the instance’s own pages used to answer that by deleting the session cookie — so the next console call, or /login itself, asked for a password. They now ask the instance to re-mint the session first: a person whose access changed keeps working with their new access, and only a session that was signed out, superseded or suspended is cleared. The documented behaviour of /login is now what happens.

  • Refresh .claude/skills/write-a-process/SKILL.md from a new scaffold. It gains six traps a production app met moving its writes onto processes: a chain that stops cannot be resumed and invoking again creates again; the invoke takes no idempotency key; SOURCE_APPLIED_UNLINKED and how to link its record; async update addresses; delete-only onRefused and bare scope refusals; and that a preview cannot run a new or changed process. Run turbobase project init in an empty directory and copy that one file over yours — never --force over your project, which overwrites everything.

  • Keep the line_index fallback when you sort a NetSuite document’s lines by line_order — the column is not guaranteed to be filled by the next poll. The v1.46.0 note said a document’s lines are rewritten with line_order the next time it is read, within four hours. That holds only for a document that changed: a re-read that finds nothing new writes nothing, so an unedited document kept line_order null. This release fills it once, on upgrade, for every stored NetSuite line whose position can be read exactly from the document it was synced with. A line it cannot place stays null until the document next changes in NetSuite, so keep ordering by line_order with nulls last, then line_index. PostgREST: purchase_order_line.order=line_order.asc.nullslast,line_index.asc.
  • Declare on: 'after_save' on a conduit to react when a record is saved on your instance, whoever saved it. Until now a conduit only woke up when a record arrived from, or landed in, another connected system — so a save made here, by your app through POST /v1/data/:recordType, by a process, by the in-product agent, by Studio or by another conduit, triggered nothing at all. It fires once per COMMITTED save, after the save commits, through the same dispatcher the two existing triggers use: the run shows up on turbobase logs --conduit, idempotency and the cycle budget apply unchanged, and a save that was refused or rolled back fires nothing. A record ARRIVING from a connected source is still after_canonicalize_inbound, so a synced record never fires twice.

  • Leave idempotencyKey off an after_save conduit. With no key declared, each banked save gets its own, which is what makes two saves of one record two runs. Declare one and it is used exactly as written — and a record saved here has no external id, so ctx.externalId is its own surrogate key and is the same string for the record’s create and every later edit. The conventional '<id>:${ctx.externalId}' therefore dedups the second save into the first and the conduit fires once in that record’s lifetime. turbobase ext lint asks for ctx.externalId in a declared key, which is right for the two inbound events and wrong for this one. If you need your own key here, put a field from ctx.change in it.

  • Read ctx.change in an after_save conduit’s from.when instead of firing on every edit. It is { kind: 'insert' | 'update', fields: [...] }, and fields names the columns the writer actually supplied — so ctx.change.fields.includes('status') is a gate rather than something you work out in the body. Both the gate and the transform see it. It is ABSENT on the two existing triggers, so a conduit you already have reads exactly what it read before.

  • Read ctx.savedBy to refuse your own writes. It is { kind: 'app' | 'process' | 'agent' | 'studio' | 'conduit' | 'user', id? }, with the conduit’s id when a conduit made the save. A conduit’s own write is one of these saves — the row it writes into the target fires after_save under the pair its to names, so a conduit that listens where it writes feeds itself. Gate on ctx.savedBy.id !== '<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. turbobase is a reserved source for the platform’s own records — it is not a connector and never will be, so do not use it as a source_system anywhere else. A change request fires on filing, on classification and on every status change, each run carrying { id, title, body, app_slug, filed_by, status } and nothing else. The projection is fixed: a column added to the table later does not join it.

  • Point a conduit’s to at turbobase:proposal to FILE a change request from a connector record — a message in a Slack channel becoming a request, for instance. The target may create a DRAFT and nothing more: a patch that names id, status or any other lifecycle field is refused, and the ledger row says which key refused it. Where the request came from is recorded on the created request’s solution_target, and the filing itself reaches an after_save conduit as savedBy: { kind: 'conduit', id } — so a conduit that both files requests and reacts to them can tell its own from a person’s.

  • Expect a conduit whose to names a system this instance does not have to be refused at deploy time. turbobase ext deploy previously accepted a target under any unknown source — including a mistyped connector id and turbobase:<anything> — and the conduit then failed on its first run with unresolved target. It now 400s with conduit_target_unknown, naming the pair. A conduit already stored with such a target is refused at the apply layer with the same sentence instead of running. If you have one deployed against a typo, this is where you will find out.

  • Declare after_save HOOKS on records your app writes and they now run. POST /v1/data/:recordType ran before_save and then nothing once the save committed, so an after_save hook on a record type an app writes never saw the app’s own saves — only ones arriving from a source. It now runs post-commit, observe-only, in the shape after_delete already uses at the same boundary: it reports direction: 'standalone' | 'outbound' and origin: 'user', it cannot refuse or revert the write, and a throw out of it does not fail the write. A hook you declared and assumed never fired for app writes does now.

  • Declare on: 'after_delete' on a conduit to react when a record is deleted on your instance. Until now a conduit could react to a record arriving, to a record landing in another system and to a record being saved — and to nothing at all when one went away, so “unpublish the counterpart”, “close the ticket” and “retract the announcement” were unbuildable. It fires once, after the delete commits, and ctx.payload is the record’s LAST STATE, which is the one thing a deletion reaction cannot read back for itself. A delete your guardrails or a before_delete hook refused fires nothing, and a repeat delete of a record that is already gone — still a 200, because a delete here is idempotent — fires nothing either. ctx.change is absent on this trigger, as it is on the two inbound ones.

  • Read kinds and origin on a turbobase:proposal conduit. The change- request projection was id, title, body, app_slug, filed_by, status; it is now those six plus kinds — the solutioning kinds the request has been classified as, [] until somebody classifies it — and origin, which says how the request arrived. origin.channel is one of widget, conduit or the request’s own creator (authored, request, …); a widget request also carries origin.route, the screen it was filed from, and a conduit-filed one carries origin.conduit_id, origin.from_source, origin.from_record_type and origin.from_external_id. Keys that do not apply are absent rather than null, so test for the key.

  • Gate on ctx.change.fields.includes('kinds') to react when a request is CLASSIFIED. Classifying a request now fires after_save on turbobase:proposal with kinds named as what changed — before this, the only run a request ever produced was its filing, where kinds is empty by construction, so a conduit keyed on classification could not be written. A classification reports kinds and nothing else: it does not report status, so a conduit watching for a decision on a request no longer fires when somebody merely works out what the request is.

  • Set app_slug and filed_by in a create-draft patch to say which app a request a conduit files is about and who reported it. Both are fields the projection already published and neither could be set, so a conduit-filed request arrived with two of its fields permanently null. An app_slug your instance does not have is REFUSED, naming the slug, and nothing is created. filed_by is recorded as REPORTED by the conduit and never as a signed-in user, so a request’s created_by still only ever names somebody who authenticated.

  • Address a Slack channel_message to a person by email to send a direct message. raw.channel took a channel id, #name, a bare name or Slack’s own <#C…|name> markup; an email address now resolves against your workspace’s mirrored members and the message is delivered into that person’s direct-message conversation. An address no member carries is refused with the address named, and a Slack app installed before this shipped is refused with im:write named — re-install the Slack app from Settings → Slack to grant it. Everything about a channel post is unchanged.

  • Name a process in a conduit’s to to start it: to: { source: 'turbobase', recordType: 'process', process: '<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 at turbobase ext deploy, naming it.

  • React to a release and to a published knowledge page. Two new reserved records publish into the same after_save seam: turbobase:app_release (id, app_slug, app_name, version, deployed_by, deployed_at), which fires on a PRODUCTION deploy and not on a preview; and turbobase:knowledge_page (id, slug, title, visibility, published_by, published_at), which fires when a page is published and not when one is written, edited or taken back to draft. Both may only be reacted to — a conduit naming either in its to is refused at deploy.

  • turbobase ext lint has new advice about an after_save or after_delete idempotency key, and it reverses what it used to say on those triggers. A declared key that carries nothing about the save now warns lifetime-idempotency-key: on a save trigger ctx.externalId is the same string for the whole of a record’s life, so a key built on it alone dedupes every later save into the first and the conduit fires once per record and never again. Omit the key, or include something from ctx.change. The advice for after_canonicalize_inbound and after_sync_success_outbound is unchanged — there ctx.externalId is exactly the right key.

  • A banked save that cannot be dispatched is now parked after five attempts and reported as a sync failure, instead of being re-claimed every five minutes for ever. The failure names the record and the conduits that were waiting for it, and the row is kept rather than dropped, so an operator who fixes the cause can replay it. Nothing changes for a save that merely fails once and then works: it is still dispatched, exactly once.

  • Turn on “Classify a change request when it arrives” (solution.classify_on_arrival) to have every new request classified as it is filed, from any channel, instead of waiting for an admin to mark it ready for solutioning. Off by default, because it spends one planner model call per filed request. It records the kinds only: the request’s solutioning state does not move and no draft solution is seeded.

  • Classifying a request no longer forgets what the request knew. The solutioning planner used to replace a request’s whole solution_target with its own hints, so classifying a request filed from the in-app widget could drop the app it was about, the screen it was filed from and the filer’s address — which is the field filed_by is read from, so a classified request could read as anonymous. The planner’s hints are now merged onto what the request already carried, and setProposalTarget merges rather than replaces. A classification that names no target of its own leaves the request’s target exactly as it was.

  • Share a file into Slack with a new file record on source_system: 'slack'. It takes two requests: POST the bytes to /v1/data/file/uploads with content-type: application/octet-stream and keep the upload.upload_id it answers, then create the record with that handle on raw.upload_id, the destination on raw.channel (a channel name, a channel id, or a person’s email address) and the file’s name on path. raw.thread_ts puts it in a thread, and raw.initial_comment, raw.title and raw.alt_txt are optional. The row settles write_status: 'synced' with Slack’s own file id as its external_id, so read the status rather than treating the create’s 200 as a file in the channel. list_record_types publishes the required paths as required_inputs — read them rather than hard-coding the list.

  • Do not edit or delete a Slack file row. Both are refused — the create with 422 at the boundary, a privileged enqueue or conduit with code: 'FILE_SHARE_IS_FINAL' — because Slack publishes no verb that replaces a shared file, and sending it again would put a second copy in the channel. Share a new file instead. list_record_types publishes ops: ['create'] for it.

  • Pin the source when you ask the catalog about file. Four connectors now publish that record name and the bare-name fallback in GET /v1/catalog/records/file and GET /v1/catalog/columns?types=file resolves to Slack rather than Google Workspace, which changes the table and the read capability it describes. Pass ?source=google_workspace (or github, azure_blob, slack) and nothing collapses. The four source:recordType couples are unchanged.

  • Read the number out of an over-size upload refusal. POST /v1/data/:recordType/uploads one byte over the 8 MiB ceiling used to answer Fastify’s own Request body is too large, which named no limit; it now answers 413 with rejection.code: 'UPLOAD_TOO_LARGE', field: 'body' and the byte limit in rejection.message, the same shape it already sent for a larger overage. Nothing to change if you were reading the status; parse the message rather than hard-coding 8 MiB if you surface the limit.

  • Expect files:write on the Slack app. A workspace connected today already grants it. One connected before file sharing existed does not, and a share then ends write_status: 'blocked' with reason_code: 'source_permission_missing' and a message naming the permission — re-install the Slack app from the instance’s Settings → Slack. No change to any app, but surface that message rather than retrying: no number of attempts grants a permission.

  • Read label on a FeedHive instance to get the workspace’s labels. A new read-only record type, feedhive:label, lands on the shared label relation with each label’s name: /rest/v1/label?source_system=eq.feedhive, or list_records on label. It needs read:label, which is the capability github:label and asana:asanaTag already use, and it appears in /v1/catalog/records and in turbobase project types. It refreshes on every sync, so a label added or renamed in FeedHive arrives on the next one.

  • Set raw.labels on a FeedHive social_post to file a post under a label. Accepted on the create and on the update, as an array of label ids — read them off the label relation above; the name is not accepted. A mirrored post now also carries its own label ids in raw.labels, so an app that reads a post and writes it back keeps the labels it had. Omit the key to leave a post’s labels alone: labels REPLACES them, so an edit sending [] would strip what somebody set in the FeedHive calendar — which is also why clearing a post’s labels is not something this field can express yet.

  • Expect reason_code: 'LABEL_UNKNOWN' on a FeedHive post filed under a label the workspace has not got. The post is SAVED and then refused when the write is sent, so read it off record_write_state rather than from the response to your write — the label is named in the message. Nothing reaches FeedHive, and one unrecognised label refuses the whole post rather than publishing it under the rest. A workspace whose labels have not been mirrored yet refuses too, and says that is why — wait for the next sync rather than treating the label as missing. Only a label you are ADDING is checked, so an edit that leaves a post’s labels as FeedHive reported them is never refused for them; a post naming no labels is unaffected and is published exactly as before. “Already there” means what FeedHive told us about that post, not what the row says — writing an unknown label onto a mirrored post’s raw.labels and then editing the post does not establish the label, and is refused like any other.

  • Read a line’s cost-tracking label from class_name / department_name / location_name, not out of the line’s raw. Every line-bearing Xero record — invoice, bill, credit note, supplier credit note, purchase order, spend money and manual journal — now projects each line’s Tracking[] entry into the line table’s existing dimension columns, so an invoice line tagged with a “Region” option reads class_name = 'West' and class_id = 'xero:{TrackingOptionID}'. When the source sends an entry with no option id the _id column stays NULL and the _name is still filled, so match on the name when the id is absent rather than treating the line as untagged. The whole Tracking[] array is still kept in the line’s raw, so nothing you read today stops working.

  • Select a source’s cost-tracking axes from /rest/v1/segment and their values from /rest/v1/segment_value. These are new and they are the canonical store for every source’s dimension values: one segment row per AXIS (a Xero tracking category, a Business Central dimension, a NetSuite custom segment, and NetSuite’s own class / department / location), and one segment_value row per value on it. A value carries segment_pg_row_id, canonical_segment, name, parent_id / parent_name, is_inactive and raw. Count one axis’s values with segment_value?select=*&segment_pg_row_id=eq.{id} and Prefer: count=exact. An archived value is present with is_inactive = true rather than missing, so filter on that column if you only want the ones a user can still assign.

  • /rest/v1/classification, /rest/v1/department and /rest/v1/location keep working unchanged, and are the ones to stop writing to. They now hold the subset of segment_value whose axis fills that typed slot, kept in step by the database, with the same pg_row_id on both sides — so every list read, filter and pg_row_id reference you have today is unaffected. They become read-only views in the release after this one, so move any WRITE you make to one of them onto segment_value (set segment_pg_row_id; the slot is derived). Reads need no change then either.

  • Replace an embed of a dimension — order?select=*,location(*) — with order?select=*,segment_value(*). PostgREST resolves an embed through a foreign key, and every <dim>_pg_row_id link now references segment_value. No app in either repository embeds one today (all reads measured were plain list reads), so this is here for completeness rather than as a break anybody is expected to hit.

  • Stop binding a source axis to a canonical one in your own code. Which typed slot a source axis fills is now a row — segment_mapping, one per (connection_id, source_system, segment), carrying canonical_segment and the source field for the outbound direction. Read it instead of hard-coding a pairing: a migration app that assumed “QuickBooks class → NetSuite class” should take the pair from this table. The mapping is created the first time an axis is seen and is never recomputed, so it is stable across a rename of the axis upstream. Read it, do not rewrite it — changing canonical_segment on an existing row is not supported in this release: the values move slot but the classification / department / location mirror does not follow them, and lines already projected under the old slot keep their typed columns until they are next synced.

  • Stop treating “this source keeps no class or department” as a fact about the source, and ask /rest/v1/segment?source_system=eq.{source} per connection instead. Xero is the case that changes: it mirrored no dimension record of any kind before this release, so an app that offered a Xero customer no region or cost centre — or asked them to type the names in by hand — was right and is now wrong. Every source that mirrors an axis answers through the same two tables, so this is one read per connection rather than a per-source list to keep up to date, and segment_mapping says which canonical slot each axis fills. An empty answer still means the connection has no axes, so the hand-typed path is a fallback rather than something to delete.

  • Read every axis a line carries, including the ones with no typed column, from /rest/v1/line_segment_value. A canonical line has three typed dimension slots and a source may define more axes than that — Business Central allows eight, a NetSuite account any number of custom segments — so each line’s full set of assignments is listed here, one row per axis, keyed by line_table and line_pg_row_id. It is read-only in this release: the values beyond the typed columns still reach the source through the line’s raw. A row is never more readable than the line it names, under the caller’s own rights and the line table’s own rule — so a user who cannot read an invoice reads none of its lines’ labels, and a reader restricted to one class reads neither the journal line tagged outside their scope nor its labels. It can be LESS readable, on the one line table whose own rule names no dimension column: turbobase.check_line cascades from its header’s subsidiary, so a class-restricted reader keeps a spend-money line and loses its labels. So a short label list is a scope answer rather than a sync gap, and the thing to reconcile it against is the line’s own typed dimension columns, not the line count.

  • Expect a scope-restricted reader to see FEWER journal lines than before. turbobase.journal_entry_line’s read rule tests class_id and department_id, so now that those columns carry a value, a user whose class or department access is restricted stops seeing journal lines tagged outside their scope. That is correct scoping rather than a regression, and a reader with no restriction configured sees exactly what they saw before — but if your app shows journal lines to restricted users and assumed they saw all of them, re-check those screens. Spend-money lines are unaffected: turbobase.check_line’s rule cascades from its header’s subsidiary and names no dimension column.

  • A Xero line’s tracking is read-only outbound, and that is now recorded rather than implied. Editing a Xero line’s class_id does not reach Xero: the writer rebuilds Tracking from the line’s inbound raw. The mapping row says so — direction = 'inbound' and a NULL source_field — so check it before offering an edit. NetSuite’s class / department / location round-trip as they always did.

  • Stop branching on record_write_state.message for a held write — three of those sentences changed. A write the connected system refused on its own access control (reason_code: "permission_denied"), a write the write-back breaker is withholding (reason_code: "connection_refused"), and a write whose target record is gone upstream (reason_code: "NOT_FOUND") each used to carry the generic “Syncing is held — your team has been notified. No action is needed from you”. All three now carry a sentence about the actual condition: what refused, whether editing the record releases it, and who has to act. status is still blocked, reason_code is unchanged on all three, and fields is still empty — so an app branching on the code or the status needs no change. An app that matched the prose, or showed it as proof that nothing was needed, must stop: the permission and gone-upstream sentences now say somebody at the other end has to act.

  • Read summary off GET /v1/events instead of looking the event code up yourself. Each row in the response now carries a resolved human sentence for that occurrence. It is derived from the row, never stored, so correcting a wording reaches rows already written. For sync.failure_recorded the sentence depends on the failure’s classification: a held refusal no longer claims the record “is re-attempted on the next run”, while a retryable one still does. Every other code resolves to the same sentence it always had. The field is optional on the wire — an instance older than this release sends none — and turbobase logs --json now prints it. The rendered one-line form is unchanged.

  • Stop treating 500 write_failed on a retried POST /v1/data/:recordType as “it may or may not have landed”. A retry carrying an idempotencyKey the caller itself committed on or after this release is now answered 200 { ok: true, replayed: true } with the first attempt’s pg_row_id and external_id, for every caller — including the three that used to get the failure back every time, forever, for that key: a role holding write:<table> without read:<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 forbidden from POST /v1/data/:recordType when a create or edit names a dimension outside the session’s own scope. A user restricted to one location, department, class or custom segment could commit a row into a different one and then not see it; those writes — and the matching deletes — are now refused before the statement runs, on invoice, order, purchase_order, item_receipt, item, time_bill, vendor_bill and seventeen more. Nothing changes for an unrestricted session, for an app_role: 'admin' one, or for a restricted session writing inside its own restriction. If a screen lets someone pick a location they do not hold, filter the picker to the session’s scope, or handle the refusal.

  • The replay above covers keys committed from this release on; an older key keeps exactly the answer it has today. The replay turns on a new idempotency_actor column, written beside idempotency_key at the moment the row commits, and nothing backfills it — so a retry under a key already in your instance falls back to the read-the-row-back check it has always used. Nothing regresses: if that check answers your retry today, it still does. But if you are one of the three callers above, a key you committed before the upgrade keeps answering the old failure, for that key, forever, and your app cannot recover it — the record did land, so stop resending it rather than writing the record again under a new key, and ask an administrator for the row’s identifiers if you need them. The column itself sits on every table carrying idempotency_key, your instance’s own cr_* / cl_* / ext_* record types included, and is rejected on a request body like external_id and originating_system; regenerate with turbobase project types if you select * into a fixed-shape type.

  • Read a line’s UNKNOWN_COLUMN refusal from lines[n].<field>, not from the bare column name. A write to POST /v1/data/:recordType whose lines[n] names a field the line table has no column for is still refused 422 with code: "UNKNOWN_COLUMN" and a message naming the line table, but field now carries the path — lines[0].description where it used to be description. Nothing changes for a refusal on the record’s own fields, which still carry the bare column name. If your app maps field onto a form input by exact string, split on the last .; if it only shows the message or branches on code, there is nothing to do. The refusal is now made before the write is attempted, so it arrives on exactly the payloads it arrived on before, and tells you which of your lines was wrong instead of only which column.

  • Send every field your manifest declares required: true when you create a row in your own table. POST /v1/data/:recordType on a table your app (or the console) declares now refuses a create that omits a required field, or sends it as null, with 422 and code: "REQUIRED_FIELD_MISSING", field naming the column (cf_sa_<name>), and stores nothing. An edit that leaves the field out is still accepted and keeps the stored value; an edit that sets it to null is refused the same way. An empty string is a value and is accepted. Until now the declaration was not enforced and such a row was stored with the field empty, so if your app relied on that, either supply the value, fill it in a before_save hook, or drop required from the manifest. Tables mirrored from a connected system are unchanged.

  • Put turbobase ext lint --ci in your deploy lane, including on a lane that ships a process. ext lint now raises each rule only on the kind it is about. The four conduit-wiring rules — a weak idempotencyKey, a missing createdFrom, an extreme cycleBudget, a same-source “conduit” — describe a cross-source bridge, and were being raised on a process too, where none of them is true: a correct process module came back with one weak-idempotency-key warning whose fix named ctx.externalId and ctx.fromSource, which a process has no access to, and --ci exited 1 on it. A correct process now exits 0. The deploy-identity rule (id-not-first-literal) is unchanged and still raised on all three kinds, so nothing that was warned about correctly has stopped being warned about. If you dropped --ci from a lane because of this, you can put it back; if you suppressed the warning in a process module by naming ctx.externalId in its idempotency key, you can go back to keying on the row.

  • Add connection_id to any PostgREST upsert whose on_conflict= names external_id. 66 more tables — journal_entry, check, subsidiary, accounting_period, label, project, subscription, every procurement line table and the rest — now carry connection_id text not null default 'default', and their unique and primary keys read (connection_id, …, external_id). A request still sending ?on_conflict=external_id fails 42P10: there is no unique or exclusion constraint matching the ON CONFLICT specification. Send ?on_conflict=connection_id,external_id. Filter syntax is unchanged and every existing row reads default, but two reads move with it: the subsidiary / posting-period embed resolves through a new column (two entries below), and external_id on its own stops identifying one row (three entries below).

  • Drop the !…_fkey disambiguator when embedding the lines of an item_receipt, return_authorization, vendor_return_authorization, journal_entry, check or transfer_order. Those six header/line pairs carried two foreign keys — the old source-id one beside parent_pg_row_id — so a bare ?select=*,item_receipt_line(*) returned 300 / PGRST201. The source-id constraint is gone and the bare embed now resolves. The explicit item_receipt_line!item_receipt_line_parent_pg_row_id_fkey(*) form keeps working; one that names the dropped source-id constraint instead (item_receipt_line_tran_fk, journal_entry_line_je_internal_id_fkey and their four siblings) has no relationship left to resolve, so drop the disambiguator or point it at parent_pg_row_id.

  • Embed a transaction’s subsidiary or posting period through the record, not the id column. order?select=*,subsidiary(*) and cash_sale?select=*,accounting_period(*) now resolve through new subsidiary_pg_row_id / posting_period_pg_row_id companion columns rather than through subsidiary_id / posting_period_id. The embed keeps working and the text columns keep their values; only the key PostgREST follows has moved. A row whose master has not synced yet embeds null instead of failing. The companions start empty, so a row written BEFORE the upgrade embeds null until the nightly system.dimension_ref_backfill sweep (17 3 * * *) reaches it — up to a day. Rows written after the upgrade resolve on write. If an app needs the subsidiary or period inside that window, read the subsidiary_id / posting_period_id text columns — and the *_name denorm where the table carries one — which keep every value they had.

  • Filter on connection_id as well as external_id wherever you need exactly one row. external_id is no longer unique by itself on any of these tables, so subsidiary?external_id=eq.1 can match two rows once a tenant connects a second account of the same source — add &connection_id=eq.default, or whichever connection you mean. Where a table’s primary key WAS the source id, that key now sits on the pg_row_id surrogate each row already carried, so pg_row_id is the single-column handle to one row.

  • Both of the above apply to your instance’s OWN record types too — the cr_<scriptid>, cl_<scriptid> and ext_<recordType> tables. Every one a tenant holds gained the same connection_id column and the same (connection_id, external_id) key at upgrade, so cr_widget?on_conflict=external_id fails 42P10 and cr_widget?external_id=eq.CR-1 can match two rows. Send ?on_conflict=connection_id,external_id, and filter on connection_id — or reach for pg_row_id — when you want one row. The table names are the tenant’s, so they are not listed here; GET /v1/catalog/records has the set for the instance you are building against.

  • Hold read:activity instead of read:zendesk_ticket_comment, read:intercom_conversation_part or read:notion_comment, and read help-desk ticket comments, messaging transcript parts and document comments from /rest/v1/activity. The three tables zendesk_ticket_comment, intercom_conversation_part and notion_comment receive no new rows: every comment now lands on the shared conversation satellite, told apart by activity_type (ticket_comment, message, comment) and source_system. Existing rows were carried across. An app that posts a reply on a help-desk ticket inserts an activity row with activity_type = 'ticket_comment' and the ticket in work_item_external_id.

  • Hold read:work_item instead of read:intercom_conversation. The conversation header folded onto the work_item row it described: channel, snoozed_until, first_response_time and resolution_time are columns on that row, and intercom_conversation has no row of its own any more.

  • Hold read:board instead of read:cr_jira_board, read:cr_jira_project or read:ap_project, and read boards and projects from /rest/v1/board. Jira boards and projects and Asana projects land on the shared board table beside GitHub’s; board.kind (board / project) tells them apart and board.project_key carries a project’s short key. An app that creates an Asana project inserts a board row with kind = 'project', title and description — board accepts its first write, under write:board.

  • Hold read:board_field instead of read:ap_section. A project’s sections are ONE board_field row per board (external_id = 'sections:<project id>') whose options array is the sections in order; a task’s section_id is still the section’s own id, which is the option’s id.

  • Hold read:entity:employee instead of read:worker, and read HR workers from /rest/v1/entity?entity_type=eq.employee. hire_date, termination_date and worker_type are columns on the employee row; employment, compensation and time_off carry entity_id (and employment.manager_entity_id) naming that row, beside the worker_id columns they already had.

  • Hold read:memory instead of read:notion_document or read:notion_block. A page is a memory_document of kind doc and its blocks are the page’s body_text; a block has no row of its own. memory_document now carries connection_id, so a filter on source_system + external_id can match one row per connection.

  • Hold read:time_entry and write:time_entry instead of read:time_bill and write:time_bill, and read or write NetSuite time from /rest/v1/time_entry. Every source’s time is on one table. duration_minutes replaces hours and is sixty times larger; author_id is the employee, description the memo, entered_on the date and work_item_external_id the task the time was logged against. A POST /v1/data/timeBill takes those column names and goes out to NetSuite in hours.

  • Hold read:credit_card_charge instead of read:card_transaction or read:credit_card_refund. Ramp card swipes and NetSuite card refunds land on credit_card_charge beside NetSuite’s charges; record_kind (charge / refund) and source_system tell them apart, refund lines are on credit_card_charge_line, and merchant_name, merchant_category_code, receipt_ref, transacted_at, settled_at and currency_code are columns.

  • Stop selecting tran_internal_id on credit_card_charge_line, credit_card_refund_line and inventory_status_change_line. The column is gone from those three line tables; join a line to its header through parent_pg_row_id, which every line carries. order_line and purchase_order_line keep it.

  • Re-point every label read at label_def and label_link — this release, not the next. Turbobase’s nine internal tagging tables are now two, and the four an app could read — app_label, app_label_def, catalog_label_def and proposal_epic_def — stop being maintained the moment this lands and are removed one release later. label_def holds the names (id, scope, name, color, description, managed_key, created_by, created_at) and label_link records what carries them (target_type, target_id, label_id, applied_by, applied_at). The pair is where every label applied, renamed or removed from here on lands; the backfill copies the four across once and nothing writes them again, so a read left on one of them keeps showing the labels as they stood at this upgrade. The removal a release later is when those four stop existing — not when they stop being right.

  • Filter on scope where you used to pick a table. app_label_def becomes label_def?scope=eq.app, catalog_label_def becomes label_def?scope=eq.catalog, proposal_epic_def becomes label_def?scope=eq.epic, and the proposal vocabulary — which proposals and solutions share — is label_def?scope=eq.proposal. Omit the filter and you get all four vocabularies in one list.

  • Filter on target_type where you used to pick a join. app_label becomes label_link?target_type=eq.app, and the thing’s identity moves from a typed column (app_id, object_id, proposal_id, solution_id) to target_id, which is text on every target. A row id arrives as its uuid spelled out, so compare it as a string rather than casting.

  • scope and target_type are different axes — do not match them up. An epic on a proposal is a scope = 'epic' name on a target_type = 'proposal' link, so a read that asks only for a proposal’s links gets its labels AND its epics. Join label_def and filter on scope to tell them apart.

  • Embed an app’s or a proposal’s labels through a named relationship, not the join. label_link.target_id carries no foreign key, so app?select=*,label_link(*) does not resolve. Use app?select=*,labels_on_app(label:label_def(id,name,color)), and on a proposal labels_on_proposal(...) or epics_on_proposal(...) — each already filtered to its scope.

  • Expect the same rows you see today, under the same row-level security. An app’s vocabulary is still readable by anyone signed in, an app’s chips still follow that app’s audience, and the catalog, proposal, solution and epic labels are still admin-only. Move the read and nothing a customer sees changes.

  • Read a column’s house name, and move off the old one within the next release. Twenty-nine columns gained the spelling the rest of the schema uses, beside the name they replace. Both names are on every row and a trigger keeps them identical, so nothing breaks today — but the old names are dropped a release from now, so move your reads and writes onto the new one now: created_at for inserted_at on account, accounting_period, ap_project, ap_section, asana_team, expense_report, expense_report_line, intercom_conversation, journal_entry, journal_entry_line, notion_block, notion_document, shipping_item, system_note, time_bill, units_type and units_type_line; currency_code for currency on card_transaction, charge and subscription; is_archived for archived on notion_document; memo for comments on project; name for title on board, board_card, opportunity and print_job; and line_index for line_number on item_receipt_line, journal_entry_line and order_fulfillment_line. item_receipt_line.line_number is frozen app contract and is kept indefinitely; the other two go with the rest.

  • Read a reporting view under its v_ name, and move off the bare one within the next release. Sixteen views gained a v_-prefixed name that returns the identical rows and columns: app_table_claim, app_user_unified, bulk_sync_progress, channel_config_admin, customization, dimension_name_drift, entity_canonical_view, inbound_change_rollup, invalid_index_health, metric_minute_recent, relationship_index_health, source_install_sync_state, sync_progress, sync_record_counts, table_size_summary and transaction_rollup. Both names work today; the bare names are dropped a release from now. customization is the one of the sixteen you also write to — v_customization accepts the same admin-only insert and routes it the same way, so move your writes with your reads. The four entity contract views — customer, vendor, employee and contact — keep their names and are not affected.

  • Stop reading customer_unpacked, vendor_unpacked, contact_unpacked and employee_unpacked; read the promoted columns on entity, or the customer, vendor, contact and employee views. Those four views spread entity.attrs into one column per field, which the promoted columns on entity now carry as typed columns. All four still work and are removed a release from now; each one’s own description says so.

  • Expect the full sync envelope on a mirrored table, and expect it empty rather than absent. 315 standard columns were added across 108 relations, so that every mirrored table of a kind carries the same ones; the register they are read against names 137 relations, and the other 29 were already complete. Every addition is nullable or defaulted, so an existing read is unaffected — but a select * returns more columns than it did, and a column that is newly present on a table is not necessarily written on it: hash, idempotency_key, write_status, synced_at and sync_version are filled by the write-back path, which is armed per record type. Treat a NULL as “not recorded”, never as a value. The register of which table is which kind, and which columns each kind carries, is src/sync/mirrored-tables.ts.

  • connection_id is now on every mirrored table, and on most of them it is not a key. 41 of the headers gained it as part of that envelope without their natural key moving, so a record the source numbered 14454 is still ONE row however many connections of that source reported it, and the column holds whichever connection wrote it last. Read it as provenance, not as a filter: on a tenant with one connection per source it is that connection, and a query that groups by it on a multi-connection tenant will not separate the accounts on those tables. The tables where it IS part of the key are src/sync/connection-scoped-tables.ts.

  • Re-read a table or column description in the OpenAPI document if you built anything on its text, and regenerate your types. 851 of the 1,598 descriptions the database publishes were rewritten in plain language, just over half of them. They reach you through GET /v1/catalog/openapi.json — a table’s comment is the description of its definition there and a column’s comment the description of that property — and so through the OpenAPI half of turbobase project types, which reads that document from a signed-in browser session. GET /v1/catalog/records, /columns and /fields are unaffected, and so is the in-product agent: their description comes from the catalog’s own semantic layer rather than from the schema, so a value’s text there has not moved. No meaning changed either way: no column changed type, nullability or what it holds. Ten descriptions on tables that several connectors project onto stopped naming one connector’s field; docs/sources/CANONICAL_MAPPING.md is where each connector’s own field names now live.

  • Handle 403 alongside 401 on every read and write, and re-authenticate on both. A session can now stop working before its token expires: the person signed out elsewhere, an administrator suspended them, or their role changed. The instance refuses those at the database, so /rest/v1, the live data stream and POST /v1/data all start answering 403 where a read used to return rows. Send the user to /login — the navigation reaches the instance, which replaces their session with one carrying their current access, so somebody whose session is otherwise fine is not asked for a password. See App auth wiring.

  • Branch on details, not on the status, to tell the two kinds of 403 apart. The error body carries "details": "turbobase:session_dead" when the session itself has ended; any other 403 means this session may not touch that table or those rows, and signing in again will not change it. Reading details is what lets you bounce the first user to /login and show the second a permissions message. Read message to show a person what happened — never to decide what to do.

  • Stop retrying a 403, and stop reading one as “no data”. It is a statement about the session rather than a transient failure, so a retry loops; and the refusal is deliberately an error rather than an empty result so your app can tell the two apart. Code that treated a 403 as a reason to back off and try again will spin on a signed-out visitor.

  • Check each POST /v1/batch sub-result’s own status, not the envelope’s. The batch stays 200 and the refusal lands one level down, as results[i].ok === false with results[i].status === 403 and the same details. Code that reads results[i].body after checking only the response status will pass an error object to a screen as rows.

  • Do not treat a successful read as proof the session is live. A query that has no rows to return can still answer 200 [] for a session that has ended, because the check runs only where a statement reaches rows — and that is the clearest case rather than the only one. Handle the 403 wherever it appears instead of inferring liveness from a request that succeeded.

  • Re-read your data on reconnect if a screen is long-lived. Changes stop arriving once the session behind a subscription ends; whether your client is told or simply goes quiet is not something to build on, so silence no longer means “nothing changed”.

  • Send the raw invite token to redeem_channel_invite, not its SHA-256 hash, and call it under an authenticated or service credential. The RPC’s first argument is renamed p_token and the function hashes it internally, so what the database stores in channel_invite.token_hash is no longer something a holder can redeem. anon no longer holds EXECUTE, so an unauthenticated POST /rest/v1/rpc/redeem_channel_invite is refused. An app still passing p_token_hash gets invite not found with the invite left open.

  • Stop calling adapter.confirm_request, adapter.confirm_decide and adapter.confirm_expire under a viewer’s session. authenticated loses EXECUTE on all three and USAGE on the adapter schema; they were SECURITY DEFINER with no authorization check of their own. The gated turbobase.confirm_decide wrapper is unchanged and is the admin door.

  • Open the CLI sign-in link as given, fragment and all. open-dashboard now builds …/auth/admin/cli#token=<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/authoring now allows 60 requests a minute from one address, applied before the bearer token is read, alongside the 60 a minute per token it already published. The numbers are deliberately equal, so a client pacing itself against its token allowance never meets the new one — but several clients sharing one outbound address (a CI runner, an office NAT, two containers on one host) now share it, and on an instance whose network does not yet pass the caller’s address through, every client of that instance shares it whatever address it calls from. If you run more than one authoring client against one instance, pace them against 60 a minute between them. Tell the two refusals apart by the body: the per-address one answers {"statusCode":429,"error":"Too Many Requests"} and the per-token one {"error":"rate_limited","reason":"too_many_requests"}. Override the per-address number with RATE_LIMIT_MCP_AUTHORING_MAX.

  • Give an external_api connection a base URL on the public internet. POST and PATCH /admin/sources/external_api/connections now refuse a base URL whose host is, or resolves to, a private, loopback, link-local or otherwise internal address — the 400 says so, distinctly from the existing malformed-base-URL refusal. A host that cannot be resolved is refused too: the screen fails closed. A connection already holding such a URL is refused on every patch until the base URL is corrected in that same patch, so a budget change on one needs the host fixed first.

  • Expect your inbound after_save hooks and your conduits to fire for records that arrive on the scheduled sweep, not only in realtime. Both seams used to run in one place — the realtime worker — so a hook or a conduit declared on a record type whose source is polled never ran at all, with no error and no conduit_event row. They now fire for every row a sweep INSERTS or UPDATES, for every poll-fed source. An unchanged re-read still dispatches nothing, so a four-hourly walk does not re-fire on rows it has already seen. If you declared one of these and had worked around it running only in realtime, remove the workaround; if you declared one and assumed it never fired, it does now.

  • Read a Salesforce record’s raw without stepping over attributes. Every SOQL row carries an attributes: { type, url } envelope describing the query rather than the record, and it was stored verbatim. New and updated rows no longer carry it. An existing row heals on its next inbound write.

  • A Salesforce connection now mirrors the whole org, so plan your reads for the real row count. Each object used to stop at 200 rows — for most of them the same 200 oldest, every run — and now pages to the end and keeps up. An app that was written against a few hundred rows of a large org will see all of them: paginate your /rest/v1/* reads and do not assume an unfiltered select returns a page’s worth.

  • Expect Salesforce rows to disappear. Deletions are mirrored for the first time — the org’s deletion log, merge losers, and a line taken off its header — and a tombstoned row is hidden from app reads by the deleted_at policy. A record your app held an id for may now read as absent.

  • Order Salesforce and HubSpot lines by line_order, not by line_index. On opportunity_line, estimate_line and order_line, line_index is the line’s IDENTITY — for a source whose lines are independent records it is a hash of the line’s own id, so sorting on it scrambles the document. The new nullable line_order carries the position a person sees, counting from 1. It is null for a source that embeds its lines in the header (NetSuite, Zoho), where line_index already is the order, so order by line_order nulls last, line_index is the portable form. Regenerate types to pick the column up.

  • Regenerate types: generated interfaces no longer include adapters this instance is not connected to. renderCatalogTypes and SEEK_KEYS now skip a record whose source the instance has no connection to. A NetSuite-only project that imported SalesforceOrderLine — which was the only interface describing order_line, and described the wrong column names — must switch to NetsuiteOrderLine, emitted per source and per line table. If you worked around this by selecting order_line columns by hand, drop the workaround.

  • Read opportunity.currency_code before adding up Salesforce amounts. The column is new and nullable; in a multi-currency org every row carries its own code, and a total across codes is not a quantity of anything. estimate and order already had it.

  • An embed off a Salesforce opportunity or quote now returns the account. opportunity?select=*,entity(*) and estimate?select=*,entity(*) returned null on every Salesforce tenant because entity_type was unset and the composite key is MATCH SIMPLE. If your app worked around that by reading raw->>'AccountId' and joining in the client, drop the workaround — and note that entity_name is now populated, so a list can render a name without an embed at all.

  • Salesforce owners resolve to names. work_item.owner_name, work_item.owner_user_id and activity.author_name are populated on Salesforce rows, and Salesforce users and queues are mirrored as entity rows with entity_type='employee' (a queue carries attrs.isQueue). An app rendering owner_id can render the name instead; one filtering a staff list on entity_type='employee' will now see queues, so exclude them with attrs->>'isQueue' where that matters.

  • Expect fewer work_item rows for a non-admin reader on a Salesforce tenant. work_item’s read policy admits a row whose owner_user_id is null, and no adapter filled that column until now — so every Salesforce case was readable by anyone holding read:work_item. A case whose Salesforce owner has a Turbobase account with the same email is now readable by that person and by an admin only. If your app showed a whole-team case list to standard users, it needs an admin reader or a different surface.

  • Salesforce activity links moved. activity.work_item_external_id now carries a Salesforce WhatId only when it names a Case; an Opportunity or Account WhatId moved to activity.entity_external_id, which also carries WhoId — the person the call was with. An Event’s created_at is now its creation date rather than its start time, so an activity feed sorted on it stops putting future meetings at the top; the start time is in raw->'__salesforce'->>'startsAt'.

  • Stop sending lines on a Salesforce opportunity to POST /v1/data/opportunity (and MCP write_record). It is now refused with 422 and code: 'LINES_NOT_SUPPORTED' instead of being accepted and silently dropped — Turbobase does not write opportunity products to Salesforce. Post the deal on its own; the products stay a Salesforce-side edit.

  • Send last_name on a Salesforce contact create, last_name and company_name on a lead, and title, status and expected_close_date on an opportunity. A create missing one is now refused at the boundary with 422 and code: 'MISSING_REQUIRED_FIELD', naming the field, rather than being saved and refused by Salesforce minutes later onto record_write_state. An account still needs something to name it — display_name, company_name, or last_name for a person account. list_record_types publishes the declaration as required_inputs, so read it rather than hard-coding the list.

  • Stop sending an update to a converted Salesforce lead. It is refused with 422 and code: 'CANNOT_UPDATE_CONVERTED_LEAD'; Salesforce will not accept one. Edit the account, contact or opportunity the lead became.

  • Address a row by either length of Salesforce record id on a write. A 15-character id — the form reports, list views and Classic URLs show — now resolves to the same record as its 18-character form when you address a row on POST /v1/data/* or MCP write_record, where it used to answer 404. A read through /rest/v1/* is not converted and still needs the 18-character form, which is what Turbobase stores and returns.

  • Expect a row you have just edited to keep YOUR value until the edit reaches the source. A scheduled sync used to overwrite a mirrored row with the source’s state even while that row’s own write-back was still queued, destroying the edit locally and then pushing the stale value upstream. It now leaves such a row alone and picks the source’s change up on the next sweep, so a read through /rest/v1/* can sit one sweep behind the source for as long as the edit is in flight. This holds for every source except Slack channel messages, whose history sweep can still overwrite an edit that has not posted yet. Nothing to change in an app — but if yours polls for the source’s answer to an edit, read record_write_state to know the write has landed rather than waiting for the mirrored value to move.

  • Read reason_code on a refused Salesforce write — it is no longer always write_failed_unexpectedly. A refusal now arrives as needs_input carrying the row’s own column in fields and Salesforce’s sentence in message, after one attempt rather than six. New values an app can see: source_value_not_accepted, source_duplicate_record, CANNOT_UPDATE_CONVERTED_LEAD, and outcome_unknown / source_response_lost for a create whose answer Salesforce lost — that one means the record may already exist upstream, so look before re-creating it.

  • Expect a plain YYYY-MM-DD sent to a NetSuite date field to land on that day — the earlier note named only a full instant. v1.41.0 announced this repair as covering “a full ISO instant written to a date field”, so an app sending a bare calendar day, which most do, read it as not applying. It applies: "2026-06-24" on a standard date field such as trandate, on a custom custbody_* / custrecord_* date field, and the same day written the account’s own way as 6/24/2026, all store 24 June, whatever time zone the account and the user behind the connection are set to. A datetime field still names an instant and is unchanged.

  • Drop the +1 day your app adds to a date before sending it, once that instance’s NetSuite bundle is TB 2.15.0 or above. An app that worked around the old one-day loss by sending the next day now writes the day after the one the person picked, and nothing refuses it — the document simply carries the wrong date, and you find out by opening it. The repair runs inside the connected account, so the compensation is still right below TB 2.15.0: ask whoever administers the connection which version of the Turbobase integration package it runs before you remove yours. Dates already stored a day early stay as they were written.

  • If your app reads turbobase.item.item_type to decide whether a product is tracked by lot or serial number, re-check what it expects there. A lot-numbered or serialized item now always carries its tracked type — lotNumberedInventoryItem, serializedInventoryItem, lotNumberedAssemblyItem or serializedAssemblyItem — whatever order the sync ran in, and on an instance upgrade the rows that were recorded wrongly are corrected in place. Before this, the untracked NetSuite reads could tag a tracked product inventoryItem or assemblyItem and nothing came along to fix it, so an app that filtered on the tracked type was silently missing those products and one that trusted inventoryItem to mean “needs no lot number” sent receipts NetSuite refused. Nothing is renamed and no column moves, so a query naming item_type keeps working; what changes is the value on the affected rows. If you filter the item list on item_type, list the tracked variants beside the plain one; if you branch on it to decide whether a receipt needs an inventory detail, you can now trust it.

  • Address a tracked product by its tracked record type on POST /v1/data/:recordType and DELETE /v1/data/:recordType. Both resolve a row by external_id AND item_type, so an edit or a delete sent to inventoryItem or assemblyItem for a product that is really lot-numbered or serialized no longer finds the row and is refused. That refusal is new: while the product was tagged wrongly the plain record type did reach it. Send lotNumberedInventoryItem, serializedInventoryItem, lotNumberedAssemblyItem or serializedAssemblyItem instead — the value the item row now carries is the one to address it by. Reads are unaffected: read:item covers every sub-type and the row-level policy on item does not look at item_type.

  • Handle reason_code = 'source_response_lost' on a refused NetSuite write — a brand-new record now reaches it on a second road. Until now the only NetSuite refusal that ended there was the one carrying the connected system’s own content-free catch-all sentence. A CREATE the connected system refuses at the HTTP level, with a body that explains nothing, used to arrive as an ordinary held row reading “no action is needed from you”; it now arrives as blocked with source_response_lost. Treat it as build-apps/skills now describes: suppress any “try again” affordance, render message, and do not offer a correct-and-save prompt — fields is empty by construction and the record may already exist upstream, so a re-save on a financial document is how a duplicate is made. There is no id to link and no settle verb to offer, which is the whole difference from write_settle_conflict.

  • record_write_state.message on that ending is plain language, never the source’s raw answer. It is written for the person looking at the record — that the record may already exist, where to go and look, and that saving again could create a second copy — and it is never longer than 500 characters, so it is safe to render as-is. An app reading the connected system’s own wording out of this column on a blocked row should stop: the column’s own contract has always said it is never the raw source error, and on this ending it is now ours.

  • Match a catalog entry by qualifiedRecordType when you have the qualified name. Every entry GET /v1/catalog/records (and /v1/catalog/records/:type) returns now also carries qualifiedRecordType — "netsuite:invoice" — the same string turbobase project types writes at the top of each generated interface. recordType ("invoice") and source ("netsuite") are unchanged, so nothing you read today moves. Do not filter recordType on a source: prefix: it never carries one. Whether a record can be written right now is writeCapability !== null here — the same answer MCP list_record_types gives as writable_now; the catalog route has no writable_now or direction key.

  • Read turbobase.activity.direction to tell a conversation apart from a comment, and turbobase.activity.delivery_status to tell whether a message you sent arrived. Both are new, nullable canonical columns on activity, and both are NULL on every row that was already there — so nothing you read today changes value. direction is inbound when the other party sent the event and outbound when your business did, and it is NULL on the event kinds that have no direction, which is most of this table: a comment, a note, a status change. delivery_status is the source’s own answer about the recipient — sent, delivered, read or failed — and is set only on outbound messages. Neither is a second spelling of write_status, which is still the platform’s record of whether YOUR write reached the source and is set in both directions. If your app renders an activity feed, add direction to the select and style the two sides differently; if you were inferring direction from author_id, stop.

  • Two relations are new and your generated types will grow: turbobase.whatsapp_phone_number and turbobase.whatsapp_message_template. Re-run turbobase project types to pick them up. Nothing existing is renamed, moved or dropped, so no query you have written needs changing.

  • If your instance connects WhatsApp Business, decide who answers each business number before anybody reads a conversation. Set whatsapp_phone_number.owner_user_id (or owner_role_id) through POST /v1/data/phoneNumber; until you do, that number’s conversations are readable by administrators only. The assignment is what controls visibility — the person it names reads that number’s messages, nobody else does, and an administrator reads everything — and it is never sent to WhatsApp. The write needs write:whatsapp_phone_number, which is deliberately not in the default capability bundle: a tenant administrator grants it per user, because the capability is the power to decide who may read a customer’s conversations.

  • A WhatsApp activity row is now filtered per business number for every non-administrator, including through a write’s RETURNING. That is a new restrictive database policy, so a reader who is assigned no number sees zero WhatsApp rows rather than an error — a [] where you might have expected a refusal. Rows from every other source on activity, memory_document and memory_chunk are unaffected and keep exactly their current semantics.

  • Send a WhatsApp message by creating a message record, and put the two addresses under raw. POST /v1/data/message takes body as a column and the addressing in raw, which is the delivery’s own shape: raw.metadata .phone_number_id (or raw.phone_number_id) for the business line it goes out ON — there is no default, and a reply from the wrong one reaches the customer under another team’s name — and raw.to (or raw.wa_id) for the customer’s wa_id. Neither is a column of turbobase.activity, which a dozen sources share, so a top-level phone_number_id is refused UNKNOWN_COLUMN; a create naming neither address is refused MISSING_REQUIRED_INPUT on the call. For a template, set raw.type: 'template' and raw.template.name, with the variables under raw.components.

  • The two WhatsApp send rules are ours to decide, not yours, and both are checked before any call is made. Ordinary text may be sent only within 24 hours of the customer’s last inbound message on that conversation, and outside that window only a template whose status is exactly APPROVED. Both inputs are read from your own mirrored data — the inbound messages WhatsApp delivered on that conversation and the whatsapp_message_template row, each scoped to the connection — so raw.last_inbound_at and raw.template.status in a payload are ignored, and so is a message you create yourself with direction: 'inbound': only a message that arrived from WhatsApp opens the window. A customer who has never written in is OUTSIDE the window, not inside it. There is no edit and no un-send: addressing a message that already has an external_id is refused as append-only, and a delete is not propagated. Sending is also held behind the per-instance sync.bidirectional.whatsapp entitlement, which ships off.

  • Do not expect WhatsApp history from before the connection. WhatsApp publishes no message-history endpoint, so conversations are mirrored from the moment the account is connected onward, and the poll cannot repair a delivery that was missed. An empty conversation list on a fresh connection means nobody has written in yet.

  • Expect write_record and delete_record to be absent from an MCP connection’s tools/list after this release, and refused if called — every client connected before it is treated as read-only until the person who connected it approves it again. Whether an MCP client may change data used to be one instance setting, Allow MCP clients to change data, and nothing else. That setting stays exactly where it is, as the administrator’s ceiling, and a second permission now sits beneath it on the connection the request arrived on — so turning the instance setting on no longer arms every client every person has ever connected. Nothing could be carried across the upgrade: the OAuth bridge hands a client the upstream token unchanged and keeps none of them, so a connection that is live at the moment of the upgrade cannot be tied back to the client holding it. The two tools are missing from the list rather than advertised and failing, because a tool that always errors is one a model retries. Reading is unaffected throughout, an access token is unchanged — it writes if it carries mcp:write — and a native app your team builds on the same OAuth server’s data resource is not affected at all.

  • Tell a person whose client has stopped writing to disconnect it and connect it again from the client’s own settings, ticking Let this client create, change and delete records when the approval page asks. That approval is what records which client is asking, which is the half the upgrade cannot recover, and the box is unticked by default — so approving without touching it reconnects a client that reads and nothing more. A client left alone is matched again the next time it renews its token, but it stays read-only until the permission itself is granted, so the reconnect is the instruction worth giving.

  • Grant or take back the permission to change records on one connection under Account → Connected apps. It says of every client whether it may change records, and the next call that client makes sees the new answer. The two refusals are deliberately different sentences, so a client can be told which of the two things to do: could not be matched to a connected client, so it is treated as read-only means connect it again, and does not have permission to change records means grant it there. See Letting a client change data.

  • Expect the stage, priority, assignee, customer and reporter words on an already-mirrored NetSuite support case to fill in about half an hour after the upgrade. status, status_name, priority, assignee_name, entity_name and reporter_name on a work_item row whose item_type is ticket were left null by an earlier full re-scan the connected account refused, and this upgrade schedules a fresh one-shot re-scan thirty minutes after it lands — late enough that the tick claiming it runs the new image. The stage word fills status as well as status_name, so an app reading either of them sees it. Nothing is pushed back to NetSuite, and a case genuinely unassigned stays null, so an app rendering a fallback for a blank stage should expect it to stop firing rather than need a change.

  • Stop matching on the foreign-key constraint name when a write names a subsidiary or an accounting period your tenant does not hold. The refusal itself is unchanged — POST /v1/data/<recordType> and POST /v1/batch still reject it with SQLSTATE 23503 — but it now comes from a check that names the column instead of a constraint, so the message reads no subsidiary matches subsidiary_id "123" and no constraint name appears in it. Match on the code and on subsidiary_id / posting_period_id, not on <table>_subsidiary_fk.

  • Expect 403 forbidden from POST /v1/data/timeBill when the time record names a subsidiary, warehouse, department or class outside the session’s own scope. A user restricted to one of those could create and edit time on time_entry anywhere on the instance; those writes, and the matching deletes, are now refused before the statement runs — the same refusal time_bill gave before the consolidation moved the rows, and the same one invoice, order and purchase_order already give. Nothing changes for an unrestricted session, for an app_role: 'admin' one, for a restricted session writing inside its own restriction, or for a source that tags none of the four dimensions — a work-tracker worklog carries no subsidiary, location, department or class, and an untagged row is visible and writable exactly as it is today. If a screen lets someone pick a warehouse they do not hold, filter the picker to the session’s scope, or handle the refusal.

  • Expect a shorter list from /rest/v1/time_entry for a user restricted on a subsidiary, warehouse, department or class. The read policy asked the capability alone, so a restricted user could see every time record on the instance; rows tagged outside their scope are no longer returned. There is no error to catch — the request answers 200 with fewer rows, which to an app looks like records going missing rather than like a rule being applied, so a screen that reconciles a total against its own cached count, or that reads a time entry by an id it stored earlier, should expect a miss. Rows carrying none of the four tags stay visible to everyone who holds read:time_entry.

  • Point a conduit’s to at turbobase:build_request to ask for a change to be built in a code repository your instance owns. The patch carries repository (as owner/name), app_slug and request — the instruction, verbatim — and all three are required; request_id is optional, and left off it is the run’s own ledger key, so two deliveries of one firing are tellable apart either way. The platform checks that the repository really is your instance’s and that your instance has build budget left for the day, then starts the build with access to that one repository. None of our credentials ever go into your repository, which is why the ask goes through the platform rather than straight to the forge. turbobase stays a reserved source for the platform’s own records — it is not a connector and never will be.

  • Read the build id off the conduit run rather than out of a table. Nothing lands on your instance: the ask IS the write. The run shows up on turbobase logs --conduit as usual, the build id the platform answered with is on the run’s to_external_id — which that line prints as to_ext= — and the master columns are empty because there is no local row to point at. A conduit that both asks for builds and reacts to records can tell its own runs apart the same way it always could.

  • Expect a refused ask to be recorded, not retried. A repository that is not your instance’s, a daily build allowance already spent, an instance that has no credential for the platform, or an ask missing one of the three required keys — each parks the run with the reason written on it, so whoever reads the ledger can see which of them it was. A platform that is merely unreachable for a moment is the one case that is not a refusal: that run retries on its own.

  • Expect the verb to come from the record you target, never from your patch. The reserved source now has two of them — turbobase:proposal files a draft change request, turbobase:build_request spends build budget — and which one runs is fixed by the record named in to. A patch key cannot switch between them.

  • Send a GetSales contact’s LinkedIn profile as raw.linkedin, and stop using any other spelling. A create or edit of a getsales contact through POST /v1/data/contact now carries raw.linkedin (the nickname after /in/) or raw.linkedin_id (a profile URL or any other LinkedIn reference) to the workspace; before this they were accepted, dropped, and the write still read synced. Any other LinkedIn key — raw.linkedin_url, raw.linkedin_profile and the rest — is now refused with UNKNOWN_LINKEDIN_FIELD naming the key you used, immediately at the write boundary and as write_status needs_input on the queued paths. If your project sends one of those, rename it; if it relies on such a write succeeding, it was never reaching GetSales. A profile alone is now enough to identify a new contact, so a create carrying nothing but raw.linkedin is no longer refused as incomplete.

  • Add @turbobase/code-standards to your app’s devDependencies (npm install -D @turbobase/code-standards). It is the lint, format, dead-code and duplicate check a new app from turbobase apps init now starts with: it catches the mistakes that compile and still break a screen — a React hook called after an early return, a promise nobody awaited, a duplicated object key — and formats your files so no turn is spent on style.

  • Add .oxlintrc.json at your app’s root with { "extends": ["./node_modules/@turbobase/code-standards/oxlint/react.json"], "ignorePatterns": ["src/types.ts", "src/lib/turbobase.ts", "dist/**", ".claude/**", "node_modules/**"] }. Keep src/types.ts ignored: turbobase project types regenerates it every session, so a finding in it is one you cannot fix. src/lib/turbobase.ts is the platform’s client, replaced by these notes rather than edited by you.

  • Add oxfmt.config.ts at your app’s root containing export { default } from '@turbobase/code-standards/oxfmt'; — the house format (tabs, semicolons, single quotes, 120 columns). Re-export it rather than copying the settings, so a package upgrade is the only change a format update needs.

  • Add code-standards.json at your app’s root with { "units": ["."], "ignore": ["src/types.ts", "src/lib/turbobase.ts", "dist/**", ".claude/**", "node_modules/**"] }. The key is ignore, singular: the check refuses any key it does not know. Your app is one unit: duplicated code is counted inside it and never against another app.

  • Add knip.json at your app’s root with { "entry": ["{hooks,conduits,processes}/**/*.{ts,js}", "oxfmt.config.ts"], "ignoreDependencies": ["@turbobase/extensions"], "ignoreExportsUsedInFile": true, "include": ["files", "dependencies", "unlisted", "exports", "types", "duplicates"] }, so the unused-code check counts your hooks, conduits and processes as used and does not ask you to install @turbobase/extensions, which the extension sandbox provides.

  • Remove "baseUrl": "." from compilerOptions in your app’s tsconfig.json and keep paths as it is. The type-aware lint reports baseUrl as an error, and paths resolve without it.

  • Add "check": "code-standards check" and "fix": "code-standards fix" to your app’s package.json scripts. If you already have a check script, keep yours and add these under another name (for example standards and standards:fix). Run npm run fix once and commit that as its own commit. Then run npx code-standards baseline and commit the code-standards.baseline.json it writes: it records the findings your app already has so npm run check passes today and fails only on new ones, and check refuses to run without it. Run npm run check before every push.

  • Add the format hook to .claude/settings.json so Claude Code formats each file it edits: run turbobase apps init <slug> in a scratch directory and copy .claude/hooks/format-edited-file.sh and .claude/hooks/format-edited-file.mjs into your project, then add { "type": "command", "command": "bash .claude/hooks/format-edited-file.sh" } to the hooks list of your PostToolUse entry whose matcher is Edit|Write. The hook does nothing until the package is installed and never blocks an edit.

  • Stop watching for a cost-tracking value to park on write_settle_conflict. A POST /v1/data/location — or department, classification, or any other value on a cost-tracking axis — whose upstream id is also held by a value on a DIFFERENT axis, or by a value belonging to a second connected system, now settles on the first attempt: the row reaches write_status = 'synced' on its own and no longer needs POST /v1/data/<recordType>/settle run against it by hand. Each axis is numbered separately upstream, so an app creating its first few values hit this routinely. A row already parked keeps that route, and the route now resolves it with 200 where it answered 409 SETTLE_TWIN_NOT_ADOPTABLE for the same reason the automatic settle refused. Nothing changes for a record type whose table keeps two rows apart on the connection alone, such as item.

  • Match a settle refusal on its prefix, not on its whole sentence. Where two rows genuinely hold one upstream id and nothing can tell them apart, the refusal carried onto sync_failure.agent_notes and into the route’s 409 message now ends with the rows it refused to choose between — … refusing to choose one to discard: <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 still 55000 and the rejection code is still SETTLE_TWIN_NOT_ADOPTABLE.

  • Order a NetSuite document’s lines by line_order, not by line_index. line_order is new on twenty line tables — order_line, invoice_line, purchase_order_line, vendor_bill_line, check_line and the rest — and it holds the position a person sees in NetSuite, counting from 1. line_index is no longer that position: it is the line’s identity. A line id is not monotonic with display position even on a document nothing has been removed from, so a surface still sorting on line_index shows the lines scrambled. line_order is null on a document not yet re-read since this release, so sort on it and fall back to line_index while it is — a document’s lines are rewritten with both values the next time it is read, within four hours. PostgREST: purchase_order?select=*,purchase_order_line(*)&purchase_order_line.order=line_order.

  • Join on line_index — it now holds the line’s own NetSuite line id on every path. This is the value a receipt line’s po_line_id and an audit row’s system_note.line_index have always carried, and until now the mirror only agreed with them after a REST re-read: a document written by the four-hourly poll stored positions 1..N instead, and one written by a realtime save stored positions counted from zero. So item_receipt_line.po_line_id = purchase_order_line.line_index matched nothing on a polled order and the wrong line on any order a line had been removed from, and the per-line audit trail attached each note to the wrong line. Both resolve now, with no change to the query.

  • Re-read any NetSuite line number your app or report has stored, because the line_index VALUES change. Every NetSuite line last written by the poll or by a realtime save gets a new line_index when its document is next read — the line id in place of a position — so a number captured before this release no longer identifies the same line, and a request keyed on the stale value succeeds while matching nothing or the wrong row. Nothing is dropped: the row is the same row with a different key. Lines written by other sources are untouched.

  • An expense line on a payable now carries its own line id, not 1000 + its position. purchase_order_line, vendor_bill_line, check_line, vendor_credit_line and credit_card_charge_line fan their lines across NetSuite’s item and expense sublists, and an expense line’s line_index was the sublist position plus 1000 so the two could not collide. Where NetSuite states the line’s id that offset is gone — an expense line whose id is 3 reads as 3 — so a filter of line_index=gte.1000 no longer selects the expense lines. Use line_kind=eq.expense, which has always been the supported way to tell them apart. The offset survives only where the two sublists would otherwise land on the same line_index, which is a document saved from a NetSuite bundle below 2.18.4 — those saves number each sublist’s own lines instead of naming them. A document read back over REST states every line’s own id, so its expense lines carry their real ids too.

  • Expand caps_by_verb when you decode tb_jwt — re-copy sessionClaims() from src/lib/turbobase.ts, or apply the same change to yours. A session whose capability list would make the token too large for a browser cookie (about 4 KB — a standard user holding the default permissions is over it) is now signed with that list packed by verb: capabilities holds only the marker "#packed", and caps_by_verb maps each verb to its space-separated remainders, so { "read": "order invoice" } is read:order and read:invoice. Until now such a user could not sign in at all — the browser discarded the cookie — so no app saw these sessions before. A client that has not adopted this reads the marker as holding nothing and DENIES every hasCapability() check for those users; it never over-grants. The database and every instance route already read both forms. Sessions small enough to fit are minted exactly as before, so nothing changes for them.
  • Expect turbobase apps deploy to record your source now. Run against a directory holding a package.json, it cuts the version exactly as before and then records that directory as the app’s newest source revision — the same thing turbobase apps push has always written, through the same collector. Nothing about the build or the live URL changes. It is what keeps the app pullable from another machine, and it is what a user’s in-app change request needs in order to be applied at all.

  • Read the two lines it prints after the deploy. recorded N source files on the instance (rev R) is the success; a line beginning source not recorded: is the failure, and it names what to do about it. A failed collection never fails the deploy — the version is already live by then — so the printed line is the only signal, and the fix is always one turbobase apps push <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.md on your disk. The deploy now says which revision that is (learning ingest queued … — reading source rev R); the warning telling you to run turbobase apps push first is left for the deploys that really do record nothing — --no-source, a prebuilt bundle, a failed collection.

  • Pass --no-source where a deploy must upload nothing from the working tree. The version is still cut and nothing from the directory goes over the wire. Reach for it on a CI preview loop that re-deploys on every commit, or anywhere the build machine’s tree is not the source of record. An app deployed this way stays un-pullable, and a change request filed against it cannot be applied automatically.

  • Nothing is recorded from a directory with no package.json. A prebuilt bundle handed straight to --no-build has no source tree to record, and a directory whose source this CLI pulled is already the newest revision, so neither writes one.

  • Credential files are withheld, by name, and they are named on stderr. .env, .env.<anything> and .mcp.json are never uploaded — turbobase apps init writes a live token into .mcp.json — while .env.example is a template and travels as source. node_modules/, dist/, build/, .git/ and .claude/ are skipped too, so a deploy records the project, not its output.

  • Ask your instance for Turbobase’s documentation instead of fetching docs.turbobase.io — call search_turbobase_docs with a phrase. It ranks our published pages and answers {count, results:[{slug, title, snippet}], currentVersion}. The pages are the ones your instance’s own release shipped, so what you read always describes the version you are building against. It sits beside search_documentation, which still searches the tenant’s own knowledge base; platform questions go to the new one.

  • Follow a hit with read_turbobase_doc to get the whole page, passing the slug the search returned. It answers {found: true, slug, title, body_markdown, currentVersion}, or {found: false, slug} for a slug that is not one of our pages — a miss is an answer, not an error, so there is nothing to retry. Both tools are also accepted inside a batch_read, so a search and two page reads can be one call.

  • Reach the same corpus over HTTP at GET /v1/catalog/docs/search?q=<words> when you are not going through a tool, with &limit=<n> up to 25 (default 8). It needs only the catalog:read scope your personal access token already carries for /v1/catalog/adopt-notes — no new scope, and no re-issue. An empty q is refused with 400 {"error":"q_required"}.

  • Read one page over HTTP at GET /v1/catalog/docs/document/:slug, with the slug percent-encoded (extending%2Fwriting-hooks). A slug in no page answers 404 carrying {found: false, slug, currentVersion}, and an instance whose build shipped no corpus answers 200 {count: 0, results: [], note} on the search and found: false here rather than failing — so a client can always tell “no match” from “nothing to search”.

  • Ask for a custom field your mapping needs with POST /v1/apps/<slug>/source-fields. Send fields: [{ name, type, label, record }], where record is the record the field belongs on (customer, salesorder) and is required. A definition naming a record the accounting system cannot resolve, or one on a custom record, is refused 400 field_unresolvable naming the field and what to change; a field the account already manages is refused 409 field_exists naming it, because this creates fields and never edits them. At most 25 fields per request — a longer batch is refused 400 {"error":"too_many_fields","limit":25}, so split a large mapping run rather than sending it in one call.

  • Store the scriptId that comes back, not the name you sent. The answer from POST /v1/apps/<slug>/source-fields carries a solution id and one entry per field, each with the MINTED id the field will actually deploy under — a bare legacy_ref on a customer comes back as custentity_legacy_ref, because NetSuite rewrites a bare name to its class prefix and truncates at 40 characters. Map against what came back, or your mapping reads a field that never exists.

  • Declare source_field:create in your manifest’s roles and have an admin assign it. A signed-in session without it answers 403 {"error":"forbidden","capability":"source_field:create"}, which the shared client raises as a permission error. A Data admin’s write:* does NOT include it: this capability writes metadata into the customer’s own accounting system rather than into mirrored data, so it is granted deliberately or not at all.

  • Ask for the release with POST /v1/apps/<slug>/source-fields/releases ({ solutionId, target: 'auto' }), then poll GET /v1/apps/<slug>/source-fields/<solutionId>. Each field reads drafted, queued, validating, deployed or failed; a failed one carries a reason you can show the person who asked. One release per request — a second ask answers 409 release_exists naming the release the request already has.

  • deployed is not yet selectable — wait for promotion to say promoted to a column. A deployed field exists in the accounting system and still has no Postgres column, so selecting it returns nothing. Each deployed entry from GET /v1/apps/<slug>/source-fields/<solutionId> carries promotion, and exactly one of its values means you can go on and map the field: promoted to a column — you can select this field now. Until then it reads registered for column promotion — not selectable yet, which can last up to six hours — the gap until the next reconcile pass — so treat the two as separate steps in whatever you show the person, and poll for the second rather than stopping at deployed. A registration for column promotion failed means the field is created upstream but its column never will be without an administrator.

  • target is your app’s own choice, and only auto can succeed from here. POST /v1/apps/<slug>/source-fields/releases takes target: 'auto' — the capability an admin assigned you is the authorisation, and the deploy is enqueued — or target: 'approved', which an app can never satisfy on its own: it answers 403 approval_required and marks that release failed, because an app may not name its own approver. So ask for auto in a one-click flow, and if you offer approved at all, show approval_required as “this one needs a person on the instance’s own release surface” rather than as a bug — and never retry it in a loop.

  • Expect ONE identity where you read two, for a person two connected systems both carry. A lead and a contact that are the same person now consolidate: one entity_canonical row, with both source records under entity_source_link. A paged read of the identity graph returns fewer rows than it did and Prefer: count=exact returns a smaller total, so a screen that caches either, or that reconciles a count against a source system’s own, wants re-checking once your instance upgrades.

  • Read entity_canonical.match_scope — not entity_type — to learn what KIND of thing an identity is. A person identity carries the entity_type of whichever member arrived first, lead or contact, so dispatching on it tells you about that member rather than about the identity. The new match_scope column is the stable answer (person for both of those buckets, the bucket’s own name for every other), it is published on every identity record type in your instance’s catalogue, and turbobase project types picks it up on the next run. A member’s own entity_type, under entity_source_link, still says what that member is.

  • Filter on match_scope, not entity_type, when you list PEOPLE. turbobase:identity_lead and turbobase:identity_contact are two lists over one scope now, and the entity_type=eq.<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. Send match_scope=eq.person (or entity_type=in.(lead,contact)) to list people in one pass. Every other kind is a scope of its own, so entity_type=eq.customer and the rest are unchanged.

  • Delete the “no counterpart exists” branch a conduit keeps for a person. atlas.resolve now answers for a lead against a contact and the other way round, so a conduit written to treat null as “this person is only in one system” will take its real branch for the first time on rows it used to skip. Null still means no counterpart, so the fallback is not wrong — it is simply no longer the only path.

  • Stop reading a web address off a person’s identity: it was their employer’s, and it is now empty. A lead’s domain was derived from its company website, which matched every colleague at one company to one identity — so an app displaying it was showing the employer’s address as the person’s, and an app grouping by it was grouping colleagues. Identities in the person scope no longer carry a domain at all, two people at one company are two identities, and the ones already merged are separated on upgrade. Read the employer’s web address from the source record’s own attrs, where it is unchanged.

  • A caller holding only read:entity:contact now reaches identities a lead created. Both are the same person, so the identity is readable to either bucket’s capability holder — and the member list is still narrowed the way it always was: that caller sees the contact behind it and not the lead. A read that returned nothing for such an identity starts returning a row, with no change on your side.

  • Handle awaiting_approval as a held write_status. It is a new value on every mirror table’s write_status column and on record_write_state.status, and it means a write your app saved is waiting for an operator to approve it before it is sent. If you branch on that column, treat it as held and not as a failure: there is nothing for the person in front of you to fix, and record_write_state.message says so. Code matching on an exhaustive list of statuses will now meet a value it has not seen.

  • Stop reading pending as “a write is on its way to the connected system”. A write held for approval used to sit at pending indefinitely — the state that means queued and in flight — so an app could not tell a write that was moving from one that had been deliberately stopped. Those rows now read awaiting_approval. The record’s reason_code carries which guardrail rule held it.

  • Expect one guardrail_hold_expired failure row per hold that was awaiting a decision when you upgrade to this version. Every confirmation token minted before this release had been written to the instance’s log store, where it stayed a live approval for a destructive write, so this release retires the holds that had not been answered yet. Each appears on the failures grid in the state the product already uses when nobody answers a hold in time, and the record is left blocked. Retry on that row re-submits the write and mints a fresh hold. No record data changes — a held write was never sent.

  • Expect a NetSuite duplicate to arrive as needs_input, not blocked. A write NetSuite refuses because another record already holds the name — a GL account, an entity, an item — used to land blocked with the generic held text, so an app showed “no action is needed from you” for a name the person in front of it could change. Those rows now read needs_input and carry NetSuite’s own sentence in record_write_state.message, with an empty fields array because the refusal names no column. reason_code is unchanged, so a branch on that keeps working; a branch on status alone will see a row move between the two held values.

  • Handle two new refusals on DELETE /v1/data/:recordType. Both arrive as 422 with { error: 'validation_failed', rejection: { code, message } } — the shape a guardrail refusal and a before_delete rejection already use — and both are raised BEFORE anything is hidden, so the record is still there and still readable when you get one. Neither is worth retrying as-is: the answer will not change until somebody changes something.

  • SOURCE_BUNDLE_TOO_OLD — the connected account’s integration package is behind. You meet it deleting a chart-of-accounts entry, an employee or a subsidiary on a connection whose account is running a package older than the release that allowed it. The message names the version the account is on and the version it needs. Show it and stop: the fix is an administrator updating the package in the connected account, not anything your app or your user can do. Allow up to an hour after an update before the refusal clears — we re-read the deployed version on a schedule, not on the delete.

  • DELETE_BLOCKED_BY_DEPENDENTS — other documents still point at the record. You meet it deleting an invoice a payment or a credit memo has been applied to, or a vendor with bills against it. The message names each blocking document by type and document number, so render it as the next action: clear those documents, then delete this one again. It is the same MESSAGE you already got when the connected system refused the delete after the fact, but not the same string to match on: this one is rejection.code === 'DELETE_BLOCKED_BY_DEPENDENTS' on the 422, and the late one is still reasonCode === 'delete_blocked_by_dependents' on the record’s write state. If you branch on the condition, match both. The change is that it now usually arrives immediately, on the request itself, rather than later on the write state.

  • Stop treating a 2xx from this route as “and nothing will come back.” That was never what it meant, and these two refusals narrow the gap rather than widening it: a delete that used to be accepted, hidden, sent, refused by the connected system and then un-hidden hours later now answers 422 on the spot in the two commonest cases. The late path still exists for a dependency we do not hold a copy of, so keep whatever handling you have for a record that reappears.

  • Expect no new refusal for a record you created in Turbobase and never synced. Neither check applies to a row with no counterpart in the connected system, so a purely local delete behaves exactly as it did.

  • Branch on four new refusal codes instead of retrying a 500. A write whose payload names a customer or vendor your instance has not mirrored yet now comes back 422 { code: 'ENTITY_NOT_FOUND', field: 'entity_id' } rather than 500 write_failed — and the message says how to link to an entity you created in the same batch. Three more join it: ENTITY_DISCRIMINATOR_REQUIRED (a customer or vendor whose attrs omits isCustomer / isVendor, naming attrs.isCustomer), INVALID_ATTRS (an attrs that is not an object), and UNSTORABLE_TEXT (a field, or a key inside raw, carrying a NUL or an unpaired half of a surrogate pair). Each names the field. A retry loop that treated the old 500 as a transient fault should treat all four as final.

  • Send entity_pg_row_id when you create an entity and a transaction together. entity_id is a foreign key onto the entity’s id in the connected system, which does not exist until that system mints it — so a batch that creates the customer first has nothing to put there. Set the entity_pg_row_id sibling to the pg_row_id the entity’s own write returned and the reference is resolved for you once the entity syncs.

  • Send a NetSuite transaction’s lines in the same call, or the create is refused now. A create of a line-bearing NetSuite transaction — sales order, invoice, purchase order, vendor bill, journal entry, credit memo, deposit, vendor credit and the rest — with no lines (or an empty array) answers 422 { code: 'ATOMIC_WRITE_REQUIRED', field: 'lines' } instead of 200. Nothing has changed about which writes eventually succeed: that record was already refused, minutes or hours later, and appeared on the sync-failure grid rather than in your response. A conversion carrying created_from_id is exempt, as it always was, and an UPDATE that omits lines still preserves them.

  • Stop creating github:pull_request records. A create answers 422 { code: 'EDIT_ONLY' }, the same way github:label already does. GitHub opens a pull request from a branch — its API takes a head and a base ref, not a field set — so the create had no endpoint to reach and the 200 was followed by a write-back failure. Editing a mirrored pull request’s title, body and state is unchanged.

  • Create journal entries. POST /v1/data/journalEntry now works; every create was refused by the database before, because journal_entry.hash was NOT NULL and no caller could supply a value for it. Xero’s manualJournal writes the same table and is fixed with it.

  • Update an app-owned table through the MCP write_record tool. An update addressed by pg_row_id to one of your own manifest-declared tables now succeeds, instead of answering 400 record type "…" is not writable for a table list_record_types had just published as writable_now: true. Rows created before 2026-10-06 are the ones that were affected.

  • Expect a lines refusal to talk about YOUR connected system. The LINES_NOT_SUPPORTED message used to explain NetSuite sublists to every connector; it now reads the explanation off the adapter you are actually on and falls back to a source-neutral sentence. The code, the field and the remedy — write the parent, take its external_id, post each child to its own record type — are unchanged.

  • Handle -32002 from a tools/call as “repeat it, or ask for less”. A tool call that outruns the instance’s own eight-second deadline now answers that code with data.retryable: true and a message naming the tool, instead of holding the connection open until the front door closes it with no JSON-RPC error at all. Its own code rather than -32001 because the remedy differs: -32001 means send the same call again, -32002 means send it again or narrow it — a smaller page_size, a tighter filter, fewer items in one batch_read.

  • Widen any check that reads -32001 as “the connection pool was busy”. An upgrade window now answers -32001 too, with retryable: true and a message saying the instance is being updated. Both conditions mean the same thing to a caller — nothing was refused, nothing changed, the call is safe to repeat — so code that branches on the code needs no change; code that matched the WORDING of the busy message does.

  • Expect a 503 with a JSON body, never a closed socket, from a request the api cuts off. /v1/* now sets its own request deadline at 120 seconds — a whole proxy window above the front door’s 60, so the front door is still the first to lose patience with a large apps push and this is only ever reached by a request nobody is still sending. The answer carries statusCode, error, message and Retry-After.

  • Expect an unexpected failure under /v1/* to answer a redacted 5xx carrying a correlationId. Previously the thrown message reached the caller; it now goes to the instance’s log under that id and the body says only that the request failed and which id to quote. A 4xx — validation, rate limiting, a guardrail refusal — is unchanged, wording included.

  • Match a duplicate refusal on source_duplicate_record, not on the connected system’s own error code. When the connected system refuses a save because it already holds a record with that name, code or entity, the record_write_state.reason_code your app reads is now source_duplicate_record instead of the source’s generic deterministic code (NS_ERROR on NetSuite). The status stays needs_input and the message is still the source’s own sentence, so a sheet that renders the message is unaffected; only a branch on the old code has to move. The new code is one condition across every record family — a duplicated account name, item name and customer all report it — which is what makes a burst of them groupable on the failures grid.

  • Stop re-sending a create your app refused once. A create the connected system answered with a bare “an unexpected SuiteScript error has occurred” is no longer retried: the record may well have been saved before the script threw, so each retry made a second real one. It now ends immediately on reason_code = 'source_response_lost' with the sentence that tells the reader to look upstream before sending it again. An UPDATE or a DELETE refused with the same sentence keeps the retry budget it has today.

  • Expect a keyless customer create to link rather than duplicate. A POST /v1/data/customer with no external_id now asks the connected system whether it already holds a customer with that email — or, when the record carries none, that company name — and sends an update to the one it finds instead of creating a second. Nothing changes for an app that already sets external_id, and a lookup that matches nothing, fails, or cannot be asked because the email or company name contains a " or a \ still sends the create. A lookup that matches MORE than one record sends nothing and refuses with reason_code = 'SOURCE_IDENTITY_AMBIGUOUS', whose message names the ambiguity — decide which record it is and link it in the connected system before saving again.

  • Stop watching for a non-inventory item create to park on write_settle_conflict. A create whose echo comes back tagged as a different sub-type of the same record now settles on the first attempt, so the row reaches write_status = 'synced' on its own and no longer needs POST /v1/data/:recordType/settle run against it by hand. Rows already parked keep that route.

  • Write a date-only field and get the day you sent. A full ISO instant written to a date field on an account behind UTC was committing the previous calendar day. It now stores the day in the value. That covers a line’s own date columns and a header’s, and the expirationdate on an inventory assignment — so a lot you receive with raw.inventorydetail = [{ inventorynumber, quantity, expirationdate }] now expires on the day you sent, where before it expired the day before and was picked a day early for the rest of its life. The fix runs inside the connected system, so it reaches an instance only once its NetSuite bundle is upgraded to TB 2.15.0 or above.

  • Set item_id on any item record and have it reach the connected system. item_id on an item is that item’s own code there, and it was only sent for four of the twelve item record types — on an assemblyItem, discountItem, nonInventorySaleItem, otherChargeSaleItem, lotNumberedInventoryItem, serializedInventoryItem, lotNumberedAssemblyItem or serializedAssemblyItem it went out as a reference to some other item, which the connected system accepts and ignores. It now sets the item’s own code on all twelve. Nothing changes for a row you read and write straight back, where the value already matches; an app that sends a DIFFERENT item_id on one of those eight was silently having it dropped and will now rename the item.

  • Send a payable line coded by category_id alone and have it accepted. A line on a check, vendorBill, purchaseOrder or vendorCredit carrying a category and neither account_id nor item_id now goes to the expense sublist instead of being sent as an item line with no item, which the connected system refused along with the whole document. A line that carries item_id, or that declares its own line_kind, is unchanged.

  • Delete records from a process with a delete step. Declare one as { id, recordType, delete: { externalId, sourceSystem } } — or pgRowId instead of externalId where you hold our row id. Both address keys are functions of the step context ((ctx) => ctx.input.externalId), the same shape update takes, and a step addressing a record it cannot resolve is refused rather than widened. It removes records the run did not create, so this is how an app clears a migration in the background instead of sending one delete per page view.

  • Write no build on a delete step — it is the third leaf kind, beside the write step and the AI step, and a delete has nothing to build. A step carrying both a delete block and a build is refused at deploy time naming the field to remove, and so is one declared rollback: 'undo': a delete cannot be undone, so a delete step is always rollback: 'keep' and a rollback some other step causes passes over it.

  • Declare onRefused: 'continue' where a refused delete should not end the run. It defaults to 'stop', which is what every step has always done. Reach for 'continue' on a tier-ordered cleanup: a record the connected system will not delete because another document still points at it is a record to leave alone, not a reason to abandon the remaining tiers. It is a delete step’s declaration only — a refused write still ends the run, so declaring it on a write step is refused when you deploy rather than being quietly ignored.

  • Read each delete’s outcome off the run’s own step ledger — disposition, refusal, pgRowId and externalId on each step entry of GET /v1/processes/runs/:runId, exactly where a write step’s outcome already lands. Those are the route’s names; through PostgREST the same rows are process_run_step, whose own columns are refusal_code, refusal_field, refusal_message, pg_row_id and external_id. A removed record reads confirmed; a refused one reads failed carrying the refusal’s own code and sentence, whether the refusing party was your guardrail, a before_delete hook or the connected system. A delete the connected system refuses is un-hidden again, so the record comes back and the row explains why.

  • Check refusal on a confirmed delete step, not only on a failed one. A delete your instance HOLDS for an operator’s approval — which is what a stock instance does to every delete, since Turbobase classifies a delete as destructive — reads confirmed with refusal: { code: 'SOURCE_AWAITING_CONFIRMATION', … }: the record is hidden in Turbobase and queued, and the connected system has not been asked yet. Every other confirmed leaves refusal null. The run does not wait for the approval and does not fail; if your cleanup is meant to run unattended, add an allow_with_audit guardrail rule for the record types it deletes.

  • Expect a delete step to pace itself to your instance’s delete cap. It waits for the rolling window to free and carries on in the same run rather than failing part-way through, so a background cleanup no longer has to spread itself thin to avoid the circuit-breaker. A step still inside a shut window when its wait runs out stops the run with the cap named on it and nothing deleted — invoke the process again to carry on.

  • Delete a process and the schedule that runs it in the SAME deploy. turbobase ext deploy --prune now refuses to remove a process while a recurring job on the instance still names it, with a 400 naming the job, the process and the three ways out — and nothing from the plan is written, not even the additions ahead of the removal. Drop the entry from turbobase/schedules.json in the same commit as the process file; a job somebody created on the Scheduled tasks page instead of in your repo is deleted there, then re-run the deploy. A process no job runs prunes exactly as before, and so does one whose job was already dead before this release.

  • Move a job onto another process in one deploy, rather than two. A schedule is screened against the body it will have once the apply lands, so repointing it at a process you keep — or one the same bundle brings — and dropping the old process applies normally. Turning the entry into a chat-prompt or system job in the same commit works the same way.

  • Declare a new process and its schedule in one bundle — POST /v1/config/apply used to refuse that, because it resolved the process id against the instance as it was BEFORE the apply while its own refusal told you to “deploy the process in the same bundle”. It now reads the processes the deploy will leave behind, so the advice works and a first deploy of a scheduled process no longer needs two passes.

  • Expect turbobase ext export to bring your schedules back. GET /v1/config/export now returns the schedules a bundle wrote, which it never did: an export/apply round trip used to lose them, and once written nothing in the product could remove them. If you have been keeping turbobase/schedules.json by hand because the export came back empty, export once and diff it against your file before your next --prune — a schedule you never declared is now in the plan.

  • Stop exporting the recurring jobs people created in the app. Those rows (system = false, listed on the Scheduled tasks page) are no longer in the export, because a bundle entry carries neither their owner nor their audience — and a prune used to list one for removal and then remove nothing. They are the tenant’s data; manage them in the app.

  • Delete those in-app jobs from a bundle.json you exported BEFORE this release, before your next deploy. The old export wrote them into turbobase/bundle.json, and re-planning that file was a no-op only because the export returned the same rows on the live side. It no longer does, so each such entry is now planned as an add and lands as a second, platform-owned copy beside the person’s own job, firing on the same schedule — the copy is not listed on the Scheduled tasks page, which shows only the jobs people own. Drop the entries from turbobase/bundle.json (or turbobase/schedules.json); a copy already written is a bundle schedule, so removing the entry and re-running with --prune deletes it.

  • Stop setting TURBOBASE_DEBUG to read the end of a refusal — and widen any script that matches on one. A refusal the instance wrote ({ error, reason } — invalid_bundle from ext deploy / ext promote, extension_invalid from a module that will not compile) is printed whole by every CLI verb and every authoring-MCP tool, where the client used to cut it at 200 characters; the refusal above is 396 once its code is prefixed, so its ways out never arrived. A runaway reason — a compiler’s entire output — still stops at 2,000 characters, and a body that did not come from your instance (a proxy’s page, a framework 500) is still shortened to one line.

  • Rename any bundle schedule whose name starts with system.. That prefix is now reserved for the platform’s own recurring jobs — it is how a schedule the project declares is told apart from one the stack seeded — and a bundle using it is refused at deploy time rather than writing a row no export could carry and no plan could remove. A name that merely contains system. is fine.

  • Restart your local instance after adding a recipe or an anatomy file. /v1/catalog/records and /v1/catalog/records/:type used to re-read the filesystem on every request to decide which anatomy document and which recipes to attach to each record. They now resolve each record’s two lookups once per process. On a deployed tenant nothing changes — those files ship inside the image on a read-only filesystem and never move — but in a local checkout a sources/<source>/recipes/<record>/<intent>.json or a sources/<source>/skills/anatomy/<record>.md you add while the instance is running will not appear in the catalog until you restart it.

  • Stop caching the catalog listing on your side to avoid its cost. The per-request filesystem walk behind it was 562 calls and about 32 ms on a published record set of 270; that is gone, and the listing is now cheap enough to call per page. Everything in the response that can change under a running tenant — whether a source is connected, whether data sync is on, whether write-back is entitled, and which records your capabilities let you see — is still read fresh on every call, so a listing you fetch now is as current as one you fetched before.

  • Stop posting a second reply to correct a GitHub comment — edit the first one. An addressed POST /v1/data/comment used to be refused 422 APPEND_ONLY; it is now accepted and corrects the reply in place, on an issue, a pull request or a discussion alike. Address it by externalId (or pgRowId) and send the new wording in body; the record’s id does not move. If your app worked around the old refusal by posting a follow-up note, that code now writes the correction AND the duplicate — drop the follow-up.

  • Take a reply down with DELETE /v1/data/comment, which was refused for GitHub and now removes the comment upstream as well as tombstoning the row. There is no undo. A GitHub issue, pull_request or label delete is still refused, and the refusal now names what to do instead — close the issue, or remove the label in GitHub — in a 422 DELETE_UNSUPPORTED rejection, where it used to be a 400 saying delete-to-source was “parked” for the whole source. Match on the code rather than on that sentence.

  • Re-read any code that writes channel_message with an externalId set. An addressed Slack write used to be accepted and POSTED as a second message; it now edits the message in place through chat.update, keyed on the {channel}:{timestamp} id the first write returned. This is the one change here your app cannot discover from a failure — there was never an error, only a duplicate — so if you have been relying on an addressed write appending, send it without an externalId instead. An id Turbobase did not mint (a conduit:… placeholder, say) is now refused rather than posted.

  • Expect delete on github:comment from list_record_types. The ops a record publishes are read per (source, record) rather than per source, so one GitHub record can report a delete the others do not. Slack messages do not report one yet: the edit is live, and the delete is held until the verb has been checked against a live workspace.

  • Declare up to 48 tables in your manifest’s tables array — the ceiling used to be 32. Nothing you have deployed stops working and no manifest needs changing: one within the old limit is accepted exactly as before. The ceiling is still enforced when you push, so a 49th table is refused with 422 turbobase.json: "tables" declares 49 entries; the limit is 48. and none of that deploy is applied. Every other limit is unchanged — 64 fields per table, 64 relationships per app — so each table you add still has to fit those.

  • 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 — with extension_id_mismatch, and stores nothing; turbobase ext lint flags it first. A copy an older instance stored under the wrong id still exists: find it with turbobase ext plan --to <env> and delete it with turbobase ext remove <id>.

  • Expect an eachRow process to act on every visit to a row, and keep your outstanding predicate excluding finished rows. When a later slice or a later run offers a row again, its update steps now write again and a step that declined runs when / build again. Create steps still run once per row key, and a crash re-delivery still resumes without writing twice. GET /v1/processes/runs/:runId step entries now carry rowKey and slice, with one set of entries per visit.

  • Add the write-a-process skill to an existing project: re-run turbobase apps init <app> . in it — it writes the missing .claude/skills/write-a-process/SKILL.md and overwrites nothing. It points your agent at the process guide’s new migration recipe and the traps that look like platform bugs.

  • Read the id of a write_settle_conflict record from record_write_state.external_id, and stop parsing it out of the message. The message now names the settle verb (see the settle note) instead of telling the reader to set external_id, which the write boundary refuses.

  • Send the POST /v1/processes/:id body bare — it IS the input. A body wrapped as { "input": { … } } reaches ctx.input and eachRow.outstanding wrapped.

  • Start a process from a terminal or a script with turbobase processes invoke <process-id> --input '<json>' (or --input-file <path>; add --sync to wait for the outcome). It needs a personal access token carrying extensions:deploy whose owner is an administrator; the run writes as that owner. Drop any workaround that kept a browser session open only to invoke.

  • POST /v1/processes/:id now accepts that same token as a Bearer. A token owned by a non-administrator is refused 403 admin_required, and one without extensions:deploy is refused 403 naming the scope — branch on those instead of the old 401 access_token_not_accepted.

  • Make the input of every { "kind": "process" } entry in turbobase/schedules.json a JSON object that passes the process’s input schema. Scheduled runs now validate it the way POST /v1/processes/:id does: input that is not an object, or fails the schema, fails the scheduled run with the field named and queues nothing. It used to be replaced with {} or passed through unchecked. Process schedules also stop failing with “not deployed” when the process is deployed.

  • Undo a finished process run with POST /v1/processes/runs/:runId/rollback (or turbobase processes rollback <runId>). It deletes, newest first, only the records that run’s ledger shows it created — never one an update step changed — and answers 202. A run still queued or running answers 409; one already rolled_back answers 200 with queued: false. Poll GET /v1/processes/runs/:runId until it reads rolled_back or failed.

  • Stop sizing guardrails.max_deletes_per_* for a process rollback. A run’s own rollback deletes now count against guardrails.max_rollback_deletes_per_min / _per_hour / _per_day (600 / 10,000 / 50,000 by default) and wait when over them instead of failing. Customer deletes keep exactly the old caps.

  • Read a rolled_back run’s reason before assuming the source is clean. It names the source deletes still being sent; a delete the source refuses restores its record and turns the run failed with the refusal named.

  • Link a write_settle_conflict row with POST /v1/data/<recordType>/settle (body { "pgRowId": "…" }), or turbobase data settle <recordType> <pgRowId> from a terminal, instead of asking support. It links the row to the id on its record_write_state.external_id — never one you send — and answers 200 { pgRowId, externalId, adopted: true, discarded }. Where your app shows a blocked row with reason_code = 'write_settle_conflict', offer this in place of “try again”, and still never re-save the row.

  • Handle the two new 409s from that route: NOT_SETTLE_CONFLICT (the row is not in that state — nothing changed) and SETTLE_TWIN_NOT_ADOPTABLE (the inbound copy of the record was edited or has a write of its own, so it is left for a person to review).

  • Drop any fixed delay between saving a file and sharing its link, and keep a retry on 404 content_unavailable instead. A file created through POST /v1/data/file is readable at GET /v1/data/file/{external_id}/content as soon as the write settles — the write records where the bytes went, instead of leaving that to the container sweep up to four hours later. One case still needs the retry: a connection that copies its files into the instance holds no bytes for a brand-new file until that sweep, and your app cannot tell which kind of connection it is on.
  • Stop reading 404 content_unavailable as “saved, but not swept yet” for a file you did not just create. It now means the contents were genuinely never mirrored, and a file whose record somehow carries no pointer at all is served by deriving where it must be rather than reaching you as that error.
  • Branch on write_status: 'needs_input' for an e-signature request the connected system refused over its own content, where you only handled blocked. Any refusal DocuSeal answers with a reason of its own — a rejected party, a value its rules forbid, anything other than a missing template or a refused credential — used to settle blocked with the generic held text (“your team has been notified. No action is needed from you.”), which was the wrong instruction: nothing was queued to retry it and the only thing that clears it is the reader correcting the record. It now settles needs_input with reason_code: 'SOURCE_REJECTED' and record_write_state.message carrying what DocuSeal asked for, and its sync_failure.category moves from source_blocked to source_validation with it. Render a correct-and-save prompt from the message rather than a support notice — and do not gate that prompt on a record_write_state.fields entry, because this refusal names no column and the sheet is empty by design. Every other connector’s fieldless refusal still keeps blocked and the generic hold.

  • Add queryRows to a replay fixture to exercise a conduit that reads. A conduit declaring reads can now be replayed offline: put a queryRows map in the fixture JSON, keyed verbatim by the PostgREST path-and-query string your transform passes to ctx.query, and turbobase ext replay --fixture serves those rows to the isolate. A key you did not supply answers [], and --trace prints one line per read — the string asked for, whether the fixture served it, and the row count — so run it once with no queryRows, copy the strings your conduit actually composed off the trace, and paste them in. That first run works even when the empty answer stops the conduit: --trace now prints the whole walk on a failed replay, with the error as its last line, where it used to print the error alone. The from.when gate gets the read too. Rows are served only to a conduit that declared reads; one that declared none still has no ctx.query, offline exactly as deployed. Replay does not mirror the declaration refusals (an undeclared resource or column, a filter outside reads[].by, the per-run budget) — those remain the engine’s.

  • Read a GetSales custom field at raw.gscf_<field id> on contact and company. Each value the workspace holds now arrives with the record and lands at its own top-level key of raw, so entity?select=external_id,raw->>gscf_<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_fields carries the whole set beside the hoisted keys. Before this the values were discarded as each record was read and no custom field appeared at all.

  • Name raw.custom_fields to change one, and expect a 422 for a field the workspace does not have. A write to POST /v1/data/contact or /v1/data/company sends the custom-field set to GetSales only when it names that column — it is a whole-set declaration upstream, so an unrelated edit no longer risks restoring a stale value. A set naming a field no mirrored GetSales row has carried is refused with UNKNOWN_CUSTOM_FIELD naming the field, rather than accepted and quietly applied to nothing; sync the workspace to pick up a field made since the last sync. Writing to GetSales stays held shut by sync.bidirectional.getsales, which still ships OFF.

  • Redeploy any process that turbobase ext deploy refused with 400 process_step_not_writable on a record type list_record_types reports writable_now: true. POST /v1/extensions asked whether the record type was writable by bare name only, so it refused the shared-ledger records (account, deposit, vendorCredit) and every record type only one connected system defines (receiveMoney, supplierCreditNote, issue, …), while the write boundary accepted them. A step now deploys whenever a system connected to the instance can write its record type. No change to the process file: keep the bare recordType ('account', not 'netsuite:account'); to pin the system, return source_system: '<system>' in the step’s parent. A step that is still refused names the record type and the connected systems in the CLI output instead of the bare code.

  • Handle a refusal from DELETE /v1/data/{recordType} for a record whose own system cannot be deleted at. On an instance with more than one system connected, a delete is now routed by the record’s own source_system rather than by the one the session signed in against — so a record belonging to a system whose delete support has not shipped is refused immediately, naming that system, where it used to be accepted and then silently reappear. The mirror case also changes: a delete your app used to see rejected because the session’s system is parked now goes through when the record itself belongs to a deletable one.

  • Address a delete by pgRowId wherever an externalId is not unique to one connection. DELETE /v1/data/{recordType}?externalId=… now answers 422 SOURCE_AMBIGUOUS when that id matches rows in more than one connection or more than one system, instead of deleting whichever copy the session happened to be attached to. pg_row_id is on every row a PostgREST read returns. The same lookup is no longer narrowed to the connection the session signed in against, so an id that is unique across your connections now resolves the record it names even when that record sits in another one — where it used to come back 404.

  • Send sourceSystem on a delete when two of your connected systems spell one record type the same way. DELETE /v1/data/{recordType} takes a new optional sourceSystem query parameter (source_system on the MCP delete_record tool), and it is the answer to 422 SOURCE_AMBIGUOUS on the RECORD TYPE — today only account, which means a GL account under NetSuite and a customer under Salesforce or Zoho CRM, so with two of those connected the record type alone does not name a table. It is a qualifier and not a destination: it says which table to look in, the record’s own system still decides where the delete is sent, and a key that names no row in the table you qualified answers 404 rather than deleting another system’s copy. Single- system instances never see this refusal and need not send the parameter.

  • Expect two more refusals from a delete, both of them immediate. A record whose system has since been disconnected answers 422 SOURCE_NOT_CONNECTED rather than being queued to the system the session belongs to; and a record whose own system cannot be read at that moment answers 422 ROW_SOURCE_UNRESOLVED, which says to retry — it is not a statement that the record is missing.

  • Stop resolving an append-only failure by hand for a record a conduit minted. A conduit that bridges one record onto a different record type inserts the new row under a temporary internal id and clears it in the same transaction. That temporary id could reach the write-back as the record’s identity, so the target system was asked to EDIT a record it had never created — refused, and parked on the failures grid for a person. Such a record now leaves as a create, the way the create-only rule always intended. A sync_failure you see for one of these from here on means the write genuinely did not land. The create-only rule itself is unchanged: a record that really does carry an identifier its source minted is still refused as an edit, and still recorded.

  • Read source_response_lost on a workflow-dispatch row as “a build may already be running”. A POST …/dispatches that GitHub answers with a 5xx or a 429 now parks record_write_state at blocked under that reason code on the FIRST attempt, with a message naming the repository, the workflow and the ref. It used to stay pending through six retries — each one a second real build in the customer’s repository — and then park under write_failed_unexpectedly. Do not re-send the dispatch from your app; the run mirrors in by itself if GitHub started one.

  • Filter the failures grid on github_http_<status>, not on write_failed_unexpectedly. A GitHub 5xx or 429 on any write-back now files sync_failure.classification named for the status — github_http_503, github_http_429 — so a screen matching the catch-all stops seeing them.

  • Stop matching reason_code = 'write_failed_unexpectedly' on a GitHub row. Every OTHER GitHub write-back — an issue, a label, a comment — that GitHub answers with a 5xx or a 429 now ends record_write_state at blocked under HTTP_503 / HTTP_429 instead, once the retries are spent, and files sync_failure.category = 'source_blocked' rather than source_error or rate_limited. The row still carries the generic held text and still clears on the user’s next save, exactly as before; only the code and the category moved. Read message rather than the code if your screen just needs something to show.

  • Re-read a stored sync_failure.root_cause_key. A request’s method and path no longer enter the key, so one source outage across many repositories, workflows or branches groups as ONE cause instead of one per record. A key held from before this release will not match the one the same failure computes now.

  • Handle the new reason_code value source_record_type_unknown on record_write_state. A write to a record type the connected system has no record of now lands status: 'blocked' with that code and a message saying which record type is missing, where it previously landed under the source’s own generic code (ns_error on NetSuite) with “Syncing is held — your team has been notified.” An app that branches on reason_code should treat it like the other blocked codes and show the row’s message: nothing on the record releases it, and fields is always empty.

  • A write for a record type the connected system does not have is now REFUSED BEFORE IT IS SENT. POST /v1/data/:recordType and every other door onto the pg→source path previously accepted it, attempted the write and recorded the source’s refusal minutes later; the write is now never sent, and the row is held with the code above on the first attempt. Nothing changes for a record type the catalogue names, or for one an active customization on the instance names — including a customrecord_* mirror, which is matched by its table as well as by its spelling. The hold clears by itself: once the record type exists in the connected system, the next write for it is sent with no retry to trigger and nothing on the row to edit.

  • Expect inline from GET /v1/data/file/<external id>/content for images, video, audio and PDF, and add ?download=1 wherever you meant a download. The route used to answer content-disposition: attachment for every type, so a link you ship as “Download” now opens the file in place instead. Anything not on the render allowlist — including HTML, SVG, XML and a file whose type the connector never recorded — is still an attachment, unchanged.

  • Use fileUrl(row) from src/lib/turbobase.ts to build a file’s URL, rather than composing the path. It takes the mirrored-file ROW and adds ?connection=<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 with Range. A ranged request answers 206 with content-range and accept-ranges: bytes and 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 answers 416. Responses carry the file’s etag, so a revisit with If-None-Match answers 304 without re-reading the connected account. cache-control is private, no-cache, so nothing is shared between users.

  • Keep sending a non-HTML Accept on your own fetch calls. A request with no session and an HTML Accept is now 302-redirected to /login and back to the file, for a person clicking a stored link from outside Turbobase. A program asking for application/json, */* or an image type still gets the unchanged 401 envelope, so nothing that parses that envelope changes.

  • Declare a record capability on any record /v1/catalog/records publishes — 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 declaring read: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 as POST /v1/processes/<id> does, and the scheduler hands off immediately rather than holding its lane for the length of the run. turbobase ext deploy refuses 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 a process schedule from anyone else, in the database, the same way it already refuses a system_job.

  • Stop re-invoking a long eachRow process — 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 of src/lib/turbobase.ts — the same call is exported flat as askModel. 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. askModel sends 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/complete requires app in the body and answers 400 app_required without it, and 404 ai_app_not_found for a slug that is not an app deployed on the instance. The spend lands in the instance’s usage under app:<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); a 429 from that one carries scope: 'instance'. A view-as session is refused with 403 view_as_session: it is read-only.

  • Re-copy src/lib/turbobase.ts, or add the new members by hand. turbobase apps init now writes ai.complete (and askModel), invokeProcess, processRun, cancelProcessRun and appSlug, plus a TurbobaseAiLimitError for 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:invoke if 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:invoke is opt-in and is in no token kind’s defaults, so a token you minted before this release cannot call it and answers 403 insufficient_scope naming the permission.

  • Start and watch a process from your app with invokeProcess(id, input), processRun(runId) and cancelProcessRun(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 actor on llm_usage, not user_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_id is 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 by user_id alone showed all of them as unattributed. actor and user_label ride v_llm_usage_by_operation and llm_usage_timeseries too, the admin-only reads behind the usage panel.

  • Expect 429 from the model proxy, and read retryAfterMs off it — or catch TurbobaseAiLimitError, which askModel throws for the same thing with limit and retryAfterMs on it. The instance limits tokens per minute, tokens per call and tokens per day, and refuses a tier it does not allow. retryAfterMs is 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 the 503 llm_unavailable / 503 ai_budget_paused envelopes you already handle.

  • Declare your app’s own, lower model ceiling in public/turbobase.json under ai: { tokensPerMinute, maxTokensPerCall, tokensPerDay, tiers } — and expect it to BIND. turbobase apps plan and 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 of ctx.steps. The ENGINE makes the call, under process:<id>, and records it on the run’s ledger with its tokens; the step’s own build stays pure and has no way to reach a model or the network. A later step reads the answer as ctx.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> and processes cancel <runId>. GET /v1/processes is 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 with turbobase 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 409 naming 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/extensions cover 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. step addresses what an earlier step of the same run landed, by its id; pgRowId and externalId address 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 — a 404 for a row the invoking user cannot see, a 409 when expectedSyncVersion has moved.

  • Repeat a group of steps per input element with forEach: { input: '<property>' } and nested steps. Inside the group, ctx.item is the element and ctx.index its 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 of ctx.steps or to address one with update: { step: 'leg:2:doc' }. One run may repeat a group at most DEFAULT_PROCESS_FOR_EACH_MAX_ITEMS (100) times — over that, the run is refused before the first write, so move the work onto eachRow, which the platform paces a slice at a time.

  • Let a step decline with when: (ctx) => boolean, or by returning null from build. Either way the step is recorded skipped and the run carries on and ends committed. A build returning null used to fail the step with STEP_BUILD_FAILED and abandon the rest of the run — if you worked around that by returning a throwaway payload, you can stop. A when that THROWS still fails the step: a broken condition is not a false.

  • Read skipped on process_run_step as “not attempted”, for either reason. It now covers a step the run never reached AND one that declined through when or a null build. No new disposition value, so nothing that switches on the column breaks — but a viewer that showed skipped as “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, and keep also spares that step’s own record from a rollback some other step caused.

  • A rollback no longer deletes a record an update step merely changed. Under onFailure: 'rollback' the run undoes only what it CREATED; an update step’s ledger row stays confirmed and 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 of ctx.row — the way writing-processes.md documents — raised STEP_BUILD_FAILED: Cannot read properties of undefined on every run, because the engine never passed the row into the isolate. If you worked around it by reading the row out of ctx.input, ctx.row now carries the whole row the eachRow.outstanding read returned.

  • Derive a received quantity from the item receipts, not from the purchase-order line. There is no purchase_order_line.quantity_received and 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_id is the order’s external_id and item_receipt_line.po_line_id is the order line’s line_index (text against integer, so cast one side). Take magnitudes on both sides, and read 0 receipt rows as “none arrived”. The same holds for billed and fulfilled quantities, which are sums over vendor_bill and item_fulfillment.

  • Name the constraint when you embed item_receipt_line. item_receipt?select=*,item_receipt_line(*) fails with PGRST201 and HTTP 300, because that line table reaches its header through two foreign keys. Write item_receipt_line!item_receipt_line_parent_pg_row_id_fkey(...) — always the parent_pg_row_id one, since the source-id column is nullable and silently drops lines that have not synced. purchase_order_line needs 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_version and customization_snapshot.run_id do not exist and never have (the concurrency token is on the HEADER, and that snapshot is keyed snapshot_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/records is the list of what you can read.

  • Reach your instance from a program with an access token: mint one carrying rest:read, and rest:write too if it has to change anything. An access token presented as Authorization: Bearer is 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/v1 is 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>/content and rest:read — not rest:write. It is the one route under /v1/data the 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 and DELETE /v1/data/<recordType>. expectedSyncVersion still decides the write, so two programs claiming the same row cannot both commit — the second gets a 409 conflict.
  • Expect 403 with {"error":"insufficient_scope","reason":"missing_scope", "required_scope":"rest:write"} when a permission is missing, and 401 when 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:write on its own is refused write_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 with rest:read defaults to a 7-day lifetime rather than 90, as mcp:data already 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.
  • Register the printers your own print agent serves: write printer with source_system: 'standalone' (POST /v1/data/printer, or write_record over the data MCP). Send name, description, state, computer_name, is_default, the new queue it serves, and what it can print — document_formats (any of pdf, raw, png, jpeg, text) and raw_languages (zpl, epl, …). Do not send external_id; it is refused. The writer needs write:printer (delete:printer to 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 into print_job.printer_external_id. A mirrored printer is still named by its external_id.
  • Read a job with its printer in one request: print_job?select=*,printer(*) (to-one; null when the job names no known printer), and a printer with its jobs with printer?select=*,print_jobs(*). The link stays soft — a job outlives its printer being removed.
  • Rename the printJob columns you write: content_type becomes document_format (+ raw_language), and printer_id becomes printer_external_id. document_format is what the document is — pdf, raw, png, jpeg or text — and content is always base64, so the encoding is no longer part of the name: pdf_base64 → pdf, raw_base64 → raw with raw_language: 'zpl' (or epl, escpos, …). PrintNode prints pdf and raw; the other three are refused on the row with format_not_supported. The create’s required_inputs on printnode:printJob are now printer_external_id, document_format and content, and a missing format answers 422 NO_DOCUMENT_FORMAT (was NO_CONTENT_TYPE).
  • Read status_message and retry_after on a print job, not upstream_message and window_resets_at. Same facts, renamed for any print service. reason content_not_encoded is now content_invalid, and format_not_supported and agent_error are new.
  • Show a PrintNode job as “sent to the printer” while status is sent. An accepted job used to say queued; it now says sent until the poll settles it succeeded or failed, and completed_at is stamped when it does. queued now means “nobody has taken it yet”.
  • Use copies, options and queue instead of putting them in raw. copies (1–999) becomes PrintNode’s qty; options is passed to the print service as given; queue is the routing key an external agent claims by.
  • To have your own program print, create the job with source_system: 'standalone' and claim it with expected_sync_version. A standalone job is never sent by Turbobase — it is a pull queue. Name source_system: 'standalone' on EVERY write, updates included, and never use the printnode:printJob name for one (that makes a PrintNode job). Over MCP this needs a PAT with mcp:data and mcp:write. An agent lists queued jobs on its queue (and claimed ones whose lease_expires_at has passed), claims one by writing status: 'claimed', claimed_by, claimed_at, lease_expires_at and attempts with the sync_version it read as expected_sync_version — a 409 / conflict means another agent won, so move on — then writes succeeded or failed with completed_at. A claimed row without claimed_by and lease_expires_at is refused by the database. The protocol is in the print job anatomy; it needs write:print_job, which is still outside the standard bundle (turbobase-dev#3461).
  • Declare a role’s scopes in public/turbobase.json — roles[].scopes was 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}}"] } }. content is a GRANT — the exact containers of memory_document the 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, and turbobase apps plan warns about it. appRole is still refused. Run turbobase apps plan . --app <slug> after adding scopes — 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 content with read:memory on the same role, or it reads nothing. A non-admin reads a memory_document row only when the role they are ACTING AS holds read:memory AND lists that row’s container exactly in content. read:memory alone reads no rows, and content without read:memory grants 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 on hasCapability('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 — not write:activity — to the same role to let it post into its granted channel, and send raw.channel as the channel ID in POST /v1/data/channel_message. A role holding write:activity:message and slack:<channel id> in content creates a channel_message in 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 needs sync.bidirectional.slack switched on for the instance. write:activity:message admits 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’s activity row, and no edit or delete of anything. It does not satisfy write:activity, and write:activity does not satisfy it: write:activity is every write on the shared activity table — 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 only write:activity to 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’s channel_message rows — not even their own post — so keep your own copy of what the app sent, and expect a retried create whose idempotencyKey already landed to come back 500 write_failed for them rather than replayed (nothing is posted twice). Members and admins post exactly as before; write:* (Data admin) satisfies write:activity:message as it satisfies every write. A role declaring write:activity:message with no slack: value in content posts nowhere, and the deploy and turbobase apps plan warn about it.

  • Read author_user_id on a Slack channel_message your app posted as the app user who posted it — the instance now sets it, and ignores a value in your payload. Every Slack activity row created through POST /v1/data is stamped with the posting session’s user id, whatever parent.author_user_id said; 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 as keptRemovedCapabilities on the role’s verdict, and the CLI prints kept (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 content value as slack:<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-creates turbobase.grantable_content_sources() with the same list. The database re-checks every stored content value 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.ts fails until the two lists agree. Until then a manifest can only grant slack: 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. For content, 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 from roles for 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 from roles, shipping "roles": [], or deleting the app retires the content values 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 removed content value 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 content value or restriction already on the role records the declaration but does not own the value: nothing is written, the report prints it as adopted (once, on the deploy that first finds it), and no later manifest of yours can retire that content value, 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:memory and no content (or no scopes at all, which changes nothing on redeploy), and each account’s deploy joins its own slack:<channel id> into roles[].scopes.content from 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 a POST /v1/apps/plan body — and add --app "${{ vars.TURBOBASE_APP_SLUG }}" to the turbobase 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 sends roles only when your manifest has the key; --app and --as app:<slug> must name the same app.

  • Upgrade the turbobase CLI before you deploy, preview, promote or plan a manifest whose roles carry scopes — an older CLI prints a scopes-only change as ok … already up to date. That is how it reads the new update_scopes verdict, including one that REMOVES a content value 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 as change … (upgrade the CLI to see details), never as up to date.

  • Send expectedUpdatedAt — the role’s updatedAt from your last read — when a script PATCHes /auth/admin/roles/:id with a whole grant, and on 409 role_changed re-read the role and apply your change again. A deploy can now change a role’s scopes (it retires a content value your app stopped declaring). A script that read the role before such a deploy and wrote its whole scopes back after it would restore the retired value — recorded as an admin’s, so no deploy retires it again. With expectedUpdatedAt the 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__, constructor or prototype — the roles API (POST/PATCH /auth/admin/roles) and the manifest’s roles[].scopes now refuse them with bad 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.0 to ## v1.33.0 sections your next turbobase project adopt adds to ADOPT.md, even though they are older than its adopted-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 into ADOPT.md under 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 — a source_file row 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), so encodeURIComponent it. The answer is an attachment with the row’s own mime_type, private, no-store and nosniff; it needs the same read:source_file capability the row does. A file whose contents have not arrived yet answers 404 content_unavailable, and one that is there and can no longer be fetched — a credential withdrawn, a blob moved in the source — answers 409 content_unavailable with 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/uploads BEFORE creating the file, and name the handle you get back. Send the bytes as the request body with content-type: application/octet-stream — not base64 inside JSON, and not a column. The answer is 201 {"ok":true,"upload":{"upload_id":…, "size_bytes":…}}, and the create then carries that id under raw.upload_id. The ceiling is 8 MiB; above it the route answers 413 with UPLOAD_TOO_LARGE, and an empty body answers 422 with EMPTY_UPLOAD. The route asks for the create’s own write:source_file capability, 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_id and path on every file create, or the write boundary refuses it immediately. POST /v1/data/file with neither answers 422 and names the first one missing — NO_FILE_CONTENTS for the bytes, NO_TARGET_PATH for the destination. Both are published on azure_blob:file’s required_inputs, so an assistant reading list_record_types is told what a create needs rather than discovering it from a refusal. path is 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_file rows for a connected Azure Blob Storage container, where only GitHub wrote them before. azure_blob now appears in /v1/catalog/records with a file record on the same shared table, so a surface already reading source_file gains the container’s blobs and should filter on source_system if it meant GitHub’s files specifically. Read the text from the knowledge document, not from the row: content_text is always NULL on an azure_blob row — 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’s memory_document, which is what the assistant grounds on. A surface that reads content_text to 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_blob ships off per tenant: until it is on, every create answers BIDIRECTIONAL_DISABLED. After that a write addressed outside the one container a connection names answers CONTAINER_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 answers CONTAINER_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_blob is 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 answer 503 source_unreachable with NetSuite’s UNEXPECTED_ERROR for every stocked item, and 200 only for items at zero. A location’s quantityCommitted is now the account’s committed figure for that whole location. Per bin and per lot it is null, because the account doesn’t report a committed figure at that level.

  • Group Jira issues by project_id and show project_name and status_name from the columns — stop reading raw->fields->project and raw->fields->status. Three columns on work_item that were NULL on every Jira row now carry the values the payload always had: project_id (the project’s numeric Jira id), project_name, and status_name (the site’s own stage word — To Do, In Review, the same value status already carried, not Jira’s three-way statusCategory rollup). project_id addresses cr_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 a raw read 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 a raw path was getting a different answer from the next app that guessed the path differently, and a screen that grouped by project_id was correctly reporting “no work linked to a project” on an account where every issue IS linked. status_name matters beyond display: the catalog pairs an <x>_id column with its <x>_name twin, so the in-product agent and turbobase project types were already telling a generated app to render status_name in place of status_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 split PROJ-123 on 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_system on a write to a table your app authored — the table’s own definition now decides, and naming a connected system is 422 SOURCE_MISMATCH. A custom table (one declared in your public/turbobase.json, or created with turbobase tables create) can be addressed by its bare slug, task, or by the customrecord_-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, so POST /v1/data/customrecord_task stamped 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 no source_system now lands source_system: "standalone" and write_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 refused 422 { "rejection": { "code": "SOURCE_MISMATCH" } } naming both systems, where it used to be accepted. If your app sends source_system on its own tables, drop the field; if it must send one, send "standalone".

  • Expect 400 record type "…" is not writable for a customrecord_ name this instance has no ACTIVE table definition for, and 422 RECORD_TYPE_AMBIGUOUS when 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 now 422 SOURCE_NOT_CONNECTED naming 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 printJob row, not by calling anything. POST /v1/data/printJob with printer_id, content_type and content, plus an optional title that shows in PrintNode’s own queue. All three are published on printnode:printJob’s required_inputs, so an assistant reading list_record_types is told what a create needs instead of discovering it from a refusal; without them the write boundary answers 422 naming the first one missing — NO_PRINTER, NO_CONTENT_TYPE, NO_CONTENT. It asks for write: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_type is pdf_base64 for a PDF or raw_base64 for a label printer’s own language (ZPL and the like), and those two and no others — a URI content kind is refused with reason content_not_encoded before 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. status is one of queued, succeeded, failed, refused, and reason names 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_message keeps PrintNode’s own sentence. On rate_limited, window_resets_at says 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 queued with its external_id set — 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 polls GET /printjobs/{id}/states and settles the row to succeeded or failed afterwards; state carries PrintNode’s own word for where it got to. Show “sent to the printer” until status is 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 printJob that already carries an external_id has 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 no external_id, so fixing printer_id on it and saving does send it.

  • Expect every print to be refused until staff turn writing on. sync.bidirectional.printnode ships off per tenant: until it is on, a create is refused with BIDIRECTIONAL_DISABLED and nothing reaches PrintNode.

  • List the printers to let somebody choose one; never write to them. printnode:printer is in /v1/catalog/records as a read-only mirror behind read:printer, one row per device on the connected account, carrying its name, its state (online / offline), and the computer_name it is attached to — which is what tells four printers called “Zebra” apart. Its external_id is the value a printJob names in printer_id. An offline printer is a legitimate target, because PrintNode queues for it. A write addressed at printer is refused as the record’s own direction, and a tenant holding two PrintNode accounts has two connections, so filter on connection_id when 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 printnode is deliberately outside the delete-capable set and a pg→source delete is refused before the adapter is reached.

  • 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, with reason_code = 'inventory_detail_required' on record_write_state.

  • Expect 422 INVALID_INVENTORY_DETAIL from POST /v1/data/:recordType for 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 answered 200 and dropped, so the account then refused the record for the detail you thought you had sent. rejection.field names the offending key (lines[0].raw.inventorydetail, lines[0].inventory_detail[0].bin, …) and rejection.message quotes 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 to issueinventorynumber, so a write-off of a numbered item issues the existing number rather than receiving a new one.

  • Read record_write_state.message for inventory_detail_required as “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; fields is unchanged (inventory_detail).

  • Read a line’s lots and bins from inventory_detail (or v_inventory_assignment) on every line table that has the column, not out of raw. 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 expect v_inventory_assignment rows with inventory_number = null and a bin_number. A query that filters on inventory_number is unaffected; one that counts rows per line will now count those bins too.

  • Order a “recently touched” list by updated_at, and use updated_since again — the column now means when the row last changed, not when it was first imported. On every mirrored table a BEFORE UPDATE trigger stamps updated_at whenever the row actually changes, whether the change arrived from the connected source or from your own POST /v1/data/{recordType} edit. Before this it was written once on import and moved only when a particular connector happened to set it, so updated_since on 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 by updated_at will 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 to created_at; and a screen that gave up on updated_since because it returned empty pages can start using it. The catalog description for the column changed to match, so turbobase project types and 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_at alone, 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.

  • Show how fresh a row is: read the new source_confirmed_at column on every mirrored table, and treat NULL as “never confirmed”, not as “just confirmed”. It is a timestamptz carrying 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 does turbobase project types once you regenerate). It is NOT last_modified_pg, which is when the row last CHANGED: a row re-read thirty seconds ago and found identical has a four-hour-old last_modified_pg and a thirty-second-old source_confirmed_at, and the second one is what a staleness badge should read. It is NOT synced_at either, which is still the other direction — when one of your writes last reached the connected system. Rows that existed before this shipped read NULL until the next confirm touches them, so render NULL as “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/:recordType and DELETE /v1/data/:recordType now answer 503 { error: 'hook_unavailable', rejection: { code: 'HOOK_UNAVAILABLE', message } } with a Retry-After header (seconds) when one of the tenant’s safety checks could not finish in time. This case used to arrive as 422 { error: 'validation_failed', rejection: { code: 'HOOK_FAILED_CLOSED' } }, so an app that branches on 422 was 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 same idempotencyKey, and show the person nothing to fix — the rejection.message is already written for them to read if you want to surface it. 422 is unchanged and still means a check returned a verdict about the data.

  • Handle HOOK_UNAVAILABLE from the MCP write_record and delete_record tools as “send it again”, not as a bad request. The same case used to reach a model as INVALID_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 inside parent instead. Both spellings are now recognised on POST /v1/data/:recordType. A create whose parent carried idempotency_key with no top-level idempotencyKey always 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_refused as a reason_code on a blocked record_write_state row, 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 no fields to fix and no edit that clears it, so an app that enumerates reason codes should route this one to the same place it routes permission_denied rather 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 502 from POST /v1/stock — it now answers 503 { error: 'source_unreachable', message }. That body never used to arrive: the edge in front of every tenant replaces an origin 502 with its own “Bad gateway” page and discards the JSON, so a failed stock read reached you as untyped HTML and the message naming what the source said was thrown away. Read 503 plus that code as “we could not ask the account”, and never as a zero.

  • Read known beside found on every item POST /v1/stock returns. found: false means “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: true is an empty shelf; found: false, known: false is an id this instance has never heard of; known: null means the answer cannot speak for the id (a truncated read). found: true is always known: true.

  • Read locationKnown on the answer when you narrow POST /v1/stock with locationId. true the location exists, false this 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. null when 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 502 from POST /v1/apps/receipt-extract — it now answers 503 { error: 'extract_failed', message }. The message is 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_unavailable in the same endpoint is also 503, so read error, not the status: llm_unavailable means no provider is configured, extract_failed means the provider could not read the image.

  • Stop branching on 502 from /v1/extensions/proposals/:id/* and /v1/extensions/:id/remove — they now answer 503 { 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 503 rather than 502 from 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 — a 409 is still a 409.

  • Stop branching on 502 from GET /v1/catalog/openapi.json — it now answers 503 { error: 'postgrest_unreachable', status }. That body never used to arrive: the edge in front of every tenant replaces an origin 502 with its own HTML page and discards the JSON, so codegen failed with no cause to show. Read 503 plus that code as “the catalogue’s upstream is down”.

  • Stop branching on 502 from POST /v1/extensions/author-conduit — it now answers 503 { error: 'author_failed' }. It shares a status with the 503 { error: 'ai_budget_paused' } refusal, so read error, not the status: ai_budget_paused means the instance is doing what it was told, author_failed means the author genuinely failed.

  • Stop branching on 502 from POST /v1/apps/:id/release-event — it now answers 503 { error: 'record_failed', message }. The message names 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 error rather than the status on POST /v1/processes/:id?mode=sync — the over-budget answer moved from 504 to 202 { error: 'sync_budget_exceeded', runId, processId, state: 'queued', message }. The run was accepted and is still going, which is what 202 means. A 504 was replaced at the edge, so the runId you need in order to keep watching the run never arrived. In this mode a 202 is the one answer that does NOT mean finished — a 200 carries the settled run.

  • Stop branching on 502 from the MCP OAuth bridge’s /oauth/authorize — it now answers 503 { error: 'temporarily_unavailable', error_description }. That is the status RFC 6749 pairs the code with, and the one an OAuth library can act on; a 502 arrived 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/roles instead 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 reading user.roles and acting_role_id out of the console’s own /__data.json payload, 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 roles list as a true answer and branch on the status, not on the list. A 200 carrying { "roles": [], "actingRoleId": null } means the person holds no roles — render nothing rather than an error — while a failed read is 503 {"ok":false,"error":"roles_read_failed"} and no session at all is 401 {"ok":false,"error":"unauthenticated"}, both as JSON rather than a redirect or a sign-in page. An app that showed “no roles” on a 503 would hide a picker the person can actually use. The failure is 503 and never 502, for the reason the 502 notes in this same release give: the edge in front of every tenant replaces an origin 502 with its own HTML page and throws the JSON away.

  • Re-deploy your processes/ modules — until now none of them installed. turbobase ext deploy and turbobase ext plan read 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 as could not read id / recordType / event and silently left out of the plan, so invoking it answered 404 process_not_found. Run turbobase ext plan to see them appear as adds, then turbobase ext deploy --to <env>. Nothing in your module changes: a process now needs only a readable quoted id:, and the record types stay where they were, on the steps.

  • Ask for current stock with POST /v1/stock instead of reading the mirrored quantity columns. Send connectionId, an itemIds array (at most 200 ids), and optionally locationId to 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 as items, one entry per id in the order you asked, each carrying locations with quantityOnHand / quantityAvailable / quantityCommitted and, where the account keeps them, a bins and a lots breakdown. 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 with 400 unknown_field.

  • connectionId is 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 with 400 naming the field. A connection the instance does not hold answers 404 connection_not_found, and one whose connector has no live stock surface answers 501 live_stock_unsupported_for_source — never an empty list, which would read as “none in stock”.

  • The answer is per location — join it to location when 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 the locationId values you get back against the mirrored location table, which is keyed on that same id and carries subsidiary_id and subsidiary_name. Reading location is scoped to the subsidiaries the session may see while the live figures are not, so a locationId that finds no row there is stock this session cannot place — show it on its own rather than folding it into a subsidiary total.

  • Treat null as “the account does not keep this”, not as zero — and check found before showing a figure. An item the source reported no stock for comes back found: false with locations: null rather than an empty location list, which would read as “in stock nowhere”. bins and lots are null when the account keeps no bin or lot detail for that item, rather than [], which would claim the detail exists and is empty.

  • found: false means “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 a locationId, 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 the known field beside it — you no longer have to resolve the id against item yourself.

  • found has THREE values, and null is not false — branch on found === true rather than on if (item.found). true the source reported stock for it, false none was reported for the question you asked — the bullet above, and never “no such item” — null this answer could not speak for it. The third arrives when the response is truncated: 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 not false.

  • 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 [] whenever truncated is false. It is the same set as the found: null entries, 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 with locationId. 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 reported found: null, never as a partial quantity.

  • Ask your administrator for read:item AND read:inventory_number if 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 with 403 naming 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’s quantity_* columns and location_summary are still there, still gated by read:inventory_number, still refreshed by sync. Reach for /v1/stock when 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": false to your app’s turbobase.json to 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, 0 or 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"} from POST /v1/apps/<slug>/proposals once 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 the forbidden a missing proposal:create capability returns, because no permission grant puts this one back.

  • Check truncated on each batch() sub-result, and page with offset when it is set. A sub-query now returns at most 1000 rows: ask for more (limit=5000, or no limit at all) and you get the first 1000 with truncated: true on that result. The field is ABSENT rather than false on a complete page, so the check is if (r.truncated), and a sub-query whose own limit is 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 is BATCH_READ_MAX_ROWS on the instance. If a surface genuinely needs tens of thousands of rows, page them with selectPage(). Leave a process’s ctx.query alone — 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_system when 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/:recordType used to answer 422 SOURCE_AMBIGUOUS for 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. account is 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, no account could be created or edited at all. Naming the system is now what chooses the table, so POST /v1/data/account with parent: { source_system: 'zoho_crm' } (or the MCP’s zoho_crm:account spelling) 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 now 400 record type "<type>" is not writable; naming one that is not connected is 422 SOURCE_NOT_CONNECTED; and naming one the addressed record does not belong to, on an edit, is 422 SOURCE_MISMATCH when that system keeps the record type in the same table and 404 row not found or not permitted when it keeps it in a different one — source_system is 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 were 422 SOURCE_AMBIGUOUS before, because the ambiguity was raised a stage above them. Branching on SOURCE_AMBIGUOUS to retry with a source_system is still right; branching on it for anything else is not, and on an edit a 404 for a row you can plainly read means the source_system you sent is not the one that row belongs to. With nothing named you still get 422 SOURCE_AMBIGUOUS on field source_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, so turbobase.entity_source_link and turbobase.entity_canonical_view list it as a member and a conduit’s ctx.atlas.resolve({ targetSource: 'standalone' }) returns its key instead of null. That key is not a connected system’s id: it is the prefix pg: followed by the record’s own pg_row_id, which is what you address the row by on /v1/data and /rest/v1/*. If your app or conduit reads a null from that lookup as “this company is not held in Turbobase”, or assumes every member key is a connected system’s id, change it now — read source_system first, and only on standalone strip the pg: 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 with pg: too, and a member that is not standalone always 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_system to create one record in all of them. POST /v1/data/:recordType now 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.sources on a multi-system create, and only then. The response gains sources: [{ 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. Every external_id in it is null: 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 and sources is absent, the record was created in the first-named system and no copy was recorded.

  • Send an idempotencyKey on 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 with sources, 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 is 422 SOURCE_NOT_WRITABLE, and one that means a different record by that name is 422 SOURCE_AMBIGUOUS — each naming the system to fix. A list entry that is not a system name at all is 422 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_system is 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_link over 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’s write_status (ok, pending, blocked, divergent, muted) and, for a refused copy, the last_error that system gave. One copy being refused never costs you the others.

  • Page entity_canonical on its new pg_row_id instead of on id. The Atlas identity table now carries the same surrogate cursor every other paged relation on the platform does — NOT NULL, unique, added beside the id primary key, which is unchanged — so turbobase project types publishes a seek key for it and a /rest/v1/entity_canonical walk no longer has to fall back to ordering on the primary key. Re-run turbobase project types to pick it up. Nothing was removed and no existing query changes behaviour.

  • Read the combined identity list over MCP as turbobase:identity_<bucket>. list_records now takes the seven identity record types the record catalogue has always advertised — turbobase:identity_customer, …_vendor, …_contact, …_employee, …_partner, …_lead, …_item, and the bare identity_<bucket> form — and pages them with page.next_cursor like any other record type. They used to be listed in the catalogue and then refused as an unknown record type. They are read-only, and get_record refuses them by name: an identity is addressed by its own id rather than by a source-side id, so read it with list_records and match on id.

  • Check warnings on a list_records read of a record type your other connected systems hold under a different name. A plain customer read on an instance connected to several systems answers with the one that publishes that exact name — unchanged, and applied.source_filter has always said which — and the envelope now also names the other systems’ words for the same kind of row (zoho_crm:account, getsales:company) plus the combined turbobase:identity_customer. Rows and status codes are unchanged; this is a new entry in an existing array.

  • Treat the initialize instructions’ “Records you can CHANGE” line as exactly the pairs list_record_types reports writable_now: true for. Both surfaces are now one derivation over one population, so they cannot contradict each other; the line covers admin-added custom records too, which it never did. It states what can be changed at all — whether a system named on it is connected is the “Connected sources” line above it. When a read behind the block fails, both lines now say the posture could not be established instead of reporting that nothing is connected and nothing is writable.

  • Subscribe to entity_canonical to watch the combined identity list, and write a DELETE handler for it. The Atlas identity table is now in the supabase_realtime publication, so a postgres_changes channel on it delivers instead of reaching SUBSCRIBED and streaming nothing for ever — which is what a channel opened on the catalogue’s table value for a turbobase:identity_<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 no deleted_at, and an Atlas merge, a rejected decision, an admin repair or a disconnected connection removes the row outright. So the “deletes never arrive, reconcile instead” rule does not hold here: write a DELETE handler, and with default replica identity payload.old carries only the primary key (id) and no entity_type. Drop the row by id; a channel filtered on any other column can never match a delete frame. Keep the reconciliation sweep as well — a delete frame carries no bucket for that policy to re-check, so do not make dropping a row conditional on one arriving.

  • Read alsoCalled and combinedRecordType off a /v1/catalog/records entry instead of asking the AI connector. Each directory entry for a record that another connected system holds under its own name now carries alsoCalled — ["getsales:company", "zoho_crm:account"] on a quickbooks:customer entry — and combinedRecordType, the turbobase:identity_<bucket> list that consolidates all of them into one row per real-world thing. They are the same answer the MCP envelope’s warnings already gave, from the directory your app already reads on cold load, so a count that looks short no longer needs a second round trip to explain. Both keys are absent on an entry with nothing to say, so an entry you read today is byte-identical; test with 'alsoCalled' in entry rather than for an empty array. Read them off the response rather than out of types.ts: like writeCapability, they are resolved live per request from what this instance has connected, so turbobase project types — which emits record shapes and seek keys — does not carry them.

  • Label a GetSales contact or company by writing its whole tag set to raw.tags in a POST /v1/data/contact or POST /v1/data/company edit (under getsales): tag ids to keep, minus the ones to remove; an empty list removes them all. A NAME in that list instead of an id is a tag GetSales does not have yet — it is created there as the record is written, reused if the name already exists (case ignored), and mirrored as a tag on the next sync. A tag still cannot be created on its own. An edit that does not name raw.tags leaves the tags alone.

  • Create a GetSales company with POST /v1/data/company under getsales, sending display_name (and company_name, the same name) plus raw.list_uuid — the list to file it in, exactly as a new contact needs. A create without a list, or with one this instance has never seen, is refused 422 (NO_TARGET_LIST, LIST_NOT_MIRRORED). It used to be refused as edit-only.

  • Keep the refresh_token your native app now gets back from /oauth/token, and renew with it instead of signing the person in again. The token response for resource=https://<instance>/rest/v1 has always been specified to carry one and never did, because the instance never asked its identity provider for a renewable session; it does now. The bearer token still lasts eight hours, so an app that ignores the field meets the sign-in screen three times a day for no reason. When a renewal returns a fresh refresh_token, store that one and discard the one you spent; when it returns none, keep the one you just sent rather than dropping it.

  • Treat an invalid_grant on a renewal as “start at /oauth/authorize”, not as an error to retry. Renewal works for thirty days from the sign-in and use does not extend it, so a long-lived connection is refused on day thirty-one and has to sign the person in once. The refusal says so in its error_description. /.well-known/oauth-authorization-server lists offline_access under scopes_supported alongside the refresh_token grant it already advertised.

  • Send only the six top-level keys POST /v1/data/:recordType reads — parent, lines, pgRowId, externalId, idempotencyKey, expectedSyncVersion. Any other top-level key is now refused 422 UNKNOWN_BODY_KEY naming it, where it used to be ignored: a body putting the record’s fields under fields or data was read as an empty parent, so the boundary created a row of NULLs and answered 200 { ok: true }. If your app sends an unrecognised key today it starts failing on upgrade — which is the point, but check before you take the version. An empty body is refused the same way (EMPTY_BODY). Custom fields still go under parent.raw.

  • Read item.quantity_on_hand instead of raw.totalquantityonhand. The total on hand is now a promoted, filterable, real-time-fresh column on turbobase.item, and existing rows are backfilled on upgrade. NULL means unknown, not zero — a service, a discount and a non-inventory sale item keep no stock figure at all, and some accounts keep none on an inventory item either. Render a blank for NULL rather than a 0; a picker told “0” goes to an empty shelf.

  • Group money on invoice, credit_memo and estimate by their new currency_code. The portable ISO 4217 key is now on all three, beside currency_id and currency_name, resolved the same way as on order and entity, and backfilled on upgrade. total on these rows is the document’s own face value, so a per-currency total built on currency_id alone was a cross-currency sum on any NetSuite ledger. NULL when the currency could not be resolved — render the bare number, never a default symbol.

  • Filter placeholder parties out of entity with the new is_placeholder column. A transaction referencing a party the sync has not pulled mints a nameless FK-guard row so the foreign key holds, and until now nothing told an app that the row was one — so a portal rendered a customer with no name and could not tell it from a real party with a blank one. Read it as coalesce(is_placeholder, false): NULL is a row written before the column existed. It is source-agnostic and maintained by the instance, so it clears by itself the moment the real party arrives. On upgrade the instance also re-fetches the placeholders whose party is inactive upstream; the ones it cannot fetch stay flagged and appear on the sync-failures grid.

  • Stop working around blank company_name, display_name, entity_id, email, phone, first_name and last_name on entity. The mirror only read the flat REST spelling of those fields, so every row written by the RESTlet / user-event path — the real-time and webhook path, i.e. exactly the records an app creates — landed with all of them NULL while the values sat in raw.fields. They are now read from both payload shapes, and existing rows are re-projected on upgrade, so an app can drop any per-account fallback that reached into raw for a customer’s name.

  • Declare your native apps in public/turbobase.json → native and run turbobase apps deploy — that is now the whole setup. The instance serves the enabled app’s current version in /.well-known/apple-app-site-association and /.well-known/assetlinks.json with no instance configuration; the operator trust list (api.nativeAppTrust / NATIVE_APP_TRUST) is gone. To stop vouching, disable the app or deploy a version without the block. An iOS id also grants the app the instance’s passkeys and saved passwords, and an Android package opens any link on the host, so declare only binaries you sign.

  • Ship an app’s own agent skills as files: put each one at skills/<slug>/SKILL.md (frontmatter name and description, then the body) and run turbobase 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 — the description is what an MCP client reads in prompts/list to choose it — and its body is replaced whole when the file changes. It lands as a DRAFT. Add --publish to publish exactly the skills that push wrote (new or changed); that needs a token carrying skills:publish as well as extensions:deploy, so keep it a separate secret from the deploy token. A slug already held by a skill your app did not write is refused (the command exits non-zero) rather than overwritten, an unchanged file writes and publishes nothing, and a deleted file deletes nothing — the orphan is reported and stays until you skills unpublish / skills delete it. The route is POST /v1/apps/:slug/skills; the app must already be deployed.

  • Declare your app’s native twin under native in public/turbobase.json to give it a sign-in callback on the instance. Native sign-in refuses a custom scheme, and until now an instance vouched for no app, so a mobile app had no https callback it could claim. Add "native": { "ios": { "appIds": ["<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-association and /.well-known/assetlinks.json naming that app, with its callback at /oauth/app-callback/<your slug>. Only the iOS universal link is limited to that path — webcredentials reaches the instance’s passkeys and saved passwords, and an Android app link can open any link on the host — so trust only apps you sign. Register exactly that URL at POST /oauth/register and add applinks:<instance host> and webcredentials:<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. ASWebAuthenticationSession matches 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_PAUSED on every write — a customer can now pause one source connection, and your writes to records that came from it are refused. POST /v1/data/:recordType answers it on a create and on an update, for the connection the addressed row belongs to rather than for the whole source: a tenant holding two accounts of one source keeps writing through the other one throughout. The message is customer-ready and says to resume the connection, so showing rejection.message is enough — do not map this onto your bidirectional-sync copy, which sends someone to sales about a switch they flipped themselves. Stripe and External API answer it too, which is the pair that may surprise you: neither has a write entitlement for the pause to hang off, so neither previously refused anything at this boundary. Any source in that posture answers it — do not special-case by source id.

  • Branch on the STATUS when a DELETE is refused — DELETE /v1/data/:recordType answers 409 with a prose error and no rejection. A pause stops a delete for the same reason it stops an edit, but the delete route reports it as a conflict rather than a validation failure, and only a 422 carries the machine-readable rejection object — so there is no SYNC_CONNECTION_PAUSED to check for on this path, and a check written for one would never match. Read error and show it; the prose is customer-ready and names the resume, exactly as the write’s does. An unreadable pause check answers 403 here rather than a retryable code, so a refused delete is never worth an automatic retry.

  • Retry 422 SYNC_CONNECTION_STATE_UNKNOWN on a write; do not report it as a pause. It means Turbobase could not read whether the connection is running, so the write was refused rather than guessed at. Nothing was sent and nothing was stored. Treat it as transient — a second attempt normally succeeds — and never fold it into the paused case, because telling someone their connection is paused when it is not sends them to a setting they do not need to change.

  • Expect <source>_connection_paused as a sync_failure classification, under category source_blocked. An event already on the queue when the customer paused is refused before the source is called: a pause parks it as quarantined and resuming makes the retry succeed. An unreadable pause check (<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 writable from POST /v1/data/:recordType when the record type is not one the connected system being written to actually has. That write used to be accepted and the row saved — and then, depending on the table, either nothing was ever sent and the row stayed pending with no failure recorded anywhere, or the change was queued and the connected system refused it later as a held failure naming a record type that had nothing to do with it. The refusal is now immediate and nothing is saved. Read /v1/catalog/records for what each connected system can write — writability is a property of the PAIR, so a record type one system takes is not one every connected system takes, even where they share a table. list_record_types answers the same way over MCP, with no write operations offered for such a pair.

  • Read one identity per real company or person from /rest/v1/entity_canonical?select=*,entity_source_link(*) instead of fetching both copies and merging them yourself. Atlas has always worked out that a business in two of your connected systems is one thing; nothing you could build on was able to read that answer, so every app re-implemented the merge client-side and none of them could page a merged list server-side. One request now returns one row per identity with every source record behind it nested under entity_source_link, pages by canonical (so a page boundary never cuts an identity in half), and carries the exact total under Prefer: count=exact. To go the other way — from a source record you already hold to the identity it belongs to, and to the other records behind it — filter the link table and embed the parent with its members nested inside it: /rest/v1/entity_source_link?connection_id=eq.default&source_system=eq.getsales&external_id=eq.<id>&select=canonical_id,entity_canonical(*,entity_source_link(*)). That nested embed is what saves the second request; drop it for entity_canonical(*) when the identity alone is all you want. Always filter on connection_id as well: the link’s key is (connection_id, source_system, external_id), so two workspaces of the same source can each hold the upstream id you are asking about. Both reads need the bucket’s own read capability — read:entity:customer, read:entity:vendor, read:entity:contact, read:entity:employee, read:entity:partner, read:entity:lead or read:item. The graph was admin-only until now and is gated per bucket from here on, exactly as entity is: you see the identities whose member rows you could already read, and the member list is narrowed to those same rows. It fails quiet rather than with an error, so an app asking without the capability gets zero rows and a 200 and will render “no duplicates” — check the capability before you conclude the instance has nothing consolidated. If your instance uses scopes as a confidentiality boundary, do not read a confidential field off the identity row. An identity is visible to anyone who can read at least one of its member records, and while the member list under it is narrowed to the records that user can open, the identity’s own display_name, primary_email, tax_id and domain are whichever member Atlas recorded them from — so they can come from a record outside that user’s subsidiary, department or class. Read those fields from the member records instead — resolve the ids under entity_source_link(...) against entity (or item), where row-level security has already narrowed them.

  • Re-generate your types (turbobase project types) to pick up entity_canonical and entity_source_link. Both now appear on /v1/catalog/records — one entry per bucket, identity_<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; GET lists them and DELETE /admin/atlas/decisions/<id> forgets one. Admin session only, and the same ledger the /data/atlas queue and the admin MCP tools (atlas_match_decisions, atlas_decide) write. A link written any other way is overwritten by the next sync.

  • Create and edit Zoho CRM tasks, calls and meetings through POST /v1/data/task · /call · /event, and drop any workaround for a write that stayed pending for ever. /v1/catalog/records already published all three as writeCapability: 'write:activity' and the write boundary already answered 200, but nothing was ever sent — so the row committed, its record_write_state parked pending with no event_id, and there was no attempt to see and nothing to retry. The write now reaches Zoho and the row settles write_status: 'synced' carrying the Zoho id, with the sidecar cleared. Two consequences to handle: a value Zoho refuses now settles needs_input with reason_code: 'source_value_not_accepted', a message naming the value, and a record_write_state.fields entry — a refusal these three records could not return before, so branch on it rather than waiting for pending to clear; and a row already stranded pending with no event is re-enqueued the next time the instance’s listener starts, so an app has nothing to re-send by hand. Writes stay gated per tenant by sync.bidirectional.zoho_crm, which defaults off, so expect a deterministic refusal until staff enable it.

  • Read write_status on /rest/v1/automation_run to tell a run you started from one you are only watching — the table now carries write_status and idempotency_key, the two columns every writable table carries. A run you dispatched with POST /v1/data/workflow_run reads pending until it reaches GitHub and synced once it has, carrying the run’s own id in external_id; one that went nowhere reads needs_input or blocked, and record_write_state says why. A run mirrored inbound is always synced. Until now the column did not exist, so a dispatch showed as settled the instant it was written and a run that never started looked identical to one that succeeded.

  • Stop offering a retry on a dispatched run whose write_status is needs_input or blocked. Those two are holds: Turbobase will not push the row again while it carries one, so a button that re-saves the same row does nothing. Surface the reason from record_write_state and let the reader start a fresh run instead.

  • Call an outside web service by writing an external_api:request row, not by fetching from your app. /v1/catalog/records gains external_api:request → table request, readCapability: 'read:request'. Create one with POST /v1/data/request (parent: { connection_id, method, path, request_body?, request_headers? }) and read the answer back off the same row at /rest/v1/request. The instance makes the call server-side with the connection’s stored credential attached, so nothing about that credential reaches your bundle or a browser. An administrator registers the service first — one CONNECTION is one service — so address it by connection_id and do not assume 'default' exists.

  • Ask a tenant admin for write:request and read:request; neither is in the standard capability bundle. Creating a request spends the tenant’s own credential against an outside service, so the write is granted per user rather than carried by every session. Without write:request the create is refused 403 with the capability named in the body envelope; without read:request the row is invisible to /rest/v1.

  • Poll the row’s status and branch on reason — a refusal is a state, never a thrown error or a dropped call. status is queued → succeeded | failed | refused; reason is null on success and otherwise one of host_not_allowed, timeout, cap_exceeded, non_2xx, network_error, connection_not_found, auth_not_configured. On cap_exceeded the row carries window_resets_at, so wait for that instant rather than retrying in a loop. elapsed_ms is set on every attempt that left the process.

  • Treat response_body as bounded and check response_truncated before you parse it. A body is cut at 64 KiB and at most 32 response headers reach response_headers; set-cookie and the other credential-bearing headers are dropped entirely. request_headers mirrors what went out with the credential’s own keys replaced by [redacted], so it is safe to show but is not the literal wire.

  • A path that would leave the connection’s host is refused before anything is sent, and so is a redirect off it. path resolves against the connection’s base URL — a relative value joins under its path prefix — and an absolute URL, a //host authority, userinfo or a backslash authority all land refused / host_not_allowed with no call made. A connection is confined to a HOST, not to a path prefix.

  • Handle a new 422 SOURCE_AMBIGUOUS from POST /v1/data/:recordType. When your session’s own source cannot write a record type and exactly one other connected system can, the create now resolves to that system instead of being refused 400 not writable — which is what lets a NetSuite session create a request. When more than one could, you get 422 with code SOURCE_AMBIGUOUS; send source_system in parent to say which. No create that succeeds today changes behaviour.

  • There is no sync.bidirectional.external_api flag to wait for. The connection is the entitlement: once an administrator has registered the service, its credential, its per-hour budget and its latency ceiling, writes execute. Do not branch on a feature flag for this source.

  • On a Xero tenant, POST /v1/data/:recordType now reaches Xero for all thirteen writable record types instead of only writing the mirror — but only once staff turn write-back on for that tenant. customer, supplier, invoice, bill, creditNote, supplierCreditNote, payment, billPayment, purchaseOrder, item, manualJournal, spendMoney and receiveMoney. The entitlement ships OFF, so an app that writes one of those today still gets the refusal it gets now, with BIDIRECTIONAL_DISABLED naming sync.bidirectional.xero. Branch on the refusal rather than assuming the write landed.

  • Read the direction off /v1/catalog/records rather than hard-coding read-only for Xero. Those thirteen of a Xero tenant’s fourteen record types publish direction: "bidirectional" and a writeCapability. account — the chart of accounts — stays read-only.

  • Send a bill, credit note, supplier credit note, manual journal or bank spend WITH its lines, in the one call. Those five commit the header and the lines together in a single transaction, so post parent and lines in the same POST /v1/data/:recordType. Do not create the header and then add lines: Xero refuses a bill, credit note, supplier credit note or manual journal that arrives with none, so a header saved on its own is a row that reads as stored and can never leave. Editing the lines means re-sending the whole set with the header — the set is replaced, not appended to.

  • A bank receipt (receiveMoney) is the exception: post the HEADER only, and put its lines in raw. POST /v1/data/receiveMoney with a lines array is refused 422 LINES_NOT_SUPPORTED, because a Xero bank receipt has no line table on this side — Turbobase mirrors it as a header and the write path reads its lines from the record’s own raw as LineItems, in Xero’s shape (Description, UnitAmount, AccountCode, …). Every other Xero record takes its lines the normal way.

  • A direct canonical write still does not work, for any of the thirteen. A write over /rest/v1/* or rpc/write_transaction from a browser session is refused 42501 at the grant layer, exactly as the build-apps skills page has said since the writer-role cutover: INSERT/UPDATE/DELETE are revoked from authenticated on every canonical table and only the write boundary can reach the role that holds them. POST /v1/data/:recordType is the door. This is unchanged by anything above.

  • Check writeCapability off the catalog before drawing an edit affordance — all thirteen now ride a capability the built-in Standard role carries. write:deposit and write:vendor_credit were the two exceptions while this change was in review; both joined the default bundle in the same release, with the NetSuite shared-ledger writes, and existing users receive them on their next sign-in. A hand-restricted user still will not have them, so read the session’s own capabilities rather than assuming the floor.

  • Set a Xero line’s GL account in the right place — it is not the same column on every table. bill, supplierCreditNote, spendMoney and purchaseOrder lines carry it as account_name (Xero’s AccountCode) and it is sent as written. invoice and creditNote lines do NOT: their line table is projected without an account column, so put the code in the line’s own raw as AccountCode and it will reach Xero. A line that sends neither is coded by Xero to the organisation’s default account, silently — set item_name (Xero’s ItemCode) or one of the two above on every line.

  • Keep a line’s external_id when you re-send lines. It is Xero’s LineItemID and it is now sent back, so an edit matches each line in place instead of making Xero mint fresh ids for the whole set. Drop it and anything holding a line id — a reference, a report, your own cache — points at a line that no longer exists. Never invent one: a new line simply has none.

  • Stop sending totals to Xero. subtotal, tax_total, total, amount_paid and amount_remaining are computed by Xero from the lines and the allocations and are never sent, so a write that sets them changes nothing upstream and the values you read back are Xero’s. Set the lines instead.

  • Do not write a status of PAID, VOIDED or DELETED on a Xero record. PAID is derived; the other two are Xero’s delete verb, which this release does not open — a delete against a Xero record is refused UNSUPPORTED_OPERATION. Settable statuses are DRAFT / SUBMITTED / AUTHORISED on documents, AUTHORISED on payments and bank transactions, and DRAFT / POSTED on manual journals.

  • A Xero validation failure now names Xero’s own reason. The error message carries every ValidationErrors[].Message Xero returned (for example Account code 'Z99' is not a valid code for this organisation) rather than a truncated envelope, so it can be shown to a user as-is.

  • Creating a Xero record has required fields, and they are refused before any call. An invoice, bill, credit note or purchase order needs a contact and at least one line; a payment needs the document it settles, a bank account and an amount; a bank transaction needs a contact and a bank account; an item needs its item_id (Xero’s Code); a manual journal needs a memo and lines.

  • Create a supplier credit with POST /v1/data/vendorCredit, lines in the same call. vendor_credit was read-only on NetSuite and is now writable end to end: the row persists against its own pg_row_id with external_id null, reaches NetSuite through the write path and settles to write_status = 'synced' carrying the id NetSuite assigned. Editing an already-mirrored credit works the same way, addressed by pgRowId. A CREATE must carry its lines array in the same request — NetSuite replaces a sublist in entirety and has no per-line endpoint, so a line-less create is refused with ATOMIC_WRITE_REQUIRED before anything is enqueued.

  • Set line_kind on a credit line, or set account_id and leave item_id empty. A credit fans its lines across NetSuite’s two sublists exactly as a vendor bill does: line_kind: 'item' for an item-coded line, 'expense' for a GL-coded one. A line that names neither goes out as an item line and NetSuite refuses the whole record for carrying no item. Set ap_account_id to choose the payables account — it previously never reached NetSuite, so a credit posted to the subsidiary’s default.

  • Create a bank deposit with POST /v1/data/deposit, and read its lines from the new deposit_line table. deposit is writable, and its lines are the “other deposits” — money banked that is not a customer payment. Each line takes entity_id, account_id, amount and memo; the account is mandatory on NetSuite’s form, so a line without one cannot commit. The customer payments already banked on a deposit stay READ-ONLY and stay in deposit.payment_summary: sending them back would un-bank them, so that sublist is never written. cash_back is NetSuite’s own arithmetic and is dropped on the way out, while cash_back_account_id travels — posting a row you just read is safe.

  • deposit_line holds the lines of deposits NetSuite sent you too, not only the ones your app created. The table is filled on read from this release, and deposits mirrored before it are backfilled on upgrade. That matters when you EDIT a deposit rather than create one: NetSuite replaces a sublist in entirety, so the write sends every line under the deposit — if you add one line to a deposit whose other lines you cannot see, the ones you cannot see are the ones that come off it. Read the table before you write, and if a deposit’s lines look short of what NetSuite shows, its backfill has not run yet.

  • turbobase.deposit now keys on pg_row_id, and external_id is nullable. If your app addresses a deposit by external_id, it still resolves — the column keeps a UNIQUE carrying exactly what the primary key carried — but a deposit your app created has external_id: null until the write reaches NetSuite. Address it by pgRowId in the meantime, as with every other bidirectional record.

  • Create and edit a chart-of-accounts entry with POST /v1/data/account. Name, number, description, parent and inactive are all editable on an existing account. The TYPE is not: a patch that changes account_type_id on an account that already exists upstream is refused with a FIELD_VALIDATION error naming that column, before NetSuite is called, because NetSuite refuses the change on any account with postings and we cannot know whether it has any. A CREATE may set any type. account.account_number is now serialized by the bundle too, so it stays real-time-fresh rather than poll-bounded.

  • Ask your administrator for write:vendor_credit, write:deposit, write:account or read:deposit if your app needs them. All four are in the default standard bundle from this release and existing users receive them on their next sign-in, but a hand-restricted user will not have them. read:deposit is on that list because it was missing: a standard session could not read a deposit at all, even though the catalog published the capability. The matching delete: capabilities exist and are deliberately NOT in the default bundle — destructive operations stay with administrators. GET /v1/catalog/records now reports all three records as bidirectional with a writeCapability, which is the string to name when a write is refused.

  • On a tenant with more than one ledger connected, check which ledger owns the row before you offer a save on these three. All three canonical tables are shared — turbobase.account is every ledger’s chart of accounts, and turbobase.deposit and turbobase.vendor_credit are Xero’s as well as NetSuite’s — and writing them is a NetSuite capability, not a table capability. A row whose source_system is anything Turbobase only reads from is refused 400 record type "…" is not writable, the same answer it gave before this release, while a NetSuite-owned row on the same table saves. GET /v1/catalog/records answers this per source and is what to branch on: it returns one entry per (source, recordType), and the entry for the ledger that owns the row carries writeCapability: null.

  • Take bundle 2.10.0 on the NetSuite account before your first write. The integration role a customer install’s RESTlet runs as held these three records at VIEW, so a create raises INSUFFICIENT_PERMISSION until the new bundle is installed — and it passes in a sandbox driven as Administrator, which is not what your customers run.

  • Read NetSuite cash refunds at /rest/v1/cash_refund, and their lines at /rest/v1/cash_refund_line. Embed the lines with cash_refund?select=*,cash_refund_line(*) — the line table’s only foreign key to the header is parent_pg_row_id, so a bare embed resolves and needs no constraint hint. Needs read:cash_refund, which is in the default Standard bundle.

  • Create and edit one with POST /v1/data/cashRefund. Pass created_from_id naming a mirrored cash sale to raise the refund against that sale — NetSuite’s own conversion sources the lines and links the two — or omit it for a counter refund with no sale on file. status, total, subtotal and tax_total are computed by NetSuite and are stripped from the outbound payload; sending them changes nothing. Needs write:cash_refund.

  • Take bundle 2.11.0 on the NetSuite account before your first write. The integration role’s TRAN_CASHRFND grant ships with it, and a customer install’s RESTlet runs as that role rather than as Administrator — so on an older bundle the create is refused upstream with INSUFFICIENT_PERMISSION after the row has already been written here. Reads need nothing new.

  • Send the WHOLE line set on an edit, not just the line you added. The sublist is replace-mode: a patch carrying one line replaces all of them. Read the record’s lines back and post the full array.

  • turbobase.refund rows now carry record_type, naming the source’s own record type — refund (Stripe), refundReceipt (QuickBooks), transactionRefund (Shopify), customerRefund or cashRefund (NetSuite). If you key or de-duplicate refunds on (source_system, external_id), add it: those two no longer identify a row on their own, because a source’s ids are unique per record type. Regenerate your types with turbobase project types.

  • GET /v1/catalog/records lists netsuite:cashRefund as bidirectional. A generated client and an app-template brief pick it up with no change on your side.

  • Edit and create NetSuite employees with POST /v1/data/employee. The record was read-only and is now writable end to end: a create persists against its own pg_row_id with external_id null, reaches NetSuite through the write path and settles to write_status = 'synced' carrying the internal id NetSuite assigned; an edit is addressed by pgRowId the same way. Name, email, phone, the inactive flag and the subsidiary are typed columns. title, department, class and location travel through the raw passthrough — they are not queryable columns, so filter on them in your app rather than in the request. A supervisor is parent_entity_id, which now reaches NetSuite as the employee form’s own supervisor field.

  • Ask your administrator for write:entity:employee if your app changes staff records. It is deliberately NOT in the default standard bundle, and it will not arrive on a sign-in: the ability to change a staff record is granted by name. read:entity:employee is unchanged and every member still has it. GET /v1/catalog/records now reports netsuite:employee as bidirectional with a writeCapability, which is the string to name when a write is refused.

  • Do not send login access, roles, passwords or HR data — the write is refused whole. giveaccess, roles, accessrole, password, password2, sendemail and requirepwdchange are refused with FIELD_NOT_WRITABLE naming the field, and so are pay (salary*, compensation*), birthdate, socialsecuritynumber, taxidnumber and the banking fields. Giving somebody a NetSuite login stays a NetSuite administrator’s act. The refusal arrives on your own request, before any row is written, and it refuses the WHOLE payload rather than stripping the field and saving the rest — so remove the field and send the request again.

  • Send the fields you changed, not the row you read. A mirrored NetSuite employee already carries giveaccess, birthdate and the rest in raw — NetSuite is asked for the whole record on every save — so reading a row and posting it straight back is refused on the first protected key it finds, even though you changed none of them. Narrow the payload to what you edited. What you never have to guard against is the reverse: the mirror’s own copy of these fields is dropped before anything is sent to NetSuite, so an edit can never push somebody’s date of birth back upstream.

  • Employees from every other connected system stay read-only, and that is not a permission you can be granted. turbobase.entity holds QuickBooks employees, Intercom admins, HubSpot owners, Ramp employees, Workday workers and Zendesk agents — six other sources — under the same entity_type = 'employee'. Writing one answers 400 record type "employee" is not writable whatever capabilities the caller holds, because the answer is decided by the row’s own source_system. Check source_system on a row before offering an edit control for it.

  • Read NetSuite support cases at /rest/v1/work_item?item_type=eq.ticket. Scope by source_system=eq.netsuite when your tenant also connects a source that lands tickets on the same table — Salesforce’s and Zoho CRM’s Case, HubSpot’s and Intercom’s ticket all use item_type='ticket'. The case’s opening message is description, its subject is subject, and the customer it belongs to is entity_id / entity_name with entity_type='customer'. Needs read:work_item, which is in the default Standard bundle.

  • Read the conversation from /rest/v1/activity, with a SECOND request — not an embed. Filter work_item_external_id=eq.<case external_id>&order=created_at.asc. turbobase.activity carries no foreign key to work_item, deliberately, so work_item?select=*,activity(*) does not resolve and no constraint hint makes it. Each row’s activity_type is case_message_incoming (the customer wrote it) or case_message_outgoing (the business did) — that pair is how you draw the two sides of the thread. Needs read:activity.

  • Raise a case with POST /v1/data/supportCase, and always name the customer. Send entity_id — NetSuite refuses a case with no customer on it, and the row is written here before the refusal arrives, so the reply comes back as a needs_input write state naming Company rather than as a failed request. subject and description are the fields that carry the case’s title and opening message. Do not send item_type; the route stamps ticket from the registry. Needs write:work_item.

  • Answer a case with POST /v1/data/caseMessage. Send work_item_external_id naming the mirrored case and body carrying the reply. Do not send activity_type — the route stamps case_message_outgoing, the business answering. Needs write:activity. The record type is caseMessage and not message, which NetSuite calls it: Slack already publishes message, on a different table, so a request naming it would be ambiguous.

  • Record the CUSTOMER’s own message by writing turbobase.activity directly, with activity_type='case_message_incoming'. Both halves of a thread reach NetSuite and both settle, so a portal that logs what the customer said keeps the two sides in one place. Use PostgREST rather than POST /v1/data/caseMessage, which stamps the outgoing direction; the same write:activity capability covers it. Before 1.1 such a row was sent and never acknowledged, so it kept write_status='pending' and every later edit posted another copy on the case — if you wrote any, re-check them against the case in NetSuite before editing them again.

  • Do not treat a reply’s external_id as a NetSuite internal id. NetSuite keeps case correspondence on two surfaces, and the reply is written to whichever one that case already uses — a message record when the case has them, the case’s own message field when it has none. On the second the reply has no record of its own upstream, so it settles with a synthetic id of the form <case external_id>:case-message:out — or :in for the customer’s own message, since the case form holds exactly one of each. Both read back through the same activity rows and both are stable across re-syncs, so use external_id to identify a message and nothing else; an app that deep-links into NetSuite by it must check the shape first.

  • Set a case’s priority through raw.priority, not the priority column. The column holds the display label NetSuite returned (“High”) because that is what a ticket list renders, and NetSuite’s own field takes the option’s internal id. priority is therefore dropped from the outbound payload; sending it changes nothing, and raw.priority travels unchanged. item_key (the case number), status and created_at are NetSuite’s to compute and are dropped the same way.

  • Take bundle 2.12.1 on the NetSuite account before your first write. The integration role’s LIST_CASE grant at FULL ships with it, and a customer install’s RESTlet runs as that role rather than as Administrator — so on an older bundle every create and every reply is refused upstream with INSUFFICIENT_PERMISSION after the row has already been written here. That refusal now settles as blocked with reason source_permission_missing and quotes the permission the source named, instead of parking as a generic hold. Reads need nothing new.

  • Read a case’s status back after answering a closed one; do not assume the reply moved it. A reply to a closed case does reopen it — the status the case moves to is resolved from the account’s own cases, by finding one NetSuite has already returned under a “re-opened” status, so the id is always one that account really has. An account where no case has ever been re-opened resolves nothing yet, and the case keeps its status until one is or until the tenant pins NS_CASE_REOPEN_STATUS. Either way the reply itself always lands.

  • GET /v1/catalog/records lists netsuite:supportCase and netsuite:caseMessage as bidirectional. Regenerate your types with turbobase project types; a generated client and an app-template brief pick both up with no change on your side.

  • Read a GetSales tenant’s activity feed at /rest/v1/activity with source_system=eq.getsales. getsales:activity is a new row in /v1/catalog/records against table activity, under the same read:activity capability Zoho CRM’s and Salesforce’s activity rows already use — so an app that already holds it needs no new grant. activity_type carries the GetSales verb verbatim (email_sent, linkedin_message_replied, note_left, pipeline_stage_changed, …), not the cross-source task / event / call vocabulary, and body is a one-line summary Turbobase renders — the full upstream payload is in raw. Read-only: a write against the couple is refused READ_ONLY.

  • Join an activity to the person or organisation it is about through the new activity.entity_external_id, and re-run turbobase project types. It is nullable and matches entity.external_id within the same connection_id. Every GetSales activity hangs off a contact or a company and none off a work item, so work_item_external_id is null on all of them — a query that reached for the work-item pointer to find an activity’s parent will return nothing. The column is source-agnostic and empty for every other adapter until one projects into it.

  • Do not present a GetSales tenant’s mirrored activity as a complete history, or use it for lifetime totals. The first pass over the history that was already there when the workspace connected reaches only as far back as the source will page — on a busy workspace that stops short of the beginning, and no row, column or flag marks where it stopped. The shortfall is written to the instance’s log and nowhere an app can read it, and the mirror does not go back for it later. Label such a view from the oldest row you can actually see rather than calling it “all activity”, and read a per-person count as a floor.

  • Do not build a live activity feed on it. GetSales has no webhook, so the four-hourly sync is the only ingress: an activity logged upstream appears in activity on the next tick, up to four hours later, and created_at is the upstream time rather than the time it arrived. Subscribing to realtime changes on the table gets you the tick, not the event.

  • Create a GetSales contact by POSTing /v1/data/contact with the list in raw.list_uuid — for example { "parent": { "display_name": "Dana Okafor", "email": "dana@globex.example", "raw": { "list_uuid": "9c2e7a10-…" } } }. This create was refused outright before. GetSales files every contact in a list and there is no default, because the list decides which automations start working on that person, so a create is refused 422 NO_TARGET_LIST naming raw.list_uuid when it names none, and 422 LIST_NOT_MIRRORED when it names a list this instance has not mirrored — sync the workspace, or check the id. A create that identifies nobody — no name, no email, no phone — is refused 422 INCOMPLETE_CONTACT_CREATE. All three answer before anything is saved. Add raw.company_uuid to link the contact to the company they work at. list_record_types lists raw.list_uuid under required_inputs, and it is still gated by sync.bidirectional.getsales, which ships OFF — read writable_now before offering the create. company and tag remain edit-only.

  • Re-pace any unattended /mcp client — the allowance is now 2,000 requests per minute, up from 600. An automation that writes one row per call and sleeps to fit inside a window can cut that sleep to under a third, and finish the same work in roughly a third of the wall-clock. Nothing else about the endpoint changes: one bucket still covers reads and writes together, it is still counted per instance process rather than per connected client, and a refusal is still a 429 carrying Retry-After and the -32000 JSON-RPC envelope with the numbers repeated in error.data. Pace against X-RateLimit-Limit and X-RateLimit-Remaining on the responses you are already getting, not against this figure — you share the window with every other caller on the same process, and it is the headers that know who else is calling. The raise reaches an instance on its next upgrade.

  • Set a Zoho field Turbobase has no column for by writing raw.<ApiName> on an UPDATE to POST /v1/data/:recordType — and audit what your app already puts in raw on a Zoho-sourced row, because those keys now reach Zoho. Only the keys a write NAMES travel, under Zoho’s own api_name (raw.Industry, and the whole mirrored record is never echoed), and a key this organisation’s layout does not carry or that Zoho maintains is dropped while the rest of the write still goes. Where a mapped column and a raw key address the same Zoho field, set the COLUMN — it wins. If your app was using raw on a Zoho row as local scratch space, move that to a column or a table of your own before upgrading: it used to stop at Postgres and no longer does.
  • On a CREATE a raw key is NOT sent — a create carries no changed-field hint, so only the mapped columns (and a custom module’s cf_zc_* columns) travel. Set a field with no column of its own in a follow-up update, after the create has come back with its Zoho id.
  • Write to a Zoho custom module’s records by addressing the record type Turbobase mirrors it under (zc_<slug>) — updates and creates both. They were refused record type "…" is not writable; a create now comes back with the id Zoho minted, stamped onto the row. Two new refusals to branch on, both 422 with rejection.code: CUSTOMIZATION_NOT_ACTIVE while the module’s table is still being built (retry after the next reconcile — it is not a bad request), and SOURCE_NOT_CONNECTED when Zoho has been disconnected, which is terminal until someone reconnects it. Deleting a custom module’s records from Turbobase is still not supported.
  • Handle record_write_state.reason_code = 'write_settle_conflict' as “the record exists — do NOT save it again”. It is the one blocked ending where the write demonstrably landed: the connected system created the record and handed back its id, and only the step that links that id onto your row failed. The row therefore reads as unsynced over a record that already exists, and a re-save creates a second one in the customer’s account. The message names the id — render it, and suppress any “try again” affordance your screen offers on a blocked row for this code alone. Nothing is re-queued; someone has to set the row’s external id to the id in the message.

  • Send raw.repository as owner/repo on every GitHub issue you create through POST /v1/data/issue, with the title in subject — for example { "subject": "Widget spins backwards", "raw": { "repository": "acme/widgets" } }. A create missing either is now refused with 422 before anything is saved (NO_TARGET_REPOSITORY naming raw.repository, or INCOMPLETE_ISSUE_CREATE naming subject), and a repository that is not owner/repo is refused as INVALID_TARGET_REPOSITORY. Before, such a create answered 200 and the issue never opened. There is no default repository, and the one you name must be one the GitHub App installation selected. list_record_types lists both under required_inputs.

  • Read writable_now before writing one of the nine newly writable record types, and expect false until staff enable the source. zoho_crm‘s call, event, task, product, quote and quote_line, and getsales’ contact, company and tag, now publish a write posture to /v1/catalog/records and to MCP list_record_types. Every one is gated by sync.bidirectional.<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, never POST /v1/data/quote_line. Zoho publishes no line module: the items ride Product_Details on the quote’s own call, so quote commits header and lines in one atomic write and a direct line write is refused as not writable. Omit lines to edit the header alone; an empty array does the same thing and is NOT how you clear a quote’s items. Zoho reads an empty Product_Details as “delete every line”, so Turbobase never sends one — there is no way to clear a quote’s lines from here today.

  • Expect POST /v1/data/<recordType> to refuse a CREATE for getsales’ company and tag, and to accept an edit. GetSales files a new record in a list and its API requires that list’s id, which Turbobase does not mirror, so the create is refused with a sentence naming where the record does come from. Create it in GetSales and edit it here once it has been mirrored. A contact is the exception and can be created — see the note about raw.list_uuid.

  • Expect a Zoho CREATE of a quote, an event or a call to come back needs_input naming a field you did not send. Each needs a value Turbobase has no column for — a quote’s Subject, an event’s End_DateTime, a call’s Call_Duration and Call_Type — so Zoho refuses it with its own MANDATORY_NOT_FOUND rather than Turbobase inventing one. Editing a mirrored record is unaffected.

  • Re-read the record counts for zoho_crm’s call, event, task and product if your app displays them. Each now counts only its own rows rather than every row in the shared table it lands on, so a Zoho task reports the tasks instead of every activity.

  • Create a stock adjustment with POST /v1/data/inventoryAdjustment, lines in the same call. inventory_adjustment was read-only and is now writable end to end: the row persists against its own pg_row_id with external_id null, reaches NetSuite through the write path and settles to write_status = 'synced' carrying the id NetSuite assigned. Editing an already-mirrored adjustment works the same way, addressed by pgRowId. A CREATE must carry its lines array in the same request — NetSuite replaces the sublist in entirety and has no per-line endpoint, so a line-less create is refused with ATOMIC_WRITE_REQUIRED before anything is enqueued.

  • Send ONE quantity per line, and send the one you mean. A line may carry adjust_qty_by (move the count by this much) or new_quantity (set the count to this). If it carries both — which every row mirrored from NetSuite does, because NetSuite computes each from the other — the relative one is sent and the absolute one is dropped. To set an absolute count on a mirrored row, clear adjust_qty_by first. quantity_on_hand and estimated_total_value are NetSuite’s own readings and are never written back, so posting a row you just read is safe.

  • Ask your administrator for write:inventory_adjustment if your app creates one. It is in the default standard bundle from this release and existing users receive it on their next sign-in, but a hand-restricted user will not have it. delete:inventory_adjustment exists and is deliberately NOT in the default bundle — destructive operations stay with administrators. GET /v1/catalog/records now reports this record as bidirectional with a writeCapability, which is the string to name when a write is refused.

  • Give a lot- or serial-tracked line its inventory_detail. Same rule as the item sublist on an order: NetSuite refuses the whole record without the assignments, and an adjustment is the record that creates the quantity.

  • Pace an MCP client against the X-RateLimit-* headers on /mcp instead of a delay you fitted by trial and error. Every response from that endpoint carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (whole seconds), and a refusal adds Retry-After equal to -Reset. They were always sent; what is new is that they are published, so they are now something to rely on. The allowance itself is 2,000 requests per minute — one bucket shared by reads and writes, counted per instance process rather than per connected client, so a thousand-row import now finishes inside one window. docs/tenant/mcp-server.md carries the whole account and is held to the code by a fence, so read the number there rather than hard-coding this one.

  • Read a /mcp 429 out of error.data, not the old top-level message — this is a breaking change to that one response. The refusal is now the same JSON-RPC 2.0 envelope every other refusal on that endpoint returns: {"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":…,"data":{"limit":2000,"remaining":0,"reset_seconds":34}}}. The flat {"statusCode":429,"error":"Too Many Requests","message":"Rate limit exceeded, retry in 45 seconds"} it used to return is gone, so any client reading statusCode off the body, or parsing the seconds out of the prose, must move to error.data.reset_seconds. This matters most for a client speaking MCP through a library: it is handed a message and never sees an HTTP header, so the body is the only place it can learn the budget at all. Match on error.code === -32000, which is this refusal and nothing else.

  • Stop treating the edge rule as your instance’s only rate limit. /build-apps/reading-data/ said there was one limit and that Retry-After was never sent; both were only ever true of the Cloudflare edge budget. /mcp, /v1/batch, the auth endpoints and the webhook ingress each carry their own counter inside the instance on top of it, and those counters do report Retry-After and a live remaining count. The page now says which layer each statement is about.

  • Branch on 422 / UNKNOWN_COLUMN from POST /v1/data/:recordType when a save names a field the record has no column for — not on 500 write_failed. That refusal was only ever reaching apps correctly for a handful of record types; for every other one — every custom record, and invoice, entity, item — the same mistake came back as an opaque server error naming nothing, so there was no way to tell a typo’d field from an outage. It now answers { code: 'UNKNOWN_COLUMN', field, message } for all of them, naming the field and the record type, and the addressed row is left unchanged. Render field against the offending input rather than showing a retry; the same payload will keep being refused until the field is dropped or the column exists.

  • Retry a POST /v1/data/:recordType create with the same idempotencyKey and read the row off the response — it no longer fails. The documented guarantee (“retries that carry the same key dedupe and return the original row”) was not honoured on a create: the second submission of a key the first had already committed came back as a generic failure with the cause withheld, so a client retrying after a lost response failed on every attempt. It now answers with the row the first call created, in the same envelope a first-time write returns, and the record is not queued for the connected system a second time. If your app guards against that failure — skipping the retry, or treating it as fatal — you can drop the guard, provided the role making the call can also READ that record type: the row is re-read as the caller, so a role granted write without read still gets the old failure. A key that has not been used before is unaffected.

  • Sign a person in from a native app by registering at POST /oauth/register and naming resource=https://<instance>/rest/v1 on /oauth/authorize and /oauth/token. That resource indicator is new and it decides which credential you are handed: name it and /oauth/token returns the instance’s own session token — the same RS256 JWT a browser session carries — which /rest/v1 (as both apikey and Authorization: Bearer), POST /v1/data/:recordType and POST /v1/batch all accept, scoped to that person’s capabilities and scopes. Omit it and nothing changes: you get the brokered upstream token for the MCP server, exactly as before. The two are listed as resources_supported on /.well-known/oauth-authorization-server. The token lasts eight hours and renews with grant_type=refresh_token for as long as the person’s approval stands.

  • Register an https callback your app’s own domain claims — an iOS universal link or an Android app link — not a custom scheme. com.example.app:/cb is refused with invalid_redirect_uri, and that is what most mobile OAuth libraries default to, so set the redirect explicitly when you configure the client. A desktop or CLI client registers http on 127.0.0.1 or localhost, on any port, unchanged. The refusal message now names both accepted shapes.

  • Send the session token as Authorization: Bearer from a native app on POST /v1/data/:recordType and POST /v1/batch. Those two routes took the session cookie only; they now take the cookie or a bearer, because an app has no browser to keep a cookie in. Nothing else changed about them — same verification, same RLS, same refusals — and /v1/catalog/* still treats a bearer as a personal access token and still 401s a session JWT presented that way.

  • Expect /.well-known/oauth-authorization-server and POST /oauth/register to answer on an instance with the MCP server switched off. They used to 503 with the rest of /oauth/*. Mobile sign-in has no switch of its own and is not behind MCP’s, so discovery and registration are always available; an /oauth/authorize or /oauth/token naming the MCP resource — or naming none, which means MCP — still refuses 503 mcp_disabled, and the metadata document stops advertising the MCP resource while it is off.

  • Point people at Account → Connected apps rather than “MCP access”. The tab is renamed, because it now lists native apps beside MCP clients, and each row says which it is. The revoke action, its URL and its response are unchanged.

  • Send raw.channel and body on a create of slack:channel_message — POST /v1/data/channel_message now refuses the call when either is missing. Both were always mandatory and were enforced by the connector after the write had been queued, so a create with no channel answered 200 with a pg_row_id, nothing was posted, and the refusal surfaced minutes later on record_write_state as a held row that named no field to fix. It is now an immediate 422 carrying error.code (NO_CHANNEL / NO_TEXT) and error.field (raw.channel / body), no row is written, and nothing is queued. raw.channel takes a channel name or an id; it is the only key that addresses the post, so an id parked on another column — work_item_external_id — is stored and ignored. Same behaviour through write_record on the MCP data server, where it comes back as a tool error in the same turn. An app that already sends complete creates is unaffected; one that relied on the 200 must read the refusal on the call. Distinct from the existing CHANNEL_NOT_MIRRORED refusal, which is a channel name the workspace directory does not hold.

  • Read required_inputs for slack:channel_message on list_record_types instead of hard-coding what a post needs. The entry now publishes raw.channel and body beside ops, from the same declaration the write boundary refuses on, so an app that supplies everything published cannot be refused for something it was not told about.

  • Expect needs_input rather than a held row when a Slack post is refused for a missing channel on a path that skips the boundary (a conduit, a replay). record_write_state now reads NO_CHANNEL with raw.channel on fields and says what is missing, where it previously read “Syncing is held — your team has been notified. No action is needed from you.” with an empty fix sheet. The matching sync_failure row moves from the source_blocked bucket to source_validation.

  • Declare reads on a conduit that has to look something up, and read it with ctx.query(...). Each entry is { recordType, columns, by?, source?, maxRows? }, and the declaration is the whole boundary — a resource, a selected column or a filtered column that is not declared is refused by name, as are select=*, embeds and any operator outside eq, neq, gt, gte, lt, lte, like, ilike, is, in. q is one PostgREST path-and-query, the same shape defineProcess’s ctx.query takes. A conduit that declares nothing has no ctx.query at all, so nothing changes for one you have already written.
  • Cap a conduit’s reads with maxQueriesPerRun (default 8, max 32) and a per-read maxRows (default 50, max 500). The query budget belongs to the RUN, so a sequence shares one across every entry rather than earning a fresh budget per target.
  • Stop adding the type discriminator and the connection filter to a conduit’s reads yourself. The engine applies both to every ctx.query — a read of channel_message is narrowed to activity_type = 'message', and a read of a connection-scoped table to the firing connection — so a query that omits them is correct rather than over-broad.
  • A two-way conduit pair no longer needs your own loop break. The opposite-direction guard now recognises an echo by the master as well as by the id, and the cycle budget reads a record’s hop off the conduit_event ledger instead of trusting the sync seam, which always said zero. If you wrote a break of your own against the old behaviour, it is now redundant rather than wrong.
  • ctx.bindings[].externalId is populated for a replica binding once the write-back confirms it. It was always null before, because nothing called the stamp; branch on it rather than treating a bound source as unidentified.
  • Stop treating write_status = 'pending' as “still in flight” — a write that never reached the connected system now ends blocked. It used to stay pending for ever once the retries were spent, so a screen that waited for it to clear waited on nothing. Handle the two new record_write_state.reason_code values and render the row’s message: source_unreachable (the connected system could not be reached, so nothing was sent — the reader’s next save is what re-sends it) and write_failed_unexpectedly (the write failed in a way nothing recognised; the generic held text applies and the team has the failure).

  • Expect a blocked row on an UPDATE and a DELETE whose write was lost, not only on a create. source_response_lost previously stamped a create alone; an update or delete whose response never came back is now stamped with the same reason code once its attempts are spent, and its message is about whether the CHANGE landed rather than whether the record exists.

  • Expect 422 SOURCE_NOT_CONNECTED on a create naming a system your instance has not connected, if the only thing it had connected was Slack. The write boundary can only judge a source_system once it has seen at least one connection: with none recorded it cannot tell a wrong name from a right one, so it accepts every name. On an instance whose Slack workspace was installed before v1.21.0 nothing was recorded at all, and a create naming any system was accepted and left with nowhere to sync. The instance now records that connection by itself on its next restart, so the same create is refused, with the connected list in the message. v1.21.0 announced this for a workspace installed after it; this is the same change reaching the workspaces installed before it. Send a system from the connected list, or omit source_system to use the one your session signed in against.

  • Stop treating SOURCE_NOT_CONNECTED on a Slack channel_message as “the customer must reconnect their workspace”. On an instance whose workspace predates v1.21.0 that refusal was never true — the workspace was installed and mirroring — and reconnecting was the only remedy it implied. After the restart that repairs the connection, the same create is refused SYNC_BIDIRECTIONAL_DISABLED instead, which names the real gate: writing back to Slack is a per-tenant entitlement our staff turn on. Both are 422 and neither writes a row, so an app that only surfaces the message needs no change.

  • Match record_write_state.reason_code on source_changed_since_read, not WRITE_CONFLICT, for a connected item somebody else edited first. A write refused because the record moved in the connected system since Turbobase last read it now carries that reason code and a message naming what the reader can do — open the record again and re-apply the change. It was reaching apps as WRITE_CONFLICT with the generic held text (“your team has been notified. No action is needed from you.”), which was the wrong instruction: nothing was going to clear it except the reader.

  • Expect ONE record_write_state row per refused connected-item write, not two. A refused write used to open a second row for the same record under the source’s own id, saying needs_input where the row your app reads said blocked. A screen that showed both, or that took the first row it found, was showing whichever arrived first; there is now one row, keyed on the record’s pg_row_id.

  • Stop shipping Approve, Reject, Submit for approval or Mark cancelled as a live button if your app took it from the time bill, expense report or sales order recipe. All six wrote the row’s status, which NetSuite recomputes for itself and strips from every write — so the button reported success and the customer’s change never left Turbobase. They now ship as kind: 'noop_today', which the chat grammar already renders disabled, and each keeps its label so the operation is still advertised. An app that renders the grammar itself needs no new code; an app that hard-coded one of the six should disable it or drop it.

  • Check a patch_status value against the connected source before you ship the button. The rendered recipe action line now names the column the action writes and says the value has to be one that source accepts. Turbobase carries no list of accepted values for any source, so the record’s anatomy is where to look — and a column the source derives refuses every value rather than disagreeing with one.

  • Set a Zoho CRM deal’s Stage to a stage that deal’s own Pipeline carries. Zoho scopes the stage list per Pipeline, not per organisation, so a stage from another Pipeline is refused even though both deals sit in the same organisation. The refusal comes back with failure_cause: source_value_not_accepted naming Stage and the value sent.

  • Send template_external_id and raw.submitters on a create of esignSubmission, and body and raw.accounts on a create of social_post — POST /v1/data/:recordType now refuses the call when one is missing. These were always mandatory and were enforced by the connector after the write had been queued, so an incomplete create answered 200 and failed minutes later on record_write_state. It is now an immediate 422 carrying error.code and error.field (MISSING_REQUIRED_FIELD / EMPTY_TEXT / NO_ACCOUNTS), no row is written, and nothing is queued. An app that already sends complete creates is unaffected; one that relied on the 200 must read the refusal on the call. Same behaviour through write_record on the MCP data server.

  • Read what a create needs from required_inputs on list_record_types, instead of hard-coding it. Every entry carries the field paths a create requires beside ops — [] where none, never absent — and it is the same declaration the write boundary refuses on, so an app that supplies everything published cannot be refused for something it was not told about.

  • Call get_write_state when a write comes back parked. New read tool on the data MCP server: pass the pg_row_id write_record returned (or an external_id once the record has one) and it answers {found, status, reason_code, message, fields, attempts, updated_at} — the source’s own recorded reason and the fields to fix. It is present whether or not the instance has MCP writes enabled, because a parked row outlives the setting that produced it. write_record’s description already told callers to read the source’s diagnosis off the failure; this is the tool that does it.

  • Edit a DocuSeal signer with POST /v1/data/esignSubmitter addressed by pg_row_id or external_id. docuseal:esignSubmitter is now writable as an UPDATE — list_record_types reports ops: ["update"] for it — and the edit reaches PUT /submitters/{id}. A create is still refused, now with EDIT_ONLY and the reason, where it previously came back 400 "not writable": an app that branched on that status for this record type should read error.code.

  • Branch on write_status: 'needs_input' for an e-signature request whose template the connected account no longer has. That refusal used to arrive as blocked with the generic held text (“your team has been notified. No action is needed from you.”), which was the wrong instruction — the only thing that clears it is the reader changing template_external_id. It now settles needs_input with reason_code: 'source_value_not_accepted', a message naming the refused id, and a record_write_state.fields entry carrying { field: 'template_external_id', label: 'Template External Id', submitted: true, requested: '<the id sent>' } — so offer an edit and re-save rather than a support link. Every other e-signature refusal keeps blocked and the generic hold.

  • Stop editing a sent signature request — POST /v1/data/esignSubmission addressing an existing row now answers 422 APPEND_ONLY where it answered 200. It applies to both addressing keys, pgRowId and externalId, and the refusal arrives before anything is written locally. The e-signature provider publishes no update verb for a request that has gone out, so the write was always going to be refused — it was just being refused in the background, after your copy had already changed and stopped matching the document your counterparty was signing. Send a new request instead: the create is an ordinary POST and is unchanged. Changing a party on the request that already went out is done in DocuSeal, not from your app — esign_submitter is granted read only and POST /v1/data/esignSubmitter answers 400 record type "esignSubmitter" is not writable. If you branch on rejection.code, read this one too: on an instance where the DocuSeal write entitlement is off, that same addressed write used to answer 422 SYNC_BIDIRECTIONAL_DISABLED and now answers 422 APPEND_ONLY. The new code is the durable one — turning the entitlement on does not make the edit work — so branch on it rather than on the entitlement’s. A CREATE is unchanged and still answers SYNC_BIDIRECTIONAL_DISABLED while the entitlement is off.

  • Expect create alone in list_record_types for docuseal:esignSubmission. The record-metadata surface published create, update; an assistant that planned an update off that list was planning a round trip the write boundary now refuses up front.

  • Expect rows in source_file for a connected GitHub repository, where the table used to be empty. Every path on the repository’s base branch gets a row — path, ref, commit_sha, size_bytes — and a text file within the 1 MiB policy also carries content_text. Nothing about the table’s shape, its route or its read capability changed; an app that read it and rendered “no files” now has files to render, and one that skipped the table because it was always empty should look again.

  • Treat a source_file row whose storage_ref is null as content that has not arrived YET, not as a file with nothing in it. A newly connected repository gets all of its rows on the first sync and its contents a batch at a time over the following ones, at 200 files per sync and one sync every four hours — about 1,200 a day, shared across every repository on the connection. So a few thousand files is a day or two rather than minutes, and a screen that reads content_text right after a connect finds some of it null for that long. Design for the null instead of waiting it out: filter on storage_ref is not null when a surface needs the bytes.

  • Do not read content_text for a file over 1 MiB — there is not one, by design. Such a file still gets a row saying it exists and how big it is, with storage_ref and content_text null, so a repository’s file count is honest without the mirror carrying its generated artefacts and binary assets. content_sha256 may or may not be set on one — it is there when the file was read before the policy declined it — so storage_ref is not null is the predicate for “the bytes are here”, never the digest.

  • Read the five new keys on list_record_types instead of guessing what a record does. Every entry now carries direction (bidirectional or read_only), writable_now (whether this tenant can change it right now), ops (the operations actually reachable — create / update / delete, and [] where none is), summary (one line saying what the record is for) and prompt (the record’s anatomy prompt, passable to prompts/get verbatim). writable_now is the same answer GET /v1/catalog/records gives as writeCapability, so the two can no longer disagree. Nothing was removed — record_type, source_system, list_path and table are unchanged.

  • Name source_system on a write_record CREATE to choose which connected system the record is routed to. The parameter used to say it could not redirect the write; it always could, and that is how an app posts into a second connected system — Slack, say — from a tenant whose other records come from somewhere else. On an UPDATE it stays a hint used to find the row and is stripped rather than written, which is unchanged.

  • Send a standing cross-source flow to the extension-authoring server rather than looking for a record type for it. The data server’s initialize instructions now name /mcp/authoring and what the two extension kinds are — hooks and conduits — so “every new note goes into that channel” resolves to author_conduit there instead of a record write that does not exist. Nothing about the authoring server’s own surface changed.

  • Add ?mode=sync to POST /v1/processes/:id when your app wants the answer on the call. It returns 200 { runId, processId, state, reason, outstanding, steps } once the run settles, or 202 { error: 'sync_budget_exceeded', runId, … } when it outlasts the instance’s budget — which is not a failure, so watch GET /v1/processes/runs/:runId for the run that is still going. Branch on error, not on the status: in this mode a 202 is the one answer that does not mean finished. Naming no mode is unchanged: 202 and a run id.

  • Handle cancelled wherever you read a process run’s state. It joins PROCESS_RUN_STATES in @turbobase/extensions, so a switch over that union stops type-checking until you add it. A cancelled run is not a failed one: records it had already sent upstream are left in place.

  • Stop treating committed as “there is nothing left to do.” A run now carries outstanding; a run that took a slice of a migration ends committed with a non-zero count, and that is your signal to ask for the next slice. The platform never schedules one for you.

  • Read the queue with GET /v1/processes/queue instead of polling run ids one at a time. Optionally ?mode=async. It answers { runs, counts } with every failure attached to the run it belongs to, scoped exactly as the single-run read is.

  • Stop a migration with POST /v1/processes/:id/cancel (every queued run of that process, in one call, answering { cancelled }) or one run with POST /v1/processes/runs/:runId/cancel. A running run stops at its next boundary — the next step, and for a migration the next row — and reports the rows it never started in outstanding.

  • Declare eachRow: { outstanding, key } on a process that moves many records rather than invoking it once per record. outstanding returns one PostgREST query naming the rows still to migrate — re-run at the start of every slice, so write the predicate that excludes what is done — and key names the column identifying a row. Each step then reads its row from ctx.row, and the write’s idempotency key is derived from that row, so re-offering it is a no-op rather than a second record upstream.

  • A step’s awaitSource wait running out no longer fails the run. Nothing was refused — the write is on the trail and the source has not answered — so the run ends committed with the steps it never reached in outstanding and its reason naming the step still waiting. If you branched on failed to detect a slow source, branch on outstanding > 0 and the reason instead.

  • Read the tracked item types your customer actually stocks. turbobase.item now carries seven more item_type values — lotNumberedInventoryItem, serializedInventoryItem, lotNumberedAssemblyItem, serializedAssemblyItem, nonInventorySaleItem, otherChargeSaleItem, discountItem. A screen that filtered item_type=in.(inventoryItem,serviceSaleItem,nonInventoryResaleItem,kitItem,assemblyItem) was excluding most of a lot- or serial-tracked catalogue and will keep doing so; widen it, or drop the filter and let the row count speak. Nothing is renamed and no existing row changes. The tracked four only appear on a tenant whose NetSuite account has Lot-Numbered or Serialized Inventory enabled.

  • Create a warehouse, a department or a class with POST /v1/data/location (also /department, /classification). All three were read-only and are now writable end to end: the row persists against its own pg_row_id with external_id null, reaches NetSuite through the write path and settles to write_status = 'synced' carrying the id NetSuite assigned. Editing an already-mirrored row works the same way, addressed by pgRowId.

  • Ask your administrator for write:location / write:department / write:classification if your app creates one. They are in the default standard bundle from this release and existing users receive them on their next sign-in, but a hand-restricted user will not have them. Reads are unchanged and still need no capability — GET /v1/catalog/records continues to report readCapability: null for these three and now reports a writeCapability beside it, which is the string to name when a write is refused.

  • Tell your customer to take NetSuite bundle 2.9.0 before relying on the write. The bundle raises the Turbobase integration role from VIEW to FULL on locations, departments and classes; on an older bundle the row is accepted in Turbobase and the write back to NetSuite fails INSUFFICIENT_PERMISSION, surfacing as a sync_failure rather than as a refused save.

  • Read FeedHive posts from feedhive:social_post on activity, not from feedhive:post. /v1/catalog/records gains feedhive:social_post → table activity, readCapability: 'read:activity' (/rest/v1/activity?source_system=eq.feedhive&activity_type=eq.social_post). It is the writable FeedHive record — what Turbobase scheduled — while feedhive:post stays the read-only knowledge row on memory_document. An app that publishes or edits a post addresses the new one; an app that only reads published history is unaffected. Writes are gated per tenant by sync.bidirectional.feedhive, which defaults off, so expect a deterministic refusal until staff enable it.

  • Start a build by POSTing a keyless workflow_run to /v1/data/workflow_run. Send ref (the branch or tag), and repositoryFullName (owner/repo) plus workflow under raw. workflow is the workflow’s FILE NAME (ci.yml) or its numeric id — never the workflow_external_id your mirror holds, which is a GraphQL node id for any run that arrived by webhook and is refused. Add the workflow’s own declared inputs beside them if it has any. The run appears in automation_run and advances in place exactly as one started by hand does. Starting a run is the ONLY write on this record type: a patch addressing an existing run — re-run it, cancel it, approve its deployment — is refused, as is a delete.

  • Read writeCapability on /v1/catalog/records before rendering a “run this” control. github:workflow_run now reports write:automation_run for a tenant entitled to GitHub write-back and null otherwise, so an app can hide the control instead of discovering the refusal at the first press. The caller also needs the write:automation_run capability itself — it is in the default standard bundle and converges onto existing users at their next login, but a hand-narrowed role will not have it, and the refusal names it.

  • A write refused for a missing permission now NAMES it, on every record type — read the error string instead of guessing. POST /v1/data/:recordType used to answer a permission failure with a bare forbidden on a create, and on an edit with something that read as “no such row”, because the row the policy did not cover was simply invisible. It now answers 403 with record type "X" needs the write:Y capability for Z, and this session does not hold it, for every writable record type rather than only workflow_run. The capability it names is the same writeCapability that /v1/catalog/records publishes for that record, so an app can surface “ask your administrator for write:Y” verbatim. Nothing that used to succeed now fails — the check asks exactly what the database already asked, including honouring a Data admin’s write:*; only the message changed. If you match on the old response, match on the status and the named capability instead.

  • Write to a table your instance OWNS at POST /v1/data/{recordType}, using the table’s own name, and send source_system: "standalone" on a create in any table that is shared with a connected system. A table an administrator created in the console (no counterpart in NetSuite, Stripe or any other connected system) used to answer 400 record type "…" is not writable on every write, delete and MCP write_record — the row could be read and not created. It now takes a create, an update by pg_row_id and a DELETE /v1/data/{slug} under exactly the read / write / delete permissions the permissions surface already grants per role, and its rows are never queued to any source. The same is true of a row you create YOURSELF in a table that is otherwise mirrored — a local customer in entity, a local GL account in account — and that is what source_system: "standalone" asks for. It is never a fallback: a create that names nothing behaves exactly as it does today, including being refused when the instance’s write-back to that system is off.

  • Stop pointing a record that belongs to a connected system at a local one. A create whose entity_pg_row_id (or a dimension reference) names a standalone row is now refused 422 REFERENCED_RECORD_IS_LOCAL, naming the field. Before this the write was accepted and then waited forever for a source id that is never issued, reporting nothing. Point it at a record the source knows, or send source_system: "standalone" so the whole record stays local. Linking to a record you created moments ago in the SAME system is unchanged and still works while it is pending.

  • Expect 403 and 404 from POST /v1/data/{recordType} where you used to get 500 write_failed. An update whose key matches no row you may write now answers 404 {"error":"row not found or not permitted"}, and a write your permissions refuse answers 403 {"error":"forbidden"} — the same two answers the MCP tools already gave. Nothing that returned 200, 400, 409 or 422 changes.

  • Re-read writeCapability from /v1/catalog/records before you branch on it — twenty-two entries that published one now report null. Each declared a write its adapter cannot perform and that POST /v1/data/{recordType} already refused with record type "…" is not writable, so the field is now computed from the write boundary as well as the adapter’s declaration. Nothing that worked stops working; an app that trusted the field stops attempting a write that could never land. Three change on every instance, because Stripe’s write-back is ungated: stripe:product, stripe:paymentIntent, stripe:refund. The other nineteen only ever published a capability on an instance where the source’s write-back had been turned on, and are jira:issue, jira:sprint, jira:worklog, jira:comment, jira:issue_link, asana:asanaProject, asana:asanaTask, billdotcom:bill, billdotcom:billPay, dynamics-365-bc:order, dynamics-365-bc:vendor_bill, dynamics-365-bc:purchase_order, dynamics-365-bc:journal_entry, zendesk:ticket, zendesk:ticket_comment, github:label, github:discussion, github:file and docuseal:esignSubmitter.

  • Write a Slack channel_message from an app or an outside AI client on any workspace-installed instance — the create is no longer refused SOURCE_NOT_CONNECTED. Slack’s connection was never registered, so every generic question the platform asks — is slack connected, may this instance create a channel_message, does the catalog list it — answered no on an instance that was plainly mirroring Slack channels and answering in them, and the documented payload could not work. Completing the Slack workspace install under Settings now registers the connection, and an instance connected before this converges to the same row with no re-authorisation. Nothing else about the payload changes. One behaviour change comes with it: on an instance whose ONLY connected system was Slack, every create naming any system was accepted, because the boundary treats “no connections known” as “cannot judge”. Now that Slack is known, a create naming a system this instance has not connected is refused SOURCE_NOT_CONNECTED with the connected list in the message — correct, and new.

  • Put a channel NAME in raw.channel if you want to, and expect a synchronous 422 when the name is unknown. raw.channel on a channel_message now takes #general, general or Slack’s <#C0GENERAL|general> markup as well as a channel id, resolved against the mirrored directory before the post. A name the directory does not hold is refused at the write boundary with rejection.field = "raw.channel" — where it previously returned 200 and failed asynchronously as channel_not_found in record_write_state, which is the shape to stop coding around. A channel id passes through untouched, mirrored or not, so keep sending an id whenever you have one.

  • Read a Slack channel, a Slack person and a Slack reaction as ordinary records, and re-run turbobase project types. /v1/catalog/records gains three entries: slack:channel on the new channel table, slack:user on entity (entity_type = 'contact', read:entity:contact), and slack:reaction on the new reaction table. A Slack person whose Slack profile email matches a contact mirrored from another system resolves to the same entity_canonical, so your people views stop showing them twice. channel carries no read capability and reaction carries read:reaction, but for a SLACK row neither is what decides: a Slack row is visible exactly as far as that channel’s messages are, so a private channel a user is not in returns no row rather than an error.

  • Read reactions from /rest/v1/reaction, not from the message’s raw.reactions — that snapshot is gone. It was only ever accurate as of the last read of the message, so it disagreed with the workspace the moment anyone else clicked an emoji. One row per {channel}:{ts}:{user}:{emoji}, fed by the reaction_added / reaction_removed events, which makes “which messages did this person react to” a query. There is no history: the table starts at the first reaction this instance receives.

  • React from an app — adding an emoji only; taking one off is not available yet. POST /v1/data/reaction with source_system: 'slack', raw.channel, raw.ts and raw.reaction calls reactions.add. Deleting the row does NOT call reactions.remove — the delete boundary refuses it, because Slack is not one of the sources cleared for delete-to-source, so do not build a flow that un-reacts through this instance. An emoji removed IN SLACK still reaches you: reaction_removed arrives as an event and the row goes away, which is unchanged by this. Adding is behind the same sync.bidirectional.slack staff switch (default OFF) and the same operator guardrails as a post, and refused the same deterministic way by both. Ask an administrator for read:reaction and write:reaction — neither is in the capability bundle a standard user is seeded with, so on that session /v1/catalog/records omits slack:reaction altogether and a write to the table is refused. An admin, or any role holding read:* / write:*, already has both. Reading the rows through /rest/v1/reaction is unaffected either way: a Slack row answers to the channel rule above, not to a capability.

  • Ask an administrator for the new mcp:write permission when your script or unattended agent has to CHANGE data, not only read it. A personal access token carrying mcp:data reaches the data MCP’s read tools; adding mcp:write beside it reaches write_record and delete_record as well, and those run the same write boundary POST /v1/data runs — your hooks, your guardrails, the token owner’s own row-level permissions, and the normal queue onward to the connected system. It is ticked separately, is refused if requested without mcp:data, and Edit on the token’s own row at /settings/tokens adds it to a token you already hold without changing its secret. Two things must both be true for a write to land: the token carries mcp:write, and the instance’s Allow MCP clients to change data setting is on — the two refusals stay distinguishable, so a refusal says which one to fix. /rest/v1 is unaffected and still refuses a token outright.
  • Correct the sentence about tokens and writing in your project’s CLAUDE.md. The scaffold turbobase apps init writes said a token carrying the data permission “can never write, whatever the instance’s MCP write setting says”; as of this release that is false, and a project scaffolded before it still carries the old wording. Re-read it from a fresh init or correct it in place — an agent session that believes the old sentence will not ask for the permission it now needs.
  • Send source_system in parent when you CREATE a record on an instance with more than one system connected, and expect a 422 SOURCE_NOT_CONNECTED if you name one that is not. POST /v1/data/:recordType used to take the target system from the session — which is whichever system was connected first — so only that one was writable and a create naming another was accepted and left with nowhere to sync. Each write is now routed by the record itself. On an UPDATE source_system is VERIFIED against the addressed record and never written onto it: restating the value you read back still works, a different one is a 422 SOURCE_MISMATCH, and a record cannot be moved between systems by editing it. Editing a record whose system has since been disconnected is refused with SOURCE_NOT_CONNECTED naming that system rather than silently saved. Omitting source_system is unchanged on a single-system instance.

  • Name any connected system in the MCP write_record tool — record_type: "zoho_crm:account" from a session signed in against another system now reaches the write boundary instead of being refused SOURCE_MISMATCH. delete_record is unchanged and still limited to the system the session signed in against.

  • Read rows from a script or an unattended agent with an access token, which you could not do before. Ask an administrator to mint a personal access token carrying the new mcp:data permission, or to add it to one you already hold — Edit on the token’s own row at /settings/tokens changes what a live token may do without changing its secret, so nothing has to be re-pasted. Present the token as a bearer at the instance’s data-MCP endpoint (<base-url>/mcp, the turbobase server in the .mcp.json init writes, which authenticates over OAuth today). Reads run as the administrator who minted it, under that person’s row-level permissions, and the session behind them never reaches your client. Three things that are not changing: it is never granted by default and has to be ticked deliberately; a token carrying it can never write, whatever the instance’s MCP write setting says — writes are still POST /v1/data as a signed-in person; and PostgREST still refuses a PAT outright (PGRST301), so /rest/v1 is unaffected. The app CLAUDE.md turbobase apps init writes said in terms that no token could read data; a project scaffolded before this release still carries that sentence, so re-read it from a fresh init or correct it in place.

  • Edit a QuickBooks bill, customer payment or bill payment from your app with POST /v1/data/bill, POST /v1/data/payment and POST /v1/data/billPayment — all three answered 400 record type "bill" is not writable before this release, on a QuickBooks-connected instance, and an edit made anywhere else stopped inside Turbobase without reaching QuickBooks. Send canonical columns on the table each one lives on: vendor_bill for bill (entity_id / entity_name is the vendor, plus tran_id, tran_date, due_date, memo and its vendor_bill_line rows), customer_payment for payment (entity_id is the customer, total is the amount, and applied_to names the invoices it pays), and vendor_payment for billPayment (entity_id is the vendor, total the amount). Creating a bill payment from a row is still refused MISSING_LINES — vendor_payment carries no column naming the bills being paid, so update an existing one rather than creating one. Record types on that connection that Turbobase has no mapper for are still refused by name, and nothing changes for any other source — a Bill.com or Xero bill is refused exactly as before.

  • POST /v1/data/item is newly accepted on a QuickBooks-connected instance too, and it used to answer 400 unknown record type "item" — a different refusal from the three above, because item was already open at the write boundary and the route could not work out which table it meant. Send item columns (item_id, display_name, sales_description, base_price, is_inactive). On a create, set item_type yourself: QuickBooks keeps every item under the one item record type, so Turbobase cannot infer the sub-type for you, and item.item_type is NOT NULL — omitting it fails the insert rather than returning a field-level 422. An update addressed by pgRowId or externalId is unaffected; the stored sub-type stays as it is.

  • Expect a QuickBooks bill, payment, bill-payment or item edit to finish settling instead of staying hybrid. Those four never marked the row synced once the write landed, so write_status sat unsettled and the QuickBooks id was never written back to external_id. If your app reads write_status to decide when an edit is done, or keys off external_id appearing, those now resolve on the same schedule as every other QuickBooks record.

  • On ANY CRM tenant, read the businesses you sell to from customer rather than company, and hold read:entity:customer. A Zoho CRM or Salesforce Account, a HubSpot, Intercom or getsales company and a Zendesk organization are all businesses you sell to, so all six now project onto turbobase.entity as entity_type='customer': they are served by /rest/v1/customer and read with read:entity:customer, and the company surface they used to sit on is retired (see below). /v1/catalog/records reports each of them under read:entity:customer with the predicate entity_type=eq.customer, so re-running turbobase project adopt and regenerating types is enough for anything that reads the catalog. Rows already mirrored are moved by the upgrade, together with the entity_type half of every reference pointing at them — a Deal, Case, Quote, Sales Order, Order or Ticket join written against ('company', entity_id) has to move with them. The upgrade carries the capability itself only in stored Zendesk permission sets — a custom role is not widened onto read:entity:customer (see the retirement note below).

  • If your app EDITS a Zoho CRM or Salesforce account through /rest/v1/*, it now needs write:entity:customer instead of write:entity:company. The write policies on turbobase.entity are armed per entity_type the same way the read policy is, so the capability the row answers to moved with its discriminator. write:entity:customer is in the default standard bundle and write:entity:company never was, so this only touches a custom role that was granted the company capability by hand — and the upgrade does NOT carry it: that role loses write:entity:company, and an admin has to grant it write:entity:customer if it should keep editing those accounts.

  • A Zendesk connection’s read scope now grants read:entity:customer in place of read:entity:company. Permission sets already stored for a Zendesk connection are carried across by the upgrade, so there is nothing to re-connect.

  • Read /rest/v1/customer, not /rest/v1/company — the company entity type is retired on every tenant, and re-run turbobase project types. Once the six connectors above moved, nothing wrote entity_type='company', so the value is removed rather than left as a surface nothing fills: the turbobase.company view behind /rest/v1/company, the read:entity:company, write:entity:company and delete:entity:company capabilities, and the RLS policies that checked them are gone. Every row still carrying the value — including rows your app wrote there itself — is re-labelled customer by the upgrade, never deleted, together with every reference pointing at it. Grants are carried only for stored Zendesk permission sets, which match Zendesk’s new scope mapping. A custom role that relied on read:entity:company, write:entity:company or delete:entity:company loses it and gains nothing in its place: if that role should keep seeing those accounts, an admin grants it read:entity:customer / write:entity:customer, knowing that reads every customer — the finance system’s included — and not only the former companies. A request to /rest/v1/company or a SQL query naming turbobase.company now fails instead of returning rows — point it at turbobase.customer and filter on source_system if you only want one connector’s rows.

  • POST /v1/data/comment on a GitHub connection to reply to an issue, pull request or discussion. Send work_item_external_id (the parent’s node id) and body, with no pgRowId or externalId — the reply lands on activity as write_status = 'pending' and picks up the id GitHub mints when it is posted. Creates only: addressing an existing reply is refused 422 APPEND_ONLY, and DELETE /v1/data/comment stays refused. The session needs write:activity.

  • Send a signature request with POST /v1/data/esignSubmission, signers in raw.submitters. The route answered 400 record type "esignSubmission" is not writable before this release. Send template_external_id (required) in parent, and the parties as parent.raw.submitters — a list of { email, name, phone, role, source_external_ref } — because esign_submission has no submitters column and a top-level key is still refused 422 UNKNOWN_COLUMN. You get back a pg_row_id with external_id null: watch that row for write_status pending → synced and the provider’s id filling in, then read each party’s signing_url off esign_submitter. The caller needs write:esign_submission, which is deliberately NOT in the built-in Standard role — an admin grants it per user — and the whole capability stays behind sync.bidirectional.docuseal, which ships off and refuses with BIDIRECTIONAL_DISABLED. esignSubmitter is unchanged: still no standalone create. Full page: Embedding a signing page.

  • Filter activity_type = 'comment' if you list a GitHub work item’s conversation from /rest/v1/activity. Transition rows — who closed it, who it was assigned to, what it was renamed to — now land on the same table against the same work_item_external_id, so a query that selected everything for an item gets them too. Their activity_type is one of status_change, assignment, labeling, planning or rename, body is a ready-to-render one-line summary (added the label bug, renamed to …), and raw.event carries GitHub’s own verb.

  • Re-run turbobase project types — /v1/catalog/records gains github:issue_event, backed by activity and read with read:activity.

  • Expect FEWER /rest/v1/* rows on a GitHub connection if your app runs as a repository-scoped principal. Rows written by the backfill walk — issues, pull requests, comments, labels, milestones, Actions runs — previously carried an empty repository scope slot, which is visible to every principal regardless of their scope; they are now stamped with the repository they came from and are filtered like everything else. An app that relied on seeing rows from repositories its principal is not scoped to should ask for that scope. Rows already mirrored keep their empty slot until they are next walked.

  • Read the new applied.source_filter on every MCP list_records / get_record answer to know whose rows you were given. On a table several connectors publish onto — entity, memory_document, activity, work_item and 34 others — a read the source DECIDED is now narrowed with source_system=eq.<source>, and that key says so (null when nothing narrowed). It applies when you pinned a source (source_system, or the source:record_type form) or when only one connected source publishes the name, so list_records {record_type: "post"} returns FeedHive posts and no longer the meeting notes sharing memory_document. A BARE name that several connected sources publish is deliberately still unnarrowed: those answers now carry a warnings line naming the sources and the qualified forms that separate them — pin one wherever your app assumed a single connector was answering. get_record’s applied.source_system changes meaning with it, from the source the NAME resolved to, to the source YOU passed (null when you passed none), which is what list_records has always meant by that key.

  • Drop any stored MCP page.next_cursor and restart that walk from its first page. Cursors minted before this release are refused — “cursor does not match the current query … Start the walk again with no cursor” — because a cursor hashes the predicates the server applied and the source predicate is new. Re-seeking one against the narrower set would silently skip every row that sorted before the last row of the old mixed page, so the refusal is the safe outcome and there is nothing to fix but the stored value. Cursors minted after the upgrade are unaffected.

  • Edit and create a CRM lead with POST /v1/data/lead. Every save of a lead was refused new row violates row-level security policy for table "entity" before this release — on a Zoho CRM or Salesforce connection, for every caller including an administrator — while /v1/catalog/records advertised writeCapability: 'write:entity:lead' throughout, so an app that trusted the catalog built an editor that could not save. Send entity columns (display_name, first_name / last_name, company_name, email, phone, status_name) and leave entity_type to the route. Handle the refusal first: until an operator switches on sync.bidirectional.zoho_crm / sync.bidirectional.salesforce for that instance — and both ship OFF — the POST itself is rejected, 422 validation_failed carrying rejection.code: "SYNC_BIDIRECTIONAL_DISABLED", and no row is touched. That entitlement is the first thing consulted, so everything below decides nothing until it is on; an editor that treats a 422 as a bad payload will tell the user to fix a field when the answer is to ask their operator. Once it is on, the caller needs write:entity:lead, plus read:entity:lead to read the row back from /rest/v1/entity — two separate grants — and the lead must be inside the caller’s subsidiary scope, or Postgres refuses it. Do not tell a customer’s admin to grant one user lead access: no control in the product does that. Neither capability is in the built-in Standard role, and the role editor’s record grid has no lead row, so the only holders today are an administrator and a Data admin (read:* / write:*) — both far wider than leads. Declare the grant your app needs instead: a roles entry in your public/turbobase.json carrying read:entity:lead and write:entity:lead is created as an ordinary role when you deploy (see v1.5.1), and the admin assigns it in Admin → Users. DELETE /v1/data/lead is still refused 400 record type "lead" is not writable — delete a lead in the CRM.

  • Ask your assistant to search the customization catalog over the DATA MCP endpoint (/mcp), instead of wiring up the authoring endpoint to do it. search_catalog (“which script handles rebates?”) and explain_object are now on both endpoints and answer identically. On /mcp they need no personal access token — the OAuth sign-in your client already has is the credential — so a project whose .mcp.json carries only the data endpoint no longer hears “there is no tool for that” when it asks about a script or a custom record. Both are instance-admin only on /mcp, and a non-admin caller is not offered them at all rather than being refused mid-call. Nothing changes on /mcp/authoring: its copies, its catalog:read scope and the scaffold turbobase project init writes are all unaffected.

  • Handle source_value_not_accepted as a reason_code on a needs_input record_write_state row, and render message — it now quotes the value. It means the connected system refused one named field because the value the record holds is not one its own configuration carries: a picklist option the customer renamed or retired, a choice their layout no longer offers. It arrives where a Zoho CRM MAPPING_MISMATCH refusal used to — the row is still needs_input, because changing the value is what makes the write land — and what moves is that reason_code is now the cause instead of the connected system’s own code, and message is plain language naming the field and the value that was refused. The connected system never repeats the value (Zoho’s whole sentence is “mapping mismatch”), so this is the only place it appears; a row whose value could not be quoted names the field alone rather than inventing one. fields carries the same field with submitted and requested beside it, so ordinary fix-sheet rendering works and an app can show what was sent without parsing the sentence. An app matching on reason_code === 'MAPPING_MISMATCH', or rendering the old message, sees neither after this release.

  • Read a customer’s signature requests from the new esign_* tables, and re-run turbobase project types. /v1/catalog/records gains docuseal:esignTemplate, docuseal:esignSubmission and docuseal:esignSubmitter, and /rest/v1/esign_template, esign_submission and esign_submitter serve them under RLS to any standard user — the three read capabilities are in the built-in Standard role. The tables are named for the RECORD CLASS rather than for DocuSeal, so an app written against them keeps working if the instance later mirrors a different e-signature provider. Nothing appears until an operator enables DocuSeal for that instance and the customer connects it.
  • Mount a signing page from esign_submitter.signing_url, not from raw. It is a promoted, typed column precisely so an app can read it, and there is deliberately NO Turbobase embed endpoint and no client helper: read the column and mount DocuSeal’s own widget on it. Treat the value as a capability — anyone holding it can act as that party, so render only the URL belonging to the person who is signed in, and never log it. Check status and the parent submission’s expires_at before mounting: an already-signed or expired request loads a page that quietly will not submit. Full page: Embedding a signing page.
  • Expect a write to esign_submission / esign_submitter to be REFUSED until an operator turns it on. Both record types are bidirectional, and every write is held shut per-instance by sync.bidirectional.docuseal, which ships off — the refusal is BIDIRECTIONAL_DISABLED. When it is on, two shapes are fixed and worth building around: a submission can be created and never edited, and a submitter can be edited and never created on its own. esign_template is read-only in every case.
  • Write a GitHub issue or pull request from your app with POST /v1/data/issue / POST /v1/data/pull_request — both answered 400 record type "issue" is not writable before this release, on a GitHub-connected instance. Send canonical work_item columns: subject retitles, status (open / closed) closes and re-opens, labels is the WHOLE label set (send the set you want the item to end with — Turbobase works out which to add and which to remove, and labels you leave in place are not touched), and assignee_id assigns or, set to null, unassigns. Sending assignee_name beside assignee_id saves a lookup and is not required. Other record types on that connection are still refused by name, and nothing changes for any other source.

  • Handle a record_write_state row with reason_code = 'WRITE_NOT_APPLIED' alongside 'WRITE_CONFLICT' when you surface GitHub write failures. A write that could not be carried out — an assignee Turbobase cannot resolve to a person in GitHub, a label set it could not read back — now lands needs_input with its own plain-language message, instead of reporting success and being reverted by the next sync. Nothing on the item is changed when this lands: show the message and let the customer re-try or make the change in GitHub. 'WRITE_CONFLICT' keeps its old meaning, which is somebody-else-changed-it-first.

  • Read work_item.description for the body of a GitHub issue or pull request — it was always NULL for GitHub rows and is now projected on every delivery, so an app that fell back to raw->>'body' can read the typed column. A description written from Turbobase now reads back the same on the next sync.

  • Read a GitHub project board from the new board family instead of decoding card order and dates out of label names, and re-run turbobase project types. /v1/catalog/records gains github:project and github:project_item, and /rest/v1/board, board_field, board_card and board_card_field_value serve them under RLS to any standard user — the four read: capabilities are now in the default bundle and reach users who already exist (a holder of the built-in Standard role gets them when this release’s migration runs; a user carrying their own bundle picks them up on next sign-in). Read a whole board in one request with board?select=*,board_field(*),board_card(*,board_card_field_value(*)) — no constraint hint needed. Sort a column with board_card?status_option_id=eq.<id>&order=position: position is an ORDER and not a count, so expect gaps, because a card in a repository the installation was never granted is refused and still holds its place. A card is NOT the issue it points at — join board_card.content_external_id to work_item.external_id, and do not expect a board’s Status on the work item, which is deliberately never written (one issue can sit on two boards). Read per-board custom fields from board_card_field_value in the column matching the field’s data_type (value_text, value_number, value_date, value_option_id/value_option_name, or value_json for a multi-valued one); board_field.options is an ORDERED array, so render it in that order rather than sorting it. Read-only for now, and boards appear only once an organisation owner has accepted the App’s new Projects read permission — the connection Health tab reports projects_permission when they have not.

  • Post to Slack by writing a channel_message through POST /v1/data, and re-run turbobase project types. /v1/catalog/records gains slack:channel_message against the activity table (read:activity), and a row written there is posted into a Slack channel. Set source_system to 'slack' on the row you write — turbobase.activity is shared by six connectors, so a row that does not name Slack is stamped with your session’s own source, is never dispatched, and posts nothing with no error to show for it. Put the channel in raw.channel — required; that shared table carries no channel column — the message in body, and, to reply inside an existing thread rather than start a new one, the parent message’s ts in raw.thread_ts. Since v1.21.0 raw.channel takes a channel name (#general, general, or Slack’s <#C0GENERAL|general> markup) as well as an id, and a name the mirrored directory does not hold is refused synchronously rather than failing later; an id still passes through untouched. Also since v1.21.0, completing the Slack workspace install under Settings is what registers the connection the write boundary checks — before that this whole payload was refused SOURCE_NOT_CONNECTED on every instance. Once Slack accepts the post, external_id on your row becomes '{channel}:{ts}' — that is what comes back on the row. The permalink and the thread it landed in are handed to a tenant after_sync hook as __result.rewrittenFields and are not written to the row. Nothing reaches Slack until staff turn on sync.bidirectional.slack for the tenant (default OFF), and the workspace’s bot token needs the chat:write scope the read-only install does not grant — the connection’s Health tab now says which. A write refused by either is a deterministic refusal in record_write_state, not a retry. Slack’s other record type, slack:message, is channel history in memory_document and is still read-only.

  • Join a work item’s conversation on activity.work_item_pg_row_id, not only work_item_external_id, and re-run turbobase project types. /rest/v1/activity gains a nullable work_item_pg_row_id holding the parent’s work_item.pg_row_id. It is the ONLY pointer a card created in Turbobase has — work_item.external_id is null until a source mints one — so a conversation view keyed on the text column alone silently shows nothing on those cards. The text column is unchanged and still set whenever the parent has an external id, so an existing join keeps working; add the new one where you render replies. Slack is the first writer: a reply in a thread bound to a work item lands as an activity row with activity_type='comment' beside the memory_document the message already produced.

  • Read GitHub Actions runs from the new automation_run table, and re-run turbobase project types. /v1/catalog/records gains github:workflow_run, and /rest/v1/automation_run serves it under RLS to any standard user — read:automation_run is now in the default capability bundle, and it reaches users who already exist: a holder of the built-in Standard role gets it when this release’s migration runs, and a user carrying their own capability bundle picks it up on next sign-in. One row per run for its whole life: filter status=eq.in_progress for what is running, conclusion=eq.failure for what broke, and page on started_at. Both those columns are LOWER-CASE where GitHub’s API shouts them, conclusion is NULL while a run is still going, and a re-run bumps run_attempt on the same row rather than adding one — so counting rows counts runs, not deliveries. Jobs, steps, logs and artifacts are not mirrored; permalink_url is the link to them.

  • Call POST /v1/processes/{id} — with the process’s input as the JSON body, under the app’s ordinary session — to run a multi-record sequence on the server instead of orchestrating it from the browser. It answers 202 {runId, processId, state: 'queued'} before the first step is written; the ordering, the wait for the source’s verdict between steps, the validation through the same write boundary as POST /v1/data, and the undo when a step fails all happen on the server and survive a worker restart. Refusals never carry a runId: 404 {error: 'process_not_found'} for an id nothing deployed, and 400 {error: 'invalid_input', rejection: {code, field, message}} when the body fails the process’s declared schema. Poll GET /v1/processes/runs/{runId} for state (queued → running → committed | failed | rolled_back) and one entry per step with its disposition and, on a failure, the refusing party’s own code/field/ message; the same rows are readable through PostgREST as process_run and process_run_step, scoped to the invoking user.

  • Import defineProcess from @turbobase/extensions (or the @turbobase/extensions/process subpath) to author one, and scaffold it with turbobase ext new process --record <type> [--then <type>] [--on-failure rollback]. A process declares a JSON-Schema input and ordered steps, each a pure build(ctx) returning the {parent, lines?} payload for one record type; ctx.query(q) reads as the invoking user exactly as POST /v1/batch does. ext deploy refuses a module whose steps are not a list, whose step has no build, or whose step names a record type the write boundary would not accept, naming the field.

  • Expect two writes of the same record type to reach the connected system one at a time, in the order you made them — the outbound worker now serialises same-type writes per source, for every writer, so a system that numbers documents per type can no longer hand one number to two of them. A second same-type write waits for the first’s round-trip; writes of different record types do not wait for each other. Nothing changes on the request: POST /v1/data still answers as soon as the row is committed locally.

  • Share a rule between two extension files with a relative import (import { requireMemo } from './shared-rule.js') instead of carrying two copies. A sibling inside the extension’s own directory is folded into the module by ext test, ext lint, ext deploy and the whole-project deploy; an npm package, a Node builtin, or a path that escapes the directory is still refused with the same message as before.

  • Declare sequence: [{ to, transform, awaitSource? }, …] on defineConduit — instead of to + transform — when one inbound record has to become several records in another system in a fixed order. Entries run one after another on the server; each later transform reads the ones already landed off ctx.materialized ({ source, recordType, masterTable, masterRowId, externalId }, in order), and awaitSource: true on an entry holds the next one until the source has minted that entry’s id so externalId is set rather than null. Every target is checked for writability on its own at ext deploy (conduit_target_read_only names the entry’s pair; an awaitSource against a record type with no write trail is conduit_target_no_write_trail, and against a target of the firing record’s own type — which merges rather than mints — is conduit_target_await_same_type). A failing entry parks its own ledger row, skips the rest with earlier target failed, and undoes nothing already materialized — use a process with onFailure: 'rollback' when you need the undo. ext replay prints one transform block per target. A single to + transform conduit is unchanged, and ctx.materialized is [] there.

  • Call POST /rest/v1/rpc/record_change_history — passing the canonical record type and the record id — to show who changed a record, instead of reading turbobase.system_note, which hands every non-admin session an empty array. You get one row per recorded field change on that one record: field, old_value, new_value, changed_at, actor, is_own_traffic (Turbobase’s own write-back is returned, flagged, not hidden), plus line_index and line_pg_row_id — both null on a body-level change, and on a line-level one already resolved to the mirror line row, so you never have to know that item_receipt_line, journal_entry_line and order_fulfillment_line spell the ordinal line_number while every other line table spells it line_index. Branch on the two answers rather than rendering both as “no history”: a session lacking the record’s own read capability gets an error naming that capability (read:order, read:item_receipt, …), while a record nothing has ever edited comes back empty with no error. A record the session may not see — outside its scope, or deleted upstream — is also empty, exactly as reading that record’s own table would be for them: the call never reveals a record their other reads hide. And on a tenant holding two connections of one source, where an id exists under both — two NetSuite accounts reuse internal ids — each change is matched to the account it came from and gated against that account’s own record, so your user gets the history of the record that is theirs and none of the other’s; if neither is theirs the answer is empty, and if both are, both records’ changes come back as one list, because the call names a record id and not a connection. Prefer “no changes are visible to you” over “this record has never changed” when you render the empty case. Both arguments are required and neither has a default — there is no call that spans more than one record — and the account-wide system_note table stays admin-only.

  • Handle 422 {error: 'validation_failed', rejection: {code: 'INVALID_UUID', field: 'pgRowId', …}} from DELETE /v1/data/{recordType} where a 500 {error: 'delete_failed'} came back before. A pgRowId that is not a Turbobase row id is now refused at the boundary and names the field, instead of reaching Postgres and escaping as a generic server error that quoted your value and named no key — the same refusal POST /v1/data/{recordType} already gives for the same mistake. No delete that succeeded before stops succeeding: a value Postgres could not parse as a uuid addressed no row either way. To address a row by the source system’s own id, send it as externalId, which is free-form text and is never screened.

  • Add connection_id=eq.<id> to any read of system_note that filters on external_id, and re-run turbobase project types. The change-audit trail now carries the same per-connection discriminator every other mirrored table already had, and its unique key is (connection_id, source_system, external_id) — a source’s own note id is unique only WITHIN a connection, because NetSuite numbers systemnote per account. So on an instance holding two connections of one source, system_note?external_id=eq.1001 returns two rows where it returned one. The table’s primary key also moves from external_id to pg_row_id, which is the column to page on and the one to persist as a reference. Every existing row keeps every value and reads back under the permanent default connection id, so a single-connection instance sees no change beyond the new column. system_note is gated at RLS on the admin / data-admin read:* wildcard, so this reaches your app only if it runs on such a principal.

  • Add source_system=eq.<source> to any read of entity, invoice, vendor_bill, credit_memo, vendor_credit, customer_payment, vendor_payment, purchase_order, account, item, journal_entry, check or deposit that assumed one accounting system — Xero is now a connectable source and writes into all thirteen. /v1/catalog/records gains fourteen xero:* entries (customer, supplier, invoice, bill, creditNote, supplierCreditNote, payment, billPayment, purchaseOrder, account, item, manualJournal, spendMoney, receiveMoney), each landing on a table you already read, so on an instance that connects Xero those rows appear beside the ones your app reads today. No table, column or capability changed; the new rows are the whole of it. Re-run turbobase project types.

  • Read status on a Xero invoice or vendor_bill as XERO’s word, not as the NetSuite/QuickBooks vocabulary. Those rows carry AUTHORISED, PAID, DRAFT, SUBMITTED, VOIDED or DELETED verbatim, where a QuickBooks row on the same table carries open / partial / paid. An app that filters status=eq.open returns nothing for Xero — filter on amount_remaining=gt.0 instead if you want a source-agnostic “still owed”.

  • Join a Xero line to its item or account BY CODE, not by id. On invoice_line, vendor_bill_line, credit_memo_line, vendor_credit_line, purchase_order_line and check_line, a Xero row leaves item_id and account_id NULL and puts Xero’s ItemCode / AccountCode in item_name / account_name; those strings join to item.item_id and account.account_number. Xero’s API publishes no id on a line at all, so an embed keyed on item_id returns nothing for these rows. credit_memo_line has no account_id / account_name pair at all — read a Xero credit-note line’s AccountCode out of that line’s raw.

  • Expect nothing writable from Xero. Every xero:* catalog entry is read_only, so POST /v1/data/:recordType refuses one; there is no flag that changes it. turbobase project types marks them accordingly.

  • Handle 422 { error: 'validation_failed', rejection: { code: 'INVALID_UUID', field, message } } from POST /v1/data/:recordType wherever your app sends a pgRowId or a <x>_pg_row_id reference. A value that could never be a UUID in one of those slots — a source system’s own id, a placeholder carried over from sample data, a number — used to reach Postgres and come back as 500 { error: 'write_failed' }; it is now refused before the write, and field names the exact slot (pgRowId, entity_pg_row_id, or lines[<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 with externalId, which stays free-form text.

  • Stop building a return’s lines by hand — set created_from_id and let the order supply them. A returnAuthorization or vendorReturnAuthorization created through POST /v1/data/:recordType with created_from_id naming a sales order (customer returns) or a purchase order (vendor returns) is now sent upstream as a CONVERSION of that document: the source copies the items, quantities and prices across and links the two records. It used to be sent as a standalone create, so a project that populated the return’s own lines is now sending OVERRIDES matched to the sourced lines by item, not a line set of its own — and every shape that can take is a problem: a line naming an item the order did not carry FAILS the whole write (unsourced_line: the sublist is static, so a line the conversion did not source cannot be added); a line naming one it did carry silently overwrites the quantity and price the order just supplied; and a line with no item_id set is matched by POSITION instead, overwriting whichever sourced line happens to sit at that index, or failing the write if the order sourced fewer lines than that. Drop them and let the order supply the lines. A return created with no created_from_id is unchanged and still a plain create.

  • Name the ORDER in a return’s created_from_id, not the invoice or the bill. One parent is supported per return type — sales order → customer return, purchase order → vendor return — and whatever id you set is sent AS a sales-order (or purchase-order) id, with no check that it names one. So an invoice id or a bill id no longer produces the unlinked standalone return it produced before: it produces a conversion request for an order that does not exist under that id, and the WHOLE WRITE FAILS with no return created at all. Use the order the goods were sold or bought on.

  • Re-read the column label, format and currencyKey your app takes from /v1/catalog/records — an entry no longer inherits authored facts from a connector that did not author them. Those facts come from the matching recipe’s table_columns, and they used to be joined on the record-type NAME alone, so any connector publishing the same name picked them up: a NetSuite or Business Central vendor_bill entry carried Bill.com’s labels and money formats. They are now joined to the authoring connector’s own entries, so an entry shows its own connector’s labels, a cross-source recipe’s, or none. Some labels therefore change and some disappear; re-run turbobase project types and stop caching a label you read once.
  • Expect authored column facts on fourteen connectors that previously had none — zoho_crm, salesforce, github, zendesk, intercom, granola, fireflies, feedhive, slack, google_workspace, dynamics-365-bc, ramp, workday and getsales. Their /v1/catalog/records entries now carry a label and format on the columns their recipe names, and their intents appear in the MCP recipes:/// resource. Nothing was removed and no column changed; if your app renders its own headings, prefer the catalog’s label where one is now present.
  • Handle the new url_column row-action kind if your app renders the chat grammar’s actions itself. A row action may now be { kind: 'url_column', url_key: '<column>' }, which opens the absolute https:// address that column holds on that row — as opposed to external_link, which composes one from a fixed prefix. Use it when the provider’s own URL is mirrored whole (GitHub permalink_url; Slack, Granola, Fireflies and Google Workspace url); keep the named column in your select list, because a row that carries no address renders the button disabled. rowColumnUrl(action, row) in $lib/chat/ui-grammar is the resolver, and it returns null for anything that is not https://.
  • Add source_system=eq.<source> to any read of entity, work_item, opportunity, activity, item, estimate/estimate_line, order/order_line or memory_document that assumed one CRM — Zoho CRM is now a connectable source and writes into all nine. /v1/catalog/records gains fourteen zoho_crm:* entries (lead, contact, account, opportunity, case, task, event, call, product, quote, quote_line, order, order_line, solution), each landing on a table you already read, so on an instance that connects Zoho those rows appear beside the ones your app is reading today. Nothing about the tables, columns or natural keys changed; what changed is that a table you may have treated as single-source no longer is. Copy sharedTable.hint from /v1/catalog/records rather than composing the predicate yourself — entity still needs entity_type beside it (lead / contact / company), and work_item needs item_type=eq.ticket. Re-run turbobase project types. An instance whose Zoho organisation has custom modules also gains one cr_<slug> catalog entry per module, with source: 'zoho_crm' and origin: 'admin', discovered per tenant — so enumerate /v1/catalog/records at run time rather than hard-coding the list.
  • Check connected before you offer granola:note as a browsable table — it joins /v1/catalog/records on every instance, carrying connected: false until a tenant connects Granola. It is a memory-class record: rows land in memory_document (kind = 'transcript', source_system = 'granola'), are admin-read at RLS (a standard user’s read returns an EMPTY set, not a 403, so branch on the body), and are read-only — a write to it is refused. note is a record-type name no connector registered before, so a bare note query that used to resolve to nothing now resolves to Granola’s rows.
  • Check source_detail.envelope_present before you read a sync failure’s source_detail as a diagnosis. A NetSuite refusal that carried nothing readable now records { "envelope_present": false, "raw_message": "…" } on turbobase.sync_failure.source_detail where it previously recorded nothing at all — so a non-null source_detail no longer implies error_name / field_id / implicated_script are in it. Render raw_message (the source’s own sentence, capped at 500 characters) when the marker is false, and leave your existing rendering alone when the marker is absent, which is every other failure. On a failure that RECURS the row keeps the earlier, richer diagnosis rather than the newer fallback, so a repeat never costs you detail you could already see.

  • Check that the signed-in user can READ the parent before you offer a transform — POST /v1/sync/events/from-chat now refuses one they cannot. The record named in created_from_id is read under the caller’s own RLS before anything is queued, so write:order_fulfillment no longer lets a user fulfil a sales order they are not permitted to see. The refusal is 403 { error: 'forbidden' } with nothing queued, and it is byte-identical to the answer for a created_from_id that matches no record at all — the endpoint will not tell your app whether a record it cannot see exists, so render it as “that record isn’t available to you”, never “not found”. A screen that lists parents through PostgREST already shows only readable rows and is unaffected; one that takes a typed-in id, or reuses an id from another user’s context, is the case that starts failing. Everything else about the route is unchanged.

  • Check connected before offering feedhive:post, and stop reading a bare post record-type name as “no such record”. /v1/catalog/records gains one entry, feedhive:post, against memory_document with readCapability: 'read:memory' — carrying connected: false until a tenant connects FeedHive, exactly as the GetSales entries do. post is a record-type name no connector registered before, so a bare query for one used to resolve to nothing and now resolves to FeedHive; pin ?source= if your app means something else by it. Rows are read-only (writeBack rejects) and carry no Atlas identity, so nothing consolidates into entity_canonical. Regenerate your types if you enumerate record types from the catalog.

  • Read a FeedHive post’s social accounts from participants, not scope_ref. One post is ONE row however many accounts it was cross-posted to, so scope_ref — a single text column — is null on every FeedHive row and the account ids live in participants instead. A query filtering these rows by scope_ref matches nothing rather than erroring.

  • Handle inventory_detail_required as a reason_code on a needs_input record_write_state row — these rows used to arrive blocked. It means the record moves stock for an item the connected system tracks by lot or serial number and those numbers were never supplied, so fields names inventory_detail and ordinary fix-sheet rendering works; write the assignment to that column on the line and save again. The message is plain language and names the line the connected system objected to, so render it as-is rather than substituting your own. If your app treats a blocked row as an outage — hiding the edit surface, or telling the reader their team has been notified — these records no longer belong in that path.

  • Read repository files from source_file — a new readable table, gated by read:source_file. A tenant connected to a code host now mirrors the files on each selected repository’s base branch as ordinary rows: one row per (repository, path), with path, ref, commit_sha, content_sha256, size_bytes and — for text under the adapter’s policy — content_text. Read it the way you read any canonical table (/rest/v1/source_file, turbobase project types); there is deliberately no /v1/files endpoint to learn. It is read-only from your app: POST/PATCH /v1/data/github:file answers 400 and will keep doing so, because a file edit has to arrive at the code host as a branch and a pull request rather than as a commit on the branch the customer ships from. Two things to code against rather than discover: storage_ref and content_text are NULL for a file over the adapter’s size policy — the row still tells you the file exists and how big it is — and only the base branch is mirrored, with no history, so a changed file repoints its row rather than adding one. A pull request’s changed files are source_file_change, joined to the pull request through work_item_external_id.

  • Expect pull requests and discussions in work_item, under new item_type values. A list built on work_item and filtered by item_type will not show them until you add 'pull_request' and 'discussion'; a list that does NOT filter will start including them. Six new nullable columns come with them — merged_at, is_draft, head_ref, base_ref, review_state, changed_file_count — plus answered_at and answer_external_id for a discussion, and they are NULL for every other item type, exactly as the existing per-source columns are. Treat every row from a code host as read-only from your app, not just the files above: writing one back to the source is not wired yet, so do not build an edit affordance over these rows expecting it to reach the code host.

  • Read a code host’s milestones from sprint and its linked issues from work_item_link — two shared tables that were Jira-only until now. A list built on either and NOT filtered by source_system will start including GitHub rows; add the filter if you meant one source. On sprint, name is the milestone title, start_at / end_at / complete_at are its opened / due / closed dates (end_at is NULL for an undated milestone — an ordinary case, not a broken row), and board_id carries the repository. On work_item_link the row reads inward link_type outward — the pull request closes the issue — with both *_work_item_external_id columns joining to work_item.external_id. Two things to code against rather than discover: neither table carries a scope slot, so these rows are visible across repositories where a work_item is not, and a work_item filed under a milestone carries it in sprint_id, which is the join from a bucket to its contents. Read-only from your app, like every other row from a code host.

  • Scope work_item and activity reads by scope_5 if your tenant registers a custom segment. Both tables now carry the generic scope slots and their read policies consult scopes_match(). Nothing changes for a row whose slots are NULL — which is every row that exists today — but a session whose scopes claim restricts a registered dimension will now see fewer rows than before, by policy rather than by query. If your app renders “no results” as an empty state, that path is newly reachable.

  • Read exchange_rate off the transaction row when you convert a figure, and stop reaching for turbobase.currency.exchange_rate. Every transaction table — order, invoice, cash_sale, credit_memo, estimate, vendor_bill, purchase_order and journal_entry — now carries the rate the source stamped on that document at posting time. Multiply the row’s own face value by it to reach the subsidiary’s base currency. turbobase.currency.exchange_rate is the CURRENT rate on the lookup row, so any total built from it changed after every sync; drop that lookup and any per-document join into currency_rate you built to work around its absence. NULL means the rate is genuinely unknown — leave the figure unconverted rather than assuming parity. History is filled in automatically on upgrade; a row whose payload never carried a rate stays NULL.

  • Set the currency on an invoice, cash_sale, credit_memo or estimate through currency_id now — raw.currency is no longer the only way. These four record types carry currency_id and currency_name columns, so the currency is filterable, embeddable and writable like every other ref instead of being reachable only inside raw. This closes the gap v1.4.x’s note left open (“open an issue if you need one”). currency_name is the source’s own display name and is source-owned in a conflict. For a portable ISO key, read currency_code — invoice, credit_memo and estimate gained it in a later release (this file is newest-first, so see the entry above); as of this one only cash_sale still needs resolving through turbobase.currency.

  • Read writeCapability on every /v1/catalog/records entry before you offer an edit, and drop any write-it-and-see probe you built to find out. It is readCapability’s twin — write:order beside read:order — and it is null whenever a PG-side edit of that record would be refused on this instance: either the record is inbound-only, or the whole source is. Before this, a read-only tenant looked identical to a writable one and the only way to tell them apart was to attempt a write per record and read the refusal back. The value is resolved live per request, so a tenant granted write-back starts reporting a capability string on the very next call — render your editing affordances off it rather than caching it at build time. Older instances omit the field; treat undefined as “unknown”, not as read-only.

  • Stop putting owner inside raw when you write a standard record — it is now dropped before the request leaves. On a NetSuite record type that is not a custom record, owner is not a field an app sets: nothing projects it to a column, nothing renders it, and the value the mirror holds came from a background refresh. It was nonetheless restated on every outbound write, so a project task whose captured owner had since gone inactive refused EVERY edit — including one that changed only how complete the task was — naming a field the editor never touched and could not correct. That refusal is gone, and affected rows go through on their next write with no re-sync, so drop any retry-with-fewer-fields workaround you built for it. A customrecord_* write is unchanged: owner is a genuine setter there and still travels.

  • Filter shared canonical tables with the concrete predicate /v1/catalog/records now publishes, and drop any pg_row_id-and-check-each-row workaround you built for these record types. sharedTable.hint used to say the instance could not name a value for seventeen record types stored alongside a same-source sibling — Google Workspace’s doc / email / file / sheet / slide, Salesforce’s account / lead / task / event, Intercom’s admin / company / conversation / ticket, HubSpot’s company / owner, Zendesk’s organization and getsales’s company. Each now publishes a real predicate (kind=eq.doc, entity_type=eq.company, activity_type=eq.task, item_type=eq.ticket), and selectTarget.discriminator carries the same one. Three rows still cannot be named and say so as before: zendesk:user (a contact or an employee per row) and NetSuite’s salesTaxItem / taxGroup.

  • Expect a read of one of those seventeen record types to return only that record type. The MCP list_records / get_record path applies the predicate above, so a read that previously came back with the source’s whole slice of entity, work_item, activity or memory_document — siblings included, labelled as the type you asked for — now comes back narrowed. If your app filtered those siblings out itself, that code is now a no-op and can go; if it relied on the extra rows, ask for the sibling record type by name.

  • Declare your app’s launcher labels in public/turbobase.json — a labels array of { name, color? } — instead of asking an admin to re-tick them on every instance. They render as a chip on your app’s card and a filter pill above the /apps grid. color is one of slate, violet, amber, emerald, rose, sky, indigo, fuchsia — any other value now FAILS the deploy rather than rendering grey, so check yours if you were already writing the key speculatively. Three things to know before you add it: the block is authoritative for your app, so a name you remove is a chip removed and "labels": [] clears them all (leave the key out entirely to keep today’s behaviour of not touching them); the colour applies only when the label is NEW to the instance, because the vocabulary is shared with every other app, so yours will never repaint an existing chip — the deploy prints what it kept; and a label is navigation only, so it changes nothing about who can open your app. Max 12 labels, 40 characters each. A preview deploy reports what it would do and attaches nothing; turbobase apps plan shows which already exist.

  • Stop retrying a write refused with a bare An unexpected SuiteScript error has occurred — the platform now does it for you. That sentence is what the source says when one of its own server-side scripts threw and it had nothing more specific to add, so it covers a passing blip as readily as a real rejection; a write-back refused that way used to be abandoned on the first attempt. It now rides the same backoff every other transient refusal does, and reaches record_write_state as blocked with reason_code = 'ns_error' only once the attempt budget is spent. Nothing changes in your payloads and no terminal outcome is new — delete any resend loop you built around this message, and do not re-prompt the user while the row is still in flight. The same sentence WITH a cause or a script appended (…has occurred: You cannot post to a closed accounting period.) is a specific refusal and still lands immediately.

  • Send created_from_id on every POST /v1/data/itemReceipt create — it is now required, and a create without it is refused 422. The rejection code is CONVERSION_SOURCE_REQUIRED and rejection.field is created_from_id; set it to the external_id of the purchase order the goods arrived against. Nothing is written and nothing is queued, so a caller that retries after filling it in leaves no half-record behind. This restricts a create that used to return 200 and then fail silently hours later against the connected system, which has no standalone item receipt at all — the receipt is a conversion of the order, and the order is what supplies its lines, quantities and costs. Updates are unaffected, and so is every other record type.

  • Handle source_receipt_not_transformable as a reason_code on a needs_input record_write_state row. It means a record that only exists as a conversion was written without naming the document it converts from, and it can only reach you now for a write queued before the refusal above shipped. fields names created_from_id, so ordinary fix-sheet rendering works; the message is plain language and does not repeat the connected system’s own sentence, which talks about the record’s lines and is misleading.

  • Stop relying on a direct /rest/v1/ write to resend subsidiary, currency or customform at the value the record already holds — those keys are now left off, and a genuinely different value still travels. The v1.8.0 note told you creates and direct PostgREST writes “still send the whole row”; that is no longer true of those three. A write made outside POST /v1/data/:recordType (direct PostgREST or SQL, a backfill, a hook) records no changed-fields hint, so it is now diffed against the record’s last successful sync and a key matching it is dropped from the outbound patch — from the typed column and from raw. That is what stops NetSuite re-sourcing the transaction, clearing a dependent and refusing the save naming a field nobody touched. Name a different value and it reaches the source exactly as before; a record that has never synced, and every create, are unchanged.

  • Handle insufficient_stock as a reason_code on a needs_input record_write_state row, and render message rather than a fix sheet. It means the connected system does not have enough of the item for the quantity requested, and it arrives where a field_validation refusal used to: the row is still needs_input, because lowering the quantity is what makes the write land. What changed is that reason_code is now the cause instead of the generic validation code, and message is plain language telling the user to reduce the quantity or add stock — the source’s own words never said which field. fields is empty (the refusal names none), so an app that renders a fix sheet and nothing else will show a needs_input row with no instruction; show message for this reason code. It deliberately does not quote the stock level, which is stale by the time you read the row — read the item’s own quantity if you need a current one.
  • Stop joining on a column that /v1/catalog/columns now marks scopeDimension — it is a permission boundary, not a link. subsidiary_id, location_id, department_id, class_id, scope_5…scope_8 and their _name twins carry a new scopeDimension field (and a description saying so) on every scope-slotted record type. They are the positional arguments of the row-access policy: two rows sharing one are filed under the same part of the business and nothing more. Using one to decide which document a row belongs to correlates on some records and silently returns the wrong rows on the rest — no error, just a wrong number on the screen. Follow the record’s own foreign key (entity_id, created_from_id, a declared relationship) instead. Additive: every existing role / idFor / displayFor value is unchanged, and the same column on the location / department / classification masters, where it IS a real parent link, is not marked.
  • Send client_id on every POST /oauth/token request to your instance’s MCP OAuth bridge — the authorization_code grant as well as refresh_token. A request without it answers 400 invalid_request. Every MCP client Turbobase supports already sends it; a hand-rolled client that omitted it must add it. A refresh for a client the user has since revoked under Account → connected MCP clients now answers 400 invalid_grant with no token: re-run the authorization instead of retrying the refresh. The refresh_token your client receives is bound to the client_id that redeemed the code — a refresh naming any other client is refused, and so is a refresh token issued before v1.10.0, which predates the binding. Expect one re-authorization per connected client after the upgrade; nothing else changes for a client that keeps using its own client_id.
  • Pass approve: true to every shipping tool on /mcp/authoring, not only deploy. apps_push, tables_apply, bundle_deploy, release_deploy, release_push, release_rollback and instrument now answer the same approval_required result deploy always did until the call carries it. Read-only tools are unchanged. The endpoint also refuses a JSON-RPC array body (400 invalid_request): send one request per POST.
  • Read the correlation id, not a cause, out of an unexpected tools/call failure on /mcp. A failure that is not about your request — a database or data-layer error — now returns a fixed message carrying the request’s correlation id instead of the underlying error text; the cause is in the instance log under that id. A PostgREST 4xx still surfaces PostgREST’s own message, and refusals of your input are worded as before.
  • Expect a header-only update through POST /v1/data/:recordType to commit even when the record holds a stale reference elsewhere — and handle the one new way it can fail. An update to an existing row that changes header columns only (no lines, nothing inside raw) is now applied to NetSuite as a field patch, validated field by field, instead of as a whole-record save. An Owner, location or list value that went inactive after it was chosen no longer refuses a patch that never mentioned it, so drop any retry-with-fewer-fields workaround you built for that. The new failure: a patched field that did not take — NetSuite left it UNCHANGED, so the readback still holds the value the record had before the patch (a field that is not inline-editable on that record type, or a script that put the previous value back) — lands as netsuite_field_validation with detail.fieldId naming the field and detail.explain.diverged carrying sent-vs-committed; treat it exactly like any other field_validation, and never read the write as having succeeded. A value NetSuite stored in another form is NOT that failure: a phone number read back in the account’s phone format, or a percent read back with its %, counts as applied. The one edge: a value re-sent in a different form from what the record already holds (raw digits for a phone already stored formatted) reads back unchanged and fails the same way, so send the value as the row holds it, or a genuinely new one. Creates, line edits, a change inside raw, and writes that bypass the write boundary (a direct PostgREST PATCH) keep the whole-record save and its behaviour.
  • Write approval_status as the source’s internal id ('2'), never the display label ('Approved'). The mirror STORES the label — approval_status projects the ref’s name with no id fallback on purchase_order and vendor_bill — so an app that read a row and PATCHed one unrelated field posted that label straight back, and the source refused the WHOLE save for a field nobody edited. Nothing landed. Two things change, and neither affects a payload that already sends an id. A label that merely restates the row’s own last-synced value is now DROPPED from the outbound payload, so the rest of your write commits. A label that is a genuine change is now REFUSED before anything reaches the source: the label cannot be resolved to an id (the codes are not portable across record types, and guessing would write the wrong approval state into the customer’s ledger), so the record’s write state goes to needs_input naming approval_status. If you need to change an approval status, send the id.

  • Expect fields on a needs_input write state to be POPULATED where it used to be empty. A source refusal of the “invalid value for the following field” shape named no field at all, so the sidecar arrived with fields: [] and an app rendering a fix sheet from it had nothing to show. It now carries the field the source named. Code that treated an empty fields as “this refusal has no field” should render the list instead.

  • Re-read the row after del(recordType, …) — a 2xx from DELETE /v1/data/:recordType is no longer final. The tombstone is committed before the delete reaches the source, and when the source terminally refuses it (or a guardrail denies it, or an approval hold expires) the row is now UN-hidden instead of staying invisible for good. It comes back readable, and on every ending that records a write state it carries write_status = 'blocked'. Do not assume a reverted row always has one — a delete stranded because the source could not be signed in to at all comes back with no record_write_state row, so branch on its absence rather than waiting for a blocked that never arrives. If your UI removed the record from a list on the 2xx and never looked again, it is showing a deletion that did not happen — re-read it on a sweep, the way you already handle removals. Do NOT wait for a Realtime frame: a row crossing deleted_at is the one transition Realtime is documented not to deliver (see Build apps → the dashboard recipe’s “do not write a DELETE handler”), and nothing here changes that.

  • Handle delete_blocked_by_dependents as a reason_code on a blocked record_write_state row. It means the source will not release the record while other documents point at it, and message names them (up to five by record type and document number, then “and N more”); when none could be resolved it says so rather than claiming none exist. Render the message as-is — it is written for the customer. Do not re-prompt for a field and do not resend: nothing on this record fixes it, and it clears only when the named documents go.

  • Read outcome on each turbobase.record_write_state.fields entry before you re-prompt the user for a needs_input row — the source now says what it did with each field it named. Every entry still carries field and label, unchanged. When the source answered, the entry also carries submitted (whether your write sent that field at all), requested and actual (what you sent and what the record held when it refused, each cut to 200 characters), and outcome: applied (the value landed, so the refusal is about something else on the record), diverged (it was cleared or changed on the way, so resending the same value will not help), or empty (you never sent it and the record requires it). A label-named entry (field: "Vendor") gains field_id (entity) so you can map it to the column your form edits. Branch on outcome when present and keep today’s behaviour when it is absent: a refusal the source could not diagnose, or a named field the readback could not be tied to (submitted is only ever stated where the source proved it), still writes the two-key entry, at most 25 entries per row are enriched, and status, reason_code and message are unchanged either way.

  • Convert a dated amount through currency_rate, not turbobase.currency.exchange_rate. currency.exchange_rate is the CURRENT rate, so a figure computed with it changes its own history after every sync. Dated rates are now mirrored, one row per pair per effective_date. Read the rate that was in force: /rest/v1/currency_rate?base_currency_code=eq.USD&quote_currency_code=eq.GBP&effective_date=lte.<document date>&order=effective_date.desc&limit=1. An amount in quote_currency_code × exchange_rate is the amount in base_currency_code — read that direction rather than inferring it from the names, which mirror NetSuite’s, whose base currency is the subsidiary’s. An empty result means no rate is held for that date; it does not mean 1. If your tenant holds more than one connection to the same source, add &connection_id=eq.<id> — each mirrors its own rate table, so limit=1 would otherwise pick between two tied rows arbitrarily.
  • Delete the “cannot total across currencies” guard you adopted at v1.6.6 — but only where you convert. That note said a consolidated total could not be computed from the mirror, and it no longer holds: total it, converting each figure through the as-of read above. A row whose currency ref could not be resolved carries a NULL code and never matches a pair filter, so keep showing those in their own currency instead of folding them in.
  • Regenerate your types (turbobase project types) — /v1/catalog/records now lists netsuite:currencyRate against table currency_rate, and the PostgREST schema carries the table with it. A project pinned to the old output will not type-check a read of it.
  • Handle a guardrail_* reason_code on a blocked record — a write our own policy stopped now reaches record_write_state instead of sitting on pending. Until now a policy-stopped write left the row on pending forever, so an app polling for the terminal state never got one and showed the write as still in flight. Match on the guardrail_ prefix rather than the whole string: the code names which policy stopped it (guardrail_denied, guardrail_rate_limited, guardrail_confirm_denied, guardrail_policy_unreadable, guardrail_rate_unavailable, and guardrail_hold_expired for an approval nobody answered in time) and the set can grow. Treat them like every other blocked code: do not re-prompt the user and do not resend unchanged — no edit satisfies a policy rule. Resending a genuinely EDITED record does re-attempt it, unchanged from before. If you were reading a stuck pending as “still working”, that timeout branch can go.
  • Read the new description on a status / *_status column from /v1/catalog/* before you render the value as a state. It says the code/label vocabulary is scoped to (record type, field) and is never portable, and that the value is one party’s assertion about one aspect of the document rather than an agreed state — a carrier’s “delivered” is not the warehouse’s “counted in”. The record’s anatomyMarkdown names the asserting party and the as-of column. Nothing in the response shape changed and an authored description still wins, so no code breaks; what breaks without it is a screen presenting a supplier’s or a carrier’s claim as your own confirmed state.
  • Do not read write_status as one of those. Its description now says plainly that it is Turbobase’s own standing on writing the row back to the source (synced / pending / retrying / needs_input / blocked / muted), not a state anyone upstream asserted — so it has no asserting party and no as-of date.
  • Stop sending external_id inside parent when you CREATE a record through POST /v1/data/:recordType — it now comes back 422 with rejection.code = "PROTECTED_COLUMN". The same holds for pg_row_id, synced_at, sync_version, last_modified_pg and originating_system: those columns belong to the platform. external_id is the record’s link back to the source system and it is minted UPSTREAM — a row you create carries null until the write-back worker commits it and back-fills the id the source issued. A value you supply points the row at a record the source never issued, so the first write-back addresses an id that does not exist and fails permanently with no retry, parking a sync_failure that cannot heal itself. Create the record without the field and read the id back off the row. connection_id and source_system are unchanged and still yours to send.
  • Keep posting a row straight back the way you read it — that still works. On an UPDATE, a protected column whose value EQUALS the row’s current one is accepted as the no-op it is, so read-modify-write against the whole row (external_id and all) needs no change; only a value that would CHANGE one is refused. One status moved with it: an update addressed by a pgRowId your session cannot see, carrying a protected column, now answers the same 422 where it used to answer 500 write_failed.
  • Read sync_attempt.error_detail -> 'detail' as JSON — stop JSON.parse-ing it as a string. The source’s failure envelope was stored stringified inside the jsonb column, so the keys that actually diagnose a refused write were present but unreachable: error_detail -> 'detail' -> 'fieldReadback' errored and error_detail -> 'detail' ->> 'scriptsOnRecordType' came back null, which reads as “my instance doesn’t capture this” rather than “it is one parse away”. It is now stored as an object and is addressable in SQL. Handle both shapes — attempt rows written before this release keep the string and are never rewritten — so try the object first and fall back to parsing a string. This is the column to read for scriptsOnRecordType / explain / fieldReadback when a write is refused naming a field you demonstrably sent.
  • Name subsidiary_id / currency_id in the patch when you mean to change one — setting subsidiary / currency under raw no longer reaches the source. A PATCH /v1/data/:recordType is narrowed to the columns you named, and that narrowing now drops those two keys out of the raw passthrough as well as out of the typed columns: restating either at its own unchanged value makes NetSuite re-source the transaction and CLEAR its entity, so the save is refused naming a field you never touched (“Please enter value(s) for: Vendor” on a bill whose vendor is right there). Naming the typed column keeps it travelling, and it wins over raw exactly as before. On a record type with no promoted currency_id column (invoice, cash_sale, estimate, credit_memo, item_fulfillment, …) raw.currency is therefore no longer a way to change currency on an existing record — open an issue if you need one. Creates and direct PostgREST writes are unaffected: neither records a changed-fields hint, so both still send the whole row.
  • Re-read customer_id on any payable line your app cached — some values change with this release. On vendor_bill_line, purchase_order_line and check_line, rows written by the bulk backfill could carry a serialised ref object ({"id": "3", "refName": null}) in customer_id instead of the id, and others were left null when the customer was in fact known. A one-shot rewrites both. If you stored customer_id outside the instance, or matched on it as a string, refresh from /rest/v1/ rather than trusting the cached value.
  • Stop treating a null customer_name on a rebillable payable line as the normal case. The v1.4.0 note told you to embed or join customer because the bulk path set customer_id with no name beside it. The name is now resolved from the mirrored customer, and stays null only where that customer has not synced or where its id is ambiguous across two connections. The embed is still correct and still the way to be certain — it is no longer needed for the ordinary row.
  • Stop diffing subsidiary / currency / customform yourself before a NetSuite UPDATE — the platform now omits them when they are unchanged. v1.4.3 told you to do this in your own app (“If your app builds updates by resending the whole record, diff against the prior row and omit unchanged trigger fields”); that work is now done for you, against the row’s mirrored raw, so a whole-record update no longer clears the record’s Customer and Order Type. Keeping your own diff is harmless. One thing you must adopt: raw.customform no longer changes a form on an UPDATE — it is dropped with the rest. Creates are unaffected, so the v1.2.0 guidance (“Set raw.customform if your record needs a specific NetSuite form”) still holds exactly as written for a create. To change the form on an existing record, do it in NetSuite.
  • Drop entity_type from any filter you built out of sharedTable.discriminators on a transaction table, and re-copy the hint from /v1/catalog/records. It was named on invoice, order, estimate, opportunity, credit_memo, customer_payment, expense_report, order_fulfillment, purchase_order, vendor_bill, vendor_payment and work_item — 50 entries — where it is a per-table CONSTANT carried for the entity foreign key and separates nothing, and since no value could be named for it the advertised predicate was a placeholder you were asked to fill in and could not. A query following it returned zero rows. source_system=eq.<source> is unchanged and is now the whole answer on those tables; entity_type=eq.customer on entity and item_type=eq.inventoryItem on item / work_item are unchanged too.
  • Handle an EMPTY sharedTable.discriminators — the note no longer always names a predicate. tax_code holds NetSuite’s salesTaxItem AND taxGroup, so the old source_system=eq.netsuite excluded nothing while reading as a complete answer. The array is now [] and the hint says to match on pg_row_id and check the row you read — the same wording /v1/catalog/objects/:id already used.
  • Re-read the hint where a filter narrows only PARTLY — it now names what still matches. Filtering activity by source_system=eq.salesforce leaves salesforce:event behind, and entity for a record type this instance cannot name a type value for leaves that connector’s other entities behind. Both used to end “or you will read (and label) the wrong row”, which reads as “filter and you are safe”. They now name the record types the predicate does not exclude.
  • A type column you cannot fill in is no longer named where the source predicate already isolates you — sharedTable.discriminators shrinks to ['source_system'] for fourteen more record types. dynamics-365-bc:item, hubspot:product, quickbooks:item, salesforce:product, shopify:product and stripe:product each store exactly one record type on item, so source_system=eq.<source> returns precisely that record type; the note used to add item_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 on entity and work_item — asana:asanaTask, asana:asanaUser, hubspot:ticket, jira:issue, jira:user, notion:notion_user, salesforce:case, zendesk:ticket — are fixed with them. The placeholder is KEPT where your answer still changes the result set, e.g. hubspot:company on entity, because hubspot:contact and hubspot:owner rows carry source_system=hubspot too — those hints now end “…or hubspot:contact, hubspot:owner will still match” rather than implying the filter is complete.
  • Re-check any branch on turbobase.record_write_state.status for the anchored-line refusal — it now lands blocked, not needs_input. When the source refuses a write because one of the record’s lines has already been received, shipped or billed, the row used to arrive needs_input with an EMPTY fields array, so a fix sheet asked the user to correct nothing at all. It now arrives status='blocked', reason_code='anchored_line', and a message naming the receipt, shipment or bill to amend. If your app renders needs_input as a form and blocked as an admin notice, this one is the notice — no field the user can edit releases the line.
  • Stop pre-checking for downstream documents before you patch an order — editing a line now succeeds when a DIFFERENT line has a receipt or fulfillment against it. NetSuite bundle 2.5.0 matches the lines it is given against the ones the record already carries and edits them in place, instead of clearing the sublist first and being refused on the anchored line. Adding a line to such an order, or dropping one from it, is still refused — but now names which line and why, instead of failing on a line you never touched. This arrives per tenant, when that tenant’s NetSuite bundle is redeployed at 2.5.0 or later; until then the write still fails, and the classification above is what changes first.
  • Stop hiding a blocked write from the user when its reason_code is source_not_transformable — show record_write_state.message. Every other blocked row carries the fixed “no action is needed from you” hold, so an app that suppresses blocked entirely was right until now. This one is different: it means the document the record was created from has nothing left to receive or convert, and the fix is that document’s status — something the user owns and can already see. The row is still blocked (nothing to type, fields is empty), so surface the message rather than a fix sheet.
  • Add source_system=eq.<source> to every read of label, customer_status and lead_source — each of the three now holds rows from two connectors, and each carries a sharedTable note on /v1/catalog/records where it carried null. The GetSales connector projects tag onto label beside Asana’s asanaTag, pipelineStage onto customer_status beside NetSuite’s customerStatus, and dataSource onto lead_source beside NetSuite’s leadSource. None of the three is connection-scoped — the key is a bare external_id — so both sources write one id space and an unfiltered read returns both connectors’ rows. Copy the hint from the catalog entry rather than composing the predicate yourself; discriminators is ['source_system'] on all three and it is the whole answer, with nothing left matching after it. An instance with no GetSales connection reads exactly as before, so adopting this ahead of one costs nothing.
  • Pin ?source= on a bare contact or company record-type query if your app must keep reading a particular connector’s rows — on a tenant where GetSales is the only connected contact source, those two names now resolve to GetSales. Unpinned resolution puts CONNECTED sources ahead of registry order, and getsales is registered after hubspot, salesforce and intercom, so nothing moves on a tenant that has one of those connected; it moves on a tenant that does not. Five entries also join /v1/catalog/records on every instance — getsales:contact, getsales:company, getsales:tag, getsales:pipelineStage, getsales:dataSource — carrying connected: false until a tenant connects GetSales, so keep checking connected before you offer one as a browsable table. tag, pipelineStage and dataSource are names no connector registered before: a bare query for one used to resolve to nothing.
  • Send '', not null, under raw when you mean to blank a field UPSTREAM. A null or undefined value under raw is no longer written to the source — it is dropped from the outbound payload, leaving the source’s own value standing. It used to travel, and because the source sets every key it is given, it cleared fields nobody edited: a vendor patch that never touched the name arrived with Company Name blank and the whole write was refused. An empty string still travels, so that is the way to ask for a blank. Typed columns (company_name: null) have always behaved this way; raw now matches them. This does not change what null does to the MIRROR — under v1.5.5 it still removes the key from the stored raw column, and that is unchanged. The two meanings are now distinct: null edits your mirror, '' edits the source.
  • Cross a finding out in INSTANCE-LEARNINGS.md when you stop standing behind it — don’t delete it. Mark the finding’s FIRST LINE in any one of three ways: strike it through (## ~~40. Outbound stage reconciliation~~), write WITHDRAWN or RETRACTED on it in capitals, or say “do not publish” on it. Until now nothing read the mark, so a finding you had withdrawn stayed in the review queue, could still be published as a knowledge-base page or as a standing rule injected into every agent turn, and was still shared as an anonymised lesson. Take the mark off and re-push and it is offered again. Deleting the section still does nothing — an absence reads as a restructured file, never as a withdrawal.
  • Keep AMEND / CORRECT / CLOSE / CORRECTED BY §N for what they already mean — this block REPLACES an earlier claim, and still publishes. They are deliberately not a withdrawal; a lead carrying only one of them is unaffected by the change above.
  • Change every money column from currency.usd to currency.native, and give it currencyKey: 'currency_code'. currency.usd and currency.eur hard-code a symbol, and mirrored money is the DOCUMENT’S own face value — so a sterling or euro order was rendered as dollars on every table that used them. The new currency.native reads the ISO code from the column currencyKey names and renders in that currency; a row that records none renders a bare number rather than inventing a symbol. currency.usd / currency.eur still work unchanged, so nothing breaks until you move — but a money column on a mirrored record is wrong until you do.
  • If you call format() yourself, pass the row: format(value, col.format, { row, currencyKey: col.currencyKey }). The third parameter is new and optional; a two-argument call still compiles and still renders, but a currency.native column reached that way has no row to read and falls back to a bare number.
  • Regenerate your types (turbobase project types) — /v1/catalog/* columns carry a new currencyKey, and the Formatter union carries currency.native. A project pinned to the old union will not type-check a recipe or grammar that uses it.
  • Read currency_code, not currency_id, when you need to know a row’s currency. currency_id holds a NetSuite internal id on a NetSuite row and an ISO code on a QuickBooks / Shopify / Dynamics one, and currency_name is account-configurable free text. currency_code is on order, vendor_bill, purchase_order, return_authorization and entity, backfilled on upgrade, and NULL where the currency could not be resolved.
  • Do not sum money across rows in mixed currencies. No historical exchange rate is mirrored (turbobase.currency.exchange_rate is the CURRENT rate), so a consolidated total cannot be computed correctly from the mirror today. Group by currency_code and label each figure.
  • Delete your apps push worktree workaround — .git is skipped by name now, file or directory. turbobase apps push <app> . used to fail from a git worktree with push rejected: invalid_path in .git, because a worktree writes .git as a one-line FILE (gitdir: …) rather than a directory, and the walker only skipped directories. If you were rsync-ing the tree to a staging dir minus .git and pushing that, push . directly again.
  • A partial patch to a TRANSACTION no longer deletes the rest of raw — and if you added raw to a patch to work around that, take it out. v1.5.2 said raw merges on update, and v1.4.3’s lines entry repeats it (“this is the OPPOSITE of raw, which now merges”). Read that as covering every record type and it is wrong — it was only ever true on the types with no line table (customrecord_*, customer, item, customerPayment, customerDeposit, timeBill). Every transaction carrying a sublist — salesOrder, purchaseOrder, invoice, vendorBill, itemReceipt, journalEntry, check, transferOrder and the rest — goes through the write_transaction RPC, which kept REPLACING the column: a patch naming raw at all deleted every key it did not mention, silently, with a 200. Measured on a live tenant: three marker fields cut a purchase order’s raw from 58 keys to 3, taking the custom body field an app read with it. Both legs merge now, so the rule is finally one rule — the keys you send win, the keys you omit survive, and clearing a key needs an explicit null, not omission. Rows already flattened by this are repaired in place on upgrade, restoring from the platform’s own copy of the last synced payload; nothing to re-sync and no action needed. lines is still the opposite and still needs care — omitted preserves, [] clears, a list replaces.
  • Reading a document’s lines out of raw was always the fragile shape — move to the line table. The repair above cannot restore what a patch removed on a row the platform has never synced, and raw is poll-fresh for anything that is not a declared link or a promoted column. order?select=*,order_line(*) (and the purchase_order_line / invoice_line equivalents) is resolved server-side, RLS-scoped, in one request, and survives every write path above — the line rows were untouched even on the six documents this bug flattened. Six pairs need the constraint named — item_receipt, return_authorization, vendor_return_authorization, journal_entry, check, transfer_order — as item_receipt_line!item_receipt_line_parent_pg_row_id_fkey(...).
  • Retry any record stuck on RCRD_HAS_BEEN_CHANGED — it writes now, and you should delete the back-off you added for it. A record NetSuite had been edited in through its own UI could end up permanently unwritable: the mirror’s raw captured version, NetSuite’s optimistic-lock token, and raw is spread onto the wire on every patch — so every write re-sent a token that could never match again and was refused with “Record has been changed”. The error names a concurrency race, so the obvious reading is “retry it”, and no retry could ever succeed. Nothing to change in your payloads: the write path now drops version and the rest of NetSuite’s framework machinery (session and workflow ids, browser-form scaffolding, record.save() option names) before anything reaches NetSuite. Affected rows unblock on their next write with no re-sync.
  • Stop reading version, sys_id, nsapi*, nl*, wf*, _csrf and the other NetSuite form keys out of raw — they are being REMOVED from it. They were never data: each was frozen at whatever the record’s state was when the payload was captured, so anything rendering one has been showing a stale value. They are now dropped on the way in (so a row loses them on its next sync), on the way out (setting one through raw on POST /v1/data/:recordType is silently ignored rather than sent), and from rows already holding them. If a screen displays one, promote a real field instead.
  • A NetSuite date-only field now arrives in raw as YYYY-MM-DD, not as an ISO instant — drop any new Date(...) that assumed a timestamp. It used to serialize as whatever UTC instant the executing user’s timezone made of local midnight, so the same field came back as 2026-03-22T07:00:00.000Z from one path and 2026-03-22T00:00:00.000Z from another, and a ::date in any other zone read the wrong day. A date-only field has no time and now says so. The promoted cf_ns_* column is unchanged in TYPE (timestamptz) and lands at UTC midnight, so a range predicate keeps working; only the raw string shape changed. datetime / datetimetz fields are untouched — an instant is still an instant.
  • Re-test the header-only patch you were told worked in v1.5.2 — on transactions it did not, and now does. The Postgres half landed correctly (omitting lines has preserved your line rows since v1.5.2), but the NetSuite half never engaged for any record written through the atomic header+line path — sales order, purchase order, invoice, vendor bill, item receipt, journal entry, check, transfer order, expense report, and the rest. Those patches still went upstream as the whole row with the sublist attached, so a header-only edit on a received or billed document was still refused (“would leave the Item Receipt … unlinked”, or a mandatory line field you never sent). Nothing to change in your code — if you added a workaround after v1.5.2 appeared not to help, remove it.
  • Stop re-sending lines just to keep a line’s tax code. Outbound lines now carry tax_code_id, so a sublist you DO replace keeps its tax code instead of coming back blank. Previously the only way to set one was a raw override (raw: { taxcode: "5" }) per line; that override still works and still wins over nothing, but the promoted column is now the default and you no longer need it. Applies to the item sublist and to the expense sublist of a bill / PO / check.
  • Stop sending a lines array you did not change — omitting it now PRESERVES the lines instead of deleting them. On POST /v1/data/:recordType, an update that carries no lines key used to run an unconditional DELETE FROM <line_table> and return 200 over a document it had just emptied. Three states now, and they are distinct: omit lines (or send the string "preserve") → the existing lines are untouched; lines: [] → cleared, because you asked; lines: [ … ] → replaced. If you have been re-sending the whole line array on a header-only patch to work around the old behaviour, delete that code — it is what makes NetSuite refuse the write (next item). Anything other than an array or "preserve" is now a 422 INVALID_LINES rather than being read as “absent”.
  • A header-only patch no longer restates the NetSuite sublist — you can finally set custbody_* fields on a received or billed transaction. The worker used to attach the full line set to EVERY update of a record type that has lines, and the RESTlet treats a supplied sublist as REPLACE (remove every line, then re-add). On a purchase order with item receipts against it, that asked NetSuite to delete the lines those receipts point at, and was refused with “would leave the Item Receipt … unlinked”. No payload shape avoided it — restating the lines verbatim produced the identical failure, because NetSuite compares nothing, it just performs the removals. Patch the header alone and the sublist is now omitted entirely. Still send only the fields you are changing: restating subsidiary_id / currency_id at their unchanged values re-sources line tax codes and fails naming a line you never sent.
  • Stop reading display-only NetSuite fields out of raw — Inline HTML, Label and Help fields are no longer captured. They hold no stored value (getValue returns whatever the form rendered), and capturing them made every later write fail: raw is spread back onto the wire on every patch, so a record carrying one was unwritable by every writer — app, chat agent and reconciler alike — with a message naming a field none of them had set. If a screen displays one, compute it in your app or promote a real field. Already-affected rows unblock without a re-sync: the write path drops these fields and reports them as skipped_display_only.
  • If you call the turbobase.write_transaction RPC directly, note that p_lines now defaults to NULL, not '{}'. NULL means “leave the lines alone”; pass '{}'::jsonb[] explicitly if you want them cleared. Omitting the argument no longer deletes the sublist.
  • Declare the roles your screens need in public/turbobase.json, and gate on the same capability strings. A new optional roles block — [{ "name": "Warehouse clerk", "description": "…", "capabilities": ["read:order", "write:order_fulfillment"] }] — creates each entry at production deploy as an ordinary role, the same one an admin manages in Admin → Roles. Declaring grants nobody anything: a declared role has zero holders until an admin assigns it in Admin → Users, so never tell a user a deploy gave them access. You cannot declare appRole (admin is assigned, never authored) or scopes (row narrowing is the admin’s) — both are deploy errors, not ignored keys. Redeploying ADDS missing capabilities to an existing role and never removes any, so trimming your manifest does not revoke anyone; a name that collides with a built-in fails the deploy. Preview deploys report the roles without creating them; promoting applies them. Run turbobase apps plan to see the verdicts before shipping — it writes nothing and exits non-zero if a role cannot be created.

  • Add hasCapability() / requireCapability() / sessionClaims() and the TurbobasePermissionError class to your src/lib/turbobase.ts from the current scaffold, then gate every screen and catch every denied write. This fixes two failures your app cannot currently explain. A read the user may not perform is filtered by RLS and comes back as an EMPTY ARRAY, not an error — so check hasCapability('read:invoice') before rendering, or a permission problem is indistinguishable from “no data”. A denied write now throws TurbobasePermissionError (not a generic Error), so catch (e) { if (e instanceof TurbobasePermissionError) … } can name the role to ask for instead of showing a raw message. hasCapability mirrors the database’s has_capability() including the admin bypass — it is UX only; RLS remains the guarantee.

  • Re-run turbobase apps init in an existing app project to pick up the new roles-and-permissions skill. Existing files are never overwritten, so this is safe. The skill teaches your agent the two rules above — that deploying a role grants nobody anything, and that a denied read is silent — plus the checklist for adding a gated screen.

  • Stop reading document_share and kb_guest_access_code over /rest/v1 — both tables are dropped. Knowledge-base guest access (the external email-code reader) is removed; a select against either now fails as an unknown relation rather than returning zero rows, so a stitched embed that names one takes the whole request down with it. document_comment is unchanged and stays readable, except that author_kind is now constrained to member / admin — drop any guest branch in your rendering, and any guest-authored rows your app had cached are gone.

  • Send only real columns in the payload of POST /v1/sync/events/from-chat, and make sure the signed-in user holds the record’s write:<table> capability. The transform route now applies the same two gates as POST /v1/data/:recordType: a caller without the capability gets 403 { error: 'forbidden', capability }, and any key that is not a column of the record’s canonical table gets 422 { error: 'validation_failed', rejection: { code: 'UNKNOWN_COLUMN', field, message } } with nothing queued. created_from_id is always accepted. Move custom fields into raw (same as the write boundary); the undocumented { fields, sublists } envelope and array-valued sublist keys are no longer accepted.

  • Check dataSyncEnabled on every /v1/catalog/records entry before you render an empty grid. An instance can run CUSTOMIZATIONS ONLY: the tables exist, the columns are real and kept in step by reconcile, and no records are mirrored — permanently, by the customer’s choice at connect time, not because a backfill is still running. connected: true, dataSyncEnabled: false is that state, and it is the one your empty-state copy should name (“data sync is off for this instance”) instead of a spinner or a “no results” message. The field is on every entry from /v1/catalog/records and /v1/catalog/records/:type.

  • For ONE refused write you no longer need that log — read source_detail on the sync failure itself. Every write the source refuses now records its own diagnosis on the failure row: error_name, implicated_script (the file the throwing stack names, parsed out for you), foreign_script_errors (what any script LOGGED in the same two-minute window), and mandatory_empty (the fields the source still considers mandatory-and-empty, by field id and label — which is how you resolve a refusal that names a display label like Status onto something you can set). It is visible in the failures drawer and on the row. Read scripts_deployed_on_record_type as “whose code COULD have run”, never as the cause — naming a script from that list is a guess, and it has been made and been wrong. Reach for source_script_log when you need the account-wide picture instead. Needs NetSuite bundle 1.24.0.

  • To read a NetSuite script’s SOURCE or its plain-English explanation, use the AUTHORING MCP (turbobase mcp) — search_catalog, then explain_object. Those tools are not on the data MCP, so an agent connected only to the data server will report that it cannot read the script; that means “not from here”, not “not available”. explain_object returns the captured definition or script source, the generated explanation, and modifiability — whether this account can change the object at all, which is worth checking before planning a change.

  • Remove any workaround you added for customer writes being rejected — and match the rejection’s field in snake_case, not camelCase. Every write to POST /v1/data/customer came back 422 MANDATORY_FIELD_MISSING naming companyName, however the request was spelled: the check read camelCase keys while that endpoint hands hooks the canonical column names, so it never saw the name it was asking for. Customer creates and updates now go through, and a partial PATCH (touching only phone, say) no longer has to restate the name. If you branch on the rejection payload, the field is now company_name / first_name / last_name.

  • New skill ns-write-failure-classifications — decode a failure before you edit the payload. The classification says WHO can fix it, the message says what: netsuite_field_validation names a field and never a cause (five unrelated bugs share it), netsuite_ns_error covers one retryable race and several that are not, netsuite_has_dependencies is a fact about the document graph and never retryable, and guardrail_hold_expired means the write never reached the source at all. Also: a climbing occurrence_count means the same broken write is being resent, which only Record has been changed will ever recover from.

  • Please choose an item to add means one of your lines is EMPTY. NetSuite does not skip a blank line — it refuses the whole record for it, and does not say which line. Measured live: a purchase order whose second line had item_id, quantity and rate all null, from a builder that emitted one row too many. Filter empty objects out of lines before sending; nothing downstream ignores them.

  • Three more write diagnostics, all per-request. options.explainSourcing diffs every field between “your values applied” and “immediately before save”, naming what NetSuite cleared in between — that is the re-sourcing trap reporting itself instead of you having to know the rule. options.sublistReadback gives requested-vs-actual per LINE, including whether an inventorydetail subrecord’s assignments actually landed. options.capturePayload echoes the wire body the RESTlet received, which is the only way to see what the adapter sent after the raw spread, ref stripping, derived-field strip and delta narrowing. capturePayload is gated (it returns your own data, not metadata) and truncated at 8 KB; the other two are not. Needs NetSuite bundle 1.23.0.

  • A transaction with downstream paperwork cannot be DELETED either. The linkage that makes a document append-only also blocks its removal: has_dependencies — This transaction cannot be deleted because it is linked to by one or more transactions. It lands as blocked, and it bites hardest in cleanup: a create → convert → delete loop leaves behind exactly the records with the most linkage. Delete the downstream document first, or leave both; retrying never helps.

  • A write refused for a field you did send: check what else is deployed on the record. The failure page now lists every script deployed on that record type at the moment the write failed (/data/sync/failures?event_id=<uuid>), each linking to its catalog entry — one measured account runs 13 User Events on salesOrder, all invisible from the app side. Pair it with options.diagnostics: true to get a requested-vs-actual readback proving whether your value landed. New skill: ns-whats-deployed.

  • connection_id is no longer sent to NetSuite. It was reaching the wire as connectionid and being STORED on the customer’s record — Turbobase bookkeeping written into their data. No action needed; noted because a record written before this may carry the stray value.

  • Debugging a NetSuite write that fails for a field you demonstrably sent: add options.diagnostics: true. The failure envelope then carries a requested-vs-actual readback of every field the write applied, the body fields NetSuite considers mandatory AND empty, the same per line across every sublist, and how many lines / inventory assignments the RESTlet actually received. Off by default (the scans walk the whole record), no redeploy needed, and it reaches sync_attempt.error_detail so the developer who hit the failure can read it without account access. Needs NetSuite bundle 1.22.0.

  • Shipping an order: read the new ns-item-fulfillment skill before writing the code. A fulfillment is a TRANSFORM of a sales order, never a standalone create (created_from_id), and its status is a MANDATORY setter — the opposite of status on every other transaction, where it is derived. The value is a prefixed code: ItemShip:A picked, :B packed, :C shipped. Only ItemShip:C posts to the GL (debit COGS, credit Inventory), so a fulfillment left at A or B moves the document and not the ledger. A lot/serial item also needs per-line inventory_detail, and the lot must already hold stock because a fulfillment ISSUES rather than creates one.

  • The append-only rule covers BILLING, and the refusal renames the child. An invoiced sales order is refused for a Revenue Commitment — the same invoice under its accounting name — so the noun in the message is not a reliable guide to what to go looking for. Check for downstream documents across all four pairs before patching: salesOrder→itemFulfillment/invoice, purchaseOrder→itemReceipt/vendorBill.

  • A transaction with paperwork against it cannot be patched — and that now includes the receipt side. purchaseOrder↔itemReceipt behaves exactly like salesOrder↔itemFulfillment: NetSuite refuses with “would leave the Item Receipt … unlinked”. Updating the RECEIPT’s own header fails too, even with no lines in the payload, because the write path replaces the sublist regardless and a transform-sourced sublist is static. Check for downstream documents before patching, in both directions.

  • invalid reference <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 is Pending Bill on creation, so receiving it refuses. Check the source’s status before changing the payload.

  • Record has been changed is a retryable race, not a bad payload — re-read and resend. NetSuite refuses the save when the record was modified between your write reading it and saving it, which happens routinely: the inbound echo of your own previous write rewrites the row seconds later, and the account’s own User Event scripts fire on every save (one measured account carries 13 on salesOrder). Measured live: committed, then refused 3s later, then committed again 4s after that. It lands as blocked with reason_code = ns_error, so do NOT re-prompt the user — reapply your change to a fresh read and resend.

  • Narrowing raw is not narrowing the patch — cut the canonical columns first. A write body has two levels: the canonical columns (subsidiary_id, currency_id, …) and raw. The re-sourcing trigger lives in the COLUMNS, but raw is the level people trim first. Measured live: two attempts on the same order seconds apart, one with raw at 76 keys and one cut to 3, failed identically — both still carried all 47 canonical columns. Send the columns you are changing and nothing else.

  • Restating a transaction’s lines: don’t build the restatement from the mirror right after your own write. A line your app wrote carries tran_internal_id NULL; the inbound echo of that same line arrives from the source carrying it, and for a window the mirror holds BOTH — measured live as three order_line rows where NetSuite held two. Since a patch replaces the line set, restating from the mirror in that window sends a duplicate line and fails for a reason that looks nothing like the cause. Read the sublist from the header’s raw->'item'->'items' (the source’s own copy), or wait until record_write_state is committed and the echo has applied.

  • Verifying a write landed: filter line tables on parent_pg_row_id, never on the source-id column (tran_internal_id). The source id is populated by the inbound sync only, so it is NULL on any line your app wrote — a query like order_line?tran_internal_id=eq.<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_id on updates. Sending a dimension back at its own unchanged value makes NetSuite re-source everything that depends on it, and that now demonstrably includes line tax codes. So a patch carrying four custbody_* fields and no lines at all is refused with Please enter value(s) for: Tax Code, naming a line you never sent, whose tax code is correct in the mirror. On an update, send only the fields you are actually changing. This bites the two-phase shape hardest — write the record, then come back to stamp cross-reference ids — because the second call is the one that fails; carry the cross-references in the first write where you can, or send the follow-up with only the custbody_* fields and no dimensions.

  • When a source write is refused for a field you demonstrably sent, read the account’s script log before touching your payload — MCP source_script_log, or GET /admin/sources/netsuite/script-log?sinceMinutes=15&type=DEBUG. Run the failing write first (the window is clamped to 60 minutes / 200 rows), then read it; window.truncated: true means you are looking at a capped slice, so treat “nothing here” as “look again, narrower”, never as “no script fired”. Full-instance-admin only, and every read is audited. Needs NetSuite bundle 1.16.0.

  • BREAKING — stop reading rows with a PAT: POST /auth/pat-exchange and the data:read scope are REMOVED. Any script, CI step or agent that traded a PAT for a session and then called /rest/v1/* now gets a 404 on the exchange. Replace it with one of: the visitor’s session cookie (inside a hosted app — nothing to change, select() already does this), or the data MCP, which signs the developer in over OAuth and exposes list_records / get_record / search_fields as tools; connect it from Settings › Integrations. The exchange minted the token OWNER’s ordinary session, so for an admin owner it carried write — a 90-day token in a project file was standing write access to production data. Reading rows is a per-user act now, carrying the real person’s identity and RLS.

  • Drop Authorization: Bearer <session-jwt> from POST /v1/data/:recordType, POST /v1/batch and the data delete route — send the session cookie. Those three accepted a bearer session solely for pat-exchange callers, and with the exchange gone nothing can obtain one; the option was removed rather than left as an auth path no credential satisfies. A same-origin app was already using the cookie and is unaffected.

  • turbobase project types no longer emits the OpenAPI half headlessly — it degrades to record shapes and says so. The record-shape half (PAT + catalog:read) is unchanged and is what generated clients are built from; only /v1/catalog/openapi.json, which is session-gated, is skipped. If your CI asserted on the OpenAPI half, pass --records-only to make the intent explicit rather than relying on the degrade path.

  • Confirm a write landed by reading turbobase.record_write_state — a 2xx from the write endpoint means QUEUED, not applied. Writes to a source are asynchronous, so an app that reports success on the HTTP response reports it over failures. Poll the row and branch on status: pending (in flight), needs_input (the source rejected the payload — fix and resend), blocked (anything the end user CANNOT fix — check reason_code: ns_error, permission_denied, closed period, workflow lock, or a guardrail hold. Do not re-prompt the user; resending unchanged will not help). The split is deliberate: needs_input is a field problem with plain-language text and a fields list, blocked is everything else and stays on the admin plane — the writer defaults to blocked when unsure, because wrongly asking a user to fix something they cannot is the worse failure. Read it through your normal session — it is already granted; do NOT reach for turbobase.sync_failure, which is admin-only by RLS and returns zero rows to an app session whatever grants you add.

  • After creating a record, keep the pg_row_id your insert returned — do not re-key on a row fetched by external_id until the write settles. With real-time inbound working, a create briefly exists as TWO Postgres rows: yours, and one the source’s webhook inserted before write-back could stamp the external_id back. The platform reconciles them and keeps your originating row, deleting the twin (write-back echo twin reconciled in the worker log). A row you re-fetched by external_id inside that window may be the twin, so anything stored against its pg_row_id dangles once it is collapsed. This only appears once inbound webhooks are actually arriving — an instance with no tunnel never shows it.

  • Debug a NetSuite write yourself with options.explain — no platform change needed. op=write / op=transform now take an options object beside fields: explain (default false) returns {applied, diverged} comparing what you sent against what the record actually holds — on success the committed record, and on failure under detail.explain, which is the case you actually want it for. Check phase before reading it: committed means the value is live; pre_save means the save threw, nothing committed, and applied there means only “assigned in memory” — NOT that NetSuite accepted the field. A field setValue accepted and NetSuite then cleared shows up in diverged instead of silently succeeding, turning Please enter value(s) for: Customer into entity: sent "35", current -1. enableSourcing (default true) turns off the re-sourcing that clears dependents; isDynamic (default false) loads the record in dynamic mode and is REFUSED alongside sublists, since dynamic records have no setSublistValue. ignoreMandatoryFields is gated per connection — honoured only while that connection is in debug mode (source_install.debug_mode_expires_at in the future, auto-off after 24h); otherwise it is stripped before the write reaches NetSuite and the refusal is logged, so a bypass you asked for and did not get never reads as a bypass that was tried and did not help. Absent options, behaviour is unchanged — this adds nothing you must adopt, only something you can reach for. Unknown keys are ignored, so a newer caller is safe against an older bundle.

  • Please enter value(s) for: X is FIVE different bugs — do not start from the field name. The same sentence has produced Customer, Tax Code, Ship From and Order Type from four unrelated causes. Triage in order: (1) is the field actually empty on the source’s copy — and check BOTH levels, a header tax item and a line tax code fail identically; (2) does your payload restate subsidiary / currency / customform even unchanged (this is the most common cause, and it clears a field you never touched); (3) does the record have lines your patch omitted; (4) is it genuinely mandatory and never supplied; (5) only then suspect another vendor’s User Event. Fixing cause 2 in one code path and leaving another is how the same bug returns wearing a different field name.

  • Check for downstream documents before patching a transaction — an order with a fulfillment/invoice/receipt cannot be patched at all. SUPERSEDED by bundle 2.5.0 — see v1.7.0 above; the lead clause above is no longer true. Restating the lines WAS refused (Items on this line have been fulfilled. To delete this line, you must first delete the corresponding line(s) in the associated transaction(s).) because the write replaced the sublist and NetSuite would not let the clear touch a referenced line. Bundle 2.5.0 matches the lines in place instead, so an ordinary patch of such an order now goes through; only adding a line to one, or dropping one from it, is still refused. Measured on a sales order with one line and one linked item fulfillment, changing a single header field with the line restated exactly as NetSuite held it. One clause here was wrong when written: omitting the lines does NOT empty them — a payload with no lines omits the sublist key entirely and NetSuite keeps what it has. (The “omitting them DELETES them” rule below is a different layer: the PG-side write RPC, not the source payload.) Do NOT delete the fulfillment to get a write through — the message describes what NetSuite would need, it is not an instruction.

  • Restate lines COMPLETELY — a partial line wipes the fields you leave off. The replace is per line as well as per set, so {item, quantity, tax_code} on a line that also had a rate and an amount removes the money, and NetSuite refuses with Please enter a value for Amount — the same underlying mistake wearing yet another field name. Read the current line set and send it back with your change applied; never assemble a line from just the fields you are changing.

  • Always restate lines on a transaction UPDATE — omitting them DELETES them. write_transaction’s update leg runs an unconditional DELETE on the line table before it reads the lines you sent, then re-inserts whatever arrived. So a header-only patch (a status, a custom body field, a closing stamp) empties the document and returns 200 OK with nothing logged. Re-sending the same lines is safe; sending a subset replaces the sublist with that subset. Note this is the OPPOSITE of raw, which now merges — the same payload obeys different rules per field. Records with no line table (customrecord_*, customer, item, customerPayment, customerDeposit, timeBill) take another path and are unaffected.

  • Before reporting that Turbobase damaged a mirrored record, check the source. A row that looks wrong is either what the source holds, or a bad write, or local-only damage — indistinguishable from the mirror alone. Check in order: (1) write_status='blocked' or all sync_event rows failed means nothing reached the source, so the source is untouched by definition; (2) op=read shows what the source actually holds; (3) never infer damage by comparing a row against a different record — a neighbouring document with more lines is not a “before” picture.

  • Do NOT resend subsidiary (or currency / customform) on a NetSuite transaction update unless the value actually changed — writing it clears entity. Measured live: on a sales order already holding subsidiary=7 and entity=35, a write of {entity, subsidiary} — subsidiary set to its OWN current value — came back Please enter value(s) for: Customer, Order Type, Ship From, while {entity} alone succeeded on that field. NetSuite re-sources dependent fields whenever a trigger field is written, changed or not, and re-setting entity in the same payload does not survive (ordering does not help). If your app builds updates by resending the whole record, diff against the prior row and omit unchanged trigger fields. Creates are unaffected, which is why this presents as “creates work, updates fail”.

  • You cannot unwind an order by clearing its lines — delete the downstream documents first. A line referenced by an item fulfillment, receipt or invoice is anchored: NetSuite refuses Items on this line have been fulfilled. To delete this line, you must first delete the corresponding line(s) in the associated …. For a rollback, delete youngest-document-first. Updated for bundle 2.5.0: sending in-place line overrides is no longer something an app arranges — the platform now detects an anchored line and matches in place by itself, so an ordinary edit works. What remains true is the part above: the anchored line itself cannot be removed, and neither can a line be added alongside it.

  • A 404 NONEXISTENT_ID in pg→source read-back failed right after your own delete is expected — ignore it. The worker re-reads every record after a successful write-back; a rollback or undo that deletes the record moments later wins the race. The warning says “PG may lag the source until the next full sync”, which is wrong in that case: nothing is lagging and no sync restores it. Treat it as confirmation the delete landed; it only matters if you did NOT delete the record.

  • If you saw failed to set links[0].rel or failed to clear links[0], stop sanitising your payload — the platform was re-introducing it. links is NetSuite’s REST HATEOAS array, an array of objects, so the write path classified it as a sublist and tried to address lines on it. It reached the wire from the mirror’s raw (the read-back stored the REST envelope verbatim and the header mapper spreads raw), which means a clean app payload still hit it. Now stripped on the write path; no app change is needed and an app-side fix could never have closed it.

  • Read structured write failures from turbobase.sync_attempt.error_detail, NOT sync_failure — the latter has no detail column. sync_failure is one row per root cause (deduped, occurrence_count); sync_attempt is one row per attempt, and errorName / stack / foreignScriptErrors / scriptsOnRecordType live there. Querying sync_failure.error_detail returns “column does not exist”, which reads as the enrichment being unavailable on your instance when it is one join away (sync_attempt.event_id = sync_failure.event_id). Use sync_failure for the reason text and occurrence count; sync_attempt for the evidence.

  • A NetSuite mandatory-field error naming a field you DID send is a customization problem — read scriptsOnRecordType before re-checking your mapping. sync_failure.error_detail now carries the script context on field_validation as well as ns_error (bundle 1.11.0). Read the three fields together: stack says whether the value was ever set (thrown at save = it was), foreignScriptErrors says whether any third-party script crashed, and scriptsOnRecordType lists every User Event deployed on that record type. An empty foreignScriptErrors does NOT mean no third-party involvement — a beforeSubmit that clears a field without throwing logs nothing, and that is the common case. Measured live: a salesOrder update failing Please enter value(s) for: Customer while sending entity_id had 16 User Event scripts on the record.

  • Stop sending NetSuite REST envelope keys in a write payload — strip links before you post. If you build a write body by spreading a record you fetched from the NetSuite REST API, you are also sending its HATEOAS links array; NetSuite then fails the whole save with failed to set links[0].rel. Rows read from Turbobase are already safe (the read path drops links) — this only bites when an app round-trips a raw source response.

  • Set line_kind: "expense" on account-coded lines when you write a vendorBill, purchaseOrder or check. These three records hold their lines in two NetSuite sublists — item-coded and account-coded (a GL account plus an amount, no item) — and line_kind is what routes each line to the right one. Until now every line was written as an item line, so an account-coded line was rejected by NetSuite: the read path could show a bill your app was unable to edit. Send { "line_kind": "expense", "account_id": "54", "amount": "250.00", "description": "November electricity" } and omit item_id / quantity / rate; a line with line_kind set to "item" still goes to the item sublist exactly as before, so existing item-only writes need no change. Reads are unchanged — line_kind has always been on the row. (Later narrowed: a line that leaves line_kind absent or null is no longer sent to the item sublist unconditionally — one naming an account_id with no item_id is routed to the expense sublist by its own shape. Setting line_kind explicitly is still the only unambiguous form.)

  • Treat is_billable on a payable line as THREE-valued — null means “not observed”, not “not billable”. vendor_bill_line and purchase_order_line gain customer_id / customer_name / is_billable (check_line already had the columns), the pair that lets a pass-through cost be rebilled to a customer. If your app lists rebillable costs, filter is_billable=eq.true rather than treating falsy as not-billable: rows synced before this release read null until the one-shot backfill and the next delta sync fill them. customer_name can stay null on rows that came from the bulk backfill even once customer_id is set — embed or join customer if the name must always render.

  • Read source, recordType and recordId off the query string to know which NetSuite record opened your app. Two new bundle scripts — a button on the record form (“TB Open in Turbobase”) and a record subtab (“TB Turbobase Subtab”) — append ?source=netsuite&recordType=<nsRecordType>&recordId=<nsInternalId> to whatever app path the NetSuite admin sets on the script deployment, so /a/orders/detail arrives as /a/orders/detail?source=netsuite&recordType=salesorder&recordId=4321. Treat all three as OPTIONAL: the same app is still opened from the dashboard portlet and from a bookmark with none of them, and recordType is NetSuite’s internal type id (salesorder), not the canonical Turbobase name (salesOrder). The user arrives signed in with their own NetSuite role either way — the scripts route through the same mint step the portlet uses, so RLS sees the real person, not a service identity.
  • Add .github/workflows/turbobase-app-deploy.yml to get previews on pull requests and production on merge. Re-run turbobase apps init <slug> . in your project (it never overwrites an existing file) or copy the template from templates/ci/turbobase-app-deploy.yml. A pull request gets its own preview URL — updated in place on every push, so reviewers keep ONE link — plus a plan of what merging would change in your config; merging to your default branch applies the config and puts the app live. Set the repo variables TURBOBASE_BASE_URL and TURBOBASE_APP_SLUG and the secret TURBOBASE_PAT (scope extensions:deploy); until TURBOBASE_BASE_URL is set every job skips rather than fails.

  • Delete .github/workflows/turbobase-tables-plan.yml if you have it. Its job is now the turbobase apps plan . step inside the deploy workflow above, which also checks guardrail-blocked writes. Keeping both runs two checkouts and two logins to answer one question.

  • Declare the source writes your app needs in public/turbobase.json, and a blocked write fails the DEPLOY instead of failing per-row in production. Add "writes": [{ "source": "netsuite", "recordType": "salesOrder", "ops": ["write"] }] — ops is write and/or delete, defaulting to ["write"], and delete is checked separately because opening a write never implies the right to delete. This is a REQUEST, not a grant: apps deploy refuses with the blocking guardrail named, and an admin opens it (Compliance → Guardrails). An app cannot grant itself a write.

  • Run turbobase apps plan . to preflight a deploy. Reports table conflicts with other apps AND the guardrail verdict on every declared write, writing nothing. Exits non-zero on either, so it works as a PR check.

  • Your hooks/, conduits/, turbobase/sync.json and turbobase/schedules.json are now the desired state — turbobase ext plan --to prod diffs the REPO against your instance and ext deploy --to prod applies it. Previously the desired state came from turbobase/bundle.json, which only ext export wrote from the instance, so a pull request could not show what merging would change. Removals stay opt-in: deleting a hook file does not delete it from the instance until someone runs ext deploy --to prod --prune, and every apply prints what it declined to remove. Adopt an existing instance’s config into your repo with turbobase ext export, then move the extensions into hooks/.

  • Get the preview URL for a PR with turbobase apps preview list <slug> --json, and update a preview in place with turbobase apps deploy <slug> . --replace-label "<text>". --replace-label opens a preview when none carries that label and re-deploys onto it when one does, so scripting “one preview per pull request” needs no bookkeeping. --json exists because the human output is prose and breaks any regex the moment a label contains two spaces.

  • New projects now ship Tailwind v4 + shadcn/ui, matching the published design system — adopting it in an existing project is a manual migration. apps init now scaffolds the exact stack docs.turbobase.io/build-apps/design-system/ specifies: Tailwind v4 as a Vite plugin (no tailwind.config.js — the theme is src/styles.css), shadcn/ui New York on the neutral base with a components.json so npx shadcn@latest add <component> works with no init step, six primitives under src/components/ui/, lucide-react, and Inter (latin subset only). A @/* path alias is declared in BOTH tsconfig.json and vite.config.ts — keep them in sync, or the editor and the build disagree. apps init never overwrites, so an existing project keeps its current styling until you migrate: take the package.json deps, vite.config.ts, src/styles.css and components.json from a fresh scaffold. The design-system page was previously unfollowable in a hand-scaffolded project, which is what this fixes.

  • Expect the agent in your project to sound different, and to be shorter. apps init now writes .claude/output-styles/turbobase-consultant.md and turns it on in .claude/settings.json. It is written for ERP consultants, NetSuite administrators and business analysts: business outcome first, source-system vocabulary, brief unless you ask for detail. Pick another style in /config, or delete the outputStyle line, to get stock Claude Code back.

  • A new PostToolUse hook warns when a business rule is written into the SPA. .claude/hooks/check-rule-placement.{sh,mjs} fires on edits under src/ that both write through the client and read like a rejection, and points at hooks/. It never blocks an edit. Delete both files to remove it.

  • ADOPT.md now refreshes at the start of every Claude Code session, so entries like these reach your project without anyone running a command. If you scaffolded before this release, re-run turbobase apps init <slug> . to pick up the session hook, or run turbobase project adopt yourself.

  • Link two records you just created by naming the other’s pg_row_id — stop round-tripping to get its source id first. Creating a customer and its order in one go used to mean POST the customer, poll until the platform minted its source id, then POST the order naming that id; every link between two new records cost a full upstream round-trip. Now you name the row you already have. Two shapes, and which one you use is decided by the column:

    • A ref column with a foreign key (entity_id on every transaction) will not accept anything but a real source id. Set the <x>_pg_row_id sibling instead and leave the ref column NULL: POST /v1/data/salesOrder {"entity_pg_row_id":"<customer pg_row_id>"}. The platform fills entity_id on the way out. entity_pg_row_id now exists on all twelve entity-referencing tables — cash_sale, credit_memo, customer_deposit, estimate, opportunity, purchase_order, vendor_bill and vendor_payment gained it, joining order, invoice, order_fulfillment and customer_payment. So vendor + purchase order in one pass works, not just customer + sales order. If a leg create was failing seconds later with Please enter value(s) for: Vendor (or : Customer), that is the shape to use — the error meant the entity was unnameable for an unsynced record, not that you had omitted it.
    • A ref column with no foreign key (created_from_id, line-level refs, custom-record links) takes the sentinel directly: {"created_from_id":"pg:<uuid>"}. It must be pg: plus a full UUID — anything else is treated as a literal source id and passed through untouched, so an existing id that happens to start pg: is never hijacked. An explicit source id always wins if you set both. If the referenced record has not reached the source yet, the write WAITS for it rather than failing — but only while that record’s own write is still in flight, and only up to 8 attempts / 10 minutes. Two records that reference EACH OTHER can never resolve (intercompany pairs do this): that fails with reference_cycle naming both rows. Write one side without the back-reference, then patch it in.
  • Create a customer or vendor directly — POST /v1/data/customer with no key now works. It used to answer 422 MISSING_KEY (“requires externalId, pgRowId, or an external_id in the payload”), on the since-obsolete grounds that the entity table had no pg_row_id and a NOT NULL external_id. Both changed: entities mint their own pg_row_id and external_id is nullable until the record reaches the source. customer and vendor were the only bidirectional record types an app could not actually create; now they behave like every other one. Send the fields you have — POST /v1/data/vendor {"company_name":"Acme Supply"} — and read pg_row_id off the response.

  • Name the entity with entity_pg_row_id and the required-entity check will accept it. ns:transactions:require-entity read only entity_id, so a transaction naming a not-yet-synced customer by its sibling was refused 422 MANDATORY_FIELD_MISSING: entityId at the boundary — before the outbound resolver ever saw the sibling. It now treats a valid entity_pg_row_id as an entity reference, exactly as it already treats created_from_id. Still refused: a transaction naming no entity at all, or a sibling that is not a UUID.

  • Stop sending a full lines array just to edit a header field. A header-only PATCH of a record that has already synced was being refused with ATOMIC_WRITE_REQUIRED across all 16 transaction types, even though a header-only edit is a legitimate operation that leaves existing lines untouched. It now succeeds. A fresh CREATE still must ship its lines in the same write — that rule is unchanged and is what the error is for.

  • Send inventory_detail on a line to move a lot- or serial-tracked item — without it the source refuses the whole record. Receipts and fulfilments of a tracked item previously had no expression at all: there was no key for it at the boundary, so the write failed with Please configure the inventory detail in line 1 no matter what you sent, and an app could raise every document around a deal without moving a unit. Put the assignments on the line: lines: [{ item: '485', quantity: 10, inventory_detail: [{ inventorynumber: 'LOT-A', quantity: 6 }, { inventorynumber: 'LOT-B', quantity: 4 }] }]. Optional per assignment: binnumber, expirationdate, inventorystatus. Use the one key inventorynumber in BOTH directions — the platform resolves it to the source’s inbound or outbound field, so you never encode that split yourself. Quantities must sum to the line’s quantity. An empty array CLEARS the assignments; omitting the key leaves them untouched. Requires NetSuite bundle 1.8.0 — an older bundle silently ignores it.

  • Read which lots landed with v_inventory_assignment, not by parsing inventory_detail yourself. One row per assignment across every inventory-affecting line table, carrying record_type, line_pg_row_id, parent_pg_row_id, item_id, inventory_number, quantity, bin_number, expiration_date. GET /rest/v1/v_inventory_assignment?parent_pg_row_id=eq.<receipt>. Its pg_row_id is derived from (line, ordinal) and is read-only — to change an assignment, write the whole inventory_detail array on line_pg_row_id.

  • Re-read created_from_id on order and vendor_bill — it was null on every mirrored row and now populates. The column exists to dispatch the PG→source transform (estimate → salesOrder, purchaseOrder → vendorBill), but the bulk backfill wrote the source value into raw under a key nothing read, so an app filtering on it matched nothing and a transform keyed off it could not fire for any historical record. Both paths are fixed and a one-shot re-scan repopulates the mirror on upgrade; no app change is required, but any logic that treated “no created_from_id” as “raised standalone” was wrong for backfilled rows and is now correct. Read “what was billed from this PO” as vendor_bill?created_from_id=eq.<po external_id>.

  • Set raw.customform if your record needs a specific NetSuite form — the platform no longer picks one for you. Turbobase used to inject a per-record form id on every create. Those ids were found against one sandbox and a form’s internal id is assigned PER ACCOUNT, so on any other account it named a form that does not exist and failed the whole record with Invalid Field Value <id> for the following field: customform. Creates now omit customform unless you supply it, and NetSuite picks its own default. If a line comes back Invalid Field Value <itemId> for the following field: item, that is the default form’s item-options list not exposing the item — send the right form as raw.customform, or ask your administrator to set NS_CUSTOMFORM_<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_id and no bare embed. A custom record has no sublist: what its form draws as one is a SECOND record type whose rows carry a List/Record field pointing at the parent, so the two mirror tables have no foreign key between them. Read children as cr_<child>?cf_ns_custrecord_<field>=eq.<parent external_id>, matching the parent’s external_id (the link is a bare id, never a pg_row_id). To get parent+children in one request the pair must be DECLARED as a computed relationship — it is not discovered. Native transactions are unaffected: <table>_line still embeds on its FK.

  • Stop stripping derived fields before you POST — and stop expecting a write to fail because you sent one. POST /v1/data/:recordType now drops the fields NetSuite COMPUTES rather than forwarding them: status (and order_status / fulfillment_status / financial_status), total, subtotal, tax_total, discount_total, shipping_total, amount_paid, amount_remaining, applied, unapplied. These have no setter upstream — a transaction is “Pending Fulfillment” because of its fulfillments, and a total is the sum of its lines — so sending one previously failed the whole write with Invalid Field Value … for the following field: orderstatus. This bit hardest on the most natural pattern there is: read a row, change one field, post it back. That now works. The strip also covers the same key arriving inside raw. approval_status is NOT stripped — it is a real setter and still writes. To move a status, create the record that moves it (a fulfillment, an invoice, a payment), not the field.

  • Send per-line overrides on a transform as lines, matched by item. When you create a record from another (created_from_id — item fulfillment from a sales order, item receipt from a purchase order), NetSuite sources the lines from the originating document and that sublist is STATIC. Your lines array is now applied as in-place overrides matched by item_id, not by position, so you can set a per-line location_id the originator wouldn’t accept. A line you omit keeps what the document gave it; omitting is “no opinion”, not “remove it” — to exclude a sourced line send its own exclusion field. Naming an item_id the document did not source is a 422, not an insert. Previously any lines on a transform failed the write outright with “trying to add or remove lines from a static sublist”.

  • Surface rejection.message on a 422 from DELETE /v1/data/:recordType — a delete can now be refused by a business rule, not just by a guardrail. Hooks gained before_delete / after_delete, so a tenant rule (“this order has a fulfillment”, “this customer has open invoices”) rejects a delete in the same structured envelope before_save already uses for writes. Treating any non-200 as a generic delete failure now hides the one message that says why. before_delete can reject but cannot patch: a delete is addressed by key (pgRowId / externalId), not by row body.

  • Expect the write-back rate caps to bind on a BURST now, and handle the denial. guardrails.max_{updates,deletes}_per_{min,hour,day} reserve a slot per write instead of counting settled work, so concurrent requests no longer all pass on the same pre-state — a parallel burst gets exactly cap through and the rest come back 422 SYNC_GUARDRAIL_BLOCKED / rate_limit_exceeded. Code that fanned out writes and never saw a denial will start seeing them. Retry the denied ones with backoff, or throttle on your side.

  • Count a failed write against your cap — a slot is not refunded. The cap governs what you ATTEMPT against the source, not what succeeded, so a write that errors upstream still consumed its slot. A retry of the SAME write is free (it is one write, attempted again), but a retry loop that mints a fresh idempotency key each time will walk through your quota. Reuse the key. An operator can clear a wedged cap in Settings → Guardrails.

  • Stop working around the duplicate-key failure on a PG-originated create — it is fixed, and the workaround is now the bug. Creating a header row and polling it for its source-minted external_id used to be able to fail: the record came back inbound before the id was stamped, was ingested as a SECOND row holding the id, and the stamp then died on <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 whose pg_row_id you were given.

  • Handle a new sync_event.state: resolved_local. If you read sync_event (or use generated types with a state union), add it — a TypeScript union that omits it will fail to compile, and a switch without a default will fall through. It means a delete that completed WITHOUT being sent upstream: the row had no external_id, so the tombstone was the whole delete. It is terminal and successful, but it does NOT mean the source has the change — do not treat it as a synonym for committed.

  • Deletes now actually reach the source. Every pg→source delete previously failed inside the adapter before NetSuite was called (a stored auth context that could never resolve), so a del() that returned {deleted:true, enqueued:true} was never applied upstream. If your app assumed a queued delete had landed, that assumption was wrong before this release and is correct after it. Re-check any records you deleted and believed were gone from the source.

  • Send transaction lines as a lines array in the SAME POST /v1/data/:recordType call — never to /rest/v1/rpc/write_transaction. Body is {"parent":{…},"lines":[{…}]}; the boundary commits header + lines atomically. The RPC is not callable by an app (it is SECURITY INVOKER and authenticated holds no write DML, so it 403s — and it 404s first, because PostgREST matches functions by argument name). The ATOMIC_WRITE_REQUIRED rejection used to name that RPC; it now names this call.

  • For a CUSTOM record, do not send lines at all — its children are separate records. A native transaction (salesOrder, purchaseOrder, invoice, …) carries a NetSuite sublist that has no per-line endpoint, which is what lines is for. A custom record’s children are their own record type pointing at a parent, so write the parent, take external_id off the response, then POST each child to its own record type. Sending lines to a custom record is a 422 LINES_NOT_SUPPORTED.

  • Expect a 422 rate-cap rejection from DELETE /v1/data/:recordType even for a row that never synced upstream. A delete of a row with no external_id still completes locally and still returns enqueued: false, but it is now recorded and COUNTS against guardrails.max_deletes_per_{min,hour,day} — it previously counted for nothing, so a bulk local cleanup ran unbounded. Batch such a cleanup under the cap, or raise it in Settings → Guardrails; the rejection arrives as SYNC_GUARDRAIL_BLOCKED with the cap named in rejection.message.

  • Replace your read-only src/App.tsx with the new starter — it now demonstrates write() and del() alongside select(). It reads with a projected column list rather than select=*, renders the { deleted, enqueued } shape verbatim, and distinguishes the loud delete modes: a guardrail deny, rate cap or parked source throws; require_confirm returns enqueued: true but is held for an operator before it reaches the source; a source refusal lands as a sync_failure rather than a throw.

  • Move validation out of src/App.tsx into hooks/ — turbobase apps init now scaffolds hooks/ and its samples/ fixtures. Your app cannot bypass POST /v1/data and its mandatory before_save (the authenticated role holds no write DML), so a rule written in the app binds only that app. Run the scaffolded example with turbobase ext test hooks/<file>.ts.

  • Re-run turbobase apps init in an existing project to pick up hooks/, samples/ and the new CLAUDE.md boundary section. It is non-destructive and SKIPS files that already exist — rename your current CLAUDE.md and src/App.tsx first if you want the new text.

  • Promote and retract hook proposals from CI with turbobase ext proposals list|promote|retract|reject. Bulk promotion is promote --auto, a dry run by default — add --apply, plus --all if you passed no filter. Needs a PAT with extensions:deploy whose owner is a live admin. A provenance='learning' proposal is refused on activation by design: those arm through operator sign-off, not bulk promotion.

  • Join a change-audit row to its LINE on the new system_note.line_index. turbobase project types now emits it. NetSuite writes the trail per line — a four-line receipt writes four notes — and every one of those rows previously carried only the parent transaction id, so a per-item history was not buildable. Read it as JOIN <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 on item_receipt_line / journal_entry_line / order_fulfillment_line the line column is spelled line_number (l.line_number = sn.line_index). Existing rows fill in on the tenant’s next audit-trail run — treat NULL as “not yet backfilled” until then.

  • Page a LINE table by passing its key as a tuple: selectPage('vendor_bill_line?select=*', { sortKey: ['parent_pg_row_id', 'line_index'] }). selectPage’s sortKey now takes an ordered tuple, and the page predicate is a row-wise comparison over it, so a line table pages with no drops and no repeats — there was previously no correct single column to seek on. Add { descending: true } to walk the same key backwards. SEEK_KEYS in your generated types carries the tuple where a table has one, so { sortKey: SEEK_KEYS.vendor_bill_line } needs no change on your side — regenerate with turbobase project types. Line ordinals are line_number, not line_index, on order_fulfillment_line / item_receipt_line / journal_entry_line.

  • Discard any nextCursor your app persisted across this upgrade — re-read from the first page. The cursor now carries every column of the seek key, and a cursor minted by an older client is REFUSED (TurbobaseCursorError, “minted by an older client”) rather than reinterpreted, because reading it as a one-column tuple would silently drop rows. Cursors held in memory for one scroll session need nothing; a cursor written to storage or a URL does.

  • Stop reading a skill’s enabled flag — publish/unpublish is the whole gate. The column is gone from turbobase.skill, and GET /v1/skills no longer returns it. turbobase skills list reports draft, unreleased or live; the disabled status it used to print does not exist. Rules pushed by turbobase skills push still land as drafts and still reach nobody until an admin publishes them — that is unchanged. On upgrade, any skill that was published but switched off is moved to draft, so nothing an operator had turned off starts injecting; publishing it brings it back.

  • Structure a TURBOBASE-FEEDBACK.md finding with ###, never ##. Every ## section of that file is read as a SEPARATE finding, so sub-headings inside one write-up (“The three gaps”, “What we’d use instead”) were each shipping to the product team as a finding of their own. Headings that read as discourse labels are now dropped, and the scaffold says so — but the reliable fix is yours: one ## per finding, ### for everything under it. Existing findings need no edit; numbered sections (## 3. …) are always kept.

  • Write your finding’s title as a CLAIM, not a topic. The title is now the sentence that reaches the fleet — it is redacted, capped at 240 characters and shipped verbatim beside the pattern label. “Line totals are pre-tax; the header total is tax-inclusive” is actionable; “Tax handling” is not, and an unnumbered section titled like the latter may be dropped as a discourse label.

  • Expect what you write in TURBOBASE-FEEDBACK.md to actually reach us — sharing is now ON by default, and everything already queued goes with it. The setting (telemetry.share_learnings, Settings › Telemetry → “Share learnings and platform feedback”) used to be off until someone ticked it, so a project could accumulate findings that were never sent and, because each entry was stamped with the setting as it stood when it was ingested, never could be. Consent is now read at SEND time: turning the toggle off stops everything including the current queue, turning it back on releases it. If your instance has a backlog you did not intend to send, turn the toggle off before the reporter’s next hourly tick. An explicit off stored before this release is re-enabled by the upgrade. What leaves is unchanged — anonymized pattern cards, and prose from TURBOBASE-FEEDBACK.md with instance name, user names, ids and source-system identifiers masked; "learnings": { "share": "off" } in a project’s turbobase.json still narrows it for that project. turbobase auth login now prints all of this and where to turn it off.

  • Read rows headlessly: mint a PAT with the data:read scope and POST it to /auth/pat-exchange. The endpoint returns a 15-minute session JWT — send it as both Authorization: Bearer and apikey for /rest/v1/*, and as Authorization: Bearer for the session-gated /v1/catalog/openapi.json. It existed since v1.0.70 but no tenant could reach it (the ingress never routed the path), so a headless agent could read a table’s schema and never one of its rows; turbobase project types reported it as an older instance and silently degraded to --records-only. data:read is opt-in and not in any token’s default scopes — an existing PAT does NOT gain this, re-mint to use it.

  • Stop copying ADOPT.md aside before you run turbobase project adopt — it now MERGES. A run that finds a new release keeps every item you have not ticked, carries the ticks you have made, drops the ticked items the instance no longer lists, and appends the new release’s. Earlier runs replaced the file, so an incremental run deleted the unticked work of every previous release. A file this command did not write (no generated header, no ## v<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 with git show HEAD:ADOPT.md only if you commit the file, so commit it if the checklist is a record you want to keep.

  • Add a pretest to your app’s package.json: "pretest": "turbobase project types -o src/types.ts || …" (the scaffold now writes it). src/types.ts is generated from the instance catalog and gitignored, so a second working copy — a worktree, a CI clone, a new laptop — has no copy, and any test importing it failed at module load with a bare ENOENT that named neither the file’s origin nor the command producing it. The scaffolded line regenerates before the tests run; if the instance is unreachable it falls back to the copy on disk with a “may be stale” warning, and only fails when there is no file at all — naming the two commands that produce one. turbobase apps init writes it into new projects; existing projects should copy the line across (the scaffold never overwrites your package.json).

  • Delete 2>&1 from .claude/hooks/session-start-refresh-types.sh in projects scaffolded before this release. The hook sent BOTH streams to /dev/null, so when the v1.0.82 verb rename broke turbobase types, the refresh failed silently for weeks and every session ran on a stale types.ts. The scaffold now mutes stdout only and its fallback line names the command that failed instead of guessing “not signed in?”. Re-run turbobase apps init in place to pick it up (existing files are kept — delete the hook first to have it rewritten).

  • Treat anything you have EVER put in public/ as published, and rotate it. A file there keeps the same /a/<slug>/<name> URL on every deploy, and until this release that URL was served Cache-Control: public, max-age=31536000, immutable — so a shared cache in front of your instance could store a copy made for an authorized user and hand it to callers who never signed in, for a year, including after you deleted the file. The origin now serves /a/<slug>/… as private, no-cache: no shared cache may keep it, and a removed file stops being served on the very next request. Copies a cache took BEFORE this release are not reachable from the instance — ask your operator to purge the edge, and treat any key, token or diagnostic script that has ever sat in public/ as disclosed.

  • Import an asset you want cached for a year instead of dropping it in public/. A bundled import gets a content hash and is served from the build-scoped base (/_tb/app-assets/<build-id>/…), which keeps max-age=31536000, immutable and re-mints its URL on every deploy. A public/ passthrough file is served from the mount path, which now revalidates: expect a conditional request answered 304 from the new ETag rather than a cache hit, so the bytes still only move when they change. No code change — this is why a public/ file no longer behaves like a fingerprinted one.

  • Run turbobase auth status first when the authoring MCP’s tools are missing from a session. It now asks each stored instance whether it still accepts your token and prints the instance’s own reason — revoked, expired, unknown (usually: the token belongs to a different instance) — plus the command that fixes that specific case. Previously it only listed what was in ~/.turbobase/credentials, so a revoked token and a server that was never configured looked identical: .mcp.json present, 0600, holding a well-formed bearer, and every tool silently absent. It prints no token material. Add --no-probe for the old purely-local listing.

  • After any turbobase auth login, re-run turbobase project init to rewrite .mcp.json. That file holds its OWN copy of the PAT, taken at init time, so a fresh sign-in does not reach it and the authoring MCP keeps presenting the dead token. Relatedly, POST /mcp/authoring now answers a rejected token with the reason on both the WWW-Authenticate challenge and the JSON body ({"error":"unauthorized","reason":"revoked","message":"…"}) instead of a bare 401, so a client that surfaces either one can say why the server failed to connect.

  • Trust turbobase kb push --dry-run as the contract — its plan is now exactly what the push executes. A page whose unpublished work is YOUR OWN is no longer reported as blocked … this page will be SKIPPED (it was, and then the push appended to it anyway); a page genuinely held by another editor now reads action SKIP — nothing is written, gets no write-set diff, and is excluded from the plan’s N page(s) / N appended counts and from the confirmation prompt’s numbers. If you script kb push, those counts changed meaning: they now count only pages that will actually be written.

  • Project a PATH into raw instead of the whole column: MCP list_records select now takes lines:raw->item->items / tag:raw->>key, and filters[].field takes raw->>key. A custom field that was never promoted lives only in raw, and both arguments validated every entry against the live column set — so it was unreachable at any price, and the only way to see one was to pull the entire payload. Use ->> when you want text comparison (like, eq); -> compares as jsonb, where is_null also matches the JSON null literal. The accepted shape is deliberately narrow: bare-word keys (letters/digits/underscore), ->> only as the last arrow, a rename allowed in select but not in filters, and no other PostgREST select syntax inside a path.

  • Name a promoted custom field the way the record does: MCP list_records select / filters now accept the source-side id (custbody_x) as well as the cf_<code>_ column. The record, the anatomy docs and the source UI all use one name and select accepted only the other, so an agent that had been told the right name was still refused. Responses and applied.select echo the cf_ column, so the canonical name is what you learn back.

  • Drop the { sortKey: … } override you were passing to selectPage for 56 mirrored tables and regenerate types — pg_row_id now exists on all of them, and it is what selectPage defaults to. selectPage’s default seek key has always been pg_row_id, and on these tables the column did not exist, so a plain selectPage('subsidiary?select=*') errored and the workaround was an external_id override — a column that goes nullable the moment a table is made writable, and a nullable seek column silently drops every row it is NULL on. The default now works. If you read SEEK_KEYS instead of hard-coding, regenerating is the whole change.

  • Re-read from the first page on those tables — page ORDER changes and a persisted cursor is refused. Where the derived key moves from external_id to pg_row_id, rows come back in surrogate order rather than source-id order. The seek key is pinned into the cursor, so a cursor minted under the old key raises TurbobaseCursorError rather than being reinterpreted — nothing is silently dropped, but anything you persisted to storage or a URL must be discarded.

  • Do NOT page sync_event, record_write_state or data_manipulation_snapshot on pg_row_id — use id. All three have a column called pg_row_id that points at ANOTHER table’s row and is nullable (on data_manipulation_snapshot it is text, not uuid). They are keyed id, and SEEK_KEYS does not offer them a pg_row_id entry.

  • Do not persist a LINE row’s pg_row_id, and keep paging line tables on the tuple. Line tables gained the column too, but a parent’s whole line set is deleted and re-inserted on every sync of that parent, so each line’s pg_row_id is a fresh uuid afterwards — fine within one paging scan, wrong as a stored reference or a saved cursor. SEEK_KEYS.<x>_line therefore still returns ['parent_pg_row_id', 'line_index'] (line_number on order_fulfillment_line / item_receipt_line / journal_entry_line), unchanged by this release — if you read SEEK_KEYS rather than hard-coding, nothing on your side changes.

  • Check any code that positionally destructures an entity-view row — pg_row_id is appended LAST on customer, vendor, contact, employee, their _unpacked variants, v_current_compensation and v_current_employment. A SELECT * against those views gains a trailing column; selecting by name is unaffected. On entity_canonical_view the new column arrives through a LEFT JOIN and is therefore NULLABLE — an identity for the source-link row, not a seek key for that view.

  • Re-run turbobase apps push if your project declares "learnings": { "share": "off" } — that declaration now actually takes effect, and it is read from your PUSHED source, not from your built bundle. It was parsed and validated at deploy time and then never consulted, so a project that had opted out was still shipping both channels — and with sharing now ON by default (the entry above), that meant everything. The reporters read the newest turbobase apps push of public/turbobase.json on every hourly tick, so a project that changes the declaration is honoured on the next tick — but a project whose last push predates the declaration is not. Nothing already suppressed is consumed: it stays queued and ships if you later revert to "inherit". Two things to know: the instance setting is still the ceiling ("off" narrows, and there is deliberately no "on"), and a manifest we cannot PARSE now suppresses that project’s material entirely — run turbobase apps push and check the file is valid JSON if you expected findings to arrive and none did.

  • Drop any entity_type / item_type filter you were adding to MCP list_records — it now narrows to the record type you asked for, and a persisted next_cursor from before this release is refused. customer, vendor, contact, employee and partner all live in entity, and the tool issued its query with no type predicate at all, so list_records {record_type:"customer"} returned every entity sub-type your capabilities let RLS through (the same held for inventoryItem and friends on item). Row counts and totals on those record types will DROP — that is the leak closing, not data going missing. A filter of your own on the same column now INTERSECTS with the record type’s rather than replacing it, so asking customer for entity_type in (customer,vendor) returns customers only; ask for vendor separately. The response echoes what was applied as applied.discriminator. Cursors held for one scroll session are fine; anything written to storage must be re-read from the first page.

  • Handle a {"ambiguous": true} result from MCP get_record — it no longer returns an arbitrary row. One source record can hold several roles at once (a NetSuite entity whose type reads “CustJob, Partner, Vendor” is one record served from three endpoints, and the mirror correctly holds one row per role under one external_id), and get_record matched on external_id alone with no type predicate and no order — so asking for the customer could hand back the partner. It now applies the record type’s own predicate, and when more than one row still matches (a tenant with several connections of one source) it returns found:false, ambiguous:true with the column that separates them, instead of guessing. List those rows with list_records filtered on external_id and pick by connection_id.

  • Copy sharedTable.hint from /v1/catalog/records verbatim — it now names the type VALUE, and it names one on item / work_item where it never did. The hint said entity_type=eq.<the kind you want> and, on the item tables, offered only source_system — which does not separate inventoryItem from kitItem, so a “filter by source_system” reader still got every item type back. It now reads entity_type=eq.customer, item_type=eq.inventoryItem, and sharedTable.discriminators lists the type column alongside. Where this instance cannot name a value for a record type, the placeholder now says so explicitly instead of implying you can supply it — do not substitute the record type’s name, which is not the type value for several connectors’ records.

  • Handle a null selectTarget.discriminator from /v1/catalog/objects/:id — it is now null when no single predicate narrows the target’s table. It always returned a string, falling back to source_system=eq.<source> even where that does not discriminate (tax_code holds NetSuite’s salesTaxItem AND taxGroup, so filtering by source narrows nothing), which read as a complete answer and was not one. The note now says what to do instead — match on pg_row_id and check the row you read. Where a type predicate does exist you get it (item_type=eq.inventoryItem in place of the old source_system=eq.netsuite), and it is byte-identical to what MCP list_records / get_record apply for the same record type.

  • Read and write QuickBooks estimates: quickbooks:estimate is now in /v1/catalog/records, projecting onto estimate + estimate_line. Re-run turbobase project types to pick it up. Embed lines with estimate?select=*,estimate_line(*) — a plain embed works, no constraint hint needed. Reading a quote out of raw on a QBO tenant now has a typed alternative, and read:estimate is the capability that gates it.

  • Do not read estimate.created_from_id to find the invoice raised from a QuickBooks estimate — it is NULL by design. The link lives on the invoice (invoice.created_from_id → the estimate), because the canonical column means “transformed FROM” and QBO’s own LinkedTxn on an estimate points forward at its child. Join from the invoice side.

  • Delete your on DELETE → remove from the list Realtime handler and put a reconciliation sweep in its place — the handler can never fire. Earlier guidance (the dashboard brief, the app skills page, build-apps) told you to remove the row on a DELETE frame; no such frame is ever sent, so rows deleted anywhere else stayed on screen until a reload. Nothing here hard-deletes a mirrored row: a delete stamps deleted_at, and the restrictive deleted_at is null SELECT policy every mirror table carries hides the tombstone from the Realtime authorisation probe — so the subscriber receives no frame of any kind, not an INSERT, not an UPDATE, not a DELETE. Instead re-run each list’s own seed query on a 30–60s timer and on tab focus, batched through POST /v1/batch, and replace the list with the result: the tombstoned row is simply absent from it. Realtime carries arrivals and edits; the sweep carries removals. Mechanism and worked pattern: /build-apps/#deletes-never-arrive-so-reconcile-instead.

  • Ask for a paged total by wrapping that one sub-query, and stop reading contentRange as a total — the v1.0.77 note promising one was wrong. That note told every project “each batch result’s contentRange carries the total”; POST /v1/batch never sent a Prefer: count= header, so every sub-response came back 0-24/* and the total was never there to read. It is real now, and opt-in per sub-query: batch([{ query: 'order?select=external_id,total&limit=25', count: 'exact' }, 'currency?select=external_id,symbol']), then read page.count — the rows matching your filter ignoring limit/offset. Bare strings behave exactly as before and still pay for no count; their count is null, and rows.length is the page, never the total.

  • Branch on result.ok, never on result.status === 200, for any POST /v1/batch sub-result. A counted sub-query comes back 206 — PostgREST answers Partial Content whenever the page it returned is shorter than the total it just computed — and the instance passes that status straight through. A === 200 check turns every counted page into a phantom failure.

  • Ask for exact counts only on the pagers you actually render — four per POST /v1/batch request, or the whole request is a 400. count: 'exact' makes Postgres scan your entire filtered set rather than the page, and no timeout on this path can call one back once it starts, so BATCH_READ_MAX_EXACT_COUNTS (4) is enforced before the first fetch and rejects the request as a whole rather than degrading per sub-query. The refreshed batch() chunks COUNT-AWARE, so passing more than four splits them across requests instead of failing on where they happened to sit in your array — but each is still a full scan and the requests go out one after another, so that buys ordering safety, not headroom. Posting to /v1/batch yourself: keep counted sub-queries to four per request. Leave the lookup reads as plain strings.

  • Refresh src/lib/turbobase.ts from the current scaffold. batch() now takes BatchQuery[] (a new export — an existing string[] call site still compiles unchanged), BatchResult gained count and contentRange, and the chunking that splits a long list into requests is count-aware, so a stale copy of this file is what turns “five totals” into a 400 (see the entry above). Its header comments also now name the embeds armed on every instance with nothing declared (created_from_po, receipt_lines, source_po — any other alias resolves only once something declares it), and record that del() emits no Realtime frame. Scaffold into a scratch directory (turbobase apps init <slug> /tmp/tb-scaffold) and copy that one file across — don’t --force over your project.

  • Re-run turbobase project types after a custom field is promoted — a NEWLY promoted cf_<code>_<scriptId> column now carries the source field’s real type, or jsonb when the source reported none. It used to be text for every field selected in the drawer whatever the source said, and the source’s SCOPE string (BODY) for anything proactive learning added — which could not be approved at all. Discovery resolves the type from the source’s VALUE type now: a NetSuite Currency lands numeric, a Date or Date/Time timestamptz, a Check Box boolean, a Multiple Select jsonb. So /rest/v1/vendor_bill?select=cf_ns_custbody_acme_inbound_shipment returns a JSON number or boolean where it returned a string, and a POST /v1/data/:recordType write to that column must send the matching JSON type. Read a jsonb cf_* column as “the type is unknown”, not “the field is structured”: NetSuite reports the value type only from bundle 1.2.0, below that the instance falls back to the Customization Analyzer capture, and with neither it stores jsonb — which holds the value but cannot be SUM()ed or range-filtered.

  • Ask your operator to Remove and re-add any cf_* column whose type you depend on — including every one promoted before this release, which is text and stays text. Columns already promoted keep the type they have: nothing re-types a live column behind you, and the one-shot repair shipping here only touches selections that were never promoted. The operator’s path is two steps on /data/sync/records: Remove the field, then re-select it under “Add fields” — removing first is mandatory, because the drawer disables a field that is still selected. Re-selection re-resolves the type against current discovery, and the next reconcile re-types the column in place with a value-preserving cast; anything that will not convert is nulled and re-projected from raw on the next sync. On a bundle older than 1.2.0, run a Customization Analyzer capture first, or the re-resolution has nothing to read and lands jsonb again.

  • Stop pattern-matching the POST /v1/batch 400 text for a bad sub-query — the message changed. It named one accepted shape (“is not a valid PostgREST query string (path must be a bare resource name)”) and now names both, the string form and { "query": …, "count": "exact" }. Surface error as it comes back rather than matching on it.

  • Replace turbobase ext logs <id> with turbobase logs --extension <id>, and turbobase ext logs <id> --follow with turbobase logs --conduit <id> --follow. ext logs is REMOVED — all log access is now one noun. Both old modes are unchanged behind the two new flags; update any script, Makefile or CI step that called the old path, because it now exits with an unknown-command error rather than doing nothing visible.

  • Read GET /v1/events to find out why a write was refused, instead of asking someone to read the server’s terminal. One PAT-scoped stream covers guardrails, hooks, scheduled tasks, conduits and sync, with a shared envelope and a shared correlation_id. Filters: subsystem, severity (a THRESHOLD — warn includes errors), code, since (10m / 2h / 3d / ISO), correlationId, extension, conduit, limit (max 500), and after as a poll cursor. Same extensions:deploy scope your deploy PAT already carries.

  • Send x-request-id on a write and you get it back as the correlation_id on every row that write causes — the guardrail verdict, each hook outcome, the sync event. turbobase logs --correlation-id <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’s redaction array names which rule groups fired. Your hook’s ctx.reject() message, your guardrail rule’s reason, your record types, your external ids and your custbody_* / cr_* / cf_* customization ids all survive verbatim — so keep writing rejection messages that tell the reader what to do.

  • Know that the log is SAMPLED and expires. High-volume events (hooks that fired, CDC guardrail decisions, sync-event transitions) are aggregated with an occurrences count rather than written per row, and info/debug events are only collected when the instance sets PLATFORM_EVENT_LOG. Retention is 90 days for error, 30 for warn, 7 for info, 1 for debug. Do not build a report on it — it is a diagnostic, not a system of record.

  • Delete a record with del(recordType, { pgRowId }) (or { externalId }) — the new DELETE /v1/data/:recordType boundary. Copy the refreshed src/lib/turbobase.ts; the client gains del() alongside write(). It soft-deletes (tombstones) the row locally and, for a delete-capable bidirectional source (NetSuite today; others parked), queues the delete to the source. NEVER issue a hard SQL DELETE against a table — apps hold no DELETE grant and it would not propagate. Returns { deleted, enqueued } (enqueued is false for a row that never synced upstream).

  • Open a record’s delete with a <record>.delete guardrail allow rule — a .write allow does NOT permit deletes. Deletes are gated separately as a destructive op: denied by default under deny-by-default, held for operator confirmation otherwise, and capped by the new per-minute/hour/day delete rate limits (guardrails.max_deletes_per_{min,hour,day}). A capped or denied delete surfaces on the guardrail audit trail.

  • Expect a delete to fail loudly, not silently, when the source refuses it — e.g. NetSuite “Use Deletion Reasons” (deletion_reason_required) or a record with dependents (has_dependencies). These land as sync_failure rows, not retries; supply the reason or remove the dependents.

  • Run turbobase apps push — not apps deploy — after editing INSTANCE-LEARNINGS.md or TURBOBASE-FEEDBACK.md. Ingest reads your pushed source tree; a deploy uploads the built bundle, which contains no markdown at all. apps push now parses your markdown itself and prints what it found (N file(s) scanned · N new finding(s)), so a deploy against an unpushed edit is no longer silent. If it prints no markdown found, nothing you wrote has reached the instance.

  • Read the line skills push / kb push prints before planning. Both now re-read your markdown first, so “nothing pending” is a statement about your file rather than about whatever a background job last happened to see. 21 already known means your edit landed and every finding in it was already dealt with — which is the expected answer after a regroup.

  • Use turbobase skills push --reapply to land a corrected ### grouping. Re-filing a rule under a new heading is deliberately the same finding, so it queues nothing; --reapply re-emits rules already applied so the new grouping reaches the instance. Rules still land draft and disabled, and a finding you dismissed stays dismissed.

  • Mint a PAT with the docs:publish scope to manage the knowledge base from the CLI. New verbs: turbobase kb list | publish | unpublish | versions | rollback | delete. Your existing PAT will NOT work for them — the scope is opt-in and is not granted to tokens minted before this release. kb push is unchanged and still runs on extensions:deploy.

  • kb publish <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 list reports whether each page is actually in the current release, which visibility alone does not tell you. kb delete is SOFT — restorable from the editor’s trash, unlike skills delete.

  • Rename guardrails enable / disable to activate / deactivate in any script that calls them. The old verbs are gone. Guardrail rules carry a three-state lifecycle (proposed | active | inactive) — the chat agent can stage a rule for an admin to review — and a boolean verb pair could not name the third state.

  • turbobase guardrails create now takes --decision, and defaults to deny. A rule can allow, allow_with_audit, require_confirm (hold the write for operator approval) or deny. --message and --note are replaced by a single --reason, which is both what a blocked writer is shown and what guardrails list prints.

  • Rewrite any --when 'recordType not_in a,b' condition as two rules. Envelope fields (recordType, source, origin) are no longer part of the --when grammar — they are flags (--record, --source, --actor). --when now expresses only a test against the row being written (total gt 10000). “Everything except X” is an allow rule at a low --priority plus a deny rule at a higher one; first match wins.

  • Expect guardrails test to report a decision, not just blocked/clear. The JSON gains decision, phase and degraded; undecided is gone. It also no longer lowercases the record you pass, so a camelCase type like salesOrder reports what the write path actually does.

  • Your write guardrails now appear on the Compliance → Guardrails page. CLI rules and UI rules were two separate stores; they are one. A rule created by either surface is visible and editable from both.

  • Drop any reference to a skill’s audience / audience_role. Both columns are gone from turbobase.skill and turbobase.mcp_server, along with the RLS clause that read them. A skill’s reach is now decided by its surfaces plus visibility alone. If you had a skill scoped to a single role, it is now visible to everyone on the instance.

  • Group your ## Agent rules under ### sub-headings. The rules beneath one heading now become ONE agent skill, named after that heading. Without headings every rule becomes its own skill — 22 rules produced 21 separate rows, slugged off whichever `identifier` each rule mentioned first, which is unreviewable. Write ### Reading this mirror safely above the rules it covers. turbobase skills push --no-group keeps the old one-rule-per-skill behaviour.

  • Expect turbobase skills push to report fewer rules than findings. It now prints N finding(s) → M rule(s); M is your heading count. Re-running is still a no-op — findings are claimed once.

  • Mint a PAT with the skills:publish scope to publish from the CLI. New verbs: turbobase skills list | publish | release | rollback | delete. Your existing PAT will NOT work for them — the scope is opt-in and is not granted to tokens minted before this release. skills push is unchanged and still runs on extensions:deploy.

  • skills publish <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 separate release verb. Undo with skills unpublish <slug>; skills rollback <version> restores an earlier set; skills delete <slug> is permanent.

  • Re-deploy onto an existing preview with turbobase apps deploy <app> --replace [preview-id]. --preview on its own opens a NEW URL every time, so the second build of a change is a second link and the reviewer you already sent one to is still looking at the old bytes. --replace keeps the URL and the label; omit the id when the app has one open preview. It implies --preview, resets the 7-day expiry (so it revives an expired link), and leaves the build it displaced in deploy history.
  • Delete your NetSuite item staleness workarounds. Items never pushed in real time — they arrived only on the four-hourly sweep, so an item read could be 4h behind NetSuite. Polling item on a timer, a “may be stale” banner, a cache-buster on item reads: all now dead weight. Items push on save like every other record type.
  • Delete the same workarounds on sales orders, purchase orders and customer deposits. Their status and amount fields moved only on the sweep whenever NetSuite recalculated them from a child transaction — fulfilling an order, billing a PO, applying a deposit. Those land within seconds now, so an app polling for status to advance can read it directly.
  • Query item_type = 'assemblyItem' — assembly items were absent entirely. They now sync and appear in /v1/catalog/records. Any query or count over turbobase.item was silently missing them; re-check anything that treated that table as the complete item list.
  • Stop coalescing nulls on transaction fields you did not expect to be null. An inline edit in NetSuite (record.submitFields, or editing a field from a list view) could push a webhook whose unrelated fields were empty, blanking tran_id, entity_id, status or total on the row. If you added defensive ?? previousValue handling for that, it is no longer needed — and it was masking real blanks.
  • Name the concrete NetSuite item type; there is no item record type. /v1/catalog/records/inventoryItem, serviceSaleItem, nonInventoryResaleItem, kitItem, assemblyItem. All five project into the one turbobase.item table — filter on item_type, do not expect five tables. ?source= still matters here: item IS a record type for Dynamics 365 BC and QuickBooks, so an unpinned catalog call for it resolves to a different connector’s record.
  • Pin ?source= on any /v1/catalog/records/:type or /v1/catalog/columns?types= call for account, project or organization. Those three names are published by several connectors onto DIFFERENT tables — account is NetSuite’s account and Salesforce’s entity; organization is Zendesk’s entity and Workday’s department. An unpinned call used to resolve by adapter-registry order regardless of what the instance had connected; it now prefers a CONNECTED connector, so the answer can change on a tenant whose first-ranked publisher was never installed. Every other record type is unaffected (the connectors agree on the table).
  • Stop hard-coding a record type’s spelling if you were working around a 404. These routes now accept the snake_case spelling of a camelCase record type and vice versa — /v1/catalog/records/sales_order describes salesOrder. The response still reports the registry’s own spelling in recordType; read it from there rather than echoing what you sent.
  • Audit every selectPage({ sortKey }) override you pass, and delete the ones on tables that now have pg_row_id. A seek key must be NOT NULL and UNIQUE. external_id stopped being both on vendor_credit, credit_card_charge, credit_card_refund, inventory_adjustment and inventory_status_change when those five gained a pg_row_id primary key and their external_id became nullable, unique only per connection (v1.0.82). An override written before that is now a silent row-dropper: <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 with pg_row_id declares external_id?: string | null, one without declares external_id: string. Keep { sortKey: 'external_id' } only for the latter.
  • Read the seek key from SEEK_KEYS in your regenerated types instead of hard-coding it. turbobase project types now emits export const SEEK_KEYS = { <table>: '<column>' }, derived from live column nullability, so selectPage('vendor_credit?select=*', { sortKey: SEEK_KEYS.vendor_credit }) self-corrects the next time you regenerate. A table absent from the map has no safe single-column key — line tables, whose key is the composite (parent_pg_row_id, line_index); page those by filtering to one parent instead of seeking across all of them.
  • Expect selectPage() to throw in two cases where it previously returned a short list. When the seek key is null on the last row of a page that has more rows: it used to return nextCursor: null there, indistinguishable from the end of the list. And when you pass a sortKey other than pg_row_id on a table whose rows come back carrying pg_row_id — that combination is always a stale override. If you hit either, the fix is the two lines above, not a catch.
  • Run turbobase apps push <app> . before apps deploy when you have edited INSTANCE-LEARNINGS.md or TURBOBASE-FEEDBACK.md. Deploy TRIGGERS the learning ingest but does not carry your markdown — it uploads the built bundle, and the ingest reads the tree your last apps push recorded. Deploy alone queues a job that succeeds against the previous revision and writes nothing, after which turbobase skills push / kb push report an empty queue. apps deploy now names the source revision it will read, and warns when there is none or when your local files are newer than it. The docs said this backwards (“your instance ingests those sections on the next apps deploy”); Connect Claude Code and the CLI reference now state the dependency.
  • Handle a new write rejection code SYNC_GUARDRAIL_BLOCKED wherever you already handle SYNC_BIDIRECTIONAL_DISABLED. Both mean the write did not reach the source, but the remedies differ and are not interchangeable: the old code means two-way sync is not sold to this tenant (“contact sales”), the new one means an operator has deliberately paused write-back for this record while rolling it out. Show the message verbatim — it is operator-authored and often names the record and a date. Treat it as temporary and retryable-by-a-human, never as a validation error on the user’s input, and do not auto-retry it.

  • Expect writes to be permitted per record type, not per tenant. An instance can now have customer writable and salesorder blocked at the same time, so an app that probed one record type once and cached “writes work here” will be wrong for the others. Check per record type, or handle the rejection where it happens.

  • Declare tables your app needs in public/turbobase.json under a new tables key — {"tables":[{"slug":"task","fields":[{"name":"title","type":"text"}]}]} — and turbobase apps deploy creates them. Field types are text / number / date / bool / select, not Postgres types. Read the columns as cf_sa_<name>, not <name>; regenerate with turbobase project types to pick them up. Omitting the key changes nothing, so an existing project needs no edit until it wants a table.

  • Expect a 409 table_conflict on deploy if another app already declares one of your field names at a different type. The message names the other app and both types; rename your field or adopt theirs. Run turbobase tables plan in CI to catch it before the deploy.

  • Remove a field from your tables block only when you mean to stop using it. Releasing a claim shrinks the declared definition but never drops the column or its data — the column stays readable, and only turbobase tables drop (confirm-gated, irreversible) removes anything.

  • Commit the new .github/workflows/turbobase-tables-plan.yml that turbobase apps init now scaffolds, and add the two repo settings it needs: a secret TURBOBASE_PAT (an extensions:deploy token — the same scope apps deploy uses) and a variable TURBOBASE_BASE_URL. It runs turbobase tables plan on every PR and fails the check when another app already declares one of your field names at a different type, so you meet that conflict on the PR instead of as a 409 on the ship. An existing project can copy the file from templates/ci/turbobase-tables-plan.yml; delete it if your app declares no tables.

  • Use turbobase tables list to see what a table’s columns are actually called before writing a query against one you did not create. Two apps can share a table, so its column set may be wider than your own declaration.

  • Record hard rules under a new ## Agent rules heading in INSTANCE-LEARNINGS.md, and push them with turbobase skills push. Use it for constraints that must hold on EVERY turn (“read lines through the parent_pg_row_id FK, never header raw”); keep narrative in ## Suggested KB edits. The knowledge base is retrieved, so a rule filed there only applies when a question lands near it — that is why the same constraint keeps needing to be re-explained. Rules land as drafts and reach nothing until an administrator publishes them.

  • Re-run turbobase project grounding -o INSTANCE.md after upgrading. The brief now leads with your instance’s standing rules, so a session starts with them instead of rediscovering them.

  • Add data-tb-id to the major regions and interactive controls in an app you maintain by hand — data-tb-id="orders-table", data-tb-id="status-filter". The in-app change-request widget walks up from the clicked node and captures the nearest one, so a pinned annotation names the component to edit instead of a bare div > span plus visible text. Generated apps now emit it; an app without it keeps the tag/text fallback and loses nothing else. The attribute is inert — never read it in app code and never style off it.

  • Run chmod 600 .mcp.json in every existing project. That file holds a live personal access token and the scaffold wrote it 0644 — readable by every account on the machine. New projects get 0600.
  • Add .mcp.json to your .gitignore if it is not already there. The scaffold now writes the rule (and merges it into a .gitignore you already have), but a project scaffolded before this release does not have it, and the CLAUDE.md in that project already told you it did.
  • Rotate the PAT in .mcp.json if the project was ever pushed to a remote — mint a new one in Settings › Access tokens and delete the old one. Check git log --all -- .mcp.json first; an empty result means it never landed.
  • Rename --version to --pkg-version in any turbobase ext publish command or CI step. The old flag collided with the CLI’s own -V, --version, so the command printed a version banner and exited 0 instead of publishing — and because the flag was required, ext publish could not be run at all.
  • Ask Turbobase support for a registry token if you use ext install / ext publish, and set both TURBOBASE_REGISTRY_URL and TURBOBASE_REGISTRY_TOKEN from it, with the URL https:// (loopback excepted). The marketplace now takes its own credential: the CLI no longer falls back to PROVISIONING_API_URL / AI_METER_TOKEN, and no longer pairs one variable with a credential resolved from another, so a marketplace command that used to work off a half-set environment now reports the marketplace as not configured. The new token is revocable on its own and is not your instance’s internal credential — see Marketplace.
  • Drop --no-browser and --name from turbobase auth login invocations. Both only configured a browser sign-in leg that never completed; auth login now takes --base-url and --token (and --no-verify). Passing a removed flag is an error, so a CI step carrying one fails until it is updated.
  • Read the item subtype on receipt, fulfillment, transfer-order and check lines from the new item_type column instead of raw ->> 'itemSubtype'. The raw key was only ever populated by the periodic REST path, so it went stale between polls; the column is real-time-fresh once the tenant’s NetSuite bundle is >= 1.5.0, and reads NULL on rows synced before that.

  • Read line memo and dimensions from typed columns on item_receipt_line (description, department_id/_name, class_id/_name), order_fulfillment_line (description) and transfer_order_line (department_id/_name, class_id/_name, location_id/_name). Existing rows backfill automatically on upgrade.

  • Read opportunity_line.tax_amount as a column — it now exists, matching every other line table. Apps that summed tax from raw can drop that branch.

  • Expect NULL, not absence, for four line fields that NetSuite does not have: rate, amount and tax_amount on order_fulfillment_line (a fulfillment does not price) and tax_amount on item_receipt_line / transfer_order_line. They are part of the row shape and always NULL — do not treat one as a sync failure.

  • Stop reading units_type_line through the item vocabulary. It is a units-of-measure conversion sublist (unit_name, plural_name, abbreviation, conversion_rate, is_base_unit) and never had item, quantity or rate columns.

  • Ship a change for review before it goes live with turbobase apps deploy <slug> --preview --label "what changed". It builds and uploads exactly as deploy does, 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 with turbobase 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 --preview with --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.ts only if you replaced the scaffolded one. Newly scaffolded projects read the base from the environment: const ASSET_BASE = process.env.TURBOBASE_ASSET_BASE || '/a/<slug>/'; then base: ASSET_BASE. A config that hard-codes base: '/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, so apps pull keeps working from any machine; the browser upload recorded only the built output.

  • Rename any app whose slug ends in -preview-<24 hex characters>. That shape is now reserved for preview URLs and is rejected on every write — apps create, the management page’s create and rename, and any direct PostgREST write. An existing app keeps working and keeps its URL, but cannot be saved again until its slug changes.

  • Retry any POST /rest/v1/proposal that was answering 403 permission denied for sequence proposal_number_seq. The number column added earlier in this release shipped without a grant on its sequence, so every proposal insert under a user JWT failed; the grant is now in place and the call returns 201. No app change is needed — the request body is unchanged.

  • Key rows in account, activity, currency, expense_category, refund, time_entry, work_item_link, location, department and classification by the new pg_row_id uuid. external_id is no longer their primary key, is no longer unique on its own, and is now nullable — a row your app creates has none until write-back commits it.
  • Add &source_system=eq.<source> to every external_id filter on those ten tables that must hit exactly one row. Each is written by two or more connectors (account by eight, location by five), so a bare external_id match can now return another source’s row: GET /rest/v1/refund?external_id=eq.re_123 → GET /rest/v1/refund?external_id=eq.re_123&source_system=eq.stripe.
  • Add &connection_id=eq.<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 all connection_id=default.
  • Send pg_row_id, not external_id, as the on_conflict column when POSTing to those ten tables with Prefer: resolution=merge-duplicates. An on_conflict=external_id request now fails 42P10 — there is no single-column unique to conflict on.
  • Embed account from cash_sale / customer_deposit through the new account_pg_row_id companion: cash_sale?select=*,account!cash_sale_account_pg_row_id_fkey(*). This supersedes the earlier note in this section that told you to hint !cash_sale_account_id_fkey — that constraint was a 3-column key requiring the account row and the transaction to agree on connection_id, which they never do, so it rejected every write from a non-default connection with 23503. It has been dropped. account_id is unchanged and still carries the value.
  • Switch every location / department / classification embed to the new companion column. The old text foreign keys are gone, so PostgREST no longer resolves order?select=*,location(*) — use order?select=*,location!order_location_pg_row_id_fkey(*). The hint is always !<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_id to be populated on write, and NULL only for rows that predate the upgrade. This supersedes the earlier note in this section that said to expect NULL “within the hour”: as first shipped nothing populated these columns on the write path, so every newly synced row read NULL for up to an hour, permanently — not just once after upgrading. Rows are now resolved by a BEFORE INSERT/UPDATE trigger as they land, and system.dimension_ref_backfill has dropped to a nightly backstop for pre-upgrade rows and for transactions that synced ahead of their dimension master. The <base>_id text columns are unchanged throughout.
  • Re-subscribe with pg_row_id if you filter realtime DELETEs. These tables’ replica identity follows their primary key, which moved from external_id to pg_row_id (and on vendor_credit_line, credit_card_charge_line, credit_card_refund_line, inventory_adjustment_line and inventory_status_change_line from (tran_internal_id, line_index) to (parent_pg_row_id, line_index)). A DELETE event’s old_record now carries the new key columns, so a subscription filtering deletes on external_id stops matching. INSERT/UPDATE payloads are unaffected.
  • Keep reading location_id / department_id / class_id. They are retained, still populated, and are not deprecated in this release — only the foreign key behind them moved.
  • Re-run turbobase project types — those ten tables gained pg_row_id / connection_id, their external_id changed from string to string | null, and every table referencing a dimension gained a nullable <base>_pg_row_id.
  • Scope line writes by connection_id if you write lines directly. order_line, invoice_line, cash_sale_line, credit_memo_line, estimate_line, opportunity_line and order_fulfillment_line now key on (connection_id, <parent id>, <line ordinal>); connection_id defaults to default, so single-connection apps need no change.
  • Stop selecting secret columns from /rest/v1/*. source_install.session_private_key, source_install.source_signing_secret, channel_config.bot_token, user_email_alias.token and inbound_email.matched_alias_token are no longer readable by the session role — a select=* or an explicit select naming one of them now returns permission denied for the whole request. Name the columns you need instead of select=*.
  • Read channel config from /rest/v1/channel_config_admin, not /rest/v1/channel_config. The view exposes bot_token_set (boolean) and bot_token_hint (last 4, masked) in place of bot_token.
  • Treat an app’s access as the FULL access of the signed-in user. An app can read every table that user’s RLS allows and subscribe to every published table — the app manifest is not an authorization boundary. Do not install an app you would not trust with that user’s account.
  • Embed the lines of vendor_credit, credit_card_charge, credit_card_refund, inventory_adjustment and inventory_status_change instead of reading them out of raw: vendor_credit?select=*,vendor_credit_line(*) now resolves through parent_pg_row_id, and no !<constraint> hint is needed — each pair has exactly one foreign key.
  • Key those five headers by pg_row_id, not external_id. external_id is now nullable and unique only per connection ((connection_id, external_id)), so ?external_id=eq.<id> can return more than one row on a multi-connection instance. Their *_line rows are identified by (parent_pg_row_id, line_index) — tran_internal_id is still present but is a plain lookup column, not a key.

BREAKING — every turbobase command moved under a group. The CLI’s 25 top-level verbs are now seven nouns (auth, project, ext, apps, kb, sandbox, mcp). There are no aliases: an old path fails with error: unknown command. Update your scripts, your CI workflow, and any package.json script that shells out to turbobase.

  • Rename in your CI and local scripts: turbobase login → turbobase auth login · logout → auth logout · whoami → auth status · use → auth use.
  • Rename the project-file generators: turbobase init → turbobase project init · types → project types · grounding → project grounding · adopt → project adopt · samples init → project samples · ci init → project ci.
  • Rename the extension verbs: turbobase new hook|conduit → turbobase ext new hook|conduit · test → ext test · lint → ext lint · replay → ext replay · deploy → ext deploy · plan → ext plan · promote → ext promote · logs → ext logs · binding inspect → ext binding · config export → ext export · extras install|status → ext extras install|status. ext install and ext publish are unchanged.
  • Replace turbobase tail <id> with turbobase ext logs <id> --follow. The tail alias is removed.
  • Rename the knowledge-base push: turbobase learnings push-kb → turbobase kb push. Same flags, same confirmation, same plan output.
  • Leave turbobase mcp alone — it did NOT move. The .mcp.json in your project spawns it by path and keeps working untouched.
  • Re-run turbobase project ci if you scaffolded the GitHub Action before this release: the committed workflow calls turbobase login, which no longer exists. The regenerated file uses turbobase auth login. (project ci will not clobber your existing file — delete it first, or edit the one line.)
  • Edit .claude/hooks/session-start-refresh-types.sh in projects scaffolded before this release. It calls turbobase types and turbobase grounding, both of which now fail — and the hook discards their output, so types.ts and INSTANCE.md just quietly stop refreshing at the start of each session. Change the two lines to turbobase project types and turbobase project grounding.

BREAKING — two changes need action before you upgrade. (1) The ns_account_id session-JWT claim is gone, not deprecated, with no compatibility window: sessions minted before this release are refused with 401 session_revoked at every route until the user signs in again. (2) source_system no longer defaults, so a canonical INSERT that omits it now fails 23502 instead of silently landing as a NetSuite row.

  • Rename the JWT claim you read: ns_account_id → source_tenant_id. A decode that reads ns_account_id now gets undefined. A source_user_id claim ships alongside it, carrying the same value as sub.
  • Rename the columns you filter on in /rest/v1/source_user_permissions: ns_account_id → source_tenant_id, ns_user_id → source_user_id. A query naming the old columns now returns 400. If you were already filtering on source_tenant_id / source_user_id (they existed as read-only generated columns), nothing changes — they are now the real base columns.
  • Drop any call to turbobase.jwt_ns_account_id(); use turbobase.jwt_source_tenant_id(), which no longer coalesces to the old claim.
  • Repoint any postgres_changes channel on sales_order to order, and any channel on customer / vendor / employee / contact to entity with filter: 'entity_type=eq.<type>'. Those four are views, and Postgres logical replication never fires on a view — the channel reaches SUBSCRIBED and then streams nothing, silently.
  • Match Realtime deltas on pg_row_id for transaction tables and on external_id for entity sub-type rows. external_id is null on a row your app just created and stays null until write-back commits it, so an external_id match drops every app-created row.
  • Replace any PostgREST read of sales_order with order — .from(), /rest/v1/, and ?select= all 42P01 on a live instance.
  • Key rows in work_item, payout, fee, worker, employment, compensation, time_off, pay_run and pay_statement by the new pg_row_id uuid, and add &source_system=eq.<source> to any external_id filter on them that must hit exactly one row — two sources can now hold the same id.
  • Send pg_row_id, not external_id, as the on_conflict column when POSTing to those nine tables with Prefer: resolution=merge-duplicates.
  • Read the customer/vendor/employee/contact name as display_name, never name or entity_name; query the class dimension as classification, not class; order transaction lists by tran_date, not created_at. One unknown column 42703s the whole select=.
  • Treat the names in docs/sources/CANONICAL_MAPPING.md → “App-read name freeze” as a stable contract: they will be added to, never renamed, so it is safe to hard-code them in your PostgREST reads.
  • Re-run turbobase types — the nine tables above gained pg_row_id / connection_id, work_item gained write_status, and their external_id changed from string to string | null.
  • Send source_system on every canonical INSERT through /rest/v1/*. The column no longer defaults to 'netsuite' (105 columns, plus every per-install cr_ / cl_ / ext_ custom-record mirror) and is still NOT NULL, so an insert that omits it now fails 23502 instead of silently landing as a NetSuite row. Updates are unaffected, and POST /v1/data/:recordType sets it for you from your session.
  • Pass source_system in p_parent when calling POST /rest/v1/rpc/write_transaction, or call it with a session. It resolves p_parent->>'source_system', then the session’s source_system claim, then raises. PostgREST always plants the claim, so a normal authenticated call is unchanged. The signature is unchanged. Lines now inherit the parent’s source, so editing a non-NetSuite transaction no longer relabels its lines.
  • Re-mint any session whose JWT lacks a source_system claim. POST /v1/data/:recordType now returns 400 {"error":"session is missing the source_system claim"} instead of falling back to 'netsuite'. Every mint stamps the claim, so this should only fire on a hand-rolled or truncated token.
  • Stop writing audience: 'users' to turbobase.scheduled_task over /rest/v1 — the value is gone from the CHECK constraint and now 400s. Use 'private' (it was already behaving identically) or 'tenant' / 'role' if you meant to share.
  • Drop any read of turbobase.scheduled_task_audience and re-run turbobase types — the table is dropped.
  • Re-read source_system on custom-record mirrors (cr_* / cl_* / ext_*) if your app filters or groups on it. Those mirrors carried 'netsuite' on every row regardless of which connector supplied the record; new rows now carry their real source. Rows written before this release keep their old value — this release does not rewrite stored data.
  • Optionally pass sourceSystem in the body of POST /v1/sync/events. Omitted, it uses the admin session’s own claim rather than a NetSuite default, so an enqueue on a non-NetSuite tenant now routes to that tenant’s adapter.
  • Stop reading /rest/v1/sales_order_v. The view is dropped. It was already stale — the sales_order → order rename repointed it at the new table while its column-gate lookups still passed the literal record type sales_order. Read /rest/v1/order and select the columns you need.
  • Stop reading /rest/v1/user_scope, /rest/v1/field_restriction, /rest/v1/scope_dimension and /rest/v1/skill_audit. All four tables are dropped. Each carried GRANT SELECT … TO authenticated, so each was reachable even though nothing in the platform read them. Row-level scoping is now turbobase.scope_slot plus the scopes JWT claim, resolved server-side by scopes_match(); there is no client-readable restriction registry replacing field_restriction.
  • Drop user_scope.* and field_restriction.* actions from any /rest/v1/rpc/record_user_event call — the RPC now accepts only the user.* namespace and raises unsupported action: <x> otherwise. Its signature and admin-only gate are unchanged.
  • Stop reading oauthClientId from GET /admin/proactive-learning/account. The field is removed from both the single-account and accounts[] shapes; it had been unconditionally null since the different-production-account wizard was retired.
  • Optional: read /rest/v1/v_sync_attempt_recent for a 24h operator rollup of sync attempts grouped by (outcome, error_code) — attempt/event counts, retry depth, in-flight count, latency, and one sample event id per group. It is security_invoker, so a non-admin session sees zero rows.
  • Rename the endpoint GET /rest/v1/failure_resolutions → GET /rest/v1/failure_resolution. The old path 404s; no alias is left behind. If you pinned the FK constraint hint, it is now failure_resolution_failure_id_fkey. Re-run turbobase types.
  • Pin the connector with ?source= when a record type has more than one. GET /v1/catalog/records/:type?source=stripe and GET /v1/catalog/columns?types=customer,invoice&source=stripe now take an optional source id — 29 record types are published by several connectors. Omit it and the first connector in adapter-registry order answers, the same rule on both routes.
  • Re-check project, organization and account on GET /v1/catalog/columns — the answer changed. The route used to collapse a duplicated type to the LAST registered connector while /v1/catalog/records/:type collapsed to the FIRST; both now resolve first-in-registry-order. project was jira’s cr_jira_project and is now netsuite’s project; organization was workday’s department and is now zendesk’s entity; account was ramp’s and is now netsuite’s. Pass ?source=jira / ?source=workday / ?source=ramp for the old answer.
  • Read resolvedSources on GET /v1/catalog/columns — a new { "<recordType>": "<source>" } map. An intersection is only meaningful once you know which customer was intersected.
  • Handle availableSources on a /v1/catalog/records/:type 404. It lists the connectors publishing that type that your session can already read, plus requestedSource when you pinned one. It is capability-scoped on purpose, so an empty list does not mean “no such record type”.
  • Find a field by CONCEPT with GET /v1/catalog/fields?q=exchange+rate (or the data MCP’s new search_fields tool) instead of guessing which record holds it. It returns ranked {record_type, table, column, pg_type} hits across every record you can read. Use it before /v1/catalog/columns, which still requires ?types= and so can only narrow a record you have already picked.
  • Stop concluding a field does not exist because list_record_types and /v1/catalog/records did not mention it. Both enumerate record TYPES, so any field not named after its record was unfindable — exchange rates are on currency.exchange_rate, an invoice’s open amount is invoice.amount_remaining, and nothing in the record list said so. If your app or its agent worked around a “missing” field, search for it and delete the workaround.
  • Expect one result per table.column, not per record type. Records share tables on purpose (entity backs 14 of them), so a hit names the record type to query plus an also_on list of the others pointing at the same column.
  • Pass source_system to MCP get_record — it is declared in the tool’s inputSchema now. The handler always read it, but additionalProperties: false had forbidden conforming clients from sending it.
  • Key your MCP record-type cache on (source_system, record_type). list_record_types adapter rows now carry source_system; customer previously appeared six identical times.
  • Read entity_type on cash_sale, credit_memo and customer_deposit — new NOT NULL DEFAULT 'customer' columns. Omit it on writes and the default fills it in. Use it to disambiguate entity_id, which is ambiguous alone (customer 91 and vendor 91 share an id space).
  • Read entity_type on estimate and opportunity — new nullable columns. Treat NULL as “the source didn’t say”, not as 'customer'; a filter written as entity_type = 'customer' silently drops every HubSpot- and Salesforce-sourced row.
  • Replace your second round-trip with ?select=*,entity(*) on those five tables — it resolves server-side through the new composite FK, RLS-scoped, in one request, and needs no !constraint hint (unlike order, invoice, order_fulfillment and customer_payment, which carry a second FK to entity and return 300/PGRST201 on a bare embed).
  • Stop reading raw.__hubspot.entityType on opportunity — the value is now the entity_type column. The raw copy is still written but is poll-bounded, not fence-fresh.
  • Expect foreign-key errors on writes to those five tables carrying an entity_id that doesn’t resolve, or that resolves under the wrong entity_type. On estimate / opportunity you may still send entity_id with a NULL entity_type; MATCH SIMPLE skips the check.
  • Filter reads by connection_id, not external_id alone. Every Layer-A table (entity, order, invoice, sync_record, …) is keyed (connection_id, …, external_id), so /rest/v1/entity?external_id=eq.92 can return more than one row on a multi-connection instance. Add &connection_id=eq.<id> wherever you assumed one row per source id, or handle the array.
  • Pass ?connection=<id> (or the x-turbobase-connection header) on POST /:source/:recordType to target a specific connection. A value naming no install now returns 404 instead of silently using the first install of that source. Omit it for the default connection, exactly as before.
  • Enumerate an instance’s connections from turbobase.source_install, which gained connection_id (text, NOT NULL, default 'default') and label (text, nullable). Both are non-secret and readable through the existing admin-only policy; label is the human-readable name a connection picker should render.
  • Do not join canonical identity through connection_id. entity_canonical deliberately has no such column — Atlas dedup stays connection-agnostic, so the same real customer under two connections resolves to one canonical entity with two entity_source_link rows.
  • Pass --connection <id> to sync:source to scope a backfill to one connection. The flag used to be parsed and discarded; a malformed value now exits 1 instead of being ignored.
  • Read item-fulfillment lines with the FK embed order_fulfillment?select=*,order_fulfillment_line(*) — no constraint hint needed, parent_pg_row_id is the only FK. If your app shows “lines not synced”, a per-shipment line total from the header, or any other fallback for fulfillments, delete it and embed instead.

  • Stop treating /v1/catalog/records/itemFulfillment → lines: [] as evidence the mirror has no fulfillment lines. That was a registry omission, not a missing table: turbobase.order_fulfillment_line has existed since v0.1 with RLS, the parent_pg_row_id FK, and both sync directions. The catalog and the data MCP’s list_record_types now both report it.

  • Re-read fulfillment lines you cached or copied while they were empty. A fulfillment re-synced before this version could have its stored lines deleted by a header-only sync, so a count = 0 you recorded may be wrong rather than merely stale. Ask your instance operator whether the backfill has run for your tenant before trusting fulfillment line counts in reports or reconciliations.

  • Never read lines out of a header’s raw — raw.item on a fulfillment is a REST link stub ({"links":[…]}) on any row synced before this version, so raw->item->items reads empty even when the line table is fully populated. This was already the rule for every header/line pair; fulfillments were the case where breaking it looked like it worked.

  • Re-read any promoted custom-field column (cf_*) your app treats as populated. A column created before its real type was known stayed jsonb, which made every sync of that record fail, so the column held stale or no values — silently, from the app’s side. Reconcile re-types it and the next sync repopulates it from raw. Date-typed columns come back NULL until that sync lands, by design.

    Correction to the note as it shipped in v1.0.80: it said the re-type happens “on upgrade”. It did not — nothing ran a full reconcile, so on an existing install the repair never fired and the affected records kept failing every sync. A recurring reconcile now runs every 6h and picks the work up on the first tick after upgrade. If you read a cf_* column between the v1.0.80 upgrade and this release, re-read it now.

  • Rename turbobase learnings propose-kb to turbobase learnings push-kb in any script or CLAUDE.md that calls it. There is no alias — the old name is gone, and so is POST /v1/learnings/propose-kb (now POST /v1/learnings/push-kb). GET /v1/learnings/pending keeps its path.
  • Expect learnings push-kb to write knowledge-base pages directly instead of creating one documentation proposal per finding. Nothing goes through the Studio any more: an existing page is APPENDED to under a ## From app developers heading (nothing already on the page is rewritten), and a page that does not exist yet is created unpublished under a “From app developers” holding page. Readers see neither until someone publishes a knowledge-base release.
  • Expect the plan to count pages, not findings. Every finding that lands on the same page is now one entry and one write — twelve findings about REC-01 append once, and several findings naming the same unwritten page create it once. --dry-run prints the exact markdown each page receives; --diff adds it to the confirmation prompt.
  • Re-run push-kb later for any page it reports as skipped — a page whose unpublished work belongs to another editor is left untouched and its findings stay pending, rather than being overwritten. The plan names the owner before you confirm.
  • Read the write-set that turbobase learnings propose-kb --dry-run now prints under each finding before you apply: the proposal body verbatim, plus the target page’s visibility change when a correction withdraws it from readers. Pass --diff to get the same block on the confirmation prompt, or --no-diff to keep the output to the plan fields.
  • Publish a proposed page yourself when you want readers to see it — nothing in this chain auto-publishes. propose-kb only ever moves a page published → admin, and a page authored from one of its proposals lands as a draft in the knowledge-base editor.
  • Expect a “Suggest a change” widget on your served app at /a/<slug>. Turbobase now injects /_tb/widget.js into your app’s entry HTML server-side, so end users can file change requests (with a screenshot of their screen) that land as draft proposals — no code change needed; it rides the instance session. If your app ships its own <meta http-equiv="Content-Security-Policy">, keep script-src 'self' and img-src 'self' data: blob: so the widget and its screenshot capture load.
  • Replace any read that pulls a transaction’s lines out of the header’s raw (select=raw, select=lines:raw->item->items) with a line-table embed: order?select=external_id,tran_date,order_line(line_index,item_id,quantity). Every line table carries a real foreign key to its header, so this needs nothing declared and works on your instance today. It is the largest single saving available on the read path — header raw reads have been measured at ~10 MB per request.
  • Add a constraint hint on the six header/line pairs that have two foreign keys — item_receipt, return_authorization, vendor_return_authorization, journal_entry, check, transfer_order. Write item_receipt_line!item_receipt_line_parent_pg_row_id_fkey(…); the hint is always !<line_table>_parent_pg_row_id_fkey. Without it PostgREST returns 300 / PGRST201 and no rows, and nothing retries it for you — the client’s fallback keys on 400/PGRST200, so an unhinted embed is a hard error in your app. Use the parent_pg_row_id constraint rather than the older source-id one: the source-id column is nullable, so hinting it silently drops the lines of records created in Turbobase that haven’t synced back yet. The other nine pairs embed bare.
  • Replace a separate select() of a *_line table plus an in-memory join on tran_external_id with the same embed. Filter, order and cap the lines by prefixing the parameter with the line table (&purchase_order_line.order=line_index.asc); those parameters narrow the lines, not the parents.
  • Embed a declared jsonb link with select=*,rel(*) rather than reading both tables — e.g. item_receipt?select=*,created_from_po(*). The join runs in Postgres and is RLS-scoped, so it returns only rows this session could already read.
  • Declare the relationships this app embeds in public/turbobase.json under relationships (name, sourceTable, sourceJsonbKey, targetTable). Declaring is what puts the link on the instance’s books; a link is never armed just because the field exists, so an embed you never declared will not resolve.
  • Use a declared relationship the moment you declare it — don’t gate a screen, a fallback query, or a retry on whether it has been materialized yet. select() and selectPage() serve a declared embed either way: natively once the fast path is armed, resolved by the instance through /v1/batch until then, returning the same rows. Arming changes how fast the read answers, never what it answers.
  • Keep a newly declared embed to the plain rel(columns) shape until it is armed. The instance-resolved path reproduces a straight forward or reverse embed exactly and refuses what it cannot reproduce — nested embeds, !inner, spread embeds, embed-scoped filters — with a 501 naming the alias, rather than quietly returning different rows. Nested embeds and !inner work natively once armed, so for those the constraint only bites in the window after you declare.
  • Stop using spread embeds (...rel(col)) entirely — armed or not. They are rejected outright on PostgREST 12, and on 14 the request succeeds but the spread column is emitted under its own name and collides with the parent’s column of that name, so the parent’s value is lost. Select the embedded column normally and read it out of the array instead.
  • Index into a declared relationship’s embed as an array, always — including a to-one link, where it is a one-element array (row.created_from_po[0], never row.created_from_po.name). A plain FK embed still follows the FK’s cardinality, so item_receipt_line is an array but currency:currency_id(symbol) is an object. Treat an empty array as “the target hasn’t synced yet”, never as “there is no link”: read the link id alongside it (select=external_id,createdFrom:raw->>createdFrom) to tell the two apart, and don’t add repair or backfill logic on the strength of an empty embed.
  • Replace unbounded select() calls on tables that can exceed 1000 rows with selectPage() — a keyset seek, not offset. An unbounded select() that comes back at exactly the cap now logs a TurbobaseTruncationError naming the query; treat that console warning as a bug to fix, not noise. The same warning now fires by embed alias when an embedded array was shortened — a short embed is otherwise invisible, because the page’s own row count is exactly what you asked for. Pass { sortKey: 'external_id' } for read-only master tables that have no pg_row_id, and mint a fresh cursor whenever the filter or select changes — replaying a stale one throws TurbobaseCursorError.
  • Refresh src/lib/turbobase.ts from the current scaffold if this project predates it: selectPage(), TurbobaseTruncationError and TurbobaseCursorError are new exports. Scaffold into a scratch directory (turbobase apps init <slug> /tmp/tb-scaffold) and copy that one file across — don’t --force over your project.
  • Replace select=* with the columns you actually render in any batch() / select() call against a synced table — * drags the whole raw JSONB blob per row. POST /v1/batch now caps each sub-query’s response and the batch total; an over-budget sub-query comes back as its own { ok: false, status: 413 } slot naming the ceiling, while its siblings still return.
  • Lower any limit sized for a table scan rather than a screen, and page instead — each batch result’s contentRange carries the total.
  • Read error (not message) on a failed batch sub-result if you surface it in the UI; the client now prefers error and falls back to message.
  • Nothing to change for long query lists: batch() chunks them automatically and preserves order, so passing 40 queries is safe. Width, not count, is what trips the cap.
  • Keep INSTANCE-LEARNINGS.md and TURBOBASE-FEEDBACK.md up to date as you work: turbobase apps init now scaffolds both, and a deploy ingests them. The first records how THIS instance’s source system is actually set up; the second records where Turbobase itself cost you time.
  • Stop relying on .claude/ reaching the instance — it is no longer uploaded by turbobase apps push. Anything the instance must have goes in tracked project files, not in your agent config.
  • Add a learnings block to public/turbobase.json if this project should opt out of either standing file, or out of sharing: {"instance": true, "platform": true, "share": "inherit"} are the defaults. "share": "off" suppresses sharing for this project; it can only narrow the instance’s telemetry.share_learnings setting, never widen it.
  • Run turbobase adopt at the start of a session to refresh ADOPT.md with anything newer than the version you last adopted, and work the list before starting new feature work.