Application datastore
The application datastore provides custom relational tables (such as users, orders, documents, and events) stored within the same globally consistent backend as agent memory.
Unified storage allows SQL predicates and vector distance rankings to execute at a single snapshot without cross-database synchronization.
The application datastore is included on the trial and on paid plans. It is not
included on Free: a Free enterprise cannot create datasets, declare tables, or
write rows, and receives PERMISSION_DENIED naming the Datasets entitlement.
Datasets created during the trial stay readable, queryable, and deletable.
Datasets
A dataset is a container of your tables. It is a peer of an agent workspace, not nested under one, and each dataset is isolated within your enterprise exactly as an agent workspace is for memory.
location is optional: omit it for the platform default, or set it to an id
from jnh locations (see Choosing a location).
Set create_api_key to get the dataset and a key scoped to it in one call:
POST /v1/datasets
{
"dataset_id": "acme.prod",
"create_api_key": true,
"api_key_scopes": [
"datastore.data:read",
"datastore.data:write",
"datastore.schema:manage"
]
}
This needs iam.apikeys:create as well as datastore.datasets:create, because
minting a credential is a management-class act wherever it happens. Creating a
dataset does not become a way around it. The built-in member role holds the
second and not the first, so a member can never mint keys for datasets.
Creating a dataset also requires a dataset selector that already covers the
new id; otherwise the request is refused with PERMISSION_DENIED. Creation
grants no reach of its own. A member's selector set starts empty (see
Dataset selectors), so for a member to create datasets an
admin first grants a role carrying a selector such as team.*; the member can
then create team.x and use it immediately.
The response carries api_key_secret once. The key is an ordinary one on the
api-keys surface: its dataset selector is exactly this dataset, its scopes and
selectors are immutable, and it is revocable independently. Omit api_key_scopes
and it gets the member-equivalent default, which can read and write data but
cannot declare tables.
A dataset id has the same shape as an agent id: at most 128 characters of
lowercase letters, digits, ., _, or -, starting and ending with a letter or
digit. The . character serves as the access-selector separator, so an id
carrying a selector metacharacter could be crafted to widen access.
The same lifecycle from the CLI:
jnh datasets create acme.prod --name Production \
--with-key --key-scope datastore.data:read \
--key-scope datastore.data:write \
--key-scope datastore.schema:manage
jnh datasets list
jnh datasets get acme.prod
jnh datasets delete acme.prod # prompts for the id; --yes to skip
list walks to exhaustion, so what it prints is every dataset your selectors
reach and never a first page that looks complete. Add -o json to any of these
for the raw API response, or -o name for the ids alone; see
CLI scripting and automation.
Choosing a location
A dataset picks its own read/write geography at creation and keeps it for life.
List the locations that serve datasets with jnh locations --surface datasets
(or GET /v1/locations, which needs no login). The SERVES column marks the
default with datasets (default); that is where a dataset created without a
location goes. An id not in that listing is refused with INVALID_ARGUMENT.
Locations come in two shapes:
| Location | Characteristics | Trade-offs |
|---|---|---|
| Single-region | lowest write latency | remote reads from other regions |
| Multi-region | globally consistent reads | higher write latency |
A multi-region location is a deliberate trade rather than a default: globally consistent reads are bought with write latency. If you pick one for global reads and find writes slower than you want, bounded-staleness reads recover read latency without giving up the write geography.
Declaring tables
You declare tables and columns by name. Jennah validates the declaration and creates the tables.
POST /v1/datasets/acme.prod/schema:declare
{
"tables": [
{
"name": "orders",
"columns": [
{ "name": "order_id", "type": "COLUMN_TYPE_STRING" },
{
"name": "customer_id",
"type": "COLUMN_TYPE_STRING",
"nullable": true
},
{
"name": "total_cents",
"type": "COLUMN_TYPE_INT64",
"nullable": true
}
],
"primary_key": ["order_id"],
"indexes": [
{ "name": "by_customer", "columns": ["customer_id"] }
]
}
]
}
Every later request names orders. A name your dataset's catalog does not hold is not found, and it is
never passed through into a query, which is what makes identifier injection
structurally impossible rather than merely filtered.
Table, column, and index names must match ^[a-z][a-z0-9_]*$ (lowercase
letters, digits, and underscores, starting with a letter), be at most 64
characters, and must not start with jennah_, which is reserved for
platform-maintained columns. A small set of reserved words, such as common SQL
keywords (select, order, limit), is refused.
Primary keys and unique indexes are unique within your dataset.
Schema changes are asynchronous
Schema changes are queued and applied in the background. A declaration
returns with each table at
SCHEMA_STATUS_PENDING; poll GET /v1/datasets/{id}/schema until every table
is SCHEMA_STATUS_READY before committing data to it.
Evolution is additive
| Change | Result |
|---|---|
| re-declaring an unchanged table | no-op |
| adding a nullable column | online ALTER TABLE |
| adding an index to an existing table | online CREATE INDEX; the table stays usable while it builds |
adding a NOT NULL column |
refused (existing rows would have no value) |
| dropping a column, retyping one, changing the primary key | refused |
tightening a column to NOT NULL |
refused |
changing an existing index (its columns, their order, or unique) |
refused |
| leaving an existing index out of a declaration | no-op (the index is kept) |
Destructive changes are refused rather than gated behind a flag. They can rewrite or lose live data, so they deserve an explicit migration path, which this version does not offer. Plan your keys accordingly.
Adding an index to a table in use
Add the index to the table's declaration and declare it again. The table stays
SCHEMA_STATUS_READY and keeps serving reads and writes while the index is
built over its existing rows. Until the index is built:
GET /v1/datasets/{id}/schemadoes not list it, and the table'sstatus_detailnames it (for exampleindex "by_customer" is being built).- It already counts against your index ceiling (
usage.indexes). - Deleting the dataset is refused with
FAILED_PRECONDITIONnaming the index; retry once it is listed.
Once built, it is listed like any other index. If it cannot be built, the
declaration reports the table SCHEMA_STATUS_FAILED with a status_detail that
names the index, and the index is not created. The table and its data are
unchanged. The usual cause is a unique index over rows that already hold
duplicate values for its columns: remove the duplicates and declare it again.
Declaring from the CLI
From the CLI a declaration is always a file. There is no flag form and no prompt-driven builder: the shape is nested enough that flags could not carry it, and a schema belongs in version control rather than in shell history.
Start from a dataset that is ACTIVE (a freshly created one can briefly report
PROVISIONING):
Write the declaration - the body of schema:declare above, as JSON or YAML,
snake_case or camelCase, and column types may drop their COLUMN_TYPE_
prefix:
# orders.yaml
tables:
- name: orders
primary_key: [order_id]
columns:
- { name: order_id, type: string }
- { name: customer_id, type: string, nullable: true }
- { name: total_cents, type: int64, nullable: true }
- { name: created_at, type: timestamp, nullable: true }
indexes:
- { name: by_customer, columns: [customer_id] }
The types are string, int64, float64, bool, timestamp, date,
bytes, json, and vector (which needs a vector block; see
Vector columns). A dataset_id in the file is dropped:
the dataset is the one on the command line, so a file cannot silently retarget
another dataset.
Then preview, apply, and read the result back:
jnh datasets schema declare acme.prod --from-file orders.yaml --dry-run
jnh datasets schema declare acme.prod --from-file orders.yaml
jnh datasets schema get acme.prod
declare reads the current catalog first and prints what would change (new
tables, new columns, new indexes) before issuing anything:
--dry-run stops there, issuing nothing. A destructive change from the table
above is refused there too, naming the column, without a request:
Adding a NOT NULL column to an existing table is refused the same way,
whether or not the table holds rows, so declare a new column nullable:
A declaration that would change nothing says so and makes no schema change, so you learn
that immediately instead of waiting seconds per table to be told the same thing.
Otherwise it applies and polls until every declared table is READY, because
schema changes are asynchronous:
Adding an index to a table that already exists prints a + index line, and the
poll also waits until that index is built and listed, since the table itself is
READY throughout:
+ index orders.by_created (created_at)
TABLE STATUS DETAIL
orders pending index "by_created" is being built
All 1 table(s) are ready.
Changing an existing index (its columns, their order, or unique) is refused
before any request, like any other destructive change, with a REFUSED line
naming the index.
--no-wait returns at PENDING instead, and --wait-timeout (default 5m)
bounds the poll. Declaring needs datastore.schema:manage; without it the diff
still prints and the request then fails, exiting 3.
Read the catalog back at any time with schema get, which renders each table's
columns (marking the ones the platform maintains), its primary key, its
indexes, its apply status, and your usage against the tier's ceilings:
TABLE orders (ready)
COLUMN TYPE NULL KEY NOTES
order_id string no pk1
customer_id string yes
total_cents int64 yes
created_at timestamp yes
INDEX COLUMNS UNIQUE
by_customer customer_id no
USAGE USED CEILING
tables 1 100
indexes 1 50
columns per table 4 128
Add -o json to either command for the raw API response.
Reading and writing
Rows are written with data:commit and read with data:query. Both are covered
on their own page: Reading and writing rows.
Vector columns
Any table may declare a fixed-width embedding column, searched by exact cosine distance:
{
"name": "embedding",
"type": "COLUMN_TYPE_VECTOR",
"nullable": true,
"vector": {
"dimensions": 3072,
"source_column": "body"
}
}
The same column in a jnh datasets schema declare file:
# docs.yaml
tables:
- name: docs
primary_key: [doc_id]
columns:
- { name: doc_id, type: string }
- { name: body, type: string, nullable: true }
- name: embedding
type: vector
nullable: true
vector:
dimensions: 3072 # your location's EMBEDDING DIMS
source_column: body # embedded server-side
There is no ANN index, deliberately: every query is already clamped to a dataset slice, which makes the scan small and an approximate index pointless.
The two embedding paths
source_column is the whole of the opt-in, and it decides which path a write
takes:
| You send | source_column set |
Result |
|---|---|---|
| a vector | either | stored exactly as given; the model is not invoked |
| nothing | yes | generated server-side, in the same statement as the row |
| nothing | no | typed error, never a silent NULL |
explicit null_value |
either | stored as NULL; the model is not invoked |
The last two rows are the important pair. Omitting a vector is almost always an oversight, and a row whose embedding silently held nothing would be invisible to the one operation the column exists for. Sending an explicit null is a different statement ("no vector yet") and is accepted, which is how you insert rows now and embed them in a later pass.
These rules apply to INSERT and UPSERT:
- When the vector is omitted, the row must set the source column, or the
commit is refused with
INVALID_ARGUMENTbecause there is nothing to embed from. UPDATEnever generates an embedding. Changing the source column with anUPDATEleaves the stored vector describing the old content. To re-embed,UPSERTthe row with the new source value and no vector.
A managed column must declare the model's width
If you set source_column, dimensions must equal the embedding width of
your dataset's location (the EMBEDDING DIMS column of jnh locations). A narrower
column is rejected by the backend rather than silently truncated, so this
is caught at declare time with a clear message. A caller-supplied-only column
is unconstrained: bring vectors of any width from your own model.
Truncation
A managed vector column brings two platform-maintained columns
(jennah_<col>_truncated, jennah_<col>_token_count). They are returned by
queries like any other column, cannot be written by you, and count against your
per-table column ceiling, because the cost belongs to the table that opted in.
truncated=true means the stored content is complete but the stored
embedding is not: the tail of your source column is unreachable by search.
token_count is what the model counted in the content it was given, so it
exceeds the model's limit rather than equalling it. Divide by the limit for a
rough sense of how many pieces to split into.
Set reject_on_truncation on a commit to make truncation fatal instead of
reported: no row from any table in that commit is written. It never fires for a
vector you supplied, because the platform embedded nothing and so has nothing to
report.
Searching an embedding column is part of the query envelope, so it is documented with the read path: see searching vector columns.
Access control
The datastore view of the model described in Roles and access control, which covers the full permission catalog, custom roles, and the selector grammar. Three independent checks, and none substitutes for another:
| Layer | Answers | Carried by |
|---|---|---|
| Tenancy | which enterprise and dataset? | your credential (enterprise) + the route (dataset) |
| Permission | what kind of operation? | your role, or an API key's scopes |
| Dataset selector | which datasets? | your role, or an API key's selectors |
Your enterprise comes only from your verified credential, never from a request body or path. The dataset comes from the request path.
Permissions
| Permission | Grants |
|---|---|
datastore.datasets:create / :read / :delete |
dataset lifecycle |
datastore.schema:read |
read the logical schema |
datastore.schema:manage |
declare and evolve tables |
datastore.data:read / :write |
data:query / data:commit |
datastore.access:read / :manage |
see / set dataset selectors |
The built-in member role holds the data-plane set but not
datastore.schema:manage: declaring tables changes a dataset's structure for
everyone who uses it, so schema authority is always a deliberate grant. It is grantable to an API key,
a service building an app has to be able to declare its own tables.
datastore.access:manage is management-class, so it can never appear in an API
key's scope: a key can never widen anyone's dataset reach.
Dataset access is default-deny
A member holds datastore.data:read and datastore.data:write and reaches
no dataset, because their dataset selector set is empty. The permissions say
what kind of operation; the selectors say where.
Dataset selectors
Same grammar as agent selectors, in a separate namespace: an exact acme.prod, a
subtree acme.*, or * for every dataset. Matching is segment-anchored, so
acme.* does not match acmecorp.prod.
The two namespaces never interact. Blanket agent reach grants no dataset reach and cannot be used to grant dataset reach to someone else, because containment is checked per namespace. That also means dataset-access administration can be delegated to someone who administers no agent access, and vice versa.
A per-project key is just an API key with one dataset selector:
POST /v1/apikeys
{
"label": "acme-prod-app",
"scopes": [
"datastore.data:read",
"datastore.data:write"
],
"dataset_selectors": ["acme.prod"]
}
Its secret is shown once. Its scopes and selectors are immutable. There is no rotation RPC anywhere in Jennah, so "rotate" means revoke and create.
The application datastore design incorporates the following specifications:
- No raw SQL: Requests use structured JSON schemas. Raw SQL input is not accepted to avoid injection vulnerabilities.
- Atomic arithmetic: The
set_expressionsfield provides atomic single-column arithmetic operations without requiring complex expression evaluation trees. - Dataset isolation: The dataset is the unit of isolation. Principals granted access to a dataset have access across its tables. Segregate data into separate datasets to isolate access.
- Single-dataset query scope: Each query is scoped to a single dataset. Cross-dataset joins are not supported.
- No triggers or stored procedures: Business logic is handled at the application layer.
- Client-provided idempotency keys: Clients supply idempotency keys to ensure retry safety across network interruptions.
Limits
Per-dataset ceilings on tables, columns, and indexes, plus datasets per
enterprise and a data:query result ceiling. GET /v1/datasets/{id}/schema
reports your usage against the per-dataset ones: tables / max_tables,
indexes / max_indexes, and max_columns_per_table.
A result count above your tier's ceiling is clamped, not refused. Asking for
more rows than your plan allows is served short. A counted ceiling exceeded
returns RESOURCE_EXHAUSTED with no schema change made, never a raw backend error.