Stateflows and operations
The routes on this page invoke what the tenant declared — stateflows, actions, scripted endpoints — move data in and out in bulk, reshape the tenant’s database, and report the service’s own runtime state. Generic entity CRUD lives on REST and GraphQL; the shared read parameters are on Query parameters.
Paths below are public paths. The gateway matches the prefix
/data/ and strips it before forwarding, so /data/admin/data/plans reaches data.svc
as /admin/data/plans. Every route except the two probes requires the four CPET headers
and a bearer token; PATCH /data/bulk is the one route that also accepts an API key. See
the request conventions.
Route index
Section titled “Route index”| Method | Path | Gate |
|---|---|---|
GET | /data/stateflows | Bearer |
GET | /data/stateflow/{name} | Bearer |
GET POST PUT PATCH DELETE | /data/actions/{name} | Bearer |
POST | /data/rest/{entity}/action/{action} | Bearer |
GET POST PUT PATCH DELETE | /data/x/{path} | Bearer, then the endpoint’s own mode |
DELETE | /data/cache | Bearer |
GET | /data/contentstore/list | Bearer |
POST | /data/bulk | Bearer |
PATCH | /data/bulk | Bearer or API key |
POST | /data/admin/data/plans | Generate |
GET | /data/admin/data/plans/{id} | Read |
POST | /data/admin/data/plans/{id}/apply | Apply |
GET | /data/admin/data/applied | Read |
POST | /data/admin/data/seeds/runs | Seed |
GET | /data/admin/data/seeds | Read |
POST | /data/admin/data/retention/runs | Retention |
GET | /data/admin/data/retention/preview | Read |
POST | /data/admin/data/retention/handoff/claim | Retention |
POST | /data/admin/data/retention/handoff/confirm | Retention |
GET | /data/admin/data/retention/handoff/pending | Read |
GET | /data/admin/data/retention/archives | Read |
GET | /data/admin/data/retention/entities | Read |
GET | /data/admin/data/locks | Read |
DELETE | /data/admin/data/locks/{resource} | Locks |
GET | /data/admin/data/actions | Read |
GET | /data/admin/data/materialize/pending | Read |
GET | /data/admin/data/materialize/stats | Read |
POST | /data/admin/data/materialize/drain | Maintenance |
GET | /data/admin/data/references/{entity} | Read |
GET | /data/admin/data/hashes | Read |
GET | /data/superadmin/tenant | superadmin |
DELETE | /data/superadmin/tenant/{tenant}/cache | superadmin |
| — | /data/superadmin/tenant/{tenant}/… | superadmin — one mirror per admin route |
GET | /data/health | none |
GET | /data/ready | none |
Stateflows
Section titled “Stateflows”Two read-only routes. They report the shape of the declared machines so a client can decide which transitions to offer; they do not start, signal, or advance anything.
| Method | Path | Purpose |
|---|---|---|
GET | /data/stateflows | Every declared machine, sorted by name |
GET | /data/stateflow/{name} | One machine |
Response
Section titled “Response”GET /data/stateflow/{name} returns the machine object directly.
{ "name": "order_lifecycle", "field": "status", "initial_state": "draft", "states": { "draft": { "name": "draft", "events": { "submit": { "name": "submit", "target": "submitted" } } }, "submitted": { "name": "submitted", "events": { "approve": { "name": "approve", "target": "approved", "has_guard": true }, "reject": { "name": "reject", "target": "draft" } } }, "shipped": { "name": "shipped", "type": "final" } }}| Field | Type | Notes |
|---|---|---|
name | string | Machine name — the {name} path segment |
field | string | Entity field the machine governs |
initial_state | string | State applied on create when the field is omitted |
states | object | Keyed by state name |
states.*.name | string | Same as the key |
states.*.type | string | final when declared; omitted otherwise |
states.*.events | object | Keyed by event name; omitted when the state declares none |
states.*.events.*.target | string | Destination state — the value a caller writes to fire it |
states.*.events.*.has_guard | bool | Present only when true |
states.*.events.*.has_actions | bool | Present only when true |
Guard, action, entry and exit bodies are never serialised. A client learns that an event has a guard, not what the guard tests.
GET /data/stateflows wraps the same objects:
{ "stateflows": [ { "name": "order_lifecycle", "field": "status", "initial_state": "draft", "states": {} } ], "count": 1}Errors
Section titled “Errors”| Status | Code | Cause |
|---|---|---|
| 400 | v2.missing_name | Detail route called with an empty name |
| 403 | v2.no_tenant | No tenant key on the request context |
| 404 | v2.stateflow_not_found | No machine of that name in the tenant’s schema |
| 500 | v2.engine_unavailable | The tenant’s engine bundle could not be resolved |
Firing a transition
Section titled “Firing a transition”There is no transition endpoint. You fire a transition by writing the state field on the
entity’s normal update route; the engine resolves the current state, matches an event by
its target, evaluates the guard, and either allows or rejects the write.
| Method | Path | Behaviour |
|---|---|---|
POST | /data/rest/{entity} | Create. Applies initial when the field is omitted; an undeclared state is rejected |
PATCH | /data/rest/{entity}/id/{id} | Fire — send the state field with the target value |
PUT | /data/rest/{entity}/id/{id} | Same handler, same behaviour |
PATCH / PUT | /data/rest/{entity} | Same, with the primary key in the body |
curl -X PATCH https://<host>/data/rest/sales_order/id/01J... \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -d '{"status":"approved","approved_by":"u-admin"}'A rejected transition returns 422 with code v2.update_failed; a guard that fails to
compile or throws returns 500 with the same code. The full rejection matrix and the
per-message status mapping are on the
Stateflows block page.
Declared actions
Section titled “Declared actions”An action is a named, parameterised SQL statement declared in the tenant’s definitions. One route serves all of them; the HTTP verb selects which section of the action runs.
| Method | Section | Parameters come from |
|---|---|---|
GET | read | Query string |
POST | create | JSON body |
PUT | update | JSON body |
PATCH | update | JSON body |
DELETE | delete | Query string |
_method and _format are stripped from the parameter map before binding — they never
reach the statement. A request with no body is valid when every parameter has a default.
curl 'https://<host>/data/actions/orders_by_customer?customer_id=01J2...' \ -H 'Authorization: Bearer <token>'{ "total": 0, "data": [ { "id": "01J8...", "total_amount": 4200 } ], "count": 1 }total is the affected-row count and is always present. data and count appear only when
the statement returned rows.
| Status | Code | Cause |
|---|---|---|
| 400 | v2.bad_path | Empty {name} segment |
| 400 | v2.bad_params | Unreadable body or invalid JSON |
| 403 | v2.no_tenant | No tenant key on the request context |
| 404 | v2.action_not_found | No action of that name, or the tenant has no registry |
| 405 | v2.action_method_not_allowed | The action declares no section for that verb. An Allow header and details.allowed[] list what it does declare |
| 500 | v2.action_failed | The statement failed |
| 500 | v2.engine_unexpected_type | Host wiring fault |
The catalogue of what a tenant declares is at
GET /data/admin/data/actions. There is no anonymous mirror — declared actions
are a service contract, not an anonymously discoverable surface.
The entity-scoped alias
Section titled “The entity-scoped alias”POST /data/rest/{entity}/action/{action} invokes the same registry through the older URL
shape. It defaults to the read section; send "_method": "PUT" in the body to select
another. The key is uppercased and removed before binding.
{ "data": [], "count": 0, "total": 3 }The alias always returns all three keys, unlike /data/actions/{name}.
Additional codes on this route: 400 v2.bad_json, 400 v2.body_read, 500
v2.registry_unavailable.
Scripted endpoints
Section titled “Scripted endpoints”/data/x/{path} dispatches to an endpoint declared in the tenant’s definitions and returns
whatever the script returns. Five verbs are registered, but each endpoint accepts exactly
one — its declared verb, or POST for a mutate endpoint and GET for a query endpoint
when no verb is declared.
| Aspect | Behaviour |
|---|---|
| Response body | The script’s return value, serialised as the whole body. No data/count envelope |
| Status | Always 200 on success |
| Request body | JSON, capped at 1 MiB. A GET on a query endpoint reads no body |
| Query string | Reaches the script as string values; a repeated key collapses to the first |
| Headers | Only an allow-listed subset is exposed to the script |
| Path parameters | Declared templates are not matched. Lookup is exact-path, so only literal paths resolve |
X-Cache | HIT or MISS on endpoints that declare a response-cache TTL |
| Status | Code | Cause |
|---|---|---|
| 400 | v2.bad_body | Unparseable JSON, or a body over 1 MiB |
| 401 | v2.unauthenticated | The endpoint requires a principal and none resolved |
| 403 | v2.forbidden | The endpoint’s auth mode refused the caller |
| 403 | v2.no_tenant | No tenant key on the request context |
| 404 | v2.endpoint_not_found | No endpoint at that path, none declared, or it is disabled |
| 404 | v2.endpoint_path_missing | Empty path after /data/x/ |
| 405 | v2.method_not_allowed | Wrong verb. The Allow header names the right one |
| 429 | v2.rate_limited | The endpoint’s own rate-limit profile refused the call |
| 500 | v2.script_error | The script threw |
| 500 | v2.script_engine_missing | No script engine for this tenant |
See Scripted endpoints for the declaration grammar and what a script can call.
Retired routes
Section titled “Retired routes”These paths were served by the previous release and no longer exist. There is no
redirect and no deprecation header — a request to any of them returns a bare 404.
| Retired path | Replacement |
|---|---|
GET /data/ramlschema | GET /data/schema (JSON), GET /data/schema/graphql (SDL), GET /data/schema/openapi.json (OpenAPI 3) — see Schema export for all eight formats |
GET /data/entities/list | GET /data/schema/entities |
GET /data/entities/list/{entity} | GET /data/schema/entities/{entity} |
GET /data/entities/references/{entity} | GET /data/admin/data/references/{entity} — now admin-gated |
GET /data/hashes/list | GET /data/admin/data/hashes — admin-gated, and a stub |
GET /data/entities/diff | POST /data/admin/data/plans |
POST /data/entities/diff/sync | POST /data/admin/data/plans then POST /data/admin/data/plans/{id}/apply |
POST /data/entities/diff/sync/{entity} | None. Plans are per datastore; there is no per-entity apply |
POST /data/entities/index/{entity} | None |
POST /data/entities/validate | None |
GET /data/stateflows/{name} | GET /data/stateflow/{name} |
Nothing in the service emits RAML, and no tenant property re-enables it. The schema routes need no configuration flag — they serve whatever the tenant’s definitions resolve to.
The xlsx upload and download that shared the /data/bulk path are also gone. That path
now imports and exports CSV, JSON and JSONL — see Bulk import and export.
Definition cache
Section titled “Definition cache”DELETE /data/cache drops every cached per-tenant resource for the calling tenant —
folded schema, connection pool, engine, endpoint and action registries. The next request
rebuilds them from source.
{ "cleared": true, "tenant": "acme:forge:prod:main" }| Status | Code | Cause |
|---|---|---|
| 403 | v2.no_tenant | No tenant key on the request context |
| 500 | v2.cache_clear_failed | Invalidation failed |
Rows are not cached, so this route never affects query results — only which definitions the next request compiles against. See Caching.
Content store
Section titled “Content store”GET /data/contentstore/list returns the tenant’s content-store catalogue as a JSON map,
sourced from the product configuration stream. Only data.svc mounts this route; sibling
hosts that embed the same API do not. Failure is 500 with code
v2.contentstore_list_failed.
The admin plane
Section titled “The admin plane”/data/admin/data/* is plane-of-control: it reshapes the tenant’s database rather than
its rows. Every route resolves its tenant from the caller’s own CPET headers, so an admin
can only ever act on their own tenant.
Each route belongs to one operation class, and each class has its own gate. On data.svc
the destructive classes are lifted to superadmin, because the service runs shared endpoint
pools where one tenant’s long-running ALTER starves every other tenant on that pool.
| Class | Gate on data.svc | Routes |
|---|---|---|
| Read | admin or superadmin | plan read, ledger, seed list, retention preview and discovery, handoff pending, lock list, action catalogue, materialize views, references, hashes |
| Generate | admin or superadmin | POST /plans |
| Seed | admin or superadmin | POST /seeds/runs |
| Maintenance | admin or superadmin | POST /materialize/drain |
| Apply | superadmin only | POST /plans/{id}/apply |
| Retention | superadmin only | POST /retention/runs, both handoff writes |
| Locks | superadmin only | DELETE /locks/{resource} |
The role check is case-insensitive and superadmin satisfies every admin gate. A denied
request returns 403 with v2.requires_admin or v2.requires_superadmin in the coded
envelope.
Every admin route targets one datastore, but the two planes read the name from different places. Omit it either way to address the tenant’s default datastore.
| Routes | Where datastore is read |
|---|---|
Every GET, DELETE /locks/{resource}, and POST /materialize/drain | ?datastore=<name> query parameter |
POST /plans, POST /plans/{id}/apply, POST /seeds/runs, POST /retention/runs, both handoff writes | the datastore field of the JSON body |
Migration plans
Section titled “Migration plans”Applying DDL is a two-step operation: generate a plan, review it, then apply it by id. The concepts — risk levels, refusals, the ledger, locking — are on Migrations.
Generate a plan
Section titled “Generate a plan”POST /data/admin/data/plans
{ "datastore": "", "allow_drops": false }Both fields are optional; an empty body is valid. The route writes a plan row and runs no DDL.
{ "plan_id": "01J8QK...", "source": "yaml", "max_risk": "medium", "warnings": ["column orders.notes widened"], "refusals": [], "steps": [ { "Order": 1, "Kind": 0, "SchemaOp": "add_column", "Entity": "orders", "Identifier": "notes", "SQL": "ALTER TABLE orders ADD COLUMN notes text", "Expected": "", "Risk": 0, "Notes": "", "Transactional": false, "Checksum": "" } ]}| Field | Notes |
|---|---|
plan_id | Identifier for the apply and ledger routes |
source | Origin of the plan. yaml for every plan this route generates |
max_risk | low, medium or high — the highest risk of any step |
warnings | Omitted when empty |
refusals | Omitted when empty. A non-empty list still returns 200 |
steps | The ordered steps, in execution order |
Apply a plan
Section titled “Apply a plan”POST /data/admin/data/plans/{id}/apply — the only route that executes schema DDL.
{ "datastore": "", "confirm_max_risk": "low", "dry_run": false }| Field | Default | Notes |
|---|---|---|
datastore | tenant default | — |
confirm_max_risk | low | low, medium or high. Any other value returns 400. An empty string means low, so a medium-risk plan is refused unless you raise it |
dry_run | false | Plans the execution without running it |
{ "plan_id": "01J8QK...", "status": "applied", "steps_applied": 4, "steps_failed": 0, "steps_skipped": 0}failed_step, halted_step and error appear only when set.
| Status | Meaning |
|---|---|
| 200 | status is applied |
| 207 | Anything else — partial application, halted plan, or an error that still produced a result |
| 400 | Missing plan id, unparseable body, or an invalid confirm_max_risk |
| 500 | The apply failed before producing any result |
Read the ledger
Section titled “Read the ledger”| Method | Path | Parameters | Response |
|---|---|---|---|
GET | /data/admin/data/plans/{id} | datastore | {tenant, plan, steps}; 404 when the id is unknown |
GET | /data/admin/data/applied | plan_id required, datastore | {tenant, plan_id, steps} |
plan carries ID, PlanID, Source, Status, Trigger, TriggeredBy, MaxRisk,
StartedAt, FinishedAt, AppliedCount, FailedCount, TotalSteps, Notes,
CreatedOn. Each ledger step carries ID, PlanID, StepOrder, Kind, SchemaOp,
Entity, Identifier, SQL, ExpectedResult, Checksum, Status, StartedAt,
FinishedAt, DurationMs, Error, Attempt, SourceURI, CreatedOn. A plan row that
exists with zero steps is legal — plans/{id} fetches steps best-effort and still returns
the plan.
GET /data/admin/data/applied without plan_id returns 400 — there is no date or status
filter, and no way to list every plan.
| Method | Path | Purpose |
|---|---|---|
POST | /data/admin/data/seeds/runs | Run seeds. Synchronous — the request blocks until the run finishes |
GET | /data/admin/data/seeds | Declared seeds with their applied state from the ledger |
Request body, all fields optional:
{ "datastore": "", "names": ["countries", "currencies"], "environment": "production", "dry_run": false, "force": false}An empty body runs every eligible seed. names restricts the run; environment filters
by the seed’s declared environment.
{ "plan_id": "01J8QM...", "dry_run": false, "applied_count": 2, "skipped_count": 1, "failed_count": 0, "outcomes": [ { "Name": "countries", "Status": "applied", "Reason": "", "Rows": 249, "Duration": 412000000 } ]}Each outcome carries Name, Status (applied, skipped or failed), Reason, Rows
and Duration — nanoseconds, not milliseconds.
The response is 207 whenever failed_count > 0, including when every seed failed. The
body is the source of truth; the status is a hint.
GET /data/admin/data/seeds returns {tenant, datastore, seeds} where each entry carries
name, entity, source, strategy, status, and — once applied — applied_at,
applied_checksum and error. A seed with no ledger row reports status: "pending". A
ledger lookup that fails is reported in that seed’s error rather than failing the request.
Retention
Section titled “Retention”Retention runs are triggered, never scheduled. The phase model, thresholds and archive declarations are on Retention and archives.
| Method | Path | Purpose |
|---|---|---|
POST | /data/admin/data/retention/runs | Execute a pass. Synchronous |
GET | /data/admin/data/retention/preview | Eligible row counts. Writes nothing |
POST | /data/admin/data/retention/handoff/claim | A pipeline claims queued rows |
POST | /data/admin/data/retention/handoff/confirm | A pipeline reports outcomes |
GET | /data/admin/data/retention/handoff/pending | Queue depth and age |
GET | /data/admin/data/retention/archives | Declared archive targets |
GET | /data/admin/data/retention/entities | Entities with an active policy |
{ "datastore": "", "entities": [], "phases": ["anonymize", "archive", "archive_delete", "purge"], "dry_run": false, "batch_size": 0, "max_rows_per_pass": 0, "grace_hours": 0}Every field is optional. entities empty means every retention-enabled entity; phases
empty means all four. grace_hours is applied only when greater than zero.
{ "plan_id": "01J8QN...", "dry_run": true, "total_applied": 0, "total_failed": 0, "entities": [], "eligible": { "audit_log": { "purge": 128400 } }}eligible is present only on a dry run. The status is 207 when total_failed > 0. Each
entry in entities carries per-phase counters under Go field names — Entity,
Anonymized, Archived, ArchiveDeleted, Purged, FKBlocked, Errors.
GET /data/admin/data/retention/preview takes datastore and entity as query
parameters and returns {tenant, datastore, plan_id, eligible} — the same counts, without
executing anything.
Handoff queue
Section titled “Handoff queue”POST /data/admin/data/retention/handoff/claim
{ "datastore": "", "target_name": "cold_storage", "entity": "invoice", "limit": 500, "claimer_id": "airflow-task-9134" }claimer_id is required — 400 without it. It is a tracking label, not a credential.
{ "claimed": [ { "handoff_id": "01J8QP...", "entity": "invoice", "row_pk": "01J2...", "target_name": "cold_storage", "target_type": "s3" } ] }POST /data/admin/data/retention/handoff/confirm
{ "datastore": "", "confirmations": [ { "handoff_id": "01J8QP...", "target_location": "s3://bucket/invoice/01J2.json", "checksum": "sha256:...", "failed": false, "error": "" } ]}An empty confirmations array is 400. The response is {"confirmed": N, "failed": N}.
GET /data/admin/data/retention/handoff/pending accepts datastore, target_name,
entity and status, and returns buckets:
{ "tenant": "acme:forge:prod:main", "datastore": "", "buckets": [ { "entity": "invoice", "target_name": "cold_storage", "status": "claimed", "count": 412, "oldest_age_seconds": 8123 } ]}Discovery
Section titled “Discovery”GET /data/admin/data/retention/archives reads the schema only — no database access.
{ "tenant": "acme:forge:prod:main", "datastore": "", "archives": [ { "name": "cold_storage", "type": "s3", "suffix": "_archive", "settings": { "bucket": "acme-archive" } } ]}GET /data/admin/data/retention/entities lists entities with an active policy. Archive
companion tables are excluded.
{ "tenant": "acme:forge:prod:main", "datastore": "", "entities": [ { "entity": "invoice", "table_name": "invoice", "pii_fields": ["customer_email"], "retention": { "active": "168h0m0s", "archive": "8760h0m0s", "archive_to": "cold_storage", "age_field": "created_at" } } ]}Durations are rendered in Go’s duration format, and zero-valued policy fields are omitted.
pii_fields lists the columns carrying a compliance type of pii, phi, pci or gdpr;
other compliance types do not put a column on this list.
| Method | Path | Parameters |
|---|---|---|
GET | /data/admin/data/locks | datastore, prefix, op_kind, include_expired |
DELETE | /data/admin/data/locks/{resource} | datastore |
include_expired is compared against the literal string true; any other value keeps
expired rows out of the list.
{ "tenant": "acme:forge:prod:main", "datastore": "", "locks": [ { "id": "01J8QR...", "resource": "migrate:acme:forge:prod:main:default", "holder_id": "01J8QQ...", "op_kind": "apply", "acquired_at": "2026-07-27T09:14:02Z", "expires_at": "2026-07-27T09:19:02Z", "hostname": "data-7f9c", "plan_id": "01J8QK...", "is_expired": false, "seconds_to_expiry": 214 } ]}Timestamps are RFC 3339 in UTC. There is no count field on this response.
{ "tenant": "acme:forge:prod:main", "resource": "migrate:acme:forge:prod:main:default", "released": true, "note": "lock row deleted; any current holder will fail next heartbeat"}Materialized refresh queue
Section titled “Materialized refresh queue”| Method | Path | Purpose |
|---|---|---|
GET | /data/admin/data/materialize/pending | Pending and failed tasks |
GET | /data/admin/data/materialize/stats | Queue depth by status, plus runner counters |
POST | /data/admin/data/materialize/drain | Run one drain pass now |
pending takes datastore and an after cursor. Page size is fixed at 100 and cannot be
changed.
{ "tenant": "acme:forge:prod:main", "datastore": "", "count": 2, "tasks": [ { "id": "01J8QS...", "target_entity": "customer", "target_field": "order_total", "target_row_id": "01J2...", "source_entity": "order", "source_op": "create", "enqueued_at": "2026-07-27T09:02:11Z", "attempts": 1 } ]}stats returns {tenant, datastore, counts, runner}. The runner block is omitted when
no runner exists for the tenant — a tenant with no cross-entity triggers has none. drain
takes no body and returns {tenant, datastore, dispatched}.
See Materialized views and refresh for which declarations actually enqueue work.
Catalogues
Section titled “Catalogues”Actions
Section titled “Actions”GET /data/admin/data/actions returns the declared custom actions, sorted by name. SQL
bodies are never included.
{ "tenant": "acme:forge:prod:main", "datastore": "", "count": 1, "actions": [ { "name": "list_orders", "description": "Orders for one customer", "entities": ["orders"], "source": "product", "methods": ["GET"], "has_access": true, "sections": { "read": { "param_count": 2, "returning": [], "access_override": false } } } ]}source is product, tenant or runtime — the layer the declaration came from. A tenant
with no registry returns count: 0.
References
Section titled “References”GET /data/admin/data/references/{entity} lists every foreign key pointing at the
named entity — the “what breaks if I drop this” view.
{ "tenant": "acme:forge:prod:main", "datastore": "", "entity": "customer", "count": 1, "references": [ { "from_entity": "order", "from_field": "customer_id", "to_field": "id", "on_delete": "cascade" } ]}Sorted by from_entity then from_field. on_delete and on_update render as cascade,
setnull, restrict or noaction, and are omitted when unset. An unknown entity is 404.
Query hashes
Section titled “Query hashes”GET /data/admin/data/hashes answers 200 with count: 0 and an empty hashes array
every time. The compiled-query cache exposes no enumeration API, so the route is a
reachability probe for the cache, not a data source. The superadmin mirror behaves
identically.
The superadmin control plane
Section titled “The superadmin control plane”/data/superadmin/* is the cross-tenant plane. Every route requires the superadmin role
strictly — admin does not pass.
| Method | Path | Purpose |
|---|---|---|
GET | /data/superadmin/tenant | Tenant keys this service holds live resources for |
DELETE | /data/superadmin/tenant/{tenant}/cache | Cache-bust one tenant |
{ "tenants": ["acme:forge:prod:main", "acme:forge:prod:sandbox"], "count": 2 }The cache-bust returns {"invalidated": "<full tenant key>"}.
Every admin route has a superadmin mirror at
/data/superadmin/tenant/{tenant}/… with the admin/data segment dropped. The handler
bodies, request shapes and responses are identical; only the target tenant differs.
| Admin route | Superadmin mirror |
|---|---|
/data/admin/data/seeds/runs | /data/superadmin/tenant/{tenant}/seeds/runs |
/data/admin/data/seeds | /data/superadmin/tenant/{tenant}/seeds |
/data/admin/data/applied | /data/superadmin/tenant/{tenant}/applied |
/data/admin/data/plans | /data/superadmin/tenant/{tenant}/plans |
/data/admin/data/plans/{id} | /data/superadmin/tenant/{tenant}/plans/{id} |
/data/admin/data/plans/{id}/apply | /data/superadmin/tenant/{tenant}/plans/{id}/apply |
/data/admin/data/retention/runs | /data/superadmin/tenant/{tenant}/retention/runs |
/data/admin/data/retention/preview | /data/superadmin/tenant/{tenant}/retention/preview |
/data/admin/data/retention/handoff/claim | /data/superadmin/tenant/{tenant}/retention/handoff/claim |
/data/admin/data/retention/handoff/confirm | /data/superadmin/tenant/{tenant}/retention/handoff/confirm |
/data/admin/data/retention/handoff/pending | /data/superadmin/tenant/{tenant}/retention/handoff/pending |
/data/admin/data/retention/archives | /data/superadmin/tenant/{tenant}/retention/archives |
/data/admin/data/retention/entities | /data/superadmin/tenant/{tenant}/retention/entities |
/data/admin/data/locks | /data/superadmin/tenant/{tenant}/locks |
/data/admin/data/locks/{resource} | /data/superadmin/tenant/{tenant}/locks/{resource} |
/data/admin/data/actions | /data/superadmin/tenant/{tenant}/actions |
/data/admin/data/materialize/pending | /data/superadmin/tenant/{tenant}/materialize/pending |
/data/admin/data/materialize/stats | /data/superadmin/tenant/{tenant}/materialize/stats |
/data/admin/data/materialize/drain | /data/superadmin/tenant/{tenant}/materialize/drain |
/data/admin/data/references/{entity} | /data/superadmin/tenant/{tenant}/references/{entity} |
/data/admin/data/hashes | /data/superadmin/tenant/{tenant}/hashes |
| Status | Code | Cause |
|---|---|---|
| 403 | v2.requires_superadmin | Caller lacks the role |
| 400 | v2.missing_tenant | Empty {tenant} segment |
| 400 | v2.invalid_tenant | A full four-part key was passed |
| 500 | v2.list_tenants_failed | Tenant inventory unreadable |
| 500 | v2.invalidate_failed | Cache-bust failed for the target tenant |
Bulk import and export
Section titled “Bulk import and export”One path, two verbs. Both require ?entity= and run through the same engine as REST, so
validation, access rules, row-level security and audit apply to every row.
| Method | Path | Direction | Auth |
|---|---|---|---|
POST | /data/bulk | Import — body is the data | Bearer |
PATCH | /data/bulk | Export — body empty, response is the data | Bearer or API key |
| Parameter | Applies to | Default | Meaning |
|---|---|---|---|
entity | both | — | Required. 400 v2.missing_entity without it |
format | both | json | csv, json, jsonl; ndjson is an alias for jsonl. Anything else is 400 v2.bad_format |
datastore | both | tenant default | — |
chunk | import | 1000 | Rows per insert chunk. Non-numeric or non-positive values fall back to the default |
skiperrors | import | false | true continues past per-row errors |
limit | export | 10000 | Row cap |
fields | export | all | Comma-separated column list |
filter | export | — | Repeatable ?filter=k=v. Equality only |
orderby | export | — | Comma-separated; prefix a column with - for descending |
Import responds with a JSON result:
{ "imported": 2481, "skipped": 3, "errors": [] }Export sets the content type from the format — text/csv; charset=utf-8 with
Content-Disposition: attachment; filename="<entity>.csv", application/x-ndjson; charset=utf-8, or application/json; charset=utf-8.
curl -X PATCH 'https://<host>/data/bulk?entity=invoice&format=csv&limit=50000&orderby=-created_at' \ -H 'Authorization: Bearer <token>' \ -o invoice.csv| Status | Code | Cause |
|---|---|---|
| 400 | v2.missing_entity | ?entity= absent |
| 400 | v2.bad_format | Unsupported ?format= |
| 403 | v2.no_tenant | No tenant key on the request context |
| 500 | v2.import_failed | The import failed |
| 500 | v2.engine_unavailable | The tenant’s engine bundle could not be resolved |
Health and readiness
Section titled “Health and readiness”Both probes bypass the tenancy middleware: no headers, no token, no tenant. The bodies are on the Data API reference.
| Method | Path | Success | Failure |
|---|---|---|---|
GET | /data/health | 200 JSON | 500, same body with "healthy": false |
GET | /data/ready | 200 text/plain ready:true | 500 ready:false |
In the health body, memstats values are MiB and NumGC is a count. dependencies is
keyed by dependency name, each entry carrying a State boolean and a Message string.
version is always the empty string — nothing in this service sets it.