Changelog
User-visible changes in each production release of the Primitive platform: new capabilities, additions and changes to the client APIs and the CLI, and fixes that change behavior you can observe. Internal work — refactoring, infrastructure, dependency bumps — is not listed. Entries are newest first; each date is a production release. An ## Unreleased section, when present, lists changes already merged since the last production release; it is date-stamped when that release ships.
2026-09-30
New
- Prompts: decisions models. A
kind = "decisions"prompt runs OpenRouter's decisions endpoint (TypeSafe System One) inside the prompt system: a[configs.decisions]block names a state and typed questions (category, boolean and numeric), the answers come back typed with their probabilities and a confidence,ctx.prompts.runreturns them asparsedwithmetrics.cost, and the admin routes, both test runners (expectedJsonSubset) andconfig push/pull/diffof akindfile carry it. The Swift client'sExecutePromptResult.Metrics.costdecodes a decisions execution. - Error analytics group server-function failures. A failed server-function invocation or task run writes a failure event fingerprinted and titled by its function key, so
errors.groups(CLIanalytics errors groups --source function_invocation|function_run) reports it beside workflow, step and integration failures; a completed, CPU-yield or reset outcome writes none. - Large documents: a refused write is reported the same way on both clients. Every local write the offline window refuses raises
document:write-refused(payloadDocumentWriteRefusedEvent:documentId,model,recordId,error) — on JavaScript through the client and through a js-baoformat2OnWriteRefusedlistener, on Swift as an event delivered before the throw reaches the caller — and, wherever the call can throw, still throws that sameDOCUMENT_OFFLINE_WINDOW_EXPIREDerror, now withdetails. - Large documents: a bulk load's write cost per record is recorded. Every ingest tick reports the rows whose counters it committed as
DOCUMENT_WRITE_COSTanalytics rows (size band, statements, rows read and written, withcountersKnownsaying whether the host could count), and the session ledger counts them once. - Admin CLI: create a collection on behalf of a user.
primitive collections create --owner <user id or email>stamps that user as the collection's owner (both system groups, both group permissions and the writer enrolment), and an admin-tokenPOST …/collectionsmay name the owner the same way. - Collections: a document's collection listing pages.
collections.listCollectionsForDocument(andGET /documents/{documentId}/collections) acceptlimitandcursorand answer the standard{ items, hasMore, nextCursor }envelope; a server function walking it sees every collection. - Large documents: snapshot on demand.
POST /documents/{documentId}/snapshots(CLI:primitive documents snapshots build) seals the open epoch and names the build asnapshots getthen describes; an empty epoch answers 200 and seals nothing; a request inside the floor is429 SNAPSHOT_TOO_SOON; an ordinary document is the typed 400. - JavaScript client:
documents.create({ documentFormat: 2 })creates a large document (andcreateWithAliasanswers its format); omitted or1creates an ordinary document. - Large documents: a bulk load's CPU by stage. Every ingest tick carries per-stage SQL counters split by statement target, and the session view sums them, so where a hosted load spends its time is measured rather than read as 0.
- CLI:
config pushreports a var changed only on the server as drift.DRIFT var: <KEY>under "Server drift — not pushed", nothing written, exit 0;--only var/<key>scopes it and--forceoverwrites with the local value — the same rule every other type already followed. - CLI:
--jsonon the destructive collection and membership verbs.collections delete,collections unshare,documents removeandmembers removetake--jsonbeside--yesand print one object on stdout ({ success, … }), with the stderr success line suppressed; a failure exits 1 with an empty stdout. - Admin CLI: a document's tags. The admin per-user document inventory reports each document's tags (present when it has at least one), and
documents listshows them in its table and under--json;documents getprints a Tags line. - JavaScript client: a model can be registered on a running client.
client.registerModel(...)(andregisterModelon a running js-bao instance) adds a model after initialization: the mapping resolves on an already-connected document, a record saves against a legacy document connected before the model existed, pre-existing records are answered, and a format-2 document's derived query tables are hydrated for it. - Large documents: a bulk-load session reports where its time went. The session view (
ingests.get,GET …/ingest/{sessionId},primitive documents ingest --wait) carries per-state windows, per-tick totals and intervals,rawBytesand throughput, and the CLI renders them — so a slow load can be diagnosed. - Every API failure carries a machine-readable
code. Every 4xx/5xx response with a body, on both/app/{appId}/api/*and/admin/api/*, is now JSON carrying a non-emptycodeyour app can branch and localize on: the handler's own code where it has one (DOC_ACCESS_DENIED,WORKFLOW_ARCHIVED,VALIDATION_FAILED, …), otherwise a status-derived default (NOT_FOUND,ACCESS_DENIED,UNAUTHENTICATED,METHOD_NOT_ALLOWED,INTERNAL_ERROR, …). Theerrortext beside it is written for a developer reading a log and may change without notice — choose your own copy bycode. The generic 409 default isSTATE_CONFLICT, deliberately distinct from the optimistic-concurrencyCONFLICTthat carriesserverModifiedAt/expectedModifiedAt. A body that already carriederrorCodekeeps it and now carries the same value undercodetoo, and no existing field on any body is removed or retyped.codeis absent only when the response did not come from the platform at all — an older server, or an intermediary's HTML 502. JavaScript client:JsBaoApiError.codeis populated on every failing path, including the raw-body path behind blob downloads and the terminal error after a refused token refresh (which keeps itsInvalid credentialsmessage and now also carries the original 401's body and code). Swift client: the same, asHttpError.serverCode. CLI:ApiError.codeis populated for the blob upload and download methods, which previously surfaced an uncoded error built fromstatusTextor the raw body. See the new Error Handling guide. - Locks: a caller can re-take its own lease. Name an
ownerwhen you acquire — JavaScripttryAcquire(key, { ttlMs, owner })/acquire(key, { ttlMs, timeoutMs, owner }), SwifttryAcquire(key:ttl:owner:)/acquire(key:ttl:timeout:owner:), andctx.api.locks.tryAcquire({ body: { key, ttlMs, owner } })from a server function — a string of your choosing up to 256 characters. Presenting the owner that already holds the key, from the same caller and the same kind of caller, succeeds with a fresh handle and lease and fences the previous handle (releaseon it answersnot_holder,renewanswerslease_lost); a different owner, or none, is refused as before, and a hold made without an owner is never re-entered.status, the contention answer and the admin list carryowner. CLI:locks acquire --owner <owner>;locks statusprintsOwner,locks listgains anOWNERcolumn, and the--jsonshapes carry it. - Server functions:
ctx.runId,ctx.sliceIdandctx.trigger.runKey. A function reads the run it belongs to (nullfor aninvoke) and the slice it is running in (nullunder the request runtime; the same idprimitive functions runsand the status route'sslice.sliceIdreport), so a task run can name itself as a lock's owner and tell a resume from a first pass.ctx.trigger.runKeycarries the key a start was coalesced by on thehttp,functionandmanualarms, ornull. - Server functions: run error codes. A failed run carries a code beside its message —
run.errorCodeon the row andfailure.details.codeon the status block both clients read — from a closed set:FUNCTION_THREW,FUNCTION_NO_HANDLER,FUNCTION_TIMEOUT,FUNCTION_INPUT_INVALID,FUNCTION_OUTPUT_INVALID,OUTPUT_SCHEMA_VIOLATION,OUTPUT_NOT_SERIALIZABLE,OUTPUT_TOO_LARGE,FUNCTION_DELETED,FUNCTION_RUNTIME_REFUSED, and sixENGINE_*codes (ENGINE_ISOLATE_EVICTED,ENGINE_CODE_UPDATED,ENGINE_STORAGE_ERROR,ENGINE_INTERNAL_ERROR,ENGINE_SLICE_DEADLINE,ENGINE_INSTANCE_LOST) for failures nothing in your code caused. An engine failure keeps the engine's own text as its message, so branch on the code, not the message. CLI:functions logs --runprintsended in the engine — no handler output(orlogs unavailable/output suppressed) for an attempt that ended in the engine before the handler printed anything. - Swift client: large documents. A document created with
CreateDocumentOptions(title:, documentFormat: 2)(orprimitive documents create --large) opens on iOS and macOS through the samedocuments.open, model and query facade as an ordinary one, andDocumentInfo.documentFormatsays which kind a document is. It needs the client's default on-disk store (storageConfig: .sqlite(directory:)); on.memorythe open throwsformat2StorageUnavailable. The client reports a base load throughDocumentSnapshotLoadEvent(document:snapshot-load:started,progress,model,loaded). A model with members in both an ordinary and a large document scopes its reads withQueryOptions(documents:)or is refused withformat2QueryScope; a server that refuses the client's formats fails the open withclientUpgradeRequiredand is not reconnected to; the document follows the room's epoch seals in place, with no reload and no download (theYDocumenthandle is replaced at each seal, so read records through the model facade), and reloads from the newest base only when the chain cannot be trusted or a replay would drop a delete (format2ReloadRequiredrefuses writes while that reload is pending;format2FoldBrokenwhen the merged view is untrustworthy). Writes made offline are judged against the sealed chain when the client returns — older writes and writes onto deleted records are dropped, ambiguous ones stand — and each verdict reaches the app asDocumentOfflineWritesResolvedEvent(documentOfflineWritesResolved: per write anoutcomeofdroppedorkept-ambiguousand areasonofoutdated,record-deleted,in-window,unverifiableorbulkIngest); a relaunch adopts the previous instance's unacknowledged writes, and a bulk load is crossed without reopening. Past the app's offline write window (largeDocumentWindowDays, 7 by default, 1–14) the document is read-only:create,update,saveand string-set writes throwJsBaoError(.documentOfflineWindowExpired)withlastSyncAt,windowDaysandoverdueMs, anddeleteand field setters emitDocumentWriteRefusedEvent(document:write-refused); reads keep answering and a sync restores writes.JsBaoClientOptions(largeDocumentStorage: LargeDocumentStorageOptions(capability:models:))names the models a device that cannot hold the whole document should load: a capped load is durable, reads of a model left out throw.format2ModelNotHydrated, and a device that cannot hold even those is refused with.format2StorageUnavailable(reasonover-quota) before any chunk is fetched.documents.evictandlogout(wipeLocal: true)remove a large document's local data along with everything else. - CLI:
documents ingestbulk-loads a large document.primitive documents ingest <document-id> --input <dir>cuts a directory of<model>.ndjson[.gz]files — or adocuments exportoutput — into an artifact, uploads it, waits for the swap and reports the result; rows merge-patch by id onto what the document holds.-yskips the confirmation,--no-waitexits once the session is committed,--timeout <seconds>bounds the wait,--jsonprints the session; the exit status is0oncomplete,1onfailedoraborted,124on timeout,130on interrupt. An app admin may run it without a grant on the document. - JavaScript client: bulk-load sessions and snapshot builds.
client.documents.ingests(create,uploadChunk,commit,abort,get,list) drives or watches a bulk-load session from a server-side job, andclient.documents.snapshots(list,get) reads a document's base builds; a build carriessource("builder","import"or"ingest") andingestSessionId. A connected client does not reload when a bulk load lands: it re-fetches only the chunks the load touched, refolds its own recent writes and reports throughdocument:snapshot-loadwithmode: "converge"andchunksReused; a write still unacknowledged when the load landed is surfaced throughdocumentOfflineWritesResolvedwithreason: "bulkIngest"— dropped when the load deleted its record, applied and marked ambiguous when the load modified it. Client 3.3.0 needs js-bao 0.9.0 or newer. - Server functions:
ctx.runtimeandassertRuntime.ctx.runtimeis"request"or"task"— the runtime the platform ran the code under, whichever door fired it — andassertRuntime("task")(or"request"), at module scope or inside the handler, refuses the other with a typedFunctionRuntimeError(name"FUNCTION_RUNTIME_REFUSED", withruntimeandrequired); the envelope and the run row carry the same code, which is now also what a request-runtimestep.sleeporwaitForEventfails with. - CLI:
functions runsandfunctions logsshow the runtime. Both tables gain aRUNTIMEcolumn (requestortask), andfunctions runs --jsonitems carryruntime. - CLI:
webhooks test --deliverdelivers the preview for real.primitive webhooks test <webhook-id> --payload '{…}' --deliverposts the signed body to the webhook's own receive endpoint, so signature verification, the body cap, the IP allowlist, the handshake rules and deduplication run exactly as they do for the provider, and the function (or workflow) actually runs. It is a real delivery — an event row is written and the signed body's dedup key is spent, permanently on thegithubscheme, so vary the payload between deliveries — and the command reports what happened rather than a status code:dispatched(the run it names was confirmed) exits0;refusednames a disabled webhook (410), an address outside the IP allowlist (403), a rejected signature (401) or a suppressed replay;unconfirmedmeans the delivery was sent but could not be proved, andprimitive webhooks events <webhook-id>has the truth. Without the flag,webhooks teststill only signs. - Swift codegen for server functions.
primitive functions codegen --lang swiftemits one<key>.generated.swiftperfunctions/<key>.toml:<Key>Input/<Key>OutputasCodabletypes from the declared schemas, plus a<Key>Functioninvoker reached through a<key>(client)factory and bound over the genericclient.functionsoverloads. Every invoker carries both verb sets —invoke, andstart,getStatus,waitFor,terminate— so a wrong input shape is a compile error instead of a400. A function with no declared schema gets aJSONValuealias rather than an empty struct, and the Swift app template regenerates the invokers fromscripts/codegen.shon every build path. - Swift client: server functions, channels and direct messages.
client.functions.invoke(key, input:)takes anEncodableinput and answers a request function's envelope decoded into yourDecodableoutput (FunctionResult<Output>), andclient.functions.start/getStatus(runId:)/waitFor/terminatedrive a task function's run the way the workflow API does. The surface is typed-only: there is no[String: Any]entry point, and a dynamic caller names the witness —invoke(key, input: nil as JSONValue?)boundas FunctionResult<JSONValue>.client.subscribeToChannel(channel, grant:)joins a channel a function authorized and returns the membership with itsexpiresAtand anunsubscribeclosure,unsubscribeFromChannel(channel)leaves, andchannelMessage,channelSubscribeFailedanddirectMessagearrive as typed events onclient.stream(for:); a refused join throwsJsBaoError(.channelSubscribeFailed). - Server functions: a function starts a task function, and iterates the app's users.
ctx.functions.start(key, input, { runKey?, contextDocId? })starts a task function from inside any running function and answers the same envelope an HTTP start does; a repeatedrunKeyreplays the existing run (existing: true), the callee'saccessgate is not evaluated, and every run in the tree belongs to the tree's root caller. Nesting is bounded at four levels below the root (409 FUNCTION_NEST_DEPTH_EXCEEDED); inside a child,ctx.trigger.kindis"function", carrying the parent function's key and run id.ctx.users.list({ limit, cursor })pages the app's users andctx.users.iterate()walks them in a single-slice loop.primitive functions runslists a nested run under its own function,FIRED BY function, with aPARENTcolumn naming the function that started it. - Server functions: an invocation log. Every invocation the platform dispatched — an HTTP call, a webhook delivery, a cron fire, a
workflow.call, a task run's settling slice — leaves a record of what it printed, what it threw and how it correlates (app, function, config version, run id, trigger), kept for seven days and outliving an archive. Read it withprimitive functions logs <function-id>(--json,--follow,--limit/--cursor), from a function withctx.api.functions.logs({ functionKey, limit }), or from the admin API — owner and admin only. A gate refusal writes no record, a value read throughctx.secret()is redacted best-effort, andtruncated,logsUnavailableandcontentSuppressedmark a capped, missing or withheld record. - Analytics: who signed up on a day. Every user-attributed event the server writes carries the user's app-join time, and
users.searchanswers by it: passsignupDay(one UTC day) orsignupStartDay+signupEndDay(an inclusive range of at most 90 days), page withoffsetwhile the response reportstruncated, and each row carriessignedUpAtandsignupDay; theusers.searchworkflow step forwards the same parameters. CLI:primitive analytics user-search --signup-day/--signup-start-day/--signup-end-day/--offset. JavaScript client:AnalyticsEventInput.user_created_at_epoch_sis ignored — the server records the join time. - Server functions: schema-typed codegen, a typed document handle, list parameters. Every
config push— andprimitive functions codegen— now also rendersfunctions/primitive-function-types.d.ts(<Key>Input/<Key>Outputfrom each function's schemas, typing the keyeddefineFunction("<key>", handler); a key the tree does not declare is a compile error and push refuses an entry naming another file's key) and one typed client invoker per function underfunctions/generated/(-o <dir>moves them) carrying both verb sets; the JS client'sfunctions.waitForresult types itsoutputby the generic.ctx.doc(documentId).model("<Model>")gives a function the same typed handle over a document's records thatctx.dbgives over a database, typed from the project'smodels/models.toml.defineQueryparameters may be arrays ({ type: "array", items: { type: "string" } }),run's params are typed from the declaration, and a declared default is coerced to its type at registration.ctx.triggercarries the fields each fire path really sets. Removed from the JS client:functions.claimApply,functions.releaseApplyandfunctions.confirmApply(the apply protocol was never reachable on a function run), and theiterationsnamespace from theprimitive-functionsprofile. - Server functions: channels. A function authorizes its caller for a named topic with
ctx.channels.authorize(channel, { userId?, ttlSeconds? })— a signed grant naming the app, the channel and one user, good for 300 seconds by default and at most 900 — and any function publishes to the topic withctx.channels.publish(channel, payload), which delivers achannel.messageframe to every live membership (up to 500 connections per channel; no listeners is a success). Expiry is the only revocation and ends the membership even while the socket stays open; renewing is authorizing and subscribing again. JavaScript client:subscribeToChannel(channel, grant)resolves with{ channel, expiresAt, unsubscribe },unsubscribeFromChannel(channel)leaves, frames arrive on thechannelMessageevent, andchannelSubscribeFailedreports a grant refused on reconnect so the app can renew it. - Server functions: a workflow calls a function, and a function takes an archived workflow's key. A
workflow.callstep whoseworkflowKeynames no live workflow resolves to the server function holding that key: its output becomes the step'soutput, a caller-mode parent has the function's ownaccessgate evaluated against the parent run's caller (a denial fails the step withFUNCTION_ACCESS_DENIED), a system parent skips it, and inside the functionctx.triggeris{ kind: "workflow", workflowKey, runId, stepId }. Archiving a workflow letsprimitive config pushgive its key to a function, so a replacement keeps its URL; a live workflow still refuses the push, naming the holder. The bridge goes one way — a function never calls a workflow. - Server functions: outbound calls, prompts, secrets, config vars and sends. A function's TOML declares
integration:<key>andsecret:<NAME>capabilities — the two kinds that name an outside credential — andprimitive config pushrefuses anintegration:with no active integration before any request is sent. Inside the function,ctx.integrations.callgoes through the egress gateway (allowlisted destinations, canonicalized URLs, redirects re-checked by origin, a deadline that covers the response body — an upstream that never finishes answersUPSTREAM_TIMEOUT),ctx.prompts.runruns any of the app's prompts,ctx.secretreads a declared secret,ctx.configVarreads any config var (once per config version), andctx.users.send/ctx.connections.senddeliverdirect.messageframes to a user's live WebSocket connections. - Server functions:
defineQueryand typed operations. A function can export queries withdefineQuery, bound to the caller through$caller;primitive functions getprints a version's queries. Every operation in the remaining API families now carries a dottedoperationIdand request/response schemas in the published OpenAPI document, so generated clients and agents see the same surface the server serves. - Task server functions. Any function can run as a task:
POST /app/{appId}/api/functions/{key}/startstarts a run instead of answering inline and returns201with a run id (JS client:functions.start,getStatus,waitFor), the handler gains astepargument, andstep.sleepcan pause for hours or days with nothing running or billed in between. A run polls through the same routes and envelopes as a workflow run, repeats arunKeyidempotently, and pins the code version it started on. A hard delete is refused while any run is unsettled, and archiving lets in-flight runs finish. Every cron fire starts a run, andoverlapPolicy = "skip"asks the engine whether the previous run is still live, a sleeping run included — while a webhook delivery runs inside the request and hands long work to a task withctx.functions.start. A run's slices have their own budgets: each slice is minted with 10 minutes of wall clock, 5 minutes of CPU and 10 000 platform calls, and every step begins with at least 5 minutes of wall clock — the platform refreshes the slice's credential between steps, so a sequence of steps runs continuously for up to 12 hours before a fresh slice starts (a refused refresh costs one pause of a little over five minutes). CPU and platform calls do not refresh: a step that exhausts a slice's CPU fails the run, and a slice past half its platform-call ceiling ends at the next step boundary. The run's status answer carries asliceblock —refreshCount,ceilingAt,lastRefreshAt, and when the slice started and settled — andprimitive functions runsshows the count in aREFRESHEScolumn. JavaScript and Swift clients:functions.getStatusanswers it asslice. A run's row settles as the run ends — the same settlement that writes its invocation record writes itsstatus,endedAtandoutput— sogetStatus,waitFor,primitive functions runs,runs waitandfunctions logsagree without anybody polling;runningmeans live or not yet known to have ended, arunningrow whose run really ended is repaired on the next read, and a run started in the last five minutes is treated as still starting. The value a task run returns is kept on the run row for the row's life (45 days) and answered whole whether or not the engine still holds the instance (over roughly 10 000 characters it is stored by reference; therunstable does not print it). See the Server Functions guide. - Server functions: a typed database handle.
ctx.db(databaseId, "<type>")gives a function a database handle typed from the model declarationsconfig pushwrites for each type, andctx.api.databases.records.*reaches the same rows; a function reads and writes every database of the app on the app's authority, with nothing to declare — scope a read to its caller in code, or through a$callerregistered query. Every paged read the SDK exposes — the database handle, the document handle,ctx.users.listand the rawctx.api.databases.records.query— answers the one envelope{ items, hasMore, nextCursor? }(only the raw operation addsprevCursor), so paging code is written once; the REST routes' own responses are unchanged. - Server functions. TypeScript you author in your config tree and push to the platform: a
functions/<key>.tomlfile states the access gate and the entry point,primitive config pushbuilds and ships the bundle, andPOST /app/{appId}/api/functions/{key}runs it synchronously. The code acts as the system — every platform call carries the app's own authority — and the signed-in caller isctx.user, a name for attribution and scoping; theaccessgate is what decides who may make the code run.capabilitiesconfigure rather than authorize:integration:<key>,secret:<NAME>, and one exact string per high-blast operation (databases:delete,users:setRole, …);primitive functions getprints a manifest of the models and API families a version touches — documentation, never consulted for authorization. A function may declare its own inbound webhook and cron schedules in the same file, calls the platform through theprimitive-functionsclient, and is operated withprimitive functions(list,get,enable,disable,archive). Versions are immutable; a run pins the code it started on. See the Server Functions guide. - Workflow usage report.
GET /admin/api/apps/{appId}/workflows/usagereports which step kinds an app's workflows configure and how often each ran, with partial-failure and truncation reporting; the CLI reads it asprimitive analytics workflow-usage. The workflow keyusageis reserved so a workflow can never collide with the route. - Swift template: the iOS App Store build lanes sign entirely from the App Store Connect API key in
fastlane/.env— they fetch or create the distribution certificate and provisioning profile themselves — so a CI runner needs no pre-installed signing identity. - Large documents. A document created with
documentFormat: 2(CLI:primitive documents create --large) holds up to 2 GB of records without loading the whole document into memory on the server or in the Node.js client, and export and import carry the entire document. The app settinglargeDocumentWindowDays(1–14, default 7) bounds how long a client may keep writing offline before it becomes read-only until it syncs. - Passkey user-verification policy. The app setting
passkeyUserVerification(preferredorrequired) names one policy that both passkey ceremonies enforce; an assertion that fails it reports the typedPASSKEY_USER_VERIFICATION_FAILEDcode in the JavaScript and Swift clients. - Swift app layer:
PrimitiveAuthManager.authFailurecarries the last failed sign-in with its typedAuthCodeand message, so an app can branch on the code instead of matching message text. - CLI and Vue template: environments accept an
iosAppIdbesidewebUrl(primitive env add --ios-app-id), andpnpm cf-deploygeneratespublic/.well-known/apple-app-site-associationfor the environment it is deploying. An app with several environments no longer has to hand-edit one static file between deploys, and an environment with no iOS client serves no association document instead of one naming another environment's app. - Prompts: strict output for chat prompts.
strictOutput = truein a[configs.chat]block sends the prompt's[prompt.outputSchema]to OpenRouter as a strictjson_schema, so the provider constrains the answer instead of it being checked afterwards. Push and the admin routes refuse it with aPROMPT_STRICT_OUTPUT_*code naming the rule — beside a config-leveloutputSchema, on a non-OpenRouter config, withoutputFormat = "text", or when the prompt declares nooutputSchema— and a model that does not support structured outputs is refusedAGENT_MODEL_CAPABILITY_MISSING. - Prompts: a decisions question's options can be supplied per run. Every
choicequestion in[configs.decisions]declarescriteriaSource:"static"keeps its options in the config'scriteriatable, and"dynamic"takes them from each run asvariables.criteria.<question>, passed toctx.prompts.runbesidestate. A run whose options are missing, malformed, or supplied for a static question is refused before the provider call, with nothing billed, assuccess: falsewitherrorCode: "PROMPT_CRITERIA_INVALID"; push and the admin routes refuse achoicequestion withoutcriteriaSource, naming it. - Analytics: per-prompt cost. A prompt event records the cost the provider reported for the run, and a run with no reported cost is recorded as cost-unknown, never as zero.
prompts.toprows carryexecutionsWithCost,totalCostandavgCost, the last twonullwhen no run in the window reported a cost. CLI:analytics promptsshowsCOSTandAVG COST, with an(n/m)marker when only some runs reported a price; the Admin Console's Prompts table shows the same. - Documents: ask what one user may do with a document.
validateAccesstakes a user — JavaScriptdocuments.validateAccess(documentId, { userId }), SwiftvalidateAccess(documentId:userId:),ctx.api.documents.validateAccess({ documentId, body: { userId } })in a server function, CLIprimitive documents access get <id> --user <uid>— and answers that user's effective access across direct, group, collection and link grants, withaccessSourceand theirappRolebeside it. An app owner or admin may name anyone and a member only themselves; anyone else gets 403DOCUMENT_ACCESS_SUBJECT_FORBIDDEN. - Documents: create a tagged large document behind an alias in one call.
createWithAliasandgetOrCreateWithAliastakedocumentFormat,tagsandmetadataon both clients, applied exactly as ondocuments.create; whengetOrCreateWithAliasfinds a document of another format than the one you state it answers 409DOCUMENT_FORMAT_MISMATCHand creates nothing. - Documents: a client that has a document's format wrong is refused, not answered in the wrong format. A client that opens a document as the other format gets
DOCUMENT_FORMAT_MISMATCH(details { documentId, declared, actual }): a waitingdocuments.openrejects with it, and a document already open is closed with thedocument:format-mismatchevent. Records requests take the format you expect —?documentFormat=1|2, CLI--document-format <1|2>on everyrecordsverb,ctx.doc(id, { documentFormat })in a server function — and a disagreement answers 409 without touching a record. - Large documents: one query or aggregation spans all of a user's large documents. Under the
opfsengine one browser store holds every large document a signed-in user has open, so an unscopedquery(),queryOne(),count()oraggregate()reads across them in one call, andaggregatetakesdocumentson both clients (JavaScriptAggregationOptions.documents, SwiftAggregateOptions(documents:)) to narrow it. - Server functions: open another user's root document.
ctx.api.users.getRootDocument({ userId })answers{ userId, rootDocId }(nullfor a member who never signed in, 404 for a non-member), andctx.api.documents.aliases.resolve/.deletetakeuserIdto act on that member's user-scoped alias, so a function with no caller can reach a member's own documents. - CLI: collections move between apps.
primitive collections export --output <dir>writescollections.jsonbeside a document export, recording every collection with its owner and members by email, its documents and its group grants.primitive collections import <dir>recreates them in another app: owners and members are resolved by email, documents by their preserved ids, a same-named collection is skipped unless--overwritemerges into it,--ownerassigns one owner to all, anything missing is reported per item with a non-zero exit, and--dry-runwrites nothing. - CLI: an integration's calls, by the server-function run that made them. A call made from a server function records the function's key and the run it came from.
primitive integrations logs <integration-id>shows a RUN column and each call's correlation, and--run <run-id>(the logs API'srunIdfilter) narrows the log to that run's calls. - CLI:
functions listshows each function's triggers. A TRIGGERS column names which functions carry a webhook or crons, and a table beneath lists each trigger's status, schedule, next and last fire.--statusfilters both, and--jsonitems carrytriggers. - Admin sessions: every sign-in is revocable, and
logoutends it on the server. Each CLI, web-admin and bootstrap login is a session:GET /admin/api/auth/sessionslists yours (kind, status, created, last refreshed, expires, and which one is current) andDELETE /admin/api/auth/sessions/{sessionId}revokes one; a super-admin can list and revoke any admin's. A revoked session's refresh is refused and an open admin WebSocket on it is closed withSESSION_REVOKED; an access token it already issued lasts until it expires (at most an hour). CLI:primitive auth sessions list(--limit,--cursor,--admin-id,--json) andprimitive auth sessions revoke <session-id>(--yesoutside a terminal);primitive logoutrevokes its session on the server, and when the server can't be reached it keeps the local credentials and exits non-zero. - Starter templates:
pnpm lintlists deprecated platform API use. The Vue template enables the@typescript-eslint/no-deprecatedrule forsrc/, so each use of a deprecated client,primitive-apporjs-baomember is reported with the note naming its replacement. An existing app can pull the template'seslint.config.tsforward to get it.
Breaking
- A unique constraint on a
stringsetfield is refused where it is declared. No writer could build a consistent key from a set of strings, so the constraint was never honored.defineModelSchema,loadSchemaFromTomlString, every generated model barrel and aJsBaoClientgivenschemaTomlnow throwUniqueStringsetError(js-bao 0.11.0); codegen refuses the schema;primitive config pushrefuses the tree in its preflight; and a function push and the database type routes answer400 UNIQUE_ON_STRINGSET. The Swift client's loader and codegen refuse it the same way, and a schema built in code is refused on its first write. Removeunique = truefrom the field, or the stringset field from the compound constraint, and regenerate. A constraint a document already recorded is ignored by the server, so nothing stored is lost. - CLI: every
<noun> list --jsonprints one envelope,{ "items": [...], "hasMore": bool, "nextCursor"?: string }. Verbs that printed a bare array (among themapps list,groups listanddocuments list) now print the envelope: read.itemsinstead of the top-level array;users listandcollections listgain a uniformhasMore; an empty result is{ "items": [], "hasMore": false }andnextCursoris omitted on the last page. There is no flag for the old output. - CLI: a
listverb prints exactly one page. Verbs backed by a paged route take--limitand--cursorand no longer fetch every page for you — notablydocuments list, which used to walk the whole cursor chain and now returns the first page. Page with--cursor <nextCursor>untilhasMoreisfalse, or usedocuments export-allfor every document; an invalid--limitis refused before any request. - Plain-text API error bodies are now JSON. The admin API's shared error helper, the app API's 401/403 permission wrappers, the admin authentication gate, the deprecated blob
PUTroute's 405, and every remaining per-handlertext/plainrefusal on both API surfaces now answer the shared JSON envelope instead of a bare string. The message text is preserved verbatim: a consumer that read the body withres.text()now readsbody.errorfrom the parsed JSON. The supported clients need no change — the JavaScript client, the Swift client and the CLI all parsed JSON first and fell back to text already — so this affects only code that called these endpoints with a rawfetchand consumed the body as text. The OpenAPI document is updated with the body each route now sends. - The direct LLM and Gemini routes are removed; all model access goes through prompts. The app API's
llm/*andgemini/*routes answer as an unknown route, a server function'sctx.apihas nollmorgemininamespace, anddirectLlmEnabledis no longer an app setting:config pushrefuses it inapp.tomlby name like any unknown key, andconfig pullnever writes it. JavaScript client:client.llmandclient.geminiare removed, withLlmAPI,GeminiAPI,LlmChatOptions,ReasoningOptions, theGemini*types, theGEMINI_ERRORcode, thellm/geminikeys ofanalyticsAutoEvents, andgetLlmAnalyticsContext()/getGeminiAnalyticsContext(). Swift client:client.llmandclient.geminiare removed, withLlmAPI,GeminiAPI, their types,JsBaoErrorCode.geminiError, andclient.llmAnalyticsContext/client.geminiAnalyticsContext. Define a prompt and run it from a server function withctx.prompts.run. - Document create routes refuse a body key they do not read.
POSTcreate, create-with-alias and get-or-create-with-alias answer 400VALIDATION_FAILEDwith adetailsentry naming the key (for examplename,parentId, or a flatscope/aliasKey) instead of ignoring it. The typed clients send only keys the routes read. - Admin API: listing every app on the server is super-admin only.
GET /admin/api/appsanswers any other console admin — including an account whose role uses the legacysuperadminspelling — with403 ACCESS_DENIEDand no app data; list the apps assigned to you withGET /admin/api/admins/me/apps, whichprimitive apps listalready reads. CLI:analytics workflow-usage --all-appsaccepts only thesuper-adminrole and refuses anyone else before sending a request. - JavaScript client (js-bao 0.10.0): a grouped
Model.aggregatewith a singlesum,avg,minormaxreturns the bare value per group. A lone operation collapses exactly as a lonecountdoes, so the shape matches the document and database aggregate routes and the CLI:{ work: 40 }, not{ work: { sum_estimatedHours: 40 } }. Readresult[group]; a read ofresult[group].sum_estimatedHoursisundefinedat run time, and nothing fails to compile. Two or more operations, and ungrouped results, are unchanged.
Changed
- Deprecations marked on every surface — nothing is removed yet. The CEL context surfaces (
GET/PATCH …/databases/{id}/metadata, the databasemetadata/celContextproperties, a type config'smetadataAccess, a collection'scontextId, CLIdatabases cel-contextanddatabases create --cel-context/--metadata, the web admin's CEL Context card) are deprecated in OpenAPI, both clients, the CLI (which warns at runtime) and the workflow step docs; thecursoralias ofnextCursorin every list envelope is deprecated andDeferredGrantListResultanswersitems(withgrantsas the legacy alias); and the adminDELETE …/apps/{appId}/users/{userId}, the app API'sDELETE …/users/{userId},ctx.users.removeandprimitive users removeare deprecated in favour ofusers disable. The Swift client carries the same marks as compiler warnings, with source-compatible overloads. - Model fields named
typeare refused.typemaps to the engine's internal_typecolumn, so a model declaring a field of that name saved the value but filtered on the model name instead.config pushon a database type or a function version's document schema,primitive functions codegen, and the document records surface now refuse the declaration with400 RESERVED_FIELD_NAME, naming the field; a schema already stored keeps working._-prefixed field names are refused for the same reason. - Server functions: no
modein config — the caller picks the runtime at each call.modeanddurableare no longer keys of a function, and a cron entry has nomode: a file that still carries one pushes with a warning naming the key as ignored,config pullremoves the line, and a later release refuses it. The route is the selector:POST /app/{appId}/api/functions/{key}runs the function inside the request and answers the result,POST …/functions/{key}/startstarts it as a task run and answers201with the run — both behind the same access gate, input schema and rate ceiling.modein the body is deprecated ("task"still starts a run; anything else,"any"included, is400 INVALID_MODE), andFUNCTION_MODE_MISMATCHis no longer answered. Every cron fire starts a task run and every webhook delivery runs inside the request. JavaScript and Swift clients:functions.invokeandfunctions.startwork on every function. CLI:functions invokeandfunctions startwork on every function,functions getprints no mode,functions configshas no MODE column, andfunctions codegen(TypeScript and--lang swift) gives every invoker both verb sets —invoke,start,getStatus,waitFor,terminate. - Server functions: database-change triggers are removed.
[[function.triggers.database]]is refused atconfig pushwith a message naming what replaces it,config pullstrips it from a tree pushed earlier, and a stored declaration no longer fires; the trigger kinds arewebhookandcron,ctx.trigger.kindis never"database", andLOOP_DETECTED/DEPTH_EXCEEDEDare gone. Do the follow-up in the handler that writes the record — write the row, then publish, notify or write again — or hand longer work to a task withctx.functions.start.functions getno longer lists watched types. - Admin API: a user's document and database inventories are paged, and reach the end.
GET /admin/api/apps/{appId}/documents?userId=…andGET /admin/api/apps/{appId}/databases?userId=…now answer the standard list shape —{ items, hasMore, nextCursor? }, withlimit(default 50, at most 100) andcursorto fetch the next page — and a client that follows the cursor sees every document or database the user holds; each used to answer a single 100-row page with nothing saying rows had been left behind. The olddocuments/databasesarray keys are still emitted for now as deprecated aliases ofitems. A document's admin metadata now lists every permission, group permission and alias, and a database's every permission, rather than the first 100 of each. CLI:documents list --user-idanddocuments export-allwalk every page; the Admin Console's per-user document and database views do too, and report a walk that could not reach the end instead of showing a partial inventory as complete. - CLI: one way to resolve the configuration tree. The
--dir/--sync-diroverride, the./configfallback used when no project could be found, the scan that guessed which environment to generate from, and--appon every command that reads or writes the tree are all removed. Aconfigverb, eithercodegenfamily,databases schema generateandscripts testsall read and write the selected environment'sprimitive/<env>/, and--env <name>is the only way to point at another one.--appsurvives where it can redirect no file write:users,apps,adminsand the database data verbs. Outside a Primitive project every app-scoped command now stops with an error naming the missingprimitive/config.json;login,logout,initandbootstrapstill work there, and the auth-free local commands (--help,guides,skill,config fields) work anywhere. - CLI:
config pushchecks the whole tree before it applies anything. Before the first mutating call, push checks every file — unrecognized keys and declared types, the workflow lints, a server function'sentry, capabilities and trigger rules, and a webhook trigger's signing-secret reference — with the server's own rules and messages; any error aborts the run with nothing applied,--dry-runreports the same refusal, andconfig diffshows the file under Invalid with the same message. Once the preflight passes, entities are applied one after another and nothing already applied is undone: a refusal only the app's state can answer (a{{secrets.KEY}}that does not exist yet, a per-app cap, a key collision) fails that entity while the rest of the tree lands, the exit status is non-zero, and re-running converges. - CLI:
config pushtypechecks a server function before it ships it. The same push that writesfunctions/primitive-db-types.d.ts,primitive-functions.d.ts,primitive-function-types.d.tsandprimitive-document-types.d.tsnow compiles your function sources against them, using the tree's ownfunctions/tsconfig.json, and refuses a function whose code does not typecheck — with the compiler's own diagnostics, per function, so the rest of the tree still lands.config push --dry-runreports the same diagnostics and ships nothing; reproduce a refusal by hand withtsc -p functions/tsconfig.json --noEmit, and skip the check withprimitive config push --no-typecheck. An npm package that ships no type declarations is not a refusal: the bundler inlines it. - The deprecated document listing and per-document invitation APIs are removed (JavaScript client 3.0.0).
documents.list()is gone: readme.ownedDocuments()for the documents a user owns andme.sharedDocuments()for the ones shared with them.ownedDocumentstakes the same options and returns the sameDocumentInfo[], so most call sites are a rename;sharedDocumentsreturns an{ items, cursor }page. The per-document invitation surface goes with it —documents.createInvitation/updateInvitation/deleteInvitation/getInvitation/listInvitations,documents.acceptInvitation/declineInvitation, andme.pendingDocumentInvitations(). Share by email withdocuments.updatePermissions(documentId, { email, permission }), and the recipient redeems the resulting app invitation withinvitations.accept(inviteToken); list a document's outstanding deferred grants withdocuments.listPendingInvitations(documentId)and withdraw one withdocuments.removePermission(documentId, { email }). Theinvitationevent channel and theDocumentInvitationandInvitationEventtypes are removed,SharedDocument.sourceis always"permission"and itsinvitationIdis gone, and opening a document or callingdocuments.validateAccess()no longer accepts a pending invitation as a side effect — a user whose only access was such an invitation is denied. The routes are withdrawn from the server in the same release, so a server on this version needs a 3.0.0 client. Swift client: the same removals, plusdocuments.listPageandListDocumentsOptions. CLI:documents exportsources its pending-share rows from deferred grants. - Swift client:
documents.createno longer opens the new document.documents.create/createDocumentwrites the new document's metadata and commits it to the server in the background, matching the JavaScript client; it does not open the document. Open it before you query or write it — a write to a document that is not open throwsJsBaoError(.notFound), andgetDoc(documentId)returnsniluntil the open resolves. Previously such a write was applied locally and read back while the client reported the document as synced, and it never reached the server. Opening a just-created document does not wait on the network: a pending create, like alocalOnlydocument, counts as a local copy, so a default-options open resolves locally, offline included. - CLI: the configuration tree moved to
primitive/.primitive/config.jsonis the project config and each environment's TOML lives atprimitive/<env>/, with the sync-state baseline committed beside it as.sync-state.json;.primitive/now holds only machine-local state (credentials,local.json, snapshot backups) and is the single.gitignoreentryprimitive initwrites. A project that still has.primitive/config.jsonand noprimitive/config.jsonbeside it — one that never migrated — is refused by every command that reads the project, with a message naming the file it found and the one it expects; anything else left under.primitive/beside a migrated tree is ignored, and a baseline still named.primitive-sync.jsonis read until the next push or pull writes.sync-state.json.guides get,guides list,skill status,--helpand--versionread nothing from the tree and still run.--appmust name the current environment's app. - Queries:
$neand$ninmatch records that never wrote the field.{ deleted: { $ne: true } }now returns "false or not set", on the server, in the browser replica and in the Swift local mirror;$ne: nullstill matches only records holding a value, and anullentry in$ninexcludes missing rows. Equality, ranges,$inand$existsare unchanged. This is a behavior change for code that relied on$neskipping absent fields. - App API responses are
Cache-Control: no-storeby default (the avatar route keeps its public, immutable caching; blob downloads keep their ETag and304). Swift client: HTTP responses are never written toURLCache— the client's session runs with the cache disabled and.reloadIgnoringLocalCacheData, without touching the host app's default session configuration. - JavaScript client:
js-bao-wss-clientnow requiresjs-bao0.6.0 or newer as its peer (it imports exports older releases do not have), so a clean Vite start resolves instead of failing on missing exports. - Server functions: the SDK package is
primitive-functions(renamed from its earlier alpha name;config pushrefuses a bundle that still imports the old one); functions importdefineFunctionfrom it. - Server functions hardening. Output is capped at the wire ceiling and a function that exceeds it settles as
failedwithOUTPUT_TOO_LARGE; the invoke rate slot is taken only after the access gate and input validation, and a rate limiter that cannot answer refuses with 503FUNCTION_RATE_UNAVAILABLEinstead of admitting; the gateway forwards only an allowlist of request headers; a caller whose user or membership disappears mid-invocation is refused asFUNCTION_CALLER_NOT_A_MEMBER. The platform ceilings for a request invocation are 5 000 ms of CPU, 128 outbound subrequests and 1 200 invocations per minute per function (a task slice's own CPU and subrequest ceilings are under "Task server functions");[function.limits]may lower any of them and never raise one,cpuMsis enforced on the deployed runtime, and an envelope whose handler ran to an answer carrieslimits— the ceilings that invocation actually ran under. - Rhai script step: the per-run operation ceiling is 250,000 (was 100,000), as the default and as the enforced maximum for
limits.maxOperations. - CLI: every environment names one app.
.primitive/config.jsonenvironments carry a requiredappId; the per-machine current app is gone withprimitive useandprimitive context(whoamistill reports the environment's app). - Analytics: cron and system workflow runs no longer count as user activity — the
sys:<appId>principal and the cron creator are excluded from top users, DAU/WAU/MAU and cohorts — and where a system run does appear it is named, not shown as a raw id.appIdanduserIdare reserved keys inanalytics.writeevent context. - Templates: every generated source is committed and regenerated on each build, so a fresh checkout builds without a codegen step and a stale artifact shows up as a diff.
- One key namespace per app. Workflows, server functions, scripts and webhooks now share one key namespace, enforced at
config push: a webhook can no longer reuse a workflow's key, and a push that would collide names the holder instead of creating a duplicate. Existing workflow/script collisions are grandfathered; cron triggers are unaffected. One exception: a function may take the key of an archived workflow — archive the workflow, then push the function. - JavaScript client (js-bao 0.6.0): each call to
initJsBaonow creates its own ORM instance with its own engine, models, subscriptions and document bindings, andresetJsBaodestroys every live instance. A model class passed to a second live instance stays bound to its first one and logs a warning; give each client its own model classes (theschemaTomlpath already does) when they must be isolated. Previously a second call silently shared the first instance's engine. - Large documents: reopening costs what changed. A reopen after a close or reload folds only the records that changed since the last open, and nothing when nothing did (a store written by an earlier client folds the whole overlay once).
- JavaScript client (large documents): existing OPFS stores are replaced by the per-user store: local rows download again on each large document's next open, and unsent writes carry over. A tab running a newer app version refuses to open a large document through an older tab's worker with
FORMAT2_STORE_OUTDATEDuntil that tab is reloaded or closed. - Swift client:
aggregate'sdocumentsnarrows a model bound to one document rather than replacing its scope, asquerydoes, so naming another document returns no rows. - Large documents: epochs seal at 1 MiB, and bases build on a cadence. The open epoch seals at 1 MiB of overlay or 32,768 Yjs items (overwritten values count), no sooner than 10 seconds after it opened unless it reaches three times either limit, or when it is a week old. A base is built every eight seals, an hour after a seal no base covers yet, or on request (
primitive documents snapshots build), rather than after every seal. Connected clients follow seals more often, and each one is smaller and pauses writes for less. Load large datasets with a bulk load, which seals once;records/bulkwrites through the overlay like any save.
Fixed
- Large documents: a document whose format lookup missed because DynamoDB had not caught up is no longer pinned as an ordinary document forever — a miss is confirmed with one strongly consistent read before it is believed, a confirmed absence answers
404 NOT_FOUND(query, count, bulk and a typed frame on the socket) with no pin, and the next request recovers. - Server functions: a task run the platform settled
failedfor an engine teardown (ENGINE_ISOLATE_EVICTED) readscompleted, with its output and a realendedAt, once the resumed run's own settle lands; a teardown sentence from the engine's own step handle settles the sliceresetrather than failing the run, and a row failed for the handler's own failure is left exactly as it was. - Prompts:
userPromptTemplatekeeps its trailing newline on prompt create, config create and config update, soconfig pushof a TOML multi-line template that ends in a newline is reported synced byconfig diff— it no longer reads modified forever. - JavaScript client (large documents): an ordinary
closeDocumentkeeps a large document's local data — records, unacknowledged writes, projection marks and query rows — so the next open re-projects nothing (Swift already did); an unscopedquery(),count()oraggregate()reads only the documents that are open, so a closed document's kept rows are never counted or returned. - JavaScript client (large documents): with the OPFS engine,
documents.evict,closeDocument({ evictLocal: true })andlogout({ wipeLocal: true })now remove the document's local store — its rows, per-document tables, bookkeeping and projected members — and the OPFS directory once it holds nothing else; a document evicted while open stays editable until its close, and a wipe reaches documents this session never opened. - JavaScript client (large documents): a restart in the middle of an epoch move no longer publishes an offline write the catch-up would have dropped — the replay debt a deferral owes is recorded durably in the store before a frame is discarded or an overlay swapped, and the next client over the same store resumes the judgement.
- JavaScript client:
openDocument's first metadata write merges its delta into the persisted row instead of replacing it, so the epoch a rotation wrote or a marker a listing wrote in another tab survives an open; a custom storage provider's merge is serialized per document. - Swift client:
localOnly: truewithdocumentFormat: 2is refused at create withJsBaoError(code: .localOnlyUnsupportedOption)before any local state moves — the same rule JavaScript enforces asLOCAL_ONLY_UNSUPPORTED_OPTION— instead of creating a document that can never sync. - Documents: a records read or write that reaches a document while its delete is in flight, or after the delete cleared the object's storage, answers
404 NOT_FOUNDinstead of500 INTERNAL_ERROR(and, on an ordinary document, instead of a silent success against the wiped object). - Documents: a records query whose
$orcarries fifty or more two-field branches no longer fails with500 "too many SQL variables"— it compiles to one statement inside the platform's parameter bound and returns, sorts, pages and counts exactly the records the branches name. - Documents and databases: cursor pagination is null-aware — sorting on a field some records lack no longer throws (
Cannot generate cursor: record missing sort field), truncates silently or skips rows; present, null and absent values page exactly once in both directions. - Documents: a unique-index entry no longer survives its record. A stale entry (its record gone, or holding another value) is an index miss for
findByUniqueandupsertByUniqueand is healed by the server on the next write, so a key can be written again after its record is deleted. - Collections: group grants are recomputed on every mutation — raising or lowering a level propagates, removing a source removes the access it gave, and a direct grant no longer overwrites a collection grant; a member's level is the highest any live source grants.
- JavaScript client (large documents): evicting a document that was never opened, or a crash in the middle of an epoch swap, no longer leaves a per-epoch overlay in IndexedDB that nothing reclaims; the open path sweeps stale overlays under the epoch lock.
- JavaScript client: a custom
yjsPersistenceFactoryis handed the epoch-scoped store name at a swap (withdocumentId,documentFormatandepochon its context), so the post-swap clear can no longer remove the fresh epoch's rows. - JavaScript client: a failed
clearData()on one document's persistence removes only that document's entries; the sharedjs-bao:yjsIndexedDB database survives an evict, a close withevictLocal, an epoch rotation and a stale-clock reset. - Workflows: a workflow create whose row was written no longer answers
400 INVALID_REQUEST "Failed to fetch saved item"when the store's post-write read-back misses — the row is confirmed with a consistent read, and a run-sync's terminal stamp lands on it. - JavaScript client:
functions.waitForright afterfunctions.startno longer rejectsNOT_FOUNDfor a live run — aRun not found404 is retried for a bounded stale-read grace and the wait resolves when the run settles. - Server functions: a task run whose handler throws settles with
errorCode: "FUNCTION_THREW"(it wasnull) on the row, the admin runs list, the status route and the client'sgetStatus/waitFor, agreeing with its invocation record. - Large documents:
records/bulkcreates no longer scan every stringset-index row of the model per record — the index is(_type, _record_id, field)and an existing database converges on its next cold operation. - CLI:
primitive functions codegen --helpdescribes the invoker the CLI writes today (invoke/start/getStatus/waitFor/terminate, the caller picks the runtime) instead of calling the Swift invoker "mode-fixed". - Workflows: a start whose run row was written but whose post-write read-back missed no longer answers 500 — the row is confirmed with a consistent read and the start answers 201 (about 2% of paced starts hit this on the agent env).
- CLI:
documents export,documents import,documents transfer-owner,databases exportanddatabases importrun with only their required positionals and act on the app the environment names; a typed app id (leading positional or--app) still selects another app. - Large documents: one unintegrable delta from a connection no longer makes the room answer every later frame from that connection with
epoch.resyncuntil the object is evicted — pending state is attributed to the frame that produced it, and a second connection is never told to resync for a delta it did not send. - JavaScript client (UMD bundle):
dist/browser.umd.jscarriesregisterModel(it had shipped only in the ESM dist), and the committed bundle is checked byte for byte against whatbuild:umdproduces. - Large documents: a bulk write refused because the document object was momentarily unreachable answers
503 DOCUMENT_UNAVAILABLEnaming the write, so a transient can be told from a real failure; an unclassified failure stays500 INTERNAL_ERROR, anddocuments records bulkreports the code and status. - Large documents: a 2 GB bulk load no longer sticks in
registering: the old-table removal is bounded in rows and bytes and paged across ticks, an interrupted removal is detected on the next tick, and a replacement session pages a failed session's leftover tables down before its own copy. - JavaScript client:
openDocument()on a client constructed withnew JsBaoClient(options)resolves instead of throwing "called before the js-bao runtime finished initializing". - JavaScript client:
open()on a document that is registered locally but holds no data waits for the initial sync instead of resolving over an empty replica, soquery()andsubscribe()see the synced records without a reload; the SwiftBaoDataLoaderand the VueuseJsBaoDataLoaderkeep a change that arrives during their first load. - Unique indexes: a unique-constraint entry only fires for an index owner the query can read — a stale entry left by a row that is gone, or that now holds another value, is a miss and is rewritten instead of throwing
UniqueConstraintViolationfor a row nothing can see. On the server, in the JavaScript client and in the Swift client (upsert(on:),upsertByUnique,findByUnique). - Large inline document updates are durable. An
updateorsyncStep2frame carrying a payload over the inline size cap with nouploadId(the Swift client sends every update inline) is put in object storage before any row names it; a stored update whose object cannot be loaded fails the read, naming the key, instead of reconstructing a document silently missing it, and a write whose store fails is refused rather than acknowledged. - Swift client: a number field holding a finite
Doubleat or above 2^63 saves and reads back instead of aborting the process inside the CRDT write. - Swift client:
DocumentContext.closereturns theCloseDocumentResult(evicted: falsewhen the server has not confirmed this client's writes), so a skipped eviction is observable through the document handle; ignoring the result takes no warning. - Swift client: the code generation build-tool plugin resolves its tool under Swift 6.4's
swiftbuildbuild system as well as the native one (the plugin's target and product now share a name). - Swift template:
scripts/smoke-test.sh ui_signintaps again on Xcode 27, which moved SimulatorKit: the scenario startsidb_companionunder aDEVELOPER_DIRfrom the newscripts/idb-developer-dir.sh(a symlink mirror of the Xcode bundle; nothing is written insideXcode.app), and its preflight reports an Xcode it cannot resolve before the build. Start an ad-hoc companion the same way. - A document that stops syncing reports it, and the client recovers on its own. An open document whose sync handshake goes unanswered for the whole handshake budget (10 s by default) now reports
documentSyncStateChangedwithstate: "error", repeated on each timeout while it stays behind, and"synced"once when it catches up, so an app can show and clear a warning. The retry re-arms after every attempt that could not start a sync cycle, at a backoff capped at 15 s, and after three consecutive timeouts on a connection that still reads as open the client rebuilds the connection itself — once per stalled document and once per connection. Two server-side causes of a document that never finished syncing are fixed in the same release: an abandoned earlier sync no longer silences every later sync attempt from that connection, and after a reconnect other users' writes reach the connection again instead of only its own sync completing. A connection error for a refused document now carries itsdocumentId. JavaScript and Swift clients alike. - CLI:
config diffno longer reports a database type as modified after an identical push — an operation with an emptyparamsset or a blankaccesscompares equal to one with none — and a Modified row names the field that differs. - CLI:
prompts testsandworkflows testsverbs take the prompt's or workflow's key as well as its id; an identifier that matches neither is refused instead of answeringNo test cases found. - Every test-case route for a prompt, integration, workflow or script — list, create, get, update, delete and attachments — answers
404naming the id when it names no block of that type in the app, instead of listing empty or accepting new cases. - Swift client: a
localOnlydocument no longer reports unsynced changes it can never clear, sodocuments.evictaccepts it withoutforceandevictAll(onlySynced:)no longer skips it every time. - Swift client: a transaction wrapped in
transactAndSyncortransactAndSyncAsyncis sent once rather than twice, and a write made from a document-opened handler is forwarded like any other. - Workflows and operations: a model-typed operation parameter rendered from a
{{ }}template is coerced with the same safe rules as workflow input (numeric string → number,"true"/"false"→ boolean, number → string;coerce: falseopts out); a value with no safe conversion still fails withFIELD_TYPE_MISMATCH. - Analytics:
users.topreports each user's all-timefirstSeenbeside the window-scoped one, so a returning user no longer reads as new. - CLI:
documents importrestores a root document — a root-marked export is applied to the target user's existing root, and only a root the import itself created takes the exported state without--overwrite. - CLI:
scripts tests run <case>passes the case'sinputVariables, matchingrun-all; a script run whose input did not arrive fails instead of being verified. - Workflows: a
switchstep's output templates resolveoutputs.*like step params do, and a skipped step's output is tolerated in the branch expression. - CLI:
config pullno longer writes a prompt's server-ownedstatusinto its TOML, except a retired one. - CLI:
documents exportandexport-allnow write a document's tags, anddocuments importrestores them unchanged; import also refuses to create a new document from a root document and drops the internal root marker, andimport --dry-runpreviews the exact run it would make. - Swift client: a single-document
syncMetadatais now authoritative for that document's row, so a revoked or deleted document is evicted from the local cache instead of lingering until a full sync. - Swift app layer:
PrimitiveAuthManagerforwards the passkey relying party (rpId) onsignInWithPasskeyandenrollPasskey, andinitialize()derives the default from the environment'swebUrl, so an app with several relying parties can name the right one per call. - JavaScript client: an awaited
documents.open()now resolves only once the document is ready to query, even when another open of the same document is already in flight; adding a model mapping and querying right after the open no longer fails intermittently with "document is not connected". - Passkey sign-in from a native iOS app no longer fails with "User verification required" when the app asked only for preferred verification.
- Swift client: a rejected Apple sign-in callback reports the server's error code and message instead of a generic "Invalid credentials", and no longer triggers a token refresh.
- Swift app layer: the login view shows the waitlist state for a waitlisted sign-in, and "Invalid code" appears only when the server rejected the code.
- Swift template: the default app icon is opaque and the Info.plist declares supported orientations, so a first App Store upload passes validation instead of being rejected twice.
- Swift template: the first archive no longer fails the models guard — model codegen runs before xcodegen regenerates the project.
- Prompts: a failed prompt run carries
upstreamStatus, the provider's own HTTP status, whenever the provider answered, for Gemini configs as well as OpenRouter ones (a Gemini failure used to read only "Upstream error"), and an upstream timeout setserrorCode: "PROMPT_UPSTREAM_TIMEOUT". JavaScript client:ExecutePromptResult.upstreamStatusanderrorCode. Swift client: the same fields onExecutePromptResult. - Databases:
saveand batch save keep a field written asnullas the valuenull, aspatchalways did, instead of removing the key, so$exists: truestill matches the record; a unique index on a field a batch create stamps now refuses a duplicate in the same batch reliably. - Collections: a collection's access listing returns every group grant and member rather than the first 100, and a group granted access to a collection holding more than 100 documents can read every one of them.
- Server functions: creating a function no longer fails with "Failed to fetch saved item" after the write succeeded — the server confirms the write before answering and returns
503 STORAGE_CONSISTENCY_PENDINGonly when it still cannot — and CLI:config pushretries that code a bounded number of times instead of reporting the function as "could not be applied". - Large documents: an edit that races a delete no longer brings the deleted record back as a partial row, on the JavaScript client or on the server, whatever order the updates arrive in. A field removed by a late concurrent write now leaves the local query rows too. Saving 8,000 records one at a time now folds in about 0.2 s instead of about 32 s.
2026-08-28
New
- JavaScript client: passkey sign-in and registration start calls accept an optional
rpId, so a native or multi-domain app can name the relying party it wants instead of relying on the request origin. - Swift client:
AuthConfig(passkeyRpId:)— or a per-callrpId:— names the passkey relying party explicitly. - CLI: environments accept a
webUrl(a normalized web origin), andprimitive initscaffolds the universal-link setup for a combined iOS + web app: the association file and an https sign-in link that opens the app where it is installed. - Swift template:
run-ios.shboots a simulator dedicated to the app and waits for the boot it started, instead of sharing whatever simulator was already open.
Fixed
- A passkey start request naming a relying party the app does not configure is rejected with a typed
PASSKEY_RP_NOT_CONFIGUREDerror (both SDKs) instead of being silently redirected to another relying party. - Document shares granted to an email address before the recipient signs up now resolve reliably: every pending grant is found at signup rather than only the first batch, a just-granted share appears on the recipient's first listing, and the app's allowed-domains policy is applied when the grant resolves.
- JavaScript client: a failed OAuth code exchange throws a typed auth error carrying the server's error code instead of a generic failure.
- CLI:
integrations testsverbs resolve the integration by key or ID and fail clearly when they cannot, instead of acting on the wrong integration. - CLI:
scripts testsnames the local case files it skips because they are not registered in the pushed config. - CLI:
email-templateshelp no longer lists retired template types. - Swift template: distribution resolves the App Store Connect key path from the app root, so fastlane finds it regardless of the working directory.
2026-08-27
New
Webhook verification schemes. Inbound webhooks can verify requests with a JWT scheme (fetching keys from a remote JWKS URL), a declarative custom detached-signature configuration, or the built-in Plaid preset — all configurable in the admin console.
Metadata reverse lookup. A resource can be found by a unique metadata value, in the JavaScript client, the Swift client, and the CLI.
Document record aggregation. Documents gained a records
aggregateendpoint matching the databases one, and the CLI'sdocuments recordsanddatabases recordsgroups expose the same verb set.Workflow step execution state. Templates can read whether an upstream step succeeded, failed, or was skipped, so
runIfconditions no longer have to re-derive it.Batch database writes in workflows. The database write step accepts multiple records per operation call.
Metadata-filtered user fan-out. The iterate-users step accepts a
metadataFilter, limiting the fan-out to users whose metadata matches a value.CLI: document management.
primitive documentsgained read and inspection verbs, permission grant and revoke, andcreateanddelete— with the owner assignable by email.CLI: database record commands. New
records get,count,aggregate,save, andpatchverbs, plusdatabases list --owner.CLI: live log following. Log inspection commands gained a
--watchmode that resumes from the last entry seen.CLI: retiring an object.
primitive <noun> archive <id>retires an integration, webhook, cron trigger, workflow or prompt from the terminal — the five types whose status can bearchived. It is the soft delete the API and console already performed: the row is kept so its history still resolves, and it goes on holding its key (and, for webhooks and cron triggers, its slot against that type's per-app cap), soenablerefuses it and there is no un-archive. Deleting the object's TOML file and running a confirmedprimitive config push --pruneremains the only way to destroy a row and get its key back. Like every destructive command it confirms first, takes--yes/-y, and refuses to act on a pipe without it.Swift client: notifications, locks, and resource metadata. A notifications inbox with push-token registration, named locks, and the full resource-metadata value API (get, set, getBatch, list, delete) — each matching the JavaScript client.
Swift client: events as
AsyncStream. Client events are available asAsyncStreamsequences, with opt-in main-actor delivery and delivery metadata on each event.Swift client: reliable workflow completion waiting.
workflows.waitFor(runId:)resolves a run's completion even when the run finishes while the client is disconnected, reconciling on reconnect; a typed overload decodes the run output.Swift client: typed subscriptions codegen. Database codegen emits a typed subscriptions factory, matching the JavaScript generator.
Admin console: error groups, connections, and run detail. Recurring errors are grouped in a dedicated analytics view, an app's live connections are inspectable, and the workflow runs table names the step a failed run failed on.
Named-lock access control. An app can install a single
lockrule set governing which keys each member may acquire or renew; without one, locks stay open to any member as before. A denied call answers 403 withLOCK_ACCESS_DENIED; release is never rule-gated.Swift client: workflow factory parity. Generated workflow factories gain the JavaScript extras —
terminate, cron-triggercreate/update, and inlinedefine.
Changed
One emailed sign-in link for an app with both an iOS and a web client. An app whose Primitive environment names a
webUrl— the origin its web client is served from — emails that origin's/oauth/callbackas the sign-in link, from BOTH clients. Tapped on a device with the iOS app installed, iOS opens the app (a universal link); read anywhere else it is the web app's ordinary sign-in page, so the same email works wherever it is opened. The server sees an ordinary allow-listed redirect target; nothing about the request is per-platform.webUrlis a normalized origin per environment in.primitive/config.json(an entry in that file, orprimitive env add <name> --api-url … --web-url <origin>when you create the environment) — https exceptlocalhost/127.0.0.1, no credentials, path, query or fragment; the CLI refuses anything else, and the resolvers that build an app read it as no web counterpart at all. The Swift library carries it intoclient.links.appBaseURL, so the outgoing link and the origins an incoming universal link is trusted from are one value;PrimitiveAuthManagersends the https target in preference to its custom scheme, andsendsEmailSignInLinkstill forces code-only when set tofalse. An app with no web counterpart is unchanged: code-only by default, the<scheme>://auth/magic-linkopt-in as before.primitive initseeds the devwebUrlwhen an app ends up with both clients and the dev callback is on its allow-list (otherwise it prints the two steps rather than turning a working code-only email into a 400), and prints the production checklist; the Vue template ships a query-scopedapple-app-site-associationexample plus the_headersrule that serves it as JSON, and the Swift template ships the associated-domains entitlement as a documented commented block. Because the web domain is compiled into the app, changing it later means an app release.Deleting a workflow or prompt keeps its history. A plain delete now archives the row instead of destroying it: runs, revisions, and test cases keep resolving, the key stays held, and
enablerefuses the archived row withWORKFLOW_ARCHIVED/PROMPT_ARCHIVEDnaming the remedy. Destroying the row and freeing its key is explicit — the API delete withhard=true, or deleting the TOML file and running a confirmedprimitive config push --prune. Archived rows are listable with--status archived, and an archived workflow no longer blocks deleting a script or config it referenced.Document permission entries type
nameas optional. The server omitsnamefor a permittee who has none; the JavaScript and Swift clients type it as optional (a breaking type change for TypeScript consumers that read it as required), and the Swift client no longer fails to decode a permission listing containing such an entry.Workflow runs stop slowing down with distance. The engine no longer pays a database round trip per step for its own bookkeeping: run setup is checkpointed across replays, the per-step user check is cached, and progress writes leave the step boundary — so multi-step runs keep sub-second step gaps even when the run is scheduled far from the database region.
Email sign-in is one flow, and one setting. A sign-in request now sends ONE email carrying both a 6-digit code and a sign-in link, and the user finishes with whichever suits them — typing the code on the device they started on, or opening the link on whichever device has their mail. Consuming either one retires both: one email signs a user in once. There is nothing to choose in advance, so
magicLinkEnabledandotpEnabledcollapse into a single[auth].emailSignInEnabled(falseturns email sign-in off entirely, for an app that only wants Google, Apple or passkeys); both old keys are retired and rejected by name with the replacement, andPUT /settingsand the admin API report them as compatibility fields always equal to the new one. The JavaScript client gainsemailSignInRequest(email, { redirectUri })and the Swift clientauth.emailSignInRequest(email:redirectUri:);magicLinkRequestandotpRequeststill work as deprecated aliases of the same issuance path, as doPOST /auth/magic-link/requestandPOST /auth/otp/request. The verify calls are unchanged.PrimitiveLogin'semailAuthMethodprop is gone, and both the web and iOS login screens show one email field and one "check your email" state with the code entry and a reminder that the link works too.The email renders from a new
email-sign-intemplate carrying{{code}},{{expiryMinutes}}and an optional{{magicLink}}inside a{{#if magicLink}}block; both credentials share one 15-minute expiry. Link issuance is fail-closed: the email carries a link only when the request names a redirect target AND that target matches the app's non-empty[auth].emailRedirectUris; with no target or an empty list the same template renders code-only, and a target that misses a non-empty list is still rejected withInvalid redirect URI. Because that one template is the only one any sign-in endpoint renders, deleting the link block from youremail-sign-inoverride means no endpoint can send a link email for your app.magic-linkandotpare retired as email types. A stored override for either is kept and still listed — labelled retired, with migration guidance — but nothing renders it, and creating, updating, previewing or test-sending one is refused; deleting still works. Re-author what you customized intoemail-sign-in({{code}}for the code, the{{#if magicLink}}block for the link) and delete the old override. Old clients calling/auth/otp/requestnow receive the new email; since that endpoint's published body carries no redirect target, it is code-only.Google sign-in is configured per client, and
app.tomlstates the whole auth surface. Google registers an OAuth client per platform, so[auth]now takes one entry per client type —[auth.google.clients.web],[auth.google.clients.ios], and so on — each with its ownclientId,redirectUrisand (forwebanddesktop, which are the types Google issues one for)clientSecret. A redirect URI belongs to the client that redirects and selects it at the callback, so an iOS custom scheme is no longer a valid redirect for the web client. The app-wideredirectUrisis renamedemailRedirectUrisand now serves the magic-link flow only; its matching is fail-closed, so an app with an empty list rejects every magic-link request — new apps are seeded with the localhost dev callback, andprimitive initappends the dev-port callback for a non-default port.googleClientId,googleClientSecret,redirectUris,passkeyRpIdandpasskeyRpNameare retired:config push,PUT /settingsand the admin API each reject them by name, with the replacement. Existing apps migrate themselves and keep signing in throughout: until an app is migrated the server reads its oldgoogleClientId/googleClientSecretas a single client —webwhen a secret is stored,ioswhen not — and its oldredirectUrisas the magic-link list, and the first time the app's settings are read or written those values are written into the new fields. A client secret still held as a raw value is moved into the app's secret store and the setting stores the{{secrets.KEY}}reference, so the secret is preserved rather than needing to be regenerated at Google. After migrating, Google sign-in also needs a client library new enough to read the per-client map; magic-link works on any version.On the read side, a stored
clientSecretis echoed verbatim: it is a{{secrets.KEY}}pointer, not a credential, so the withheld-value machinery is gone and with itgoogleClientSecretSet,googleClientSecretStatus,hasGoogleAuthand the forced-migration gate that refused every settings write while an unmigrated value was stored. A settings write now succeeds whatever is stored; a literal simply cannot be written back./oauth-configpublishes the client map with a per-entryusable, and no longer publisheshasOAuth,hasWebOAuth,googleClientId,authorizationUrlor an app-wideredirectUris— a single flag could only ever be right for one platform. Client-version floor: read availability as "the provider is enabled AND my platform's entry is usable". The JavaScript client'scheckOAuthAvailable()and exportedgoogleWebClientAvailable(config)compute it over thewebentry; the Swift client'sAuthConfigInfo.googleSignInAvailableandAppConfigInfo.googleAvailablecompute it over theiosentry. A client older than these reads the removed flags as absent and hides the Google button — it does not crash, but it will not offer Google sign-in until it is upgraded. Apps scaffolded from a template hold a copy of that code and do not auto-update; re-apply the login changes or re-scaffold.Access rules are required on prompts, workflows, and integrations. Every create surface requires an
accessRule, and execution fails closed — a resource with no rule refuses callers. Integration calls are checked against their own rule on every call.Credentials are configured by reference, not by literal. Webhook signing secrets and Google client secrets accept only secret references on writes. Existing literal signing secrets keep verifying deliveries, but editing such a webhook requires migrating it to a reference; the same holds per Google client entry. The separate integration-secret store is retired — app secrets are the only credential store. Rotation grace periods are capped at 30 days.
Server-side configuration is authored in TOML only. The CLI's config-setting flags are removed: author changes in the sync directory and push them. Integration test cases move into the config tree the same way.
CLI command reorganization.
syncnow lives underconfig;settings getis folded intoapps get;collections docsis renamed todocuments;database-typesis renamed todatabase-type-configs; metadata category configuration is split into its own noun; per-subject analytics is consolidated underanalytics(theworkflows analyticsgroup is gone); and the deprecatedllmgroup andworkflows publishare removed.--diris the sync-directory override everywhere, with--sync-dirkept as a hidden deprecated alias.Unresolved workflow template references fail the step instead of substituting silently.
config sync pushalso validates{{ }}expressions statically, so unknown roots and references to undeclared steps fail at push time rather than at run time.Prompt steps declare their output type.
prompt.executetakesexpect = "text"(the default) or"json", andcontentis typed byexpectalone rather than by the active prompt configuration. Workflows that relied on automatic parsing needexpect = "json".Workflow push always sends the full definition. A field omitted from your TOML now clears the server value instead of silently preserving whatever was there.
config diffandconfig pushagree about what changed. Every resource type push handles — webhooks, integrations, cron triggers, blob buckets and test cases included — is compared for CONTENT against the state the server currently holds, by both commands: a cleandiffmeans push has nothing to apply, a comment-only edit is a change to neither, and a differencediffreports is one push applies or names (server drift, a conflict, or an immutable field) rather than silently skipping. A file push would reject — an unrecognized or retired key, or a value whose spelling is not its declared type — is reported bydiffwith the identical message instead of reading as in sync. Breaking: a quoted number where a number is declared (timeoutMs = "300000") is now a validation error both commands report; write it unquoted.config pullhas never emitted that spelling, so only hand-authored files are affected. The exceptions are the two genuinely dual-encoded cases, unchanged: a prompt'stemperature/topP, and JSON fields, which may be a native table or JSON text.app.tomlis the whole truth, and the fullconfig diffcompares it. App settings were the one configuration surface the whole-treeconfig diffdid not look at — anapp.tomledit reported every count as zero, which read as "nothing to push" over a push that would change the running app. They are now compared per field like every other type, and shown as aModifiedapp-settings row (withconfig diff --only appkeeping its per-field detail). Breaking: they were also the one surface where an omitted key preserved the server's value. Push now appliesapp.tomlas the complete state: a key the file does not carry is cleared, or reset to its declared default where the server has one —magicLinkEnabled,otpEnabledandwaitlistNotifyAdminstotrue,[cors] modeto"universal",[invitations] limitto5, the remaining booleans tofalse. Runconfig pull --only appbefore your next push and review the file: any setting configured outside the CLI that is missing from it will be cleared.[app].nameand[app].modeare now required keys — their absence is a validation error from both commands rather than a silent reset. Aconfig pullfollowed by aconfig pushremains a no-op.forEachdefault cap raised to 500. A 250-row provider page iterates without configuration, and an over-cap list fails with an error naming the limit and themaxItemsoverride instead of being silently truncated.Declarative locks apply to synchronous runs and to
workflow.call, not just queued runs. A step that loses a lock underonContention: failis now distinguishable from an application error in run status and error analytics.Role-aware records API. Admin data routes are consolidated under
records/*with role-aware authorization,databases.listrequires a bounding filter, and bulk record operations carry their payload underdata— the oldfieldskey is rejected with a clear error instead of silently writing nothing.Stricter analytics and webhook limits. A filter set over the cap is rejected with an error instead of having filters silently dropped, and an app is limited to 50 webhooks.
Swift client: one breaking API migration. Every deprecated symbol queued for the next major is removed in a single batch —
get*()methods become properties, millisecondIntparameters becomeTimeInterval, event, awareness, and analytics payloads (and codegen row data) become[String: JSONValue], generated code moves behind aclient.codegenfacade, anddatabases.subscribereturns anEventSubscription. Internal transport types are no longer public, andBlobManageris an actor.Swift client: the libraries build under the Swift 6 language mode with strict concurrency.
Swift client: connectivity and list options behave as described. Network-path monitoring works, with gating and reporting separated (the ineffective probe option is removed);
includeSystemreaches the server;waitForLoadandserverTimeoutMsare implemented; cached documents are served immediately while sync refreshes them; andme.ownedDocumentsreturns cached rows right away with a background refresh — pass.networkfor the previous always-blocking behavior.JavaScript client: typed
openDocumenterrors. Fast-fail open errors carry error codes instead of being plainErrors, matching the Swift client.The direct LLM/Gemini proxy is off by default. The deprecated raw proxy routes answer 403 until the app opts in with
directLlmEnabled = trueinapp.toml; managed prompts are unaffected. The routes are removed in the next major server release.Availability status is server-owned.
statusis no longer authored in TOML for prompts, integrations, workflows, or webhooks — useprimitive <noun> enable|disable. Creation always yields an active object, and pull no longer exports status.Workflow guards read the full app-vars map.
vars.KEYinrunIfandsuccessWhenCEL resolves every app config var, including synced vars, matching template rendering. Secrets stay declared-only.Workflow document writes enforce the document model's field types. A value that violates a declared type (a number into a boolean field) is rejected with a remediation message instead of being stored raw; the same typing applies to
upsertOnmatching.
Fixed
- Integration test cases run the request they declare. A test run executed
GET /regardless of the case's documented method, path, body andinputVariables; it now runs exactly what the case's TOML declares, and a case's fixtures are its only attachments. - CLI: pushing a prompt with several configs works on the first push. A fresh multi-config prompt no longer fails with "Config name already exists" and lands without re-runs; the seeded config takes its authored name, and a config validation failure is attributed to the entry that caused it, activation included.
- JavaScript client: a truncated
documents.list()response no longer evicts documents. Eviction now follows the full scope walk instead of trusting a short page, so documents a client still has access to stay available locally. - CLI:
config pushadopts what local sync state never recorded. Webhooks, integrations, blob buckets, and group/collection type configs that already exist on the server are adopted by key instead of failing the push as conflicts; a genuine mismatch is reported naming both values. - CLI: cross-app
config pushkeeps the target app's name. Pushing configuration to a different app with--only appno longer carries the source app's name, and a push that applied nothing leaves sync state untouched. - CLI: Swift codegen's
--verify-projectchecks only its own group. A second Xcode group named "Generated" no longer causes false verification failures. - Reopening a long-lived document after a cold start is fast: stored updates are compacted durably instead of being replayed one at a time, and concurrent reconstructions of the same document are deduplicated.
- Edits to
localOnlydocuments are no longer transmitted to the server, in both the JavaScript and Swift clients. - Workflow runs are debuggable while in flight — run steps are visible before the run reaches a terminal state, child runs started by
workflow.callappear in the runs list, andworkflows runs failuresshows the actual error for each failure and honors--limit. - Workflow
forEachcorrections: sources written as{{ }}templates resolve, checkpoints are scoped per iteration so iterations no longer collide, classified conflict error codes survive durable error capture, and arunIf-gated step downstream of a skipped step is skipped instead of failing on source resolution. - Workflow templates express boolean fallbacks correctly —
|| false,default:, and the boolean filter all handlefalse. - Workflow run status is reconciled on the server, so every client reports one consistent terminal status, and runs spawned per user by an iterate-users step finalize correctly.
- Webhook hardening: deduplication keys derive from signed material and are domain-separated, so unsigned fields cannot forge duplicate suppression; JWT verification rejects tokens whose key id cannot be matched to a resolvable key; the test endpoint refuses to send an unsignable preview and verifies caller-supplied headers; and a duplicate webhook key returns a conflict error instead of failing opaquely.
- Error-group analytics no longer mint a separate group for messages that differ only by embedded identifiers or numbers, distinct step and run failures are no longer collapsed together, timestamps are epoch seconds, JSON key casing is preserved, and each group carries an exemplar.
- Adding a user by email is reliable: addresses are normalized across every provisioning path, transient conflicts are retried, and failures are descriptive instead of an opaque server error.
- Secret references containing invisible formatting characters are rejected up front instead of failing confusingly later, and setting a configuration variable no longer misreports an unrelated write conflict as a duplicate key.
- Legacy admin app routes enforce per-app access checks, and admin sessions can refresh their tokens.
- CLI:
config syncround-trips correctly — pull preserves[[include]]fragments and no longer writes workflow files a later push would reject, push lands a schema change together with an operation rewrite that depends on it, change detection agrees withdiffand consults live server state, anddiffdetects webhookworkflowKeyand promptaccessRulechanges. - CLI:
inithonors the configured server as well as theskip_installanddev_portkeys, query-shapedoperations executeresults print in the standard list envelope, the upgrade hint names the package manager the CLI was installed with, and a vulnerable archive-extraction dependency is patched. - JavaScript client: device reachability no longer overwrites the app-set
networkMode, challenge-driven token refreshes are capped so a refresh loop can no longer occur, switching accounts re-authenticates the connection as the new user, five missing event names are present onJsBaoEvents, and the published package no longer ships unresolvable source maps. - Swift client: the connection lifecycle now matches the JavaScript client — re-authentication mid-connection keeps long-lived connections alive through token rotation, an authentication failure triggers a token refresh instead of failing the connection, server-pushed frames are no longer dropped, sync state and presence reset on disconnect with exactly one disconnected status per close, stalled syncs recover through a watchdog, queued updates flush on open, and forcing a reconnect no longer tears the connection down.
- Swift client: authentication is durable — sessions restore on cold start with leeway on token expiry, magic-link, one-time-code, and passkey sign-in connect automatically, concurrent unauthorized responses coalesce into a single refresh, failed refreshes retry on a backoff, a network outage during a refresh is no longer reported as an authentication failure, and signing out fully clears the session.
- Swift client: the document lifecycle matches the JavaScript client — initial sync, the close and evict cycle, metadata frames, eviction on authoritative listings, permission bootstrapping at open time,
documentLoadedanddocumentOpenedfiring once per open, and typed errors on an open timeout. - Swift client: writes are correct and ordered — outbound flushes are serialized and drained on send confirmation, typed
save(in:)writes only the fields that changed, a zero-debounce save can no longer report synced before flushing, and documents with large externally stored updates load on a cold start. - Swift client:
transactAndSyncon an unopened document throws instead of crashing, an un-encodable URL path segment throws, reserved characters in URL values are percent-encoded so a plus-addressed email resolves the right user, reconnect backoff no longer overflows, removing all event handlers no longer deadlocks the event emitter, anddocuments.validateAccessandgetRootcall real endpoints. - Swift client: opening a database no longer misreads existing data as empty,
findandfindByUniquereturn string-set members, and the in-app debug inspector's storage panel finds the local database again. - Admin console: analytics filters on the same field merge with operator-aware deduplication instead of stacking or overwriting each other, invalid filters surface the server's error instead of failing silently, secret-reference validation matches the server's rules, and the custom detached-signature webhook editor enforces the rules it states.
- Starter templates and libraries: the Swift template's Xcode project pin stays in sync with the app's resolved packages and its development auth bypass is removed, both starter templates carry the same platform instructions, newly scaffolded Vue projects ignore
.eslintcacheand.wrangler, the Vue library accepts newer peer dependency versions and no longer ships unresolvable declaration maps, and the Swift client package ships consistent resolved dependencies. - Workflow database reads return real booleans instead of SQLite-raw 0/1.
- Workflow templates: a terminal
|| 0fallback resolves to a typed 0 instead of an empty string. - Duplicate
Server-Timingheaders on a response are merged into one. - JavaScript and Swift clients: connecting reconciles the whole document scope, documents deleted server-side are evicted from the local cache (bounded by what the listing walk actually proved), a walk that returns zero documents no longer evicts everything, and the reconciliation re-arms when the signed-in user changes.
- Swift client: a NULL in a strictly-decoded boolean field no longer makes generated models drop the whole row.
- Swift client: signing out closes every open document and empties the local record cache, so the signed-out account's data is never visible after the next sign-in, and a pending offline create stops retrying at logout.
- iOS template: every build regenerates typed workflow invokers alongside model types through one codegen entry point, and an offline
scripts/codegen.sh --checkgate fails when the committed invokers are stale. - CLI:
config diffandpushagree on comment-only TOML edits and quoted-number values, a prompt push that fails partway can be retried, andanalytics --helplists each command once. - CLI: a failing
workflows codegen --checknames the exact command that regenerates the stale files.
2026-07-24
New
- Conditional document writes. Single-document
save,patch, anddeleteacceptupsertOn,precondition(compare-and-set), andifNotExists; bulk updates accept a per-operationprecondition. A failed guard rejects the write withCONDITION_NOT_METand rolls back the whole batch. Preconditions are field-equality only. - Multiple sign-in providers per account. A user can link more than one OAuth provider (for example Google and Apple); signing in with either resolves the same account.
- Error-event analytics. Failures are recorded as fingerprinted error events in a dedicated dataset, queryable with the
errors.groupsquery type — via the HTTP API, the workflowanalyticsstep, orprimitive analytics errors-groups. - Parameterized workflow fragments. A workflow fragment include accepts parameters, so one fragment serves multiple call sites.
primitive blob-buckets headreads a blob's metadata without downloading its content.primitive connections listinspects an app's active client connections.
Changed
- Unified list pagination. Every list endpoint returns
{ items, hasMore, nextCursor }. The previouscursorfield and per-endpoint array keys remain as deprecated aliases for one deprecation window — migrate readers toitemsandnextCursor. - Stricter list-input validation. Zero, negative, or non-integer
limitvalues and malformed cursors now return400instead of being silently coerced.
Fixed
- The JavaScript client advances through every page of a list result instead of stopping after the first.
- Users who linked a second sign-in provider are no longer locked out of the first.
- A just-created document appears in the creator's owned-documents list immediately.
- Group-membership reads paginate fully instead of silently truncating for users with many memberships.
- CLI: date-time field values are serialized correctly on
primitive config push, and workflow lock configuration ([workflow.lock]) survives config push and pull. - Swift client:
waitForWriteConfirmationandwaitForInSyncwait for the write to actually be confirmed, and reconnect backoff no longer overflows during long outages.