Records & Data API
Once a structure is applied, rows move through record workflows or the App Data MCP / Data API. Both enforce the same principal, workspace, and capability rules. Neither accepts SQL.
Record workflows
The data.appdata.record.* family is the engine-native surface (org-member token or an exposed virtual wrapping these steps):
| Intent | Typical type (discover the live name) |
|---|---|
| Insert | data.appdata.record.write |
| Read one | data.appdata.record.read |
| Query | data.appdata.record.query |
| Update | data.appdata.record.update |
| Delete (soft) | data.appdata.record.delete |
| Batch | data.appdata.transaction.apply |
| Documents | data.appdata.document.request-upload / .confirm / .query / .download-url / .delete |
document.* needs an end-user principal and a storage.* connection. Org-member files on the hosted site are apphost.file.*, not this family.database_slug may be omitted when the authenticated app has exactly one serving database. If the app has zero or several, name the slug — otherwise the run fails closed. Prefer passing it whenever you know it.
Writes take an optional durable idempotency_key. The same key plus the same payload replays; the same key plus a different payload is a conflict.
From the Node SDK:
import { LtIntegWorkflowsClient } from "@ltinteg/workflows-sdk"
const client = new LtIntegWorkflowsClient({
baseUrl: "https://workflow-api.orkestia.dev",
token: process.env.ORKESTIA_TOKEN,
})
const run = await client.start("data.appdata.record.query", {
/* table + filters from the live schema */
})
const rows = await run.wait()
data.appdata.record.write (or its siblings) by catalog name. Compose those steps into a virtual and expose it. The browser starts virtual.<uuid>@<version> with the @orkestia/auth JWT.Deletes are soft. A deleted row is hidden from live reads; it is not gone forever.
App Data MCP (Data API)
For a signed-in app user (or an agent acting as one), the App Data MCP is a seven-tool Data API. You do not list the whole catalog into the conversation. You do not send SQL.
discover({ query: "properties" }) → small ranked list of views / functions
describe({ resource: "property" }) → columns, filters, kind
read({ resource, where, limit }) → rows
create({ resource, row })
update({ resource, row, where }) → where is mandatory
delete({ resource, where }) → where is mandatory; soft delete
call({ function, args }) → RPC, not SQL
where is a structured object, for example { "hectares": { "gte": 50 } }. Never send SELECT, semicolons, or comments.
// Shape only — column names come from describe(), not from this page.
await read({
resource: "property",
where: { active: { eq: true } },
order: [{ field: "name", dir: "asc" }],
limit: 20,
})
Workspace
If the table is organization-owned, pass the app workspace (the app-organization UUID) when the user is inside one. Do not invent it. List the user's workspaces through the identity workflows first — see Ownership.
What the Data API hides
- Physical tables (
_t_*) are not part of the API. - Live views already hide soft-deleted rows.
- Column names come from
describe, not from memory or this page.
Isolation (what the platform guarantees)
- Principal binding —
end_user_uuidandidentity_app_uuidcome only from the verified token metadata. A caller-supplied value that disagrees is rejected. - Active workspace — for
ownership.kind = organization, the workspace id is server-resolved. Absent → fail closed. - Capability gate —
{namespace}:{verb}(and optional resource ref) runs before the handler. A denied read is a failed workflow, not an empty list. - Owner filter — owner-scoped tables always filter to the bound principal.
Every invocation is audited (allowed or denied).
