Data model reference
Exhaustive reference for records, schemas, documents, folders, lookups, references, and version history: methods, parameters, field types, validation rules, limits, envelope shape, error codes, and an honest "Notes & limits" for each area.
This page does not reproduce the raw endpoint reference — the full request/response shapes are in the generated API reference (OpenAPI / Scalar). Method names below are the Node SDK sub-client methods. The spec is currently at 0.45.0. PATCH and create-by-
typeName-alone require SDK 0.26+ — relevant only if your own integration pins an SDK build older than that; every first-party client is well past it.
Conventions
The list envelope
List, lookup, and version endpoints return a paginated envelope:
{ "data": [ /* items */ ], "nextCursor": "opaque-string-or-null" }
Drain it by feeding the verbatim nextCursor you were given back as startFrom,
until nextCursor is null — that is the only end-of-results signal, on every endpoint
that returns this envelope. A page can come back short, or even empty, while more
results remain; never infer completion from page size or fullness. search.content
and usage endpoints are not enveloped (they return their own shapes).
On list and lookup endpoints, the cursor is opaque and encrypted — never construct,
parse, or store one long-term; a caller-constructed startFrom is rejected with 400,
and a cursor is only valid for the exact query parameters it was issued for. Version
history endpoints (get*Versions) resume from a plain row id instead — still echo
nextCursor back rather than building your own, but it is not encrypted, and a
caller-supplied id is the expected shape there rather than a rejected one.
Page size (limit)
limit is capped, not clamped: on every list/lookup method on this page (listRecords,
lookupRecords, listSchemas, listDocuments, listFolders, listUsers, and the
app-context/access-profile list endpoints in the identity & access
reference) the range is 1–100, defaulting to 20 — a
request for limit: 200 is rejected with 400, not silently reduced to 100 and served.
The SDK does not clamp or auto-paginate on your behalf either: request more than 100 and
the call rejects, exactly as the raw HTTP call would. Page in a loop using nextCursor
(above) rather than requesting a single oversized page. (GET /v1/admin/* audit/access-log
endpoints are a different family with a wider 1–500 range, default 200 — not covered by
this convention.)
Identifiers
typeName, field ids, and lookup field names share one grammar: ^[A-Za-z0-9_-]{1,64}$
— letters (any case), digits, underscore, hyphen; 1–64 characters. camelCase,
snake_case, PascalCase, and kebab-case are all valid. The names userId and
scopes are reserved and may not be redeclared as lookup fields or payload keys.
Timestamps
Creation and modification timestamps are returned as ISO-8601 UTC strings.
Optimistic concurrency
Records, documents, and folders accept an optional expectedVersion on update/patch.
Supply the version you last read; the write is rejected with 409 VERSION_CONFLICT if
the entity changed since, leaving it untouched. Omit it for last-write-wins.
Schemas — client.schemas.*
| Method | Purpose |
|---|---|
createSchema(body) | Create a schema. |
getSchema({ id }) | Fetch by id. |
updateSchema({ id, body }) | PUT full-replace (no PATCH). |
deleteSchema({ id }) | Delete. |
listSchemas({ startFrom?, limit?, surface? }) | List (list envelope). |
getSchemaVersions({ id }) | Version history (list envelope). |
Schema fields
| Field | Type | Required | Notes |
|---|---|---|---|
typeName | string | yes | Immutable after create. Identifier grammar. Unique within your ownership scope — a different owner may declare a schema under the same typeName via basedOn (see below). |
displayName | string | yes | Human-readable name. |
description | string | no | |
fields | FieldDef[] | no | Omit for a bare schema (no validation). |
lookupFields | LookupDef[] | no | Max 10 (declared by you); see lookup notes. |
renderHints | map keyed by fieldId | no | UI hints; does not affect storage/validation. |
capabilities | map<string,boolean> | no | auditHistory (default true) and triggersEnabled (default false, 0.43.0+ — the opt-in a schema must declare before a trigger rule may fire on its records; declaring a rule against a schema without it is a 400). The two are independent: turning audit history off does not turn triggers off. ⚠️ capabilities is replaced in full when you supply it, so a PUT that sends a partial map to change one flag drops the other — omit the block entirely to preserve it. A PUT that would turn triggersEnabled off while rules still fire off the schema is refused 409, as is a DELETE of the schema. |
indexMode | enum | no | HYBRID | SEMANTIC | TEXT | NONE. Type-level default for instances. Omit = no default. |
storageProfile | enum | no | STANDARD (default) | LOW_LATENCY | LARGE_PAYLOAD. |
allowedSurfaces | string[] | yes | Non-empty. Any of record, document, user, entity. Identity entities in every namespace — org, client, or one you register — bind under the single entity surface, and the schema's typeName tells them apart. An entity-surface schema is written by an ordinary scoped credential in its own context, same as record/document. A user-surface schema stays root-key only: your users are tenant-global, so their schema has one tenant-wide home. GET /v1/schemas?surface=entity reads the caller's own context and the tenant-wide home together, newest context first, through a single cursor. |
active | boolean | no | Default true. Inactive schemas reject new record creation. |
userId / scopes | string / string[] | no | Ownership defaults for the schema itself. scopes is <namespace>:<value> entries, max 2. |
basedOn | string | no | Schema id of the lineage base this schema customizes. The first schema created under a typeName has no basedOn and becomes that name's shared base (must be created by a root/unscoped credential with no userId/scopes); every other schema of that name must declare basedOn, pointing directly at the base (one hop — a variant cannot base off another variant). Immutable once set. |
FieldDef
| Field | Type | Notes |
|---|---|---|
fieldId | string | Required. Identifier grammar. |
fieldType | enum | string | number | boolean | date | enum | array | object | reference. |
required | boolean | Enforced on create. |
searchable | boolean | Field text enters the full-text search lane. |
filterable | boolean | Field available as a search filter (no relevance influence). |
description | string | |
validation | object | Validation rules (below). |
enumValues | array | Allowed values for enum fields. |
sensitive | boolean | Redact-at-write + search-exclusion + read-masking + blind-indexed lookup. |
inline | boolean | Keep the field on the record row when the payload is stored out of line (≥ 4 KB, or always on LARGE_PAYLOAD): it appears in list/lookup projections without includePayload, and a trigger rule may project it into input.record. Cannot be combined with sensitive. A schema update that would stop keeping a field inline while a trigger rule still projects it is refused (409); a record or document whose inline fields total more than 224 KB is refused (400). |
targetTypeName | string | reference only: the type pointed at (required for references). |
targetField | string | reference only: target lookup field to resolve against (default externalId; must be a unique lookup on the target). |
cardinality | enum | reference only: one (default) | many. |
targetSurface | string | reference only (required for references): surface the target lives on — record, document, user, or the name of an entity-backed namespace (org, client, or one you registered via POST /v1/namespaces). Not a closed enum — the namespace must already be registered and entity-backed, or the schema is rejected at authoring time. |
Validation rules (the validation object)
required, minLength, maxLength, min, max, pattern, email, url, phone,
step, multipleOf, minItems, maxItems. Rules are enforced at record write; a
violation returns a 400 with a readable message before the record is persisted.
64-bit number range (platform-wide, independent of any schema min/max). Every JSON
number you write — in any field, whether or not your schema declares it, and in a search
request body as well as a payload — must fall within the signed 64-bit range
(−9223372036854775808 to 9223372036854775807), carry at most 38 significant digits, and be
finite. A value outside that range (or a magnitude below roughly 1e-130) is rejected with a
400 naming the field. Store a large whole number you need to preserve exactly — an external
id, an account number, epoch-nanoseconds — in a string field: it keeps the digits byte-exact
and still supports exact-match lookup, where a number field would round or reject. (A record
written before this rule that holds an out-of-range number may now read back with that field
absent or coerced to a string.)
LookupDef
A lookup declares either a single field or a composite of fields — never both, and
fieldName is optional on LookupDef precisely because a composite entry omits it:
- Single-field: a bare field name string, or
{ fieldName, unique }.unique: trueenforces one record per value per tenant+context. - Composite (
recordsurface only):{ fieldNames: [...] }— two or three field names, in the order they'll be queried; that order is fixed once the schema is live. A composite lookup cannot setuniqueorrangeEnabled. Code that reads or writes a schema'slookupFieldsmust handlefieldNamebeing absent whenfieldNamesis present — this is the one breaking shape change for typed clients this release. sortBy— which field this lookup's results are ordered by:createdAt(the default),lastUpdated, or any other declared field (must not besensitive). This is authored once, on the schema, not chosen per-query — see "Sort order" below for how that interacts with theorderquery parameter.
RenderHintDef
label, widget (text | textarea | select | date | checkbox), order,
section, helpText, displayField (marks the headline field; at most one per schema).
Schema versioning
schemaVersion is a public revision counter: 1 on create, prior + 1 on each update.
Records and documents are stamped with the governing schemaVersion at write and keep
that value even after the schema evolves. getSchemaVersions returns the immutable
version-row history (same envelope and row shape as record versions).
Notes & limits — schemas
- No PATCH. Schemas are PUT-replace only. Collection fields (
fields,lookupFields,renderHints,capabilities) are replaced in full on update — supply the complete intended set; omitted scalar fields are preserved. typeNameis immutable after creation.- Creating a schema is idempotent by
typeName, within your own ownership scope. Re-issuingcreateSchemafor atypeNameyou already own returns your existing schema rather than failing, so re-running your own provisioning step is safe. To change a schema, update it (PUT-replace). - A different owner reusing an existing
typeNamemust declarebasedOn. The first schema created under a name becomes that name's shared base (root/unscoped credential only, nouserId/scopes); every other owner defining a schema under the same name must setbasedOnto the base's schema id, or the create is rejected with a400. A variant stays the same conceptual type as the base for references, listings, and blueprints, while declaring its own fields/validation. - A bare schema runs no payload validation; all string values are still text-indexed for search when its index mode permits.
- Schema-field reference targets are declarable today; write-time existence/type
enforcement of references is not yet active — a
referencefield carries the link but is not yet validated against the target on write.
Records — client.records.*
| Method | Purpose |
|---|---|
createRecord(body) | Create. typeName and/or schemaId (see below). |
getRecord({ id }) | Fetch by id (always full payload). |
updateRecord({ id, body }) | PUT — replace mutable fields; payload replaced in full. |
patchRecord({ id, body }) | PATCH (RFC 7386). SDK 0.26+. |
deleteRecord({ id }) | Hard delete (+ tombstone). |
listRecords({ type, userId?, scope?, startFrom?, limit?, includePayload? }) | List (list envelope). scope is one <namespace>:<value> filter (e.g. org:<id>) per call. |
lookupRecords({ type, field, value?+sortFrom?+sortTo? | values?+sortFrom?+sortTo? | from?+to? | prefix?, order?, startFrom?, limit?, includePayload? }) | Lookup, one mode (list envelope). For a composite lookup, field is comma-joined and pairs with values (array, order matches the schema's fieldNames). order (asc|desc) is direction only — see Sort order below; the sorted field itself is the schema's sortBy, not a call-time choice. |
lookupRecordsByBody({ ... }) | Body-based lookup (sensitive-safe). |
getRecordVersions({ id }) | Version history (list envelope). |
getRecordTombstone({ id }) | Tombstone for a deleted record. |
Create / update fields (RecordRequest)
| Field | Type | Notes |
|---|---|---|
typeName | string | The record type. See type-identification below. Immutable; ignored on update. |
schemaId | string | Schema to validate against. See below. Immutable; ignored on update. |
payload | object | Validated against the schema. On PUT, replaces the stored payload in full. |
status | string | Lifecycle/workflow status (default ACTIVE). |
folderId | string | Group with a folder. Cannot currently be cleared once set. |
userId | string | Ownership (Vectros user UUID). Subject to token identity auto-assign. |
scopes | string[] | Ownership entity edges, each <namespace>:<value> (org:..., client:..., or a namespace you registered) — at most two. On update, an explicit scopes replaces the full set; omit to leave ownership unchanged; [] clears it. Subject to token identity auto-assign. |
externalId | string | Stable id you assign. Immutable. Unique within tenant+context+typeName (idempotent create). Max 256 chars. |
indexMode | enum | Per-record override: HYBRID/SEMANTIC/TEXT/NONE. Immutable after create. |
expectedVersion | number | Optimistic concurrency. Ignored on create. |
Type identification (SDK 0.26+ either-or): provide typeName or schemaId
(at least one). With only typeName, the server resolves the schema to your own
basedOn variant when one exists, otherwise the shared base — typeName is unique within
an ownership scope, not tenant+context-wide (see basedOn in the schema fields above).
With only schemaId, it resolves the type from the schema. With both, they must agree.
An SDK older than 0.26 always sends both.
RecordResponse (selected fields)
id, typeName, schemaId, schemaVersion, externalId, payload,
payloadExternalized, payloadBytes, status, folderId, userId, scopes,
indexStatus (PENDING_INDEX | INDEXED | SKIPPED | FAILED, null for store-only;
SKIPPED = no indexable text, so nothing was indexed — stored + retrievable, not an error),
indexFailure (present only when indexStatus is FAILED — an object with a stable code
and a human-readable message; see the code table in the operations-trust reference),
indexMode, createdBy, createdAt, updatedAt, version.
For an externalized (large) payload, list/lookup responses return only the indexed
projection and set payloadExternalized: true; fetch the full payload via by-id GET or
pass includePayload: true on the list/lookup call.
Automatic ownership lookups
Every record is automatically lookup-indexed by userId and by scopes
without declaring them and without counting against the 10-field cap — so
listRecords({ type, userId }) and listRecords({ type, scope: 'org:<id>' }) resolve
directly (scope takes one <namespace>:<value> filter per call).
Notes & limits — records
- PUT replaces the payload in full — it is not deep-merged. Use PATCH (0.26+) for a true partial payload update.
- PATCH patchable keys:
payload,status,folderId,userId,scopes,expectedVersion. Immutable keys (typeName,schemaId,externalId,indexMode) are rejected if present. Withinpayload, a key set tonullis deleted; a top-level patchable field (such asstatusorfolderId) set tonullis not a delete — it is rejected with400. Clearing a top-level field is not supported. typeNameis immutable after creation. To change a record's type, write a new record and delete the old one.folderIdcannot currently be cleared once set.- Delete is hard delete — there is no soft-delete status that lingers in the index.
- Batch write and batch get are implemented; batch lookup is still reserved.
POST /v1/records/batchwrites up to 50 records in one call andPOST /v1/records/batch-getfetches up to 100 by id. Batch write returns HTTP200with a per-itemresultsarray even when items failed, so inspect the results rather than the status code.POST /v1/records/lookup/batchis still reserved and returns501 not_implemented— do not depend on it.
Documents — client.documents.*
| Method | Purpose |
|---|---|
ingestDocument(body) | Inline text ingest. |
uploadDocument(body) | Request a presigned upload URL (file path). |
getDocument({ id }) | Fetch by id. |
getDocumentText({ id }) | Retrieve the retained text — always available for text-ingested documents; for file uploads unless uploaded with storeText: false. |
getDocumentDownloadUrl({ id }) | Presigned download URL for a file-backed document. |
updateDocument({ id, body }) | PUT — full replace; text re-ingests. |
patchDocument({ id, body }) | PATCH (RFC 7386). SDK 0.26+. |
deleteDocument({ id }) | Hard delete (+ tombstone). |
listDocuments({ userId?, scope?, startFrom?, limit? }) | List (list envelope). scope is one <namespace>:<value> filter per call. |
lookupDocuments(...) / lookup-by-body | Lookup on a schema-bound document's lookup fields. |
getDocumentVersions({ id }) | Version history (list envelope). |
Upload fields (FileUploadRequest — uploadDocument)
In addition to fileName/fileType/indexMode/ownership/payload/schemaId/externalId:
| Field | Type | Notes |
|---|---|---|
storeText | boolean | Default true: the extracted text is retained — retrievable via getDocumentText and usable by document-ask. Set false to discard the extracted text once indexing completes (search and the original-file download are unaffected; getDocumentText then 404s and document-ask 409s). Fixed at ingest: it cannot be changed later, and a re-upload keeps the original choice. |
Ingest / update fields (DocumentRequest)
Text-ingested documents always retain their body (it IS the document) — there is no retention flag on this path.
| Field | Type | Notes |
|---|---|---|
title | string | Required. |
text | string | Inline ingest body. Required on POST ingest; on PUT/PATCH it re-ingests write-through. |
indexMode | enum | HYBRID/SEMANTIC/TEXT/NONE. Optional if the bound schema sets a default; otherwise required. Fixed at creation. |
folderId | string | Defaults to the context root. Cannot be cleared once set. |
payload | object | Structured data (records parity). Validated + lookup-indexed when schemaId is set; undeclared keys pass through as free-form, filterable in search. Replaced in full on PUT. |
schemaId | string | Optional schema to validate + lookup-index the payload against. |
userId | string | Ownership (Vectros user UUID). |
scopes | string[] | Ownership entity edges, each <namespace>:<value>, at most two. Same replace-on-update semantics as records. |
externalId | string | Stable id you assign. Immutable. Unique within tenant+context (idempotent ingest). Max 256 chars. |
expectedVersion | number | Optimistic concurrency. Ignored on create. |
Upload handshake (uploadDocument)
uploadDocument returns uploadUrl, expiresAt (15 minutes), and requiredHeaderName
requiredHeaderValue. PUT the raw bytes touploadUrlwithout an Authorization header, setContent-Typeto the file's MIME type, and set the header namedrequiredHeaderNametorequiredHeaderValueexactly. The URL is single-use, enforced by an S3 conditional-write precondition baked into its signature: this header is part of that signature, so a PUT that omits it (or changes its value) fails with a403 SignatureDoesNotMatchbefore reaching this service at all — not this API's JSON error shape. PollgetDocumentuntilstatusisINDEXED.
DocumentResponse (selected fields)
id, title, externalId, status (PENDING_UPLOAD | UPLOADED | EXTRACTING |
PENDING_INDEX | INDEXED | SKIPPED | STORED | FAILED; SKIPPED = extraction produced no
indexable text, so nothing was indexed — stored + retrievable, not an error),
indexFailure (present only when the status is FAILED — an object with a stable code and a
human-readable message; see the code table in the operations-trust reference), indexMode, storeText,
folderId, payload, payloadExternalized, schemaId, schemaVersion, textBytes,
userId, scopes, fileType, fileSize, createdAt, lastModified,
version.
Notes & limits — documents
- PUT replaces the payload in full; PATCH (0.26+) merges. PATCH patchable keys:
title,text,folderId,schemaId,userId,scopes,payload,expectedVersion.indexMode,externalId, andstoreText(text retention is fixed at ingest) are immutable and rejected. - An update re-runs the indexing pipeline; old content is removed from the index as the new content is written.
getDocumentTextserves the retained text: always available for text-ingested documents, and for file uploads unless uploaded withstoreText: false(which discards the extracted text once indexing completes — the original file stays downloadable). Returns 404 when the text is not retained or extraction has not completed.- The presigned PUT must omit the Authorization header — the URL itself carries the grant.
Folders — client.folders.*
| Method | Purpose |
|---|---|
createFolder(body) | Create (optionally under a parent). |
getFolder({ id }) | Fetch by id. |
updateFolder({ id, body }) | PUT — name/description/ownership. |
patchFolder({ id, body }) | PATCH (RFC 7386). SDK 0.26+. |
deleteFolder({ id }) | Delete (rejects non-empty). |
listFolders({ userId?, scope?, startFrom?, limit? }) | List (list envelope). scope is one <namespace>:<value> filter per call. |
getFolderVersions({ id }) | Version history (list envelope). |
Create / update fields (FolderRequest)
| Field | Type | Notes |
|---|---|---|
name | string | Required. |
description | string | Optional. |
parentFolderId | string | Applied at create only; ignored on update. Omit to create under the context root. |
slug | string | Stable, sibling-unique slug; derived from the name when omitted. Lowercase letters/digits/hyphens. Immutable. |
userId | string | Ownership (Vectros user UUID). |
scopes | string[] | Ownership entity edges, each <namespace>:<value>, at most two. |
expectedVersion | number | Optimistic concurrency. Ignored on create. |
FolderResponse (selected fields)
id, name, description, parentFolderId (null only for a true root),
slug, depth (0 at root), isProtected, userId, scopes, createdAt,
lastModified, version.
Notes & limits — folders
- No move / reparent.
parentFolderIdis fixed at creation; there is no operation to relocate a folder in the hierarchy. - Delete rejects a non-empty folder with a 400 — remove children first. "Non-empty" means
ANY of the three things a folder can hold: sub-folders, documents (file-backed and
text-ingested), and records. Enumerate them with
GET /v1/folders?parentFolderId=,GET /v1/documents?folderId=andGET /v1/records?folderId=. The guard counts items your own credential may not be able to read, so a folder in a shared tenant can refuse to delete for a narrowly-scoped credential without that credential being able to see why. Emptying it then needs a credential whose reach covers the whole folder. - The context root folder is protected (
isProtected: true) and created lazily on first folder interaction. Unparented folders are placed under it, so a folder created without a parent still has a non-nullparentFolderId(the root's id). - PATCH patchable keys:
name,description,userId,scopes,expectedVersion.slugandparentFolderIdare immutable and rejected.
Scripts — client.scripts.* and POST /v1/scripts/execute
A script is a stored, versioned JavaScript program (POST /v1/scripts pushes an immutable new
version of a name; GET/DELETE /v1/scripts/{id} manage versions) that the platform runs for you in
a sandbox with the vectros.records, vectros.documents and vectros.folders host functions. Two
things run a script: a trigger rule (asynchronously, when a record event fires it) and
POST /v1/scripts/execute (synchronously, as one atomic call).
Synchronous execution (POST /v1/scripts/execute)
| Field | Type | Notes |
|---|---|---|
scriptRef.name | string | The script's name. |
scriptRef.version | string | A version number ("3") or "latest", resolved at request time. |
input | object | Free-form JSON your script receives as input.params. Optional; {} when absent. |
Response: { result, execution: { id, durationMs } }. result is whatever the script returned,
converted as JSON.stringify would; the platform makes no promise about its shape. It is returned
only after the script's writes have committed — on any failure the body is the standard error
envelope and nothing the script staged is committed: rows are written only when the transaction
commits, and a document body or a large payload is decided at that point and written to storage only
after the commit, so an aborted execution leaves nothing behind. (The one residual sits after a
successful commit, outside the response: if storing a body fails then, the row exists and the body
does not until the platform's repair lands — a 200 guarantees the rows; read a document's text
before relying on it.)
Permission. scripts:x — the permission to execute — separate from scripts:c (push). Bare
scripts:x grants every script in the context; scripts:x:<name> grants exactly that name, every
version. Execution discloses no source (scripts:r is not needed), and scripts:x alone can read or
write nothing: inside the script every host call is enforced by your credential's data scopes exactly
as the equivalent REST call would be.
What the script sees. input.event is the literal "execute", input.userId is your
credential's user id (or null), and input.params is your input object verbatim. That is the same
input grammar a trigger rule uses, so one script can serve both by branching on input.event;
input.schemaId, input.recordId and input.scope are absent on this path. params is not
validated: a script's declaredInputContract is documentation for readers, not an enforced schema
— a script that needs a shape checks for it and throws, which reaches you as a 400 SCRIPT_ERROR
carrying the script's own message.
Trigger execution — what the script sees
A rule fires on a record event and the script receives the record as an image, not a pointer:
| Key | Present on | Value |
|---|---|---|
input.event | all | "CREATE", "UPDATE" or "DELETE". |
input.schemaId | all | The schema the rule fires off. |
input.recordId | all | The record's id — what vectros.records.get accepts. |
input.userId, input.scope.<ns> | all | The record's stamped ownership (the values the rule's ${{ input.* }} grant resolved against). |
input.record | all | The rule's declared fields, from the row as written — on DELETE, from the deleted row (the only way a script can see it). Absent only for a firing dispatched before this release rolled out. |
input.previous | UPDATE | The same fields from the row before the write. |
fields is required when you declare the rule (POST /v1/triggers), and [] projects nothing
beyond the identity. Every entry must be a field the schema keeps inline — inline: true,
filterable, or a lookup field — and not sensitive; the rule is rejected with a 400 listing the
projectable fields otherwise. A rule that declares fields must also be able to read every record it
fires on: its grant must hold a records:r clause covering the schema's type (a grant with no read at
all is rejected), and that clause may not carry a literal data_scope constraint (a
${{ input.<dim> }} placeholder is fine — it resolves to the firing record) or the rule is rejected; and if
at execution the rule's grant cannot read the firing record, record and previous are delivered as
empty objects. The reason is the storage tier: a payload moves out of the row above
4 KB, only the inline fields ride the change event a trigger fires from, and a field outside that set
would reach a script for small records and silently vanish for large ones. A field the record does not
carry is undefined, never null. record plus previous are capped at 224 KB serialised; an
over-cap firing is not run and is recorded as an INPUT_TOO_LARGE failure naming the size. A record
last written before its schema inlined a field may lack it in previous (or in record on DELETE)
until it is written again.
Reads inside the script. A get sees the script's own staged writes. A list, query or lookup
never shows a row the script has deleted, shows a row it re-saved with the updated content, and does
not show a row it created — you hold that row's id from create(). (The same holds inside a
trigger execution.)
Budget — "seconds of useful work". 15 s of wall clock (never more than 16), a bounded number of
statements and of host calls (a runaway script is stopped early with RESOURCE_LIMIT_EXCEEDED), and
one transaction of at most 100 storage rows after merging — stated in rows because that is the
storage transaction's own limit, not a platform choice:
| Operation | Rows |
|---|---|
folders.create / folders.delete | 3 |
documents.create (text) | 1 + one per range-indexed lookup field + one per reference field |
records.create | 1 + range-indexed fields + references (+ over-budget fallback rows) |
any update via a full save | the same as its create |
So "create a folder and file N plain text documents" holds up to 97 documents (48 with one range
field or one reference each, 32 with both). Over the limit the error is 400 WRITE_BUFFER_CAP_EXCEEDED
with total and limit, and nothing is committed.
Limits and metering. The request takes the write-path limits once, before the script runs —
burst, monthly credit ceiling, principal quota — with no free-read allowance, so an account over its
credit ceiling is refused (402) even for a read-only script. Each read the script performs is
metered as a read and each write as a write; execution time beyond what those operations include is
charged as script execution time (see GET /v1/usage, whose execution section covers both trigger
and synchronous scripts). Concurrent executions per app are bounded; at the limit the response is
429 with Retry-After: 2.
Retries — the Idempotency-Key header. Optional, 1–128 characters of letters, digits, ., _,
: or -. A request repeated under the same key within 24 hours receives the same response as the
first attempt without executing again; a duplicate still in flight is 409 IDEMPOTENCY_IN_PROGRESS;
the same key on a different request is 422 IDEMPOTENCY_KEY_REUSED. If an execution's outcome cannot
be determined — the script did not finish inside the budget while its writes may have committed — the
response is 500 EXECUTION_OUTCOME_UNKNOWN carrying execution.id, and a keyed retry receives that
same answer: inspect what the script would have written, then use a new key for a new attempt.
With a key present the serialised result is capped at 16 KB (256 KB otherwise): a retry-safe
script returns ids, not payloads. Without a key, a lost response cannot be distinguished from a
failed one — re-read before retrying.
Errors (standard envelope, errorCode named): 400 SCRIPT_ERROR · RESOURCE_LIMIT_EXCEEDED ·
WRITE_BUFFER_CAP_EXCEEDED · RESULT_TOO_LARGE; 403 missing scripts:x ·
AUTHORIZATION_DENIED; 404 unknown script; 409 CONCURRENT_MODIFICATION (names the ids to
re-read; never a version) · IDEMPOTENCY_IN_PROGRESS; 413 body over 256 KB; 422
IDEMPOTENCY_KEY_REUSED; 429 burst / concurrency / probe budget; 500 EXECUTION_OUTCOME_UNKNOWN
or an internal error with a correlationId; 503 writes frozen · EXECUTION_NOT_STARTED ·
EXECUTION_CONTAINER_BUSY (retry after Retry-After); 504 TIMEOUT.
Lookups & references
Lookup modes
Exactly one mode per call:
| Mode | Parameters | Constraints |
|---|---|---|
| exact | value (single field), or values (composite — array, order matches fieldNames) | Any lookup field. A single-element values means the same as value. Sensitive fields must use the body variant. May be narrowed with sortFrom/sortTo (below). |
| range | from + to | Both required. Inclusive, ascending. Non-sensitive, single-field lookups only. |
| prefix | prefix | String, non-sensitive, single-field lookups only. Ascending. |
Supplying zero or more than one mode is a 400. Range with only one bound is a 400. Prefix on a non-string field is a 400.
Composite lookups (record surface only)
A LookupDef can name two or three fields together via fieldNames instead of a single
fieldName. Query it with a comma-joined field (field: 'status,area') and a matching
values array in the same order — repeated query params on GET, an array in the body on
POST. Field order is fixed at authoring time and cannot be changed later; you may query a
leading run of the declared fields (the first field alone, the first two, and so on)
but never a later field on its own — declare a separate lookup for that. Supplying fewer
values than declared returns records grouped by the unspecified fields; a requested
sort order then applies within each group, not across the whole result. A composite
lookup cannot be unique or rangeEnabled, and its schema's allowedSurfaces must be
record only.
Sort-key window (sortFrom / sortTo)
An exact-value lookup accepts optional sortFrom/sortTo bounds on the lookup field's
own sort key — inclusive, either given alone, expressed in that field's own units (epoch
milliseconds for a timestamp field such as createdAt/lastUpdated). On a composite
lookup, a sort-key window requires the full tuple of values; it is not available on a
grouped/partial query. Records with no value on the sorted field sort ahead of records
that have one, and are never included in a bounded window. The result still pages with
the standard { data, nextCursor } envelope.
Sort order (order) vs. sort field (sortBy)
An enumeration lookup accepts an order parameter — asc (default) or desc — as a
query param on GET, or the order option on the SDK's lookupRecords. order picks
direction only. Which field the results are ordered by is not a per-query choice — it
is the lookup's sortBy, declared once on the schema (see LookupDef
above; defaults to createdAt). Passing a sortBy query parameter here has no effect —
it is silently ignored, not rejected — because the field it would need to change is fixed
by the schema, not the request. If you need results ordered by a different field, change
the schema's sortBy for that lookup (or declare a second lookup field with a different
sortBy); don't guess a sortBy query parameter and assume it took effect. (Unrelated
same-named field: RenderHintDef.order above is a form-builder display position, not a
query sort — don't confuse the two.)
Unique vs non-unique (enumeration)
- A
uniquelookup field returns at most one record and is enforced unique on write. - A non-unique lookup field is an enumeration — it returns every record sharing the
value, paginated via the
{ data, nextCursor }envelope.
Sensitive-field lookup (body variant)
For a sensitive field, the exact value must not travel in a URL. The GET lookup rejects
value on a sensitive field and directs you to the body-based variant, where the value
travels in the request body and is blind-indexed server-side. Range and prefix are not
available on sensitive fields (a blind-indexed value has no usable order).
References
A reference field links to another record: it declares targetTypeName (required),
targetField (default externalId; must be a unique lookup on the target), cardinality
(one/many), and targetSurface. The platform can additionally maintain per-field
reverse-reference rows (opt-in) to index the inverse direction.
Notes & limits — lookups & references
- Lookup fields you declare are capped at 10 per schema (the three automatic ownership lookups do not count).
- The reverse-reference list endpoint is not yet available — you cannot query back-references through the API today.
- Reference targets are declarable now; write-time existence/type enforcement of a reference against its target is not yet active.
Version history — getRecordVersions / getDocumentVersions / getSchemaVersions / getFolderVersions
Each returns the { data, nextCursor } list envelope of immutable version rows for an
audited entity.
Version-row fields
| Field | Type | Notes |
|---|---|---|
id | string | Version-row id. |
changeType | enum | CREATE | UPDATE | DELETE. |
previousContent | string | JSON-stringified snapshot of the state prior to this change. Populated on UPDATE/DELETE; null on CREATE. JSON.parse to inspect. |
previousVersion | number | Version number before this change; null on CREATE. |
changeReason | string | Caller-supplied or system-derived reason. |
changedBy | string | Id of the user / key responsible. |
createdAt | string | ISO-8601 timestamp of the row. |
changedFields | object | Field-level diff: summary, changed field names, count, per-field old→new detail. Null on CREATE. |
Audit capability & retention
- Audit history is governed per schema by
capabilities.auditHistory(default true). Setting it false stops recording the change-history trail for that type's data; it never deletes or affects the records/documents themselves. Tombstones on delete are recorded regardless. - Version rows are written asynchronously (typically 1–3 seconds after a write returns) — poll briefly if you read history immediately after a write.
- Audit and version history are retained compliantly; heavy historical content is externalized to a write-once, retention-governed store, and the history forms a tamper-evident continuity chain. The full retention and integrity posture lives in ../operations-trust/compliance.md.
Errors
The platform returns a uniform error contract. Common cases for the data model:
| Status | Meaning (data-model context) |
|---|---|
400 | Validation error: schema-field violation, bad identifier, multiple/zero lookup modes, range/prefix on a sensitive field, prefix on a non-string field, delete of a non-empty folder, immutable field present in a PATCH, a top-level field set to null in a PATCH, a number outside signed 64-bit range / with more than 38 significant digits / non-finite, a caller-constructed (non-verbatim) startFrom, or a startFrom used with different query parameters than it was issued for (the error names the field, where applicable). |
404 / "not found" | The entity does not exist, or belongs to another tenant/context, or is out of the caller's token scope — a single uniform shape (the message never distinguishes these). |
409 VERSION_CONFLICT | expectedVersion did not match — the entity changed since you read it; it is left untouched. |
501 not_implemented | A reserved-but-unbuilt operation (batch record lookup). |
Where to go next
- explanation.md — the concepts and the why.
- how-to.md — runnable guides for every operation above.
- ../operations-trust/compliance.md — version history retention, the tamper-evident chain, and sensitive-data handling.