Skip to content

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.

POST /v1/datasets
{
  "dataset_id": "acme.prod",
  "display_name": "Production"
}

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}/schema does not list it, and the table's status_detail names it (for example index "by_customer" is being built).
  • It already counts against your index ceiling (usage.indexes).
  • Deleting the dataset is refused with FAILED_PRECONDITION naming 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):

jnh datasets create acme.prod --name Production
jnh datasets get acme.prod          # wait for ACTIVE

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:

+ table   orders (4 column(s), 1 index(es))

--dry-run stops there, issuing nothing. A destructive change from the table above is refused there too, naming the column, without a request:

REFUSED   orders.total_cents would be retyped from int64 to string

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:

REFUSED   orders.region is NOT NULL; a new column must be 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:

TABLE   STATUS   DETAIL
orders  pending
All 1 table(s) are ready.

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_ARGUMENT because there is nothing to embed from.
  • UPDATE never generates an embedding. Changing the source column with an UPDATE leaves the stored vector describing the old content. To re-embed, UPSERT the 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_expressions field 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.