# Layer — Full documentation Search endpoint: https://hevlayer.com/api/ask/pro # Agents Source: https://hevlayer.com/docs/pro/agents import Edition from "../../components/docs/Edition.astro"; import Callout from "../../components/docs/Callout.astro"; Layer ships agent skills as files, in the [`hev/layer`](https://github.com/hev/layer) repository. They are plain `SKILL.md` documents with YAML frontmatter — no harness-specific format and nothing to install beyond putting the directory where your agent looks. ```sh git clone https://github.com/hev/layer.git ``` | Skill | Use it for | | --- | --- | | `hevlayer-search-app` | Building a search application: schema design, connecting a source, chunking, indexing, querying, generating the UI. | | `hevlayer-docs` | Answering Layer questions from the docs instead of from memory. | | `hevlayer-layer-cli` | Driving the `layer` CLI — environments, indexes, pipelines, install. | `hevlayer-search-app` also carries `store-capabilities.md`, a generated table of which wire features each backend serves — the answer to "can I do this?", so an agent reads coverage instead of guessing and hitting a [`422 UnsupportedByStore`](/docs/pro/stores#the-fail-fast-contract). ## Install the skills Claude Code picks the skills up from a clone with no setup: `.claude/skills/` in the repository links to `skills/`. For any other harness that reads a skill directory: ```sh scripts/install-skills.sh ``` That copies the skills into `$AGENT_SKILL_HOME`, defaulting to `${CODEX_HOME:-~/.codex}/skills`. Point it elsewhere with the variable: ```sh AGENT_SKILL_HOME=~/.config/my-agent/skills scripts/install-skills.sh ``` If your harness has no skill directory, paste the body of a `SKILL.md` into `AGENTS.md` or the equivalent. The files are written to work either way. The repository's own `AGENTS.md` is the starting point. The skills teach Layer against the local Compose stack — the gateway, Postgres and the CPU embedding service on your laptop. No account, no license, no API key. ## Query the docs from the command line These docs are queryable from the command line. The same engine behind the `⌘K` search on this site ships as a CLI, so your coding agent can search, read, and cite the Layer docs directly — no scraping, no MCP server, no API key. This is what `hevlayer-docs` drives. The `layer` CLI, which `hevlayer-layer-cli` drives, also lets agents operate environments, indexes, pipelines, UDFs, and Function runs. ```sh go install github.com/hev/ask/cmd/ask@latest ``` The `ask` binary is self-contained; any agent harness that can run a shell command can use it. From a Layer checkout, build the `layer` CLI when the agent should operate Layer environments instead of only searching docs: ```sh go build -o layer ./apps/layer-cli ``` ### Ask ```sh ask --endpoint https://hevlayer.com/api/ask/pro search "cache is down" ``` ```json { "results": [ { "title": "Concepts", "heading": "Document cache", "url": "/docs/pro/concepts#document-cache", "group": "Overview", "snippet": "The document cache does two jobs: pull-through document reads..." } ] } ``` From here your agent typically runs `section get` on the winning id and answers with the citation. ### The verbs | Verb | Returns | | --- | --- | | `overview` | Orientation context plus the full section map with stable ids | | `search ""` | Ranked sections with snippets and deep links | | `section get ""` | One section: summary, exact identifiers, source URL | | `glossary get ""` | A product term resolved through its aliases (`watermark` → stable watermark) | ### Why answers stay grounded Search runs over a committed, reviewable digest of these docs — the same corpus, heading by heading, that renders on this site. Every anchor in it is verified against the rendered pages in CI, so a cited deep link like [/docs/api/query#stable-reads](/docs/pro/api/query#stable-reads) always resolves. When the docs change, the digest is rebuilt and recommitted with them. Every verb above is a read against the public docs. Nothing to sign up for, nothing to configure beyond the endpoint URL. The docs are also available as plain text for direct ingestion: [/docs/pro/llms.txt](/docs/pro/llms.txt) (index) and [/docs/pro/llms-full.txt](/docs/pro/llms-full.txt) (full corpus). The CLI is the better path for agents that can run commands — it ranks, resolves aliases, and costs a fraction of the tokens. # CLI retrieval Source: https://hevlayer.com/docs/pro/cli-retrieval ## JSON contract Retrieval commands emit JSON by default. `-o json` makes that intent explicit; `-o names` is rejected. Supply a JSON request object through `-f request.json` or `-f -` for stdin. The request uses the existing gateway grammar; the CLI preserves numeric IDs, filters, ranking expressions, and unknown fields for gateway validation. It does not rewrite a query into another retrieval mode. ```sh layer query articles -f query.json -o json layer search articles -f search.json -o json layer read articles -f read.json -o json layer capabilities articles -o json layer capabilities primary --store -o json ``` Query, search, read, and scan replies have this envelope: ```json { "contract": "layer.retrieval.v1", "data": { "rows": [], "next_cursor": "opaque" }, "headers": { "x-layer-next-cursor": "opaque" } } ``` `data` is the complete gateway JSON body, including actual hybrid legs, routing, performance, rerank degradation, and future response extensions. `headers` includes any returned `x-layer-next-cursor`, `x-layer-stable-as-of`, `x-layer-warning`, `x-layer-cache`, and `traceparent`. Missing fields stay missing. A successful command prints one JSON object. Failures print no result, return exit 1, and report the HTTP status and gateway error code on stderr. Malformed local input returns exit 2. Error messages and credentials are not copied from gateway bodies. Requests are not retried or redirected. `--timeout 30s` bounds each command (including both capability reads). SIGINT and SIGTERM cancel in-flight requests. Cancellation does not delete a server scan job: use `scan delete` explicitly. Request files are limited to 8 MiB and responses to 32 MiB; reduce page size if a response exceeds the limit. ## Supported retrieval APIs | Command | Gateway API and semantics | | --- | --- | | `query NS -f FILE` | `POST /v2/namespaces/NS/query`; vector, stored-vector seeds, BM25, HybridText, Embed, ordered/filter-only queries, batch and supported native fusion/aggregation bodies | | `query-all -f FILE` | `POST /v2/query`; federated retrieval across body-specified namespaces | | `explain NS -f FILE` | Native `explain_query` plan where the store supports it | | `search NS -f FILE` | `POST /v2/namespaces/NS/search`; schema-based query embedding, actual ANN/BM25/fuzzy leg reports and optional reranking; no pagination | | `read NS -f FILE` | `POST /v2/namespaces/NS/documents`; `ids` plus optional `include_attributes`, returning documents and missing IDs | | `capabilities NS` | Namespace store report plus metadata/schema; fails if either read fails | | `capabilities STORE --store` | `GET /v2/vectorstores/STORE/capabilities`; store report only | | `scan create NS -f FILE` | Create count, IDs or values selection; filters, full text, HybridText or ANN radius where supported | | `scan list NS` / `scan get NS ID` | Discover jobs and read status, failure and completeness | | `scan results NS ID --limit 1000 --offset 0` | Read a bounded IDs/values page; limit 1..10000, offset nonnegative | | `scan delete NS ID` | Delete server scan state | This is a consumer of the existing [query](/docs/pro/api/query), [search](/docs/pro/api/search), and [embedding](/docs/pro/api/embed) contracts. Unsupported store operations retain their gateway error; the CLI implements no local fuzzy, regex, dense, or hybrid search engine. ## Capabilities and actual legs Namespace capability output is `{contract, capabilities, metadata}`. `capabilities` identifies the selected store and its declared features and limits. `metadata.schema` identifies namespace indexing, including `full_text_search`, `fuzzy`, vector definitions, and any `embed` model declarations. Store support alone does not prove an attribute is indexed, nor that a particular query ran every possible leg. Preflight must inspect both the feature report and the schema. Preserve the receipt in the private integration, then inspect the query response's actual `hybrid` echo. HybridText can execute a single BM25 leg when fuzzy indexing is absent; `hybrid.legs: 1` alone does not prove dense or fuzzy retrieval. Query routing may add ANN only where configured and supported. `/search` reports individual effective leg kinds and dropped legs; use those reports to label runs. Never infer executed legs from a command name. BM25 uses `rank_by: ["text", "BM25", "query"]`. HybridText uses `rank_by: ["text", "HybridText", "query", {"fuzziness":"auto"}]`. Fuzzy and regex filters are sent using the gateway grammar only where the store and attribute support them. There is no separate CLI regex contract: check the effective store report and a successful gateway response for the exact requested semantics. Missing support is a gap, not a fallback engine. ## Dense embedding compatibility For schema-managed embeddings, query with `rank_by: ["text", "ANN", ["Embed", "query text"]]` and inspect the namespace's `embed` declaration. The gateway resolves that declaration for query embedding. A source declaration alone does not prove historical vectors were produced by it: verify the existing corpus embedding manifest before claiming compatibility. `search` also uses the namespace's schema embedding declaration; set `"rerank": false` explicitly when reranking is unwanted. For numeric ANN, including batch, federated and radius-scan selectors, `query`, `query-all`, and `scan create` require `--embedding-space space.json`. The receipt must contain matching `query` and `document` identities: ```json { "query": { "model": "model-name", "revision": "immutable-model-revision", "dims": 2, "normalization": "l2", "distance_metric": "cosine_distance" }, "document": { "model": "model-name", "revision": "immutable-model-revision", "dims": 2, "normalization": "l2", "distance_metric": "cosine_distance" }, "document_vectors_verified": true, "document_manifest_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } ``` Numeric ANN accepts one vector per leg; multivector bags are outside this verified-space CLI contract. The CLI checks identity equality, complete fields, finite vector components, and dimension agreement before querying. This is an assertion from a verified private corpus receipt; the CLI cannot authenticate the provenance of stored vectors or the manifest. Pin the manifest and verify its contents separately. Do not fill it with guessed identities. `nearest_to_id` reuses the stored vectors without creating query embeddings. Compatibility verification requires no row writes or bulk reembedding. ## Pagination and exhaustive selection Pass a returned `data.next_cursor` (or the corresponding response header) as `cursor` in the next query body. The CLI fetches one page and does not claim that a top-k result exhausts a namespace. Some stores use ordered queries plus an advancing ID filter instead of cursors; unsupported cursor modes fail. For exhaustive selection, create a job with the required selector, for example: ```json {"mode":"ids","source":"origin","exhaustive":true,"filters":["year","Gte",2020]} ``` Poll `scan get` until the gateway reports completion, then page results by `offset` and `limit`. Check failures, totals, and `truncated` where returned; never treat an unfinished, truncated, capped or failed scan as exhaustive. Fetch selected IDs with `read` as needed. Job creation and result pagination are separate operations so cancellation does not lose the continuation ID. ## Pinning an executable Build from a committed source revision in the private monorepo: ```sh cd apps/layer-cli revision=$(git rev-parse HEAD) go build -trimpath -ldflags "-X main.version=cli-retrieval-v1.$revision" -o /tmp/layer-retrieval . /tmp/layer-retrieval version shasum -a 256 /tmp/layer-retrieval ``` Record source revision, executable hash, Go version, contract version, namespace/store capability receipt, corpus manifest, embedding identities, and actual query legs in the private integration. Pass gateway credentials through the existing environment configuration, outside agent tool payloads. Gold relevance labels belong outside retrieval tools and receipts. The namespace `/v1/.../query` compatibility alias is covered by the v2 query command. Agent-specific retrieval at `/v2/agents/NAME/query` is outside these row-retrieval commands; its embedding-profile and tool-loop contract remains in the agent SDK. The CLI does not expose telemetry metric queries as document retrieval. # Layer CLI Source: https://hevlayer.com/docs/pro/cli import Edition from "../../components/docs/Edition.astro"; For queries, document reads, capability receipts, and exhaustive scans, see [CLI retrieval](/docs/pro/cli-retrieval). The `layer` CLI operates hevlayer from the terminal. It manages named environments, initializes adopted namespaces for sharding, observes index, pipeline, and UDF state from the gateway, mints and revokes API keys, shows VectorStore and Warehouse health, and runs Function manifests. Every read goes through the gateway API with an API key; besides the [`install` lifecycle](#install-an-aws-environment), only `run` touches Kubernetes — it applies the Function CR, registers the UDF spec with the gateway, triggers discovery, and optionally watches until the queue drains. `run` is the only command that needs a kube context: set it on the environment with `--kube-context`/`--kube-namespace` or per invocation with `--context`/`--kube-namespace`. ## Install From the repository root: ```sh go build -o layer ./apps/layer-cli ``` ## Install An AWS Environment `layer install` provisions a full AWS environment — Terraform (VPC, EKS, IAM/IRSA, S3, ECR) followed by the Helm release — as a single shot. `--profile demo` (the default) installs the lean footprint; `--profile indexing` adds the dedicated document-cache node pool. `layer install status` reports release and workload health, and `layer install uninstall` tears the environment down. The command runs from a `hev/layer` source checkout, which carries the Terraform configuration and the Helm chart it drives: ```sh git clone https://github.com/hev/layer cd layer go build -o layer ./apps/layer-cli ./layer install ``` Missing required inputs prompt interactively on a TTY; the command prints the resolved plan and asks for confirmation before provisioning anything. The [Install](/docs/pro/install) page is the reference for every flag, profile, and value it sets. ## Configuration `layer` reads named environments from `~/.hevlayer/config.toml`. The directory is created with mode `0700`; the config file is written with mode `0600`. ```toml active = "partner" [envs.partner] base_url = "https://aws-us-east-1.hevlayer.com" api_key = "..." kube_context = "partner-cluster" kube_namespace = "hevlayer" [envs.local] base_url = "http://localhost:8080" api_key = "dev" kube_context = "kind-hevlayer" ``` Resolution order is: | Priority | Source | | --- | --- | | 1 | Explicit flags such as `--base-url`, `--api-key`, `--context`, and `--kube-namespace` | | 2 | `LAYER_BASE_URL`, `LAYER_API_KEY`, and the `HEVLAYER_` twins | | 3 | Environment selected by `--env` or `LAYER_ENV` | | 4 | Active environment in `~/.hevlayer/config.toml` | | 5 | Built-in base URL default | A shell exporting `LAYER_BASE_URL` or `LAYER_API_KEY` keeps the env-var-only behavior and does not need a config file. `--env` and `LAYER_ENV` select an environment for one invocation without changing the active environment. | Flag | Environment | Default | | --- | --- | --- | | `--base-url` | `LAYER_BASE_URL`, `HEVLAYER_BASE_URL` | `https://aws-us-east-1.hevlayer.com` | | `--api-key` | `LAYER_API_KEY`, `HEVLAYER_API_KEY` | none | | `--env` | `LAYER_ENV` | active config env | | `-o`, `--output` | none | `table` | Output formats are `table`, `json`, and `names`. ## Environments ```sh layer env add partner --base-url https://aws-us-east-1.hevlayer.com \ --api-key "$LAYER_API_KEY" --kube-context partner-cluster \ --kube-namespace hevlayer layer env use partner layer env ls layer env show partner -o json layer env rm partner ``` `env add` prompts for missing values on a TTY. On a non-TTY, the required values must be supplied by flags. API keys are masked in `env ls` and `env show`. ## Initialize a Namespace ```sh layer init products --shards 8 layer init products --shards 8 --watch=false layer init products --shards 8 --poll-interval 5s ``` `init` calls `POST /v2/namespaces/{namespace}/init` through the selected gateway environment. The command creates or reattaches to the namespace shard marker, starts the shard backfill, and watches by default until `shard_lag_rows` reaches `0` and scatter/gather is active. Re-running with the same shard count is idempotent; requesting a different shard count returns a conflict message instead of changing the marker. ## Run A Function ```sh layer run -f tag-products.yaml layer run -f tag-products.yaml --index amazon-products-staging layer run -f tag-products.yaml --detach layer run -f tag-products.yaml --rm ``` The input is a Kubernetes `Function` manifest. `--index` overrides `spec.targetNamespaces` with one target. `--context` selects a kubeconfig context; `--kube-namespace` selects the Kubernetes namespace for the Function CR. `--no-apply` skips the Kubernetes apply step for workers managed outside the operator. `spec.version` is registered with the gateway as the Function completion marker version. Bump it before re-running a Function after changing a model, prompt, taxonomy, or worker write contract. `--detach` returns after registration and discovery. Without `--detach`, the CLI polls UDF status until discovery has completed and `pending_count` and `processing_count` are both zero. A drained queue with failures exits non-zero. `--rm` deletes the gateway registration and, unless `--no-apply` is set, the Function CR after the queue drains cleanly. A drain with failures leaves both in place so you can inspect them. Watch a run from another terminal: ```sh layer udf list layer udf get product-tags --watch ``` `udf list` lists registered UDFs with pending, processing, failed, discovery sweep count, and indexed rate. `udf get` shows those fields for one UDF; `--watch` polls until `pending_count` and `processing_count` are both zero. ## TUI Bare `layer` on a TTY opens the read-only operations TUI (`layer browse` is the explicit spelling); on a non-TTY it prints usage and exits `2`. Press `i`/`f`/`p`/`k`/`e` to switch between indexes, functions, pipelines, keys, and environments from any view, `enter` to open a detail view, and `esc`/`q` to back out. Every view has a non-interactive command twin with the same data — the TUI humanizes timestamps and sizes; the commands emit raw values for scripting. | TUI view | Command | | --- | --- | | Environments | `layer env ls` | | Functions | `layer udf list` | | Function detail | `layer udf get UDF_ID [--watch]` | | Indexes | `layer index list` | | Index detail | `layer index get NAME` | | Pipelines | `layer pipeline list` | | Pipeline detail | `layer pipeline get ID` | | Keys | `layer keys ls` | | Key detail | `layer keys get KEY_ID` | The keys views are read-only like the rest of the TUI: minting and revoking stay in the commands. ## Keys ```sh layer keys mint cohort-reader --owner acme \ --entitle vectorstore.prod-turbopuffer=read \ --namespaces "cohort-*" \ --claim warehouse.prod-snowflake="notes:cohort:*:read" layer keys ls layer keys get cohort-reader layer keys revoke cohort-reader layer keys rm cohort-reader ``` `keys mint` creates the key through the gateway and prints the token once — alone on stdout, so `layer keys mint … | pbcopy` captures it; the metadata table goes to stderr. There is no way to print it again. `keys revoke` is the default way to retire a key and keeps its audit record. `keys rm` is rare permanent cleanup and accepts only a key already in the `Revoked` phase. | Flag | Shape | | --- | --- | | `--entitle` | `TARGET[=SCOPE[+SCOPE]]`, repeatable. Targets are `vectorstore.`, `warehouse.`, `agent.`, or `layer`. | | `--namespaces` | Upstream-namespace globs for the vectorstore entitlement, comma-separated. | | `--claim` | `TARGET=STRING`, repeatable. Appends an opaque claim string to that target's entitlement. | | `--expires-after` | Duration or `never`; defaults to `365d`. | For agent invocation, use `--entitle agent.` without scopes; the entitlement is an empty object and does not accept namespace restrictions. `--entitle layer=admin` mints an admin key. For anything longer than a couple of flags, write the object instead: `layer keys mint -f key.yaml` takes the same `ApiKey` manifest `kubectl apply` does. `keys ls` and `keys get` show metadata only — key id, owner, phase, entitlement targets, expiry, last seen — never tokens or hashes. `revoke` is idempotent and keeps the record; `rm` hard-deletes it. All `keys` commands call the gateway key routes, which require a key with the `layer` entitlement at `admin` scope (or the bootstrap gateway key); no kube access is involved. ## Vector Store And Warehouse ```sh layer vectorstore list layer vectorstore get layer vectorstore get prod-turbopuffer -o json layer warehouse list layer warehouse get prod-snowflake ``` `vectorstore list` shows declared stores with kind, default marker, region, reachability, and turbopuffer org id. `vectorstore get` prints endpoint, credential Secret reference, inbound auth mode, reachability, observed generation, and the turbopuffer dashboard link when the store has `spec.turbopuffer.orgId`. Without a name, `vectorstore get` selects the default-marked store, or the only store when exactly one exists. `warehouse list` leads with name, kind, phase, verification time, and consumer counts. `warehouse get NAME` shows the Snowflake source identity, credential Secret reference, verification status, failure reason, and what still depends on the warehouse. These commands are read-only and gateway-backed; they never read or print Secret contents. ## Ask The Docs `layer ask` queries the committed docs digest with the `ask` CLI. It is keyless and local by default: from a checkout, it finds `site/.hev-ask`, prefers a sibling `../ask` source checkout, and falls back to the docs site's installed `@hevmind/ask` package or an `ask` binary on `PATH`. ```sh layer ask tree layer ask grep "warm cache" layer ask cat api/query layer ask glossary get watermark layer -o json ask tree ``` Use `--endpoint` to query a deployed hev ask endpoint instead of the local digest: ```sh layer ask --endpoint https://hevlayer.com/api/ask tree ``` ## Inspect An Index ```sh layer index get shop-products layer index get shop-products -o json ``` `index get` reports row count, size, schema summary, last write, stable watermark and lag, and index (WAL) and cache state on any gateway. The RECENT SNAPSHOTS table (and `index list`'s last-snapshot column) additionally need snapshot history, which is served only by a managed/pro gateway; timestamps and sizes are raw (epoch-ms, bytes) and `-o json` carries the full snapshot list where available. `PURGE` reports `pending` while the gateway has discovered an outstanding namespace cleanup intent, `none` when its backlog is empty for this name, or `unknown` if the gateway's `/metrics` endpoint cannot be read or S3 intent discovery has not succeeded. A deleted index with a pending purge remains inspectable by name through `index get`; it does not reappear in `index list`. ## Manage Snapshots ```sh layer index snapshot shop-products --field category layer index policy shop-products --facet-field category --facet-field brand --interval 5m --retention 30d ``` `index snapshot` creates an on-demand snapshot job and waits for it to complete; `index policy` writes the same `facetFields`, `interval`, and `retention` shape used by `Index.spec.snapshot`, so shared-gateway namespaces can enable the automatic writer without applying a Kubernetes resource. Neither command requires an operator or license, but both require S3-compatible object storage; the default CE Compose bundle does not configure it. Snapshot jobs also require a source that supports scanning; Postgres serves that scan in id order. `--source` accepts `auto`, `stored`, `cache`, or `origin` (the default). `snapshot` is an alias for `stored`. ## Delete An Index ```sh layer index delete shop-products layer index delete shop-products shop-products-staging layer index delete --prefix shop- layer index delete --prefix shop- --yes ``` `index delete` works without object storage, including the default CE Compose bundle. When S3 is configured, it first writes a durable cleanup intent. If that write fails, it returns an error without deleting upstream; retry the command. After deleting the upstream namespace, the gateway clears its in-memory state and returns HTTP 200 with `namespace deleted; snapshot and cache purge continues in the background and may take a few minutes`. Document cache rows, snapshot mirrors, S3 snapshots/checkpoints/search history/clickstream/shard metadata, and the operator-discovered Index CR are cleaned up in the background. Incomplete cleanup retries with exponential backoff (1 second up to 60 seconds). With S3 configured, intent records survive gateway restarts and are removed only after cleanup succeeds. Without S3, cleanup retries live in memory and do not survive a gateway restart. Recovery checks that the upstream namespace is absent before purging; an intent whose upstream delete failed remains pending until the client retries the delete. `layer_namespace_purge_pending{namespace="NAME"}` on `/metrics` reports outstanding intents discovered by that gateway; restart discovery runs at startup and every 30 seconds. Do not reuse a namespace name until its purge has drained, since its cache and S3 paths are shared with the deleted index. Pass one or more names, or `--prefix` to delete every index whose name starts with that prefix — name arguments and `--prefix` are mutually exclusive, and the prefix must be non-empty. The prefix form lists the matched indexes for confirmation before deleting; on a TTY it prompts, and `--yes` skips the prompt. `--yes` is required when stdin is not a TTY. A prefix that matches nothing exits `0` without deleting. ## Pipelines ```sh layer pipeline list layer pipeline get product-images ``` `pipeline list` reads registered pipelines and fans out to the [pipeline status API](/docs/pro/api/pipelines#wait-for-completion) for each one's live queue depth (`pending`, `processing`, `failed`, `rate/min`); `pipeline get` adds target namespace, distance metric, and created-at. A pipeline with no worker staged into it yet renders without queue counts rather than erroring. Reads need only an API key — no kube access. `layer push` is deferred to the managed build/dev-loop milestone. # Concepts Source: https://hevlayer.com/docs/pro/concepts import Edition from "../../components/docs/Edition.astro"; import Callout from "../../components/docs/Callout.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; import StoreMatrixLink from "../../components/docs/StoreMatrixLink.astro"; import ShardingCompare from "../../components/docs/ShardingCompare.astro"; ## Wire protocol matching Layer matches the Turbopuffer wire contract to make migration straightforward. Compatible request bodies remain usable when an application moves to the gateway; the Layer clients expose the full API beyond that compatible subset. With Turbopuffer as the store, Layer forwards native requests after gateway validation. With another store, Layer translates supported operations into that store's native calls, and a valid request the store cannot serve returns `422 UnsupportedByStore` instead of silently dropping part of it. The [capability matrix](/docs/pro/stores) lists what each store supports and [how we validate it](/docs/pro/stores#how-we-validate-it). See the [API reference](/docs/pro/api/introduction) for authentication and client setup. ## Gateway enhancements Layer also enhances its backends through gateway features, including [agentic search](/docs/pro/api/agents), while keeping one client endpoint. [Hybrid text fusion](/docs/pro/api/query#hybrid-text-fusion) combines retrieval legs, [query routing](/docs/pro/api/query#query-routing) selects a strategy, [scans](/docs/pro/api/scans) select or count matching rows, and [federated queries](/docs/pro/api/federated-query) combine named namespaces. Support depends on the gateway routes, capability checks and backend operations that compose each feature. The API reference calls out current integration gaps at the relevant feature; missing integration does not define the product's scope. The [Layer clients](/docs/pro/api/introduction#install) expose these additions; plain HTTP can call the same API. Native requests and enhanced requests can share the gateway endpoint. Where Layer needs bookkeeping attributes, it reserves the `_hevlayer_*` prefix. Treat these fields as read-only; the [document model](/docs/pro/document-model) defines the contract. ## Control loops Layer uses a control loop as a core primitive for managing your indexes. It reconciles index state against metrics emitted by the search system, which is how Layer applies row-level transformations ([UDFs](/docs/pro/functions)) and keeps an index's stable view current. Related: [UDFs](/docs/pro/functions), [snapshots](/docs/pro/api/snapshots), stable watermark. ## Kubernetes autoscaling Because Layer is stateless, you can autoscale every tier independently. Karpenter handles node-level scaling, and KEDA scales pods against signals from an embedded PostgreSQL queue. The data in that queue is used for scaling decisions only — it carries no non-recoverable system state. ## Scatter/gather Layer partitions every Turbopuffer namespace into hash buckets, called shards, by assigning each row it writes a reserved `_hevlayer_shard` attribute (xxh64 of its id, modulo the shard count). The gateway then scatters a query to every bucket in parallel, one `_hevlayer_shard`-filtered query per shard, and gathers the results: it merges and re-ranks the combined rows down to your requested `top_k` before returning them. Sharding stays invisible to the client — you issue one query and get one ranked result set. The same scatter/gather path backs [scans](/docs/pro/api/scans) (filter, full-text, and radius) and [UDF](/docs/pro/functions) discovery scans. For an existing turbopuffer namespace adopted by Layer, initialize sharding with `POST /v2/namespaces/{namespace}/init` and a `shard_count`. The gateway writes a reserved namespace marker, stamps new writes immediately, and runs an embedded scan-and-patch backfill for rows that do not yet have `_hevlayer_shard`. Scatter/gather activates only after namespace metadata reports `layer.shard_lag_rows: 0`; until then queries and scans use the single-namespace path so unstamped rows are not missed. Run `layer init --shards N` to create the namespace shard marker, start the backfill, and watch `shard_lag_rows` drain until scatter/gather activates. ## Document cache The Layer document cache does two jobs. Document [reads](/docs/pro/api/query#fetch) are served pull-through: the gateway checks the cache first, and on a miss reads through to turbopuffer (or S3 for snapshots), returns the row, and backfills the cache best-effort. [Pipeline](/docs/pro/api/pipelines) chunk handoff uses the same store as the queue between CPU and GPU workers. Neither job makes it a hard dependency: document reads fall through to origin if the cache is unavailable, and chunk reads fall back to S3 backing (see [Failure modes](/docs/pro/failure-modes)). One logical cache serves every path, with different uses (document fetch, pipeline chunks, snapshot field-values) separated into dedicated cache sets. ## Glossary | Concept | Meaning | | --- | --- | | Wire protocol | The HTTP methods, paths, request fields, response shapes, and status codes exchanged by client and server. | | Wire feature | An individual operation or option whose backend support is declared in the capability matrix. | | Gateway | The Layer service that receives client requests, validates them, and executes them against the configured stores. | | [VectorStore](/docs/pro/api/vectorstores) | A serving connection to the backend that stores and queries rows. | | [Warehouse](/docs/pro/api/warehouses) | An upstream source connection, separate from the store serving retrieval requests. | | Namespace | A named collection of rows addressed through `/v2/namespaces/{namespace}`. | | Document / row | An ID and application attributes, optionally including vectors. | | [Scan](/docs/pro/api/scans) | Row selection that returns matching IDs, field values, or a count; supported selectors depend on the backend. | | Shard | A hash bucket within a namespace, identified by the reserved `_hevlayer_shard` attribute. | | Scatter/gather | Running subqueries across shards or namespaces and combining their results into one response. | | Leg | One subquery contributing to a hybrid or federated result. | | RRF | Reciprocal rank fusion: combining ranked lists using each result's position in its input lists. | | Tokenizer policy | The rules that turn input text into retrieval tokens, including word boundaries, case normalization, and token limits. | | Route | A retrieval strategy, such as `hybrid_text`, `semantic`, or `fused`, selected by the query router where supported. | | Routing policy | The deterministic, versioned rules used to select an `Auto` route. | | Deferral | An `Auto` response with `executed: false`: the application must supply an embedding before the selected route can execute. | ### Pro runtime terms | Concept | Meaning | | --- | --- | | Document cache | Layer-managed hot records keyed by namespace and document id, plus cache sets for pipeline chunks and snapshots. | | Stable watermark | Epoch-ms cut tracked by the consistency watcher when turbopuffer reports up-to-date, or when a backing store without an index watermark settles its `row_count` across consecutive polls. | | Ready signal | Whether a namespace is fully indexed: `indexed` / `index_lag_rows` on [namespace metadata](/docs/pro/api/namespace-metadata), reconciled from the latest snapshot when every row's vector is indexed. | | [Pipeline](/docs/pro/api/pipelines) | A PostgreSQL-backed state machine for CPU extraction and GPU embedding work. | | [Snapshot](/docs/pro/api/snapshots) | A content-addressed S3 facet histogram written after a namespace is observed stable. | | Facet listing | The distinct values for a field, precomputed in snapshots as `fields[].values[].v` or computed on demand by a values scan. | | Facet count | The document count for a facet value, returned as `fields[].values[].n` in snapshots and `values[].n` in values scan results. | | [UDF](/docs/pro/functions) | A stateless worker the gateway coordinates over existing rows to enrich, fan out, or re-upsert data. | | [Operator](/docs/pro/kubernetes/operator) | The Kubernetes operator that reconciles Layer's CRDs — functions, pipelines, scaling, and cluster config. | | CRD | Custom Resource Definition: the Kubernetes-native resources the operator reconciles — [functions](/docs/pro/functions), [pipelines](/docs/pro/api/pipelines), [scaling](/docs/pro/scaling), and [indexes](/docs/pro/indexes). | | PromQL | The Prometheus query language. The gateway proxies it to the embedded VictoriaMetrics so you can query metrics without a separate scraper. | # Dashboard Source: https://hevlayer.com/docs/pro/dashboard import Callout from "../../components/docs/Callout.astro"; The Layer dashboard is the operator UI that ships in-cluster alongside the gateway, as the `layer-dashboard` Deployment and Service. This page covers running it: the access it needs, how to reach it, how to gate it, and how to turn it off. ## Access it needs The dashboard is read-mostly and backed by three sources, each with its own grant: - **The gateway API** — the same endpoints customers use, plus the Prometheus-compatible metrics proxy at `/v2/metrics`. Authenticated with a gateway bearer (`LAYER_GATEWAY_API_KEY`). In `deriveFromStore` mode this is the default `VectorStore` credential; in `keys` mode it is the configured inbound worker key. It does not touch PostgreSQL, Aerospike, or VictoriaMetrics directly — metrics and cost arrive through the gateway proxy and `/v2/cost`. - **The Kubernetes API** — reads `hevlayer.com` CRDs (VectorStores, Indexes, InfraRules, Pipelines) and the workload objects behind them (pods, deployments/statefulsets, HPAs, KEDA ScaledObjects, nodes) through RBAC bound to its ServiceAccount. `dashboard.kubeAccess.enabled` grants the read role; with it off the dashboard still runs but the cluster/scaling views show a "kube access not configured" banner. `dashboard.writeAccess.enabled` adds the operator write role: Index spec patches, Karpenter NodePool disruption, VectorStore and Warehouse create/edit, and namespace-scoped Secret writes for credentials. Set it `false` for a read-only install. - **Cost data** — the cost view reads the gateway cost API. turbopuffer spend is metered from upstream billing counters; AWS spend is read by the gateway from Cost Explorer using the gateway IRSA role. The dashboard does not need direct AWS cost API permissions for the standard cost view. In a Terraform install, still pass `dashboard.serviceAccount.roleArn` from the `layer_dashboard_role_arn` output so the dashboard ServiceAccount is annotated with its cluster-specific IRSA role at first boot. ## Who spent the queried bytes The cost tab has a **queried bytes · top callers** panel. Pick a namespace and a window (last hour by default, or 6 or 24 hours) to see which Function, pipeline or API key accounts for the most Turbopuffer billable queried bytes, with each caller's operation breakdown (`query`, `ranked_query`, `scan`, `fetch`, `passthrough`, `upsert`, `patch`, `delete`, `snapshot`). - Bytes come from `hevlayer_tpuf_billable_bytes_queried_total`, summed across gateway instances after a per-series `increase`, so a restarted gateway does not hide or double count its traffic. A cache hit adds none. - Callers are the bounded labels the gateway emits: a registered Function name, a pipeline id or a verified API key name. `unknown`, the `other` overflow bucket and series written before the caller labels existed are shown, marked as unattributed, and never named as the top spender. - The panel lists the top 20 callers and rolls the rest into one line. - The namespace is checked against the gateway with the dashboard's own key first; a namespace that key cannot read returns no data. Namespaces that no longer exist are not listed. - Counters restart with the gateway, so the first minutes after a deploy can undercount. - **Historical coverage is partial.** Before the instrumentation rollout the gateway did not meter `scan_page`, `fetch`, `fetch_many`, `fetch_vector` or Function discovery bytes, and it recorded no caller identity. Older totals are partial metered-path figures, not full namespace billing, so do not compare them with these expanded counters to claim a cost reduction. Expanded coverage begins at the rollout, and the panel always shows a coverage note saying so. Earlier caller attribution is unknown, not zero: `unknown`, `other` and unlabeled series are shown as they are, never reconstructed or zero-filled. Full historical namespace billing needs the provider's historical namespace export. - Figures come from the metrics store, so they lag the gateway by at least one scrape interval; the last seconds of traffic may not appear yet. The same data is at `GET /api/cost/attribution?namespace=&window=1h`. The underlying query is: ```promql sum by (caller_kind, caller, operation) ( increase(hevlayer_tpuf_billable_bytes_queried_total{namespace=""}[1h]) ) ``` ## Networking The dashboard is an operator tool. **Reach it over a port-forward** rather than exposing it publicly: ```sh kubectl port-forward -n svc/layer-dashboard 8081:8081 ``` Then open `http://localhost:8081`. Customer workloads only ever receive the gateway base URL and credentials — never the dashboard. ## Basic auth HTTP Basic auth sits in front of every dashboard route and is **required** — the dashboard refuses to start without it. Set credentials through the chart: ```yaml dashboard: basicAuth: user: ops password: ``` The chart render fails if either field is blank while the dashboard is enabled. ## Disabling the dashboard The dashboard is optional. Disable it and the Deployment, Service, RBAC, and ingress all skip rendering: ```yaml dashboard: enabled: false ``` The gateway and transform runtime run unchanged without it; you lose only the operator UI. ## Operational notes ### Pipeline queue states The pipeline overview combines gateway queue counters with the matching `Pipeline` resources and their operator-managed Deployments and KEDA ScaledObjects. A queue with pending work is presented as: - **PAUSED** when every matching `Pipeline` has `spec.paused: true`. Its workers are intentionally at zero replicas; pending documents remain queued. - **CRON-GATED** when its cron ScaledObject is inactive. The card shows the next UTC window, scaler activity, and managed worker replica counts. - **STALLED** when work is pending with no processing or indexing activity and neither an intentional pause nor a closed cron window explains the stop. Failed-document counts, operator condition messages, and missing or inactive managed workers appear as reason hints on the same card. A growing queue that is still processing remains **BACKLOG** rather than **STALLED**. The **data** tab is split into indexes, vector store, and warehouse views. Indexes come from `/v2/namespaces`; vector stores and warehouses come from the gateway's `/v2/vectorstores` and `/v2/warehouses` projections, so the dashboard shows the same credential-safe shape as the CLI and SDKs. When write access is enabled, the vector store and warehouse panes can create and edit those CRDs. Raw credentials are accepted only in the form submission; the dashboard writes them into Kubernetes Secrets first, then creates or patches the CR. The read views keep showing only Secret references. The dashboard is intentionally read-mostly. Mutating actions (UDF pause, InfraRules or scaling edits, data-supply CRD apply) are gated through CRD apply or explicit confirm dialogs, and write access is governed separately by `dashboard.writeAccess.enabled`. # Demos Source: https://hevlayer.com/docs/pro/demos Every demo below is a live app built on Layer that reimplements nothing. Each composes shipped gateway features — [routing](/docs/pro/api/query#query-routing), [hybrid text fusion](/docs/pro/api/query#hybrid-text-fusion), fuzzy matching, [local embedding serving](/docs/pro/api/embed), [pipelines](/docs/pro/api/pipelines), [snapshots](/docs/pro/api/snapshots), and the [function runtime](/docs/pro/functions) — over a different corpus, and makes the gateway's behavior legible in the UI. They are also the fastest way to see what the gateway does without standing up a cluster. The composition the demos share (embed, text legs, ANN leg, fuse, reorder the top) is also one gateway call: [`/search`](/docs/pro/api/search). | Demo | What it shows | Corpus | | --- | --- | --- | | [shelf](https://shelf.hevlayer.com) | The query router, made legible | Books | | [chart](https://chart.hevlayer.com) | Query routing on clinical search, with a number | PMC-Patients case reports | | [hybrid-text](https://hybrid-text.hevlayer.com) | Hybrid text fusion, proven with qrels | BEIR/SciFact abstracts | | [wiki](https://wiki.hevlayer.com) | Auto routing plus CPU-only Lattice embedding, at scale | Simple English Wikipedia | | [lens](https://lens.hevlayer.com) | Text-to-image search with zero GPUs | Wikimedia Commons Quality images | These deployed applications demonstrate retrieval behavior. Running their full workloads can require external stores, model artifacts, and data preparation; they are separate from the local Compose quickstart. ## shelf — book search that shows its routing **Live:** [shelf.hevlayer.com](https://shelf.hevlayer.com) · **Source:** [github.com/hev/shelf](https://github.com/hev/shelf) One search box, three routes. Type an author, a title, or a vibe; the gateway's `Auto` rank expression picks keyword (`hybrid_text`), `semantic`, or a `fused` blend from the shape of the query, and shelf renders that decision as a badge with the reason. The routing policy keys on token count, so the canned chips visibly change route as the query gets longer. This is the text-native routing showcase: it makes the [query router](/docs/pro/api/query#query-routing) decision the hero, not a footnote. Built on the [query router](/docs/pro/api/query#query-routing) (`Auto`), [hybrid text fusion](/docs/pro/api/query#hybrid-text-fusion), and fuzzy matching. ## chart — clinical patient-notes search that shows its routing **Live:** [chart.hevlayer.com](https://chart.hevlayer.com) The same routing hero on the corpus with the sharpest bimodal query distribution there is: clinicians search both by exact token (`metformin 500mg`, `CABG`, `aspirn`) and by clinical picture (`elderly woman with progressive dyspnea and bilateral lower-extremity edema`). chart is the first Layer demo with real relevance judgments — PMC-Patients ReCDS qrels — so the routing and hybrid claims are measured, not asserted. Behind the search box, an open-weight Gemma cascade (vLLM, scale-to-zero on the GPU pool) reads each note once and extracts clinical events and facet labels: the [function runtime](/docs/pro/functions) showcase. The corpus is published, de-identified case reports (PMC-Patients, CC-BY-NC-SA). It is a search demo — not raw EHR, and not clinical advice. Built on the [query router](/docs/pro/api/query#query-routing), [hybrid text fusion](/docs/pro/api/query#hybrid-text-fusion) with fuzzy matching, [pipelines](/docs/pro/api/pipelines), the [function runtime](/docs/pro/functions), and [snapshots](/docs/pro/api/snapshots). ## hybrid-text — hybrid text fusion over SciFact **Live:** [hybrid-text.hevlayer.com](https://hybrid-text.hevlayer.com) · **Source:** [github.com/hev/hybrid-text-fusion-demo](https://github.com/hev/hybrid-text-fusion-demo) The eval-shaped sibling of the routing demos, over ~5,000 scientific abstracts from BEIR/SciFact. One query string fans out into a full-input BM25 leg plus one fuzzy leg per token, fused by reciprocal rank fusion — so results survive typos and morphological variants without losing BM25's signal. It is purely lexical: no embeddings, no GPU, no vector index. SciFact ships qrels, so the UI flags known-relevant abstracts and the demo scores nDCG@10 / recall@10; every search also shows its gateway round-trip time and a fusion inspector (tokens, legs, RRF constant). Built on [hybrid text fusion](/docs/pro/api/query#hybrid-text-fusion) and fuzzy matching. ## wiki — all of Simple English Wikipedia, routed and embedded on CPU **Live:** [wiki.hevlayer.com](https://wiki.hevlayer.com) · **Source:** [github.com/hev/wiki](https://github.com/hev/wiki) The routing hero at corpus scale: one `Auto` query over all 283,997 Simple English Wikipedia articles (1.74M paragraph rows) routes each search to full-text, semantic, or a fused RRF blend, and the UI renders the gateway's `routing` echo beside every result. The semantic leg is the [Lattice](/docs/pro/api/lattice) showcase — the whole corpus embedded through `prefer: lattice`, an ~8 MB int4 lookup-table artifact served in-process on the gateway CPU, with `performance.embedding_ms` and `embedding_tokens` echoed whenever the chosen route embeds. No GPU anywhere in the write or query path. Built on the [query router](/docs/pro/api/query#query-routing), [hybrid text fusion](/docs/pro/api/query#hybrid-text-fusion), and [local embedding serving](/docs/pro/api/lattice) (`prefer: lattice`). ## lens — text-to-image search with zero GPUs **Live:** [lens.hevlayer.com](https://lens.hevlayer.com) · **Source:** [github.com/hev/lens](https://github.com/hev/lens) Cross-modal search over Wikimedia Commons [Quality images](https://commons.wikimedia.org/wiki/Commons:Quality_images): type `sunset over water`, get sunsets. The schema is two lines — a string `image_url` attribute with a [local CLIP](/docs/pro/api/embed#images) embedding profile. The gateway fetches each image and runs CLIP's image tower in-process on CPU at write time, then embeds query text with the same checkpoint's text tower at query time. The app posts writes and queries and renders the echo; it contains no embedding, tokenizer, or image-preprocessing code, and there is no GPU worker or autoscaler pool anywhere in the path. Every result pairs the fixed serving contract (`prefer: local`, gateway CPU) with the live `performance.embedding_ms` echo, and carries its Commons attribution and license. Built on [local CLIP serving](/docs/pro/api/embed#images) (`serving.prefer: local`, `modality: image`) and [full-runtime schema configuration](/docs/pro/indexes#schema-attribute-embedding). # Document model Source: https://hevlayer.com/docs/pro/document-model import Edition from "../../components/docs/Edition.astro"; import StoreNote from "../../components/docs/StoreNote.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; Layer reserves the `_hevlayer_*` attribute prefix for its own bookkeeping. **These attributes are read-only.** The gateway stamps and maintains them; your writes and [UDF](/docs/pro/functions) completion patches must not set or change them. The gateway rejects or overwrites any `_hevlayer_*` value you send, and editing one directly breaks Layer's guarantees. The reserved set depends on the engine backing the namespace.
| Attribute | Type | Purpose | | --- | --- | --- | | `_hevlayer_upserted_at` | integer (epoch ms) | Server-stamped on every write. The watermark Layer's [stable reads](/docs/pro/api/query#stable-reads) are taken against. | | `_hevlayer_shard` | integer | Hash bucket stamped on every row Layer writes (`xxh64(id) % shard_count`). The count is the namespace's shard marker, or `LAYER_SHARD_COUNT` (default 16) before one exists. Not related to Turbopuffer's own [sharding](/docs/pro/api/upstream-features#sharding). Lets the gateway [scatter/gather](/docs/pro/concepts#scattergather) a query across the shards of one namespace. | | `_hevlayer_udf__v` | string | Function completion marker. The gateway stamps the Function's `spec.version` here when a worker completes a row. Hyphens in the Function id are normalized to underscores. | | `_hevlayer_udf__stale_after` | integer or null | Function invalidation marker. Discovery reclaims rows once this epoch-ms timestamp expires; completion clears the marker. |
The `_hevlayer_` prefix also namespaces internal cache sets (snapshot field-values and search-history clickstream), but those are cache keys, not part of your document schema. # Failure Modes Source: https://hevlayer.com/docs/pro/failure-modes import Edition from "../../components/docs/Edition.astro"; import Callout from "../../components/docs/Callout.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; Layer strives to degrade gracefully: queries and document fetch served from turbopuffer keep functioning when components around them fail. This page details the scenarios where that does not apply. ## Read A gateway outage returns a connection error to the client. SDKs do not retry directly against the backing store. Check gateway health and connectivity; store failures remain visible through the gateway response. The document cache is stateless and can scale to zero with no disruption: document fetches fall through to origin (turbopuffer, or S3 for snapshots) on a miss or cache outage, so a cache failure degrades latency, not availability. ## Write Successful writes require success from the backing store. If the gateway is unreachable, the client returns the connection error. Cache warming is best-effort and does not replace the durable store write. ### Pipeline stop-writes The primary failure mode for writes through a healthy gateway is Aerospike stop-writes during a multi-stage pipeline job: staged documents stay warm in the cache but carry no vector data yet, and once that data exceeds the Aerospike drive allocation the cache rejects further writes. The pipeline does not stall. Each stage persists its chunk bodies to S3 before it touches the cache, and pipeline state lives in PostgreSQL, so the Aerospike write is best-effort: on stop-writes the gateway logs the skipped write and the stage still completes. Downstream chunk reads degrade to the S3 backing for as long as the cache is rejecting writes. Recovery is automatic. The Helm document cache restarts on stop-writes by default (`documentCache.autoRestartOnStopWrites: true`) and clears its Aerospike backing file on pod start (`documentCache.storage.resetOnStart: true`); the gateway reconnects in the background and refills the cache from S3 on demand. No pipeline work is lost — S3 and PostgreSQL are the durable recovery boundary and must stay healthy. Operator signals: - `layer_aerospike_op_duration_seconds{status="aerospike_stop_writes"}` — the stop-writes condition itself, the same series the [dashboard](/docs/pro/dashboard) charts. - `hevlayer_cache_cold_responses_total` — reads being served from S3 backing instead of the cache while it recovers. - `hevlayer_document_cache_cold_starts_total` and `hevlayer_document_cache_cold_start_seconds` — the demand-triggered reconnect-and-refill cycle after the cache restarts. - Gateway warn logs `Aerospike chunk write failed (best-effort)` and `Aerospike chunk read failed; falling back to S3 backing`. ### Planned gateway rollout A gateway rollout is a short, real outage, not a zero-downtime handover. The gateway runs as a single pod because its embedded Postgres (the Pipeline and Function queues) is pod-local; the chart rejects `gateway.replicas` above 1 with `gateway.queue.embedded`, and a second replica or a PodDisruptionBudget would not add availability (a budget of one blocks the roll or does nothing). During a roll the old pod stops answering at the start of its pre-stop drain (`gateway.drain.preStopSleepSeconds`, 20s), the new pod starts after it exits, and readiness follows its startup. The gap is bounded by that drain plus the pod's startup, not by traffic. What survives it: - Queue state: Postgres data stays on the pod's volume, so Pipeline and Function queues, claims and leases are intact when the pod returns on the same node. Losing the node is a different event: node-local storage goes with it. - Function and Pipeline workers built on the Layer extract image: they wait the gap out and carry on without a relaunch. See [Gateway rollouts](/docs/pro/functions#gateway-rollouts). - Setup calls and reads that SDKs can safely repeat. What does not: requests made during the gap fail with a connection error or `503` (`gateway_draining` from the pod itself, or a proxy `503`). Retry reads and idempotent setup; do not blindly resend a write or Function completion whose outcome you did not see. Serving through a rollout needs the queue state off the pod (external Postgres) and more than one gateway replica. That is a deployment and spend decision, not a default. ## Client failures When the gateway is unreachable, the SDKs return the gateway connection error. They do not retry directly against a backing store. That keeps the client surface backend-neutral: a namespace may be backed by turbopuffer or another store selected by `VectorStore`, and callers should not need a store-specific credential or fallback path. # FAQ Source: https://hevlayer.com/docs/pro/faq import Edition from "../../components/docs/Edition.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; This page answers the questions the rest of the docs don't: licensing, pricing, trials, and where the project is headed. ## What is the licensing for hev layer? The standalone gateway is source-available under the Business Source License 1.1 in [hev/layer](https://github.com/hev/layer). It is free to self-host at any scale and converts to Apache-2.0 on the change date in its `LICENSE`. The operator, function runtime, and dashboard require a commercial license. Licensed installs use signed license keys for trial and commercial access. The key is supplied to Helm as `license.token` or through a referenced Kubernetes Secret. See [licensing](/docs/pro/licensing) for the install and lifecycle details. ## How much will it cost? Layer is licensed per operator deployment: one license per operator install, so a production, staging, and DR cluster each carry their own. Replicas within an install do not count. Pricing is not final for the design-partner cut. Start with a trial license; commercial terms are handled directly with each design partner. ## How do I start a trial? Use the [trial signup](/#start-trial). Submit your work email and hev layer emails a signed trial key plus the current install instructions. ## How do I know whether my license is healthy? Call [`GET /v2/license`](/docs/pro/licensing#license-states) on your gateway. The gateway reports `licensed`, `grace`, or `floor` from the key configured in the install. Operator and dashboard license surfaces use the same state model when their enforcement surfaces ship. ## Who built hev layer? [Adam Hevenor](https://hevmind.com/about). hev layer is a [hev mind](https://hevmind.com) product. # Functions Source: https://hevlayer.com/docs/pro/functions import Edition from "../../components/docs/Edition.astro"; import CodeTabs from "../../components/docs/CodeTabs.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; A `Function` is a User Defined Function (UDF) that runs over rows that already exist in an [Index](/docs/pro/indexes). It is the right shape for classifiers, enrichment, backfills, fan-out from an existing row, and deterministic re-upserts. UDFs are best defined in YAML and invoked by the [layer CLI](/docs/pro/cli#run-a-function). The operator creates worker resources; the gateway owns discovery, queueing, retries, leases, and completion markers. Workers own their data writes. Discovery-triggered Functions scan existing rows after registration, then repeat on `schedule.discoveryIntervalSeconds`. Changing `spec.version` makes discovery due again so rows with older completion markers are queued. `POST /v2/udfs/{id}/discover` processes at most `max_pages` source pages across all requested namespaces (default 10, range 1–100). `page_size` limits each page (default 1,000, range 1–1,000). Unfinished namespaces take turns so a large namespace cannot keep smaller ones out of discovery. The response includes `pages_scanned`, `complete`, and an opaque `next_cursor`. Pass `next_cursor` as `cursor` to continue. A stale cursor returns HTTP 409; omit it to resume the latest committed progress. Gateway replacement preserves progress in Postgres. Changing the Function version starts discovery from the beginning. A checkpoint advances after canonical source observation and queue insertion commit. Interrupted pages can be replayed; queue and receipt fences protect against unsafe acknowledgment. A completed sweep records traversal, not a snapshot census of a changing source. Source-write triggers and subsequent sweeps recover changes behind the cursor. Claims rotate among pending namespaces, including when workers request one item at a time. Claim `limit` is 1–1,000; selection considers at most 64 pending namespaces per batch. Paused Functions do not scan or claim work. The operator copies gateway `active_namespaces` and `pending_count` into Function status `activeNamespaces` and `queueDepth`. Use a [Pipeline](/docs/pro/api/pipelines) when external data becomes rows in Layer. Use a Function when compute starts from rows that are already in Layer. ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Function metadata: name: tag-products namespace: layer spec: targetNamespaces: - products inputs: - id - title version: v1 filter: - category - Eq - outdoor worker: image: .dkr.ecr.us-east-1.amazonaws.com/hev-tag-products:latest dispatch: pull computeClass: cpu batchSize: 32 timeoutSeconds: 30 schedule: discoveryIntervalSeconds: 300 leaseSeconds: 120 maxInFlightBatches: 8 maxConcurrentScans: 1 retry: maxAttempts: 8 initialBackoffSeconds: 5 maxBackoffSeconds: 300 triggers: - discovery scaling: pool: cpu mode: autoscale replicas: min: 0 max: 6 ``` ## Selection Use `targetNamespaces` for explicit namespaces. Use `indexSelector` when labels on `Index` resources should choose the namespaces. Workers claim only from the namespaces the Function currently targets. When you narrow `targetNamespaces`, queue rows already pending in a namespace you removed stay queued and are not claimed (nothing is deleted); adding the namespace back makes them claimable again. A Function that uses `indexSelector` has no fixed list and claims from the namespaces the selector chose. `filter` preserves arbitrary JSON, including array-form turbopuffer filters. The operator stores the shape as-is; the gateway evaluates it during discovery after AND-ing it with the generated completion-marker predicate. Do not include a version-marker predicate in `filter`; the gateway creates that from `spec.version`. ## Worker | Field | Purpose | | --- | --- | | `image` | Worker image. | | `dispatch` | `pull` for SDK claim/poll workers, `push` for HTTP `/run` workers. | | `computeClass` | `cpu` or `gpu`. Defaults to `cpu`; when `scaling.pool` is omitted, the operator maps this to the stock `cpu` or `gpu` pool. | | `port` | Push-dispatch service port. | | `batchSize` | Rows per batch. | | `timeoutSeconds` | Worker call timeout. | | `podSpec` | Optional pod-level merge patch. | To apply the CR, register the gateway UDF, trigger discovery, and watch the queue with one command, use [`layer run -f`](/docs/pro/cli#run-a-function). The worker pod receives `HEVLAYER_UDF_ID`, `HEVLAYER_BASE_URL`, `HEVLAYER_UDF_BATCH_SIZE`, `HEVLAYER_UDF_TIMEOUT_SECONDS`, `HEVLAYER_UDF_LEASE_SECONDS`, and `LAYER_GATEWAY_API_KEY`. The gateway bearer is sourced from the default `VectorStore` credential in `deriveFromStore` mode, or from the configured inbound worker key in `keys` mode. ## Simple classifier The Python client turns a normal function into the claim/process/complete loop. `output="tags"` is client-side metadata: the resource does not declare an output attribute. `run_udf_worker` sends the returned value as a completion `attributes.tags` patch, and the gateway stamps the reserved completion marker in the same patch. The Go client drives the same worker protocol directly, as does the TypeScript client — claim a batch, process rows, report completions and failures. ```python import asyncio from hevlayer.udf import PermanentError, TransientError, run_udf_worker, udf @udf(inputs=["id", "title", "description"], output="tags", kind="tags") def tag_product(*, id: str, title: str | None, description: str | None) -> list[str]: if not title: raise PermanentError(f"{id}: missing title") try: text = f"{title} {description or ''}".lower() except TypeError as exc: raise TransientError(str(exc)) from exc tags: list[str] = [] if "wireless" in text: tags.append("wireless") if "waterproof" in text: tags.append("waterproof") return tags or ["uncategorized"] if __name__ == "__main__": asyncio.run(run_udf_worker(tag_product, udf_id="product-tags")) ``` ```go package main import ( "context" "os" "strings" hevlayer "github.com/hev/layer-go" ) func tags(title, description string) []string { text := strings.ToLower(title + " " + description) var out []string if strings.Contains(text, "wireless") { out = append(out, "wireless") } if strings.Contains(text, "waterproof") { out = append(out, "waterproof") } if len(out) == 0 { out = []string{"uncategorized"} } return out } func main() { ctx := context.Background() udfID := os.Getenv("HEVLAYER_UDF_ID") layer := hevlayer.NewClient( hevlayer.WithBaseURL(os.Getenv("HEVLAYER_BASE_URL")), hevlayer.WithAPIKey(os.Getenv("LAYER_GATEWAY_API_KEY")), ) for { claimed, err := layer.ClaimUdfItems(ctx, udfID, &hevlayer.UdfClaimRequest{ WorkerID: "tag-products-0", Limit: 32, }) if err != nil { continue } var done []hevlayer.UdfCompleteItem var failed []hevlayer.UdfFailItem for _, item := range claimed.Items { title, _ := item.Input["title"].(string) description, _ := item.Input["description"].(string) if title == "" { failed = append(failed, hevlayer.UdfFailItem{ Namespace: item.Namespace, ID: item.ID, Kind: "permanent", Message: "missing title", }) continue } done = append(done, hevlayer.UdfCompleteItem{ Namespace: item.Namespace, ID: item.ID, Attributes: map[string]interface{}{"tags": tags(title, description)}, }) } if len(done) > 0 { layer.CompleteUdfItems(ctx, udfID, &hevlayer.UdfCompleteRequest{ WorkerID: "tag-products-0", Items: done, }) } if len(failed) > 0 { layer.FailUdfItems(ctx, udfID, &hevlayer.UdfFailRequest{ WorkerID: "tag-products-0", Items: failed, }) } } } ``` ```typescript import { Hevlayer } from "hevlayer"; function tags(title: string, description: string): string[] { const text = `${title} ${description}`.toLowerCase(); const out: string[] = []; if (text.includes("wireless")) out.push("wireless"); if (text.includes("waterproof")) out.push("waterproof"); return out.length ? out : ["uncategorized"]; } const udfId = process.env.HEVLAYER_UDF_ID!; const layer = new Hevlayer({ baseUrl: process.env.HEVLAYER_BASE_URL, apiKey: process.env.LAYER_GATEWAY_API_KEY, }); while (true) { const claimed = await layer.claimUdfItems(udfId, { worker_id: "tag-products-0", limit: 32, }); const done = []; const failed = []; for (const item of claimed.items) { const title = typeof item.input.title === "string" ? item.input.title : ""; const description = typeof item.input.description === "string" ? item.input.description : ""; if (!title) { failed.push({ namespace: item.namespace, id: item.id, kind: "permanent", message: "missing title", }); continue; } done.push({ namespace: item.namespace, id: item.id, attributes: { tags: tags(title, description) }, }); } if (done.length > 0) { await layer.completeUdfItems(udfId, { worker_id: "tag-products-0", items: done }); } if (failed.length > 0) { await layer.failUdfItems(udfId, { worker_id: "tag-products-0", items: failed }); } } ``` In Python, function parameters are keyword-only and named to match `inputs`; raise `TransientError` for retryable work and `PermanentError` for unrecoverable input. In Go and TypeScript, report the same split through `FailUdfItems` / `failUdfItems` with `kind: "transient"` or `kind: "permanent"`. ## Backfill original-file references `layer-extract originals` is a stock Function worker. With `groupByParent: true` it claims whole documents whose page rows have an `original_sha256` and no `original_blob`, stores the cached original in the namespace's private blob storage, and completes every page with just `original_blob` and `original_bytes`. It is row-preserving and idempotent. It does not check the blob set before storing: that check is a query billed at the size of every original already stored. It downloads, hash-checks and stores by hash instead, so a repeated document is stored again with the same bytes and the same reference. Only a row with no URL at all is looked up by hash, and never during a [backfill](/docs/pro/kubernetes/backfill-a-function). Bytes come from a sealed manifest of cached originals, or through the `clouddrive` connector using the Warehouse you mount on the pod (`podSpec`, container `udf`). Rows indexed before `original_*` existed are matched by their `webUrl` and attached only if the downloaded bytes hash to the row's own `content_hash`. A document whose only copy came from a `rest` Warehouse is fetched through that Warehouse's connector instead (declare `LAYER_ORIGINALS_REST` and mount its credential on the pod); select legacy rows with `content_hash NotEq null`, which every page row satisfies. Pipelines retain originals by default (`extract.retainOriginals: false` opts out) and write the same attributes at ingest, so the Function is only for rows that predate it. See [Original files](/docs/pro/api/mcp#original-files). ## GPU classifier More complicated classifiers (e.g. a vision-language classifier) may require a model to run on a GPU. The [shop source](https://github.com/hev/shop) provides a product-color classifier example. ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Function metadata: name: product-color namespace: layer spec: targetNamespaces: - amazon-products inputs: - id - image_url version: v1 worker: image: .dkr.ecr.us-east-1.amazonaws.com/hev-shop-udf-product-color:latest dispatch: pull computeClass: gpu batchSize: 8 timeoutSeconds: 120 schedule: leaseSeconds: 300 maxInFlightBatches: 2 triggers: - discovery scaling: pool: gpu mode: autoscale replicas: min: 0 max: 2 ``` `worker.computeClass: gpu` defaults omitted `scaling.pool` to the [built-in `gpu` pool](/docs/pro/scaling). The stock pool selects `layer.hev.dev/node-role=worker-gpu`, requests one NVIDIA GPU, and carries the worker and NVIDIA tolerations: ```yaml computePools: - name: gpu kind: gpu maxReplicasPerWorkload: 4 nodeSelector: layer.hev.dev/node-role: worker-gpu layer.hev.dev/compute: gpu tolerations: - key: layer.hev.dev/node-role operator: Equal value: worker-gpu effect: NoSchedule - key: nvidia.com/gpu operator: Exists effect: NoSchedule resources: requests: { memory: 4Gi, nvidia.com/gpu: "1" } limits: { memory: 10Gi, nvidia.com/gpu: "1" } ``` The worker loads the model once at startup and classifies per row. CLIP zero-shot classification labels each product image with its dominant color: ```python import asyncio import io import httpx import torch from PIL import Image from transformers import pipeline from hevlayer.udf import PermanentError, TransientError, run_udf_worker, udf COLORS = ["black", "white", "gray", "red", "blue", "green", "brown", "multicolor"] classifier = pipeline( "zero-shot-image-classification", model="openai/clip-vit-large-patch14", device="cuda" if torch.cuda.is_available() else "cpu", ) @udf(inputs=["id", "image_url"], output="color", kind="classification") def classify_color(*, id: str, image_url: str | None) -> str: if not image_url: raise PermanentError(f"{id}: missing image_url") try: resp = httpx.get(image_url, timeout=10.0, follow_redirects=True) resp.raise_for_status() image = Image.open(io.BytesIO(resp.content)).convert("RGB") except httpx.HTTPError as exc: raise TransientError(f"{id}: image fetch failed: {exc}") from exc except OSError as exc: raise PermanentError(f"{id}: undecodable image: {exc}") from exc scores = classifier(image, candidate_labels=COLORS) return scores[0]["label"] if __name__ == "__main__": asyncio.run(run_udf_worker(classify_color, udf_id="product-color")) ``` The worker image needs `torch`, `transformers`, `pillow`, and `httpx` alongside the `hevlayer` Python client. Bake the model weights into the image so autoscaled pods do not re-download them on every cold start. Sizing for inference: keep `worker.batchSize` low and `worker.timeoutSeconds` high enough for one batch of forward passes, and make `schedule.leaseSeconds` outlast a full batch so claims do not reissue mid-inference. `replicas.min: 1` keeps a warm worker when model cold-start dominates; `min: 0` scales to zero between sweeps. ## Document grouping Rows written by [PDF extraction](/docs/pro/pdf-extraction) are one row per page, with ids like `report#p3` and a shared `_hevlayer_parent_id`. A classifier over the whole document should run once, not once per page. Set `spec.groupByParent: true` and the gateway claims by document: ```yaml spec: targetNamespaces: - pages inputs: - id - text groupByParent: true ``` - A claim returns one item per document. The item's `id` and `input` are the first page by id, and `pages` lists every page of the document, including that one, each with its own `id`, `input_revision` and `input`. - The gateway claims the sibling pages for the same worker, whether or not discovery has queued them yet, so no other worker can take a page of a document that is in flight. The claim `limit` counts the rows picked from the queue, not the pages added with them. - Echo `pages` (`id` and `input_revision` per page) on the completion item. The gateway writes the same `attributes` (and `vector`, if any) plus the version marker to every page row. Each page is fenced by its own revision, so a page that changed during the run is requeued like any stale claim. - Echo `pages` on a failure item and on a heartbeat item to fail or renew every page of the document together. - Once every page carries the marker, discovery does not queue the document again. Bumping `spec.version`, or changing a page's declared inputs, runs the whole document again. - Rows without `_hevlayer_parent_id` behave as they do without the option: one item per row, no `pages`. A document groups at most 10,000 pages. In Python, `run_udf_worker` echoes `pages` for you. Declare a `pages` parameter to receive the `input` of every page, in id order: ```python @udf(inputs=["id", "text"], output="doc_type", kind="classification") def classify_document(*, id: str, text: str | None, pages: list[dict]) -> str: return classify("\n".join(page["text"] or "" for page in pages)) ``` In Go, TypeScript and REST, copy the claimed item's `pages` entries into the completion, failure or heartbeat item. ## Scaling `spec.scaling` is the same scaling config [Pipelines use](/docs/pro/api/pipelines#scaling): a pool from the built-in set or `InfraRules/default`, a mode, and replica bounds. For Functions, `mode: autoscale` emits a KEDA `ScaledObject` triggered by `layer_udf_queue_depth`. Replica maxima above the pool's `maxReplicasPerWorkload` are rejected in status. For GPU Functions on a scale-to-zero pool, set `spec.scaling.warmWindowSeconds` to hold the worker — and its node — warm for a cooldown after the queue drains, so adjacent batches skip the cold start (fresh node, image pull, model load) before the pool returns to zero. See [Workload scaling](/docs/pro/scaling#warm-window). ## Writeback Workers own data writes. The common single-attribute case uses the Python client's sugar: `@udf(output="tags")` makes `run_udf_worker` send returned values as `attributes.tags` in the completion call — in Go (or over REST) the same thing is `attributes` on each completion item. The gateway applies those attributes and the reserved completion marker in one `patch_columns` write. Completion attributes must not use the reserved `_hevlayer_*` prefix. Embedding Functions can include `vector` on each completion item. The Python helper emits `vector` for `@udf(kind="embedding")` return values. The gateway fetches the existing row, merges returned attributes and reserved markers, then re-upserts the full row with the replacement vector. Python workers that need more control can declare the `tpuf` parameter, write through the client, and return `None`; completion then stamps only the marker. Use deterministic IDs when a Function creates rows so at-least-once retries remain idempotent. Deleting a Function garbage-collects operator-managed Kubernetes resources. It does not delete already-written attributes. ## Lifecycle ```sh kubectl get function product-tags kubectl describe function product-tags layer udf get product-tags kubectl patch function product-tags --type=merge -p '{"spec":{"paused":true}}' kubectl patch function product-tags --type=merge -p '{"spec":{"paused":false}}' curl -X POST -H "authorization: Bearer $LAYER_GATEWAY_API_KEY" \ $LAYER_GATEWAY_URL/v2/udfs/product-tags/reset-failed kubectl delete function product-tags ``` Registration in the gateway's UDF registry happens at reconciliation, not at first discovery run — a Function created with `spec.paused: true` (or paused later) is registered immediately with `paused: true`, so it is observable from creation onward. API callers can pass `paused: true` alongside `id` and `spec` to `POST /v2/udfs` to register atomically without starting discovery: When the Function spec changes, reconciliation upserts the registered UDF definition in place. Pending, processing, failed, and indexed queue state remain attached to the same UDF id. ``` GET /v2/udfs → 200 {"udfs": [{"id": "product-tags", "paused": true, ...}]} GET /v2/udfs/product-tags/status → 200 {"udf_id": "product-tags", "paused": true, ...} ``` A `404` from `/v2/udfs/{id}/status` means the Function was never registered — a real failure, not an intentional pause. `paused` on the `Udf` and `UdfStatus` resources is the single source of truth for "is this installed and paused" versus "does this exist at all." ## Version markers `spec.version` is the re-run safety rail and defaults to `v1`. On completion, the gateway stamps `_hevlayer_udf__v` with that version, normalizing hyphens in the Function name to underscores. For `metadata.name: product-color`, the marker is `_hevlayer_udf_product_color_v`. Discovery automatically looks for rows whose marker is missing, differs from `spec.version`, or has an expired `_hevlayer_udf__stale_after` marker. Bump `spec.version` when a model, taxonomy, or prompt changes. To check the change on live data before it overwrites outputs, run it as a [candidate](#candidate-runs). ## Candidate runs Test a changed prompt, taxonomy or model against live data before it replaces the live output. A candidate run is derived from the live Function, so you do not write a second Function. Candidate runs use the gateway API; the `Function` resource has no candidate field. 1. Register the candidate. Name the output attributes it produces and the new `version`. Anything else (`worker`, `inputs`, `filter`, `target_namespaces`) overrides the live value only if you set it. ```sh curl -X POST -H "authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "content-type: application/json" \ $LAYER_GATEWAY_URL/v2/udfs/doc-type/candidate \ -d '{"version":"v2","outputs":["doc_type"], "worker":{"image":".dkr.ecr.us-east-1.amazonaws.com/doc-type:v2"}}' ``` This registers `doc-type-candidate`. Discovery, claims, retries and status work as for any Function (`GET /v2/udfs/doc-type-candidate/status`). Run a worker for it with `HEVLAYER_UDF_ID=doc-type-candidate`. The worker returns the same attribute names as the live Function. The gateway writes them to `_hevlayer_cand__` and stamps a separate completion marker. The live output and live marker never change. A candidate completion may set only attributes listed in `outputs` and cannot write vectors. 2. Compare. The report covers rows the candidate has completed. ```sh curl -H "authorization: Bearer $LAYER_GATEWAY_API_KEY" \ "$LAYER_GATEWAY_URL/v2/udfs/doc-type/candidate/compare?sample=5" ``` ```json { "rows_compared": 3, "rows_agreed": 2, "agreement_rate": 0.667, "outputs": [{ "output": "doc_type", "compared": 3, "agreed": 2, "agreement_rate": 0.667, "confusion": {"invoice": {"invoice": 2, "receipt": 1}} }], "disagreements": [{"namespace": "docs", "id": "c", "output": "doc_type", "live": "invoice", "candidate": "receipt"}] } ``` `confusion` counts rows by live value, then candidate value, for outputs whose values are strings, numbers or booleans. A missing value counts as `null`. Array outputs, such as tags, compare without regard to order and have no confusion table. `max_rows` (default 10,000, maximum 100,000) bounds the scan; `truncated` is `true` when it stopped early. The report also carries the candidate's `pending_count`, `processing_count` and `failed_count` so you can tell a finished run from a partial one. 3. Promote, or discard. ```sh curl -X POST -H "authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "content-type: application/json" \ $LAYER_GATEWAY_URL/v2/udfs/doc-type/candidate/promote -d '{}' curl -X DELETE -H "authorization: Bearer $LAYER_GATEWAY_API_KEY" \ $LAYER_GATEWAY_URL/v2/udfs/doc-type/candidate ``` Promote copies each shadow value into the live output, stamps the live marker with the candidate version, clears the shadow attributes, and updates the live Function's `version`, `worker`, `inputs`, `filter` and `target_namespaces` to the candidate's. It then deletes the candidate. The live Function's `invalidates` fire as for any completion. Promote returns HTTP 409 while the candidate has pending or processing rows; pass `"allow_partial": true` to promote what is finished. Rows the candidate did not finish keep the old marker, so the live Function reprocesses them at the new version. Discard clears the shadow attributes and deletes the candidate without touching live output. For a Function managed by a `Function` CR, update the CR's `spec.version` and `spec.worker` to the candidate's values after promoting. Otherwise the next reconcile upserts the old definition, and discovery reprocesses every row. ## Tuning knobs | Knob | What it bounds | | --- | --- | | `worker.batchSize` | Rows per worker batch. | | `worker.timeoutSeconds` | Worker call timeout. | | `schedule.leaseSeconds` | How long a claim is held before reissue. | | `schedule.discoveryIntervalSeconds` | Time between discovery scan jobs. | | `schedule.maxInFlightBatches` | Concurrent worker batches per UDF. | | `schedule.maxConcurrentScans` | Concurrent namespace discovery jobs. | | `retry.maxAttempts` | Tries before a row lands in `failed`. | ## Gateway rollouts The gateway is one pod (embedded Postgres holds the Function queues, so the chart refuses `gateway.replicas` above 1). Rolling it out takes the gateway away for tens of seconds. A Function worker waits that out instead of exiting: the work it holds and the work still queued both carry on once the new pod answers, with no relaunch. The worker image's gateway client treats these as "not here yet" and polls with backoff (1s doubling to 10s) for up to `HEVLAYER_GATEWAY_OUTAGE_SECONDS` (default 240, kept under the 300s default lease; `0` never waits): | Gateway answer | Reads, status, claim | Completion and failure | | --- | --- | --- | | No connection (refused, connect timeout) | Wait | Wait, then send the same body: it never arrived | | `503` `gateway_draining` | Wait | Wait, then send the same body: the drain answer precedes every handler | | Other `502`/`503`/`504`, timeout, reset | Wait | **Not resent**: it may have been delivered. The lease expires and the item is claimed again | | `401`, `403` | Stop | Stop | | `429` | Bounded attempts, then stop | Stop | Credential denial, throttling and spend guards are answers from a running gateway, so they still stop the worker's run immediately; only silence is waited out. A wait that outlasts the window ends the call with an error, as before, and the worker restarts with its durable claim ledger and hold marker intact. Completions stay fenced by the echoed `input_revision` and the worker's lease, so a completion that arrives after the lease expired is a conflict (see below), not a duplicate write. ## Completion conflicts Workers can set `report_dispositions: true` on `completeUdfItems` to receive HTTP 200 with `updated` and an `items` array in request order. Each outcome has an `index` (the submitted item position), a `disposition`, and a bounded `reason` for conflicts. Check every outcome; HTTP 200 alone is not full success. | Reason | Disposition | Worker action | | --- | --- | --- | | No conflict | `completed` | Output and completion receipt acknowledged. | | `stale_claim` | `reclaim` | Drop computed output. The gateway safely requeues the still-owned claim; acquire current inputs normally. | | `claim_not_owned` | `discard` | Drop the old claim. Let the current owner or normal lease recovery proceed. | | `prepared_receipt_mismatch` | `stop` | Preserve evidence and investigate. Never replace the prepared output with a different replay. | | `prepared_owner_changed` | `stop` | Current ownership, generation or live lease does not authorize receipt adoption. Preserve the receipt. | | `prepared_source_changed` | `stop` | Source changed after a prepared write; preserve the receipt and investigate. | | `ownership_lost_before_ack` | `stop` | Output may have persisted. Preserve the receipt and investigate before further processing. | | `source_absent` | `stop` | No output or indexed marker is written. Investigate source removal. | Safe stale recovery is part of completion: it fences the echoed input revision and worker owner and never requeues a claim with an unacknowledged prepared receipt. It does not require resetting failures or retrying a pipeline. Expired claims cannot prepare new output. Acquisition fences earlier receipts, even when a worker name is reused. Without `report_dispositions`, completion retains batch conflict HTTP 409 and adds bounded `reason` and `disposition` fields. Store failures remain non-200 and can occur after earlier items in a batch succeeded. Retry the identical request after a transient transport/store failure; prepared receipts prevent replacement output and recognize already acknowledged completions. Do not convert an unknown store error into success or replay changed output. Claims with a durable unacknowledged completion include `prepared_receipt`: its original `input_revision`, `intent_digest` and `output_digest`. These are protected digest-only evidence; the gateway does not retain output values. A worker recovering a durable prepared completion submits the current claim's `input_revision` and the original receipt's `prepared_input_revision`, with exactly the original attributes and vectors. This explicitly requests adoption. The gateway checks the strong current source, retained source-change fence, original output intent, current owner and live lease before transferring the receipt to the new generation. Identical repeated calls are safe; ACK counters advance once. Changed source, changed output, absent proof or conflicting ownership preserves the receipt and stops recovery. Never recompute a timestamp or inference result to replace an uncertain prepared output. Source epochs fence changes and absence observed through the gateway. They do not prove an unobserved delete-and-recreate performed directly in the store. Recovery requires authoritative current input reads; uncertain source history must remain stopped. Legacy receipts lacking retained source proof are preserved and rejected, even if their current output looks plausible. ### Discovery cost bounds Scheduled discovery spends at most ten pages per chunk and waits `discoveryIntervalSeconds` before continuing its durable cursor. A chunk that reaches the page budget is incomplete; it is not a failed sweep. Gateway restarts resume committed progress. Failed chunks use exponential delays capped at one hour. A pause stops a running chunk at the next page boundary. That is not a failure: the chunk ends without an error (a manual `POST …/discover` returns 200 with `complete: false`), and the failure count is unchanged. Eight consecutive failures pause the Function. Its `Ready` condition reports `DiscoveryAutoPaused` with recovery guidance. Check target namespaces, filter schema and upstream availability, then correct the Function definition or explicitly resume it. Manually paused Functions remain paused. Discovery sends the stale version marker predicate together with the user filter to the store, including rows with missing markers. Gateway-observed changed inputs are recovered from durable input revisions. For stores exposing valid namespace creation and modification timestamps, completed unchanged targets need one metadata request and no row scan. Approximate row counts and byte counts are not change detectors. Stores without reliable timestamps continue bounded filtered scans. Namespace writes bypassing the gateway must clear the affected Function marker when they change a Function input. ## Canonical document read cache The runtime Function read cache is disabled by default. Pro startup can compose the shared cache from an existing reviewed authority configuration, as described below. `LAYER_FUNCTION_READ_CACHE=exclusive-writer` does not enable it: process-local invalidation cannot fence an abandoned provider request that commits later, even with one gateway. Without configured authority, canonical reads use the store. Multi-replica cache activation requires durable mutation fencing and an enforced upstream writer credential boundary; neither a topology assumption nor this old environment setting establishes that boundary. The following certificate behavior describes the source cache implementation; it is not an enabled production consistency guarantee. Canonical ID reads use a separate cache of complete, committed attribute rows. Projected scans and the ordinary document cache do not certify a canonical row. A cache miss makes one strong ID query for the missing IDs in the batch; confirmed missing IDs are cached as absent. Cache errors fall back to the store. When the retained shared fence certifies namespace ID type and generated columns, the strong miss query reuses that identity without another metadata request. Missing identity or schema/delete epoch invalidation requires fresh strong metadata; a process-local metadata TTL does not certify it. Shared certificates are scoped by the canonical provider/writer domain and full namespace identity. A row's cache key also includes its durable namespace epoch, document revision and ID; sibling membership includes the namespace revision. The original shared fence is retained and validated around provider and cache work. Mutations record durable pending state and advance affected versions before provider dispatch; successful certificate publication precedes exact-token completion. An abandoned or uncertain write cannot become readable merely because a later write succeeds. The Aerospike adapter gives rebuildable certificates a one-hour TTL. Expiry or cache loss causes a miss; it does not remove durable pending or poisoned state. A new gateway process can reuse shared certificates only after validating the same durable identity, independent witness and retained fence. Restart does not itself create a fresh cache scope or certify rows. Missing or mismatched authority prevents certificate use and selects strong uncached reads; loss of an original fence fails the protected operation. Pending or poison prevents cache reuse, and shared-mode mutations are rejected when their required authority is unavailable. If native embedding produces attributes absent from the write body, the client requires a complete upstream read rather than certifying an incomplete row. Conditional and filtered mutations invalidate their certificate identity. A cache miss never becomes a partial row. Claim and completion read each namespace's IDs together. A write containing both upserts and patches performs one post-write canonical read for their combined IDs. Cache hits retain all Function input columns and completion markers, even when a worker later projects the returned attributes. ### Parent and sibling lookup for workers Replace the worker's separate page-two and parent-filter queries with `POST /v2/udfs/{function_id}/items/lookup`. Use the same authenticated gateway and the current claim's `worker_id`, `namespace`, `id` and `input_revision`: ```python lookup = await client.lookup_udf_siblings( function_id, { "worker_id": worker_id, "namespace": item["namespace"], "id": item["id"], "input_revision": item["input_revision"], "include_attributes": ["page", "text"], }, ).model_dump() siblings = lookup["documents"] page_two = next( (row for row in siblings if row["id"] == lookup["page_two_id"]), None ) ``` `client` here is the worker's authenticated HTTP client, with the gateway base URL already configured. The response contains `udf_id`, `namespace`, `parent_id`, `page_two_id` (null when absent), and `documents`, each with `id` and `attributes`. IDs and `page_two_id` are returned independently of the attribute projection. The parent comes from the claimed source's reserved `_hevlayer_parent_id`; the caller cannot substitute another parent. The lookup requires a live owned claim and rejects a stale revision with HTTP 409. `page_two_id` identifies a sibling whose numeric `page` attribute is 2. The first lookup of an uncached parent performs one strong provider query and certifies complete membership and full rows. Repeated lookups use the cache. Source mutations invalidate membership before dispatch. Cache eviction or failure causes a fresh strong lookup. Groups with 10,000 or more siblings fail instead of returning incomplete membership. This endpoint does not silently fall back to a full namespace scan for another store kind. For a [backfill](/docs/pro/kubernetes/backfill-a-function#look-up-a-documents-other-pages) Function, the lookup makes no provider read. It answers from the sibling groups the backfill scan holds, and returns only the Function's inputs. ### Per-item query budget Set `spec.queries_per_item` in the gateway API or `spec.queriesPerItem` on the Function resource to limit provider query attempts. Omit it for accounting without a cap; zero allows only cache hits. Status exposes `query_budget` with `queries_per_item`, `provider_queries`, `rejected_queries`, `exhausted_items`, and `discovery_queries`. The ledger is keyed by Function ID, Function version, namespace and source ID. A batch query charges one attempt to every participating item, once per unique ID, so `provider_queries` sums item charges, not physical queries. Reads that a gateway write makes for write-triggered Functions are charged to those Functions, never to a [backfill](/docs/pro/kubernetes/backfill-a-function). `hevlayer_function_provider_queries_total` and `hevlayer_function_billable_bytes_queried_total` (labels `function`, `stage`) count each physical read and its billed bytes once per Function. A multi-query charges its number of query legs. Reservations commit before dispatch: errors, 429s, cancellation and transport failures retain the charge. Cache hits and empty ID lists cost zero. Retries, lease recovery, completion replay, failed-item resets and gateway restart retain the ledger. A version change starts a new budget; increasing the cap allows work to continue. Source edits alone do not reset the budget for an existing ID. Claim hydration, canonical write-trigger reads, completion reads and the claim-bound lookup all reserve queries. Discovery scans appear as `discovery_queries` and in `provider_queries` as namespace overhead; they are not subject to an individual item's cap. The provider's `fetch_many` and `scan_page` billing remains visible in the billing metrics. `provider_queries` is the sum of item charges and discovery overhead, so shared batch queries can contribute more than one item charge. Metadata calls and writes retain their existing billing attribution and are outside this query-attempt cap. Workers must use the claim-bound lookup for document reads: arbitrary queries made outside these Function operations cannot be associated with an item and are outside its budget. An exhausted reservation returns HTTP 409 before contacting Turbopuffer. Completion keeps its existing revision, source and receipt fences; a budget error does not acknowledge work or discard a prepared receipt. Real-namespace measurement and certified-cache activation remain held. Before activation, an operator must enforce one canonical provider/writer identity across every registered dispatcher, prohibit endpoint aliases and bypass credentials, and verify the shared namespace ledger under its original PostgreSQL session guards against the independent object-store witness. Registration alone does not establish this writer boundary. Restoring or replacing authority requires fencing all old dispatchers and bypass writers, proving every accepted provider request terminal, and rotating the writer binding and witness incarnation while retaining old history. Production drain verification, initial provisioning and gateway configuration remain unavailable; multiple gateway processes alone do not establish safe cache coherence. Only after these prerequisites and separate activation authorization, verify document-cache configuration and Aerospike readiness. Warm certified canonical rows through claim hydration, a parent lookup, or confirmed unconditional writes under the retained shared fences. Ordinary cache scans and public document reads can use projected/eventual cache rows and do not establish this prerequisite. Use the lookup API in the worker and pin gateway and worker revisions. Record provider query/billing deltas for namespace metadata, the write trigger, claim, sibling lookup, fan-out patches and completion, including misses and retries. A synthetic provider-call regression is not a measurement of the BCC namespace. Keep all Function pauses until the specific cheap-read acceptance and an explicit runtime handoff authorize a bounded measurement. ### Shared Function-cache startup Pro gateways accept `LAYER_FUNCTION_READ_CACHE_CONFIG`, a path to a reviewed JSON configuration. The Helm value `gateway.functionReadCache.configMapName` mounts an existing reviewed ConfigMap with key `cache.json` read-only and sets that path; its default is empty. The chart does not create authority or review inputs. The obsolete `LAYER_FUNCTION_READ_CACHE` flag does not enable caching. This composition is independent of field-stats capture; configure one writer authority, not both. Every configured Turbopuffer alias must use the exact same endpoint and static credential generation, and all aliases receive the same fenced HTTP client. Unreviewed aliases refuse startup. Required fields are `store_ref`, `writer_domain`, `writer_boundary_evidence`, `terminal_drain_evidence`, `witness_prefix`, `witness_incarnation`, `witness_writer_binding`, `max_namespaces`, `max_generations`, `versions_per_namespace`, and `key_bytes`. Quotas have no defaults. Startup validates the existing independent S3 witness and PG ledger policy; it never creates or repairs authority. Aerospike, S3 and the transform control pool must be configured. Witness binding must match the actual endpoint/key-bound client. Missing configuration leaves reads strongly uncached. Lost authority, pending writes, poison, or failed guard validation cannot certify cache rows. Evidence references identify operator-reviewed records; strings alone do not enforce credentials or prove a terminal drain. Before adoption, the runtime coordinator must establish the canonical all-writer boundary and provision the existing PG policy and independent witness using a proved drain. No production reset, credential change, or activation follows from this source configuration. The measurement owner runs the bounded warm-document acceptance only after that handoff. ### Certified-cache recovery boundary Certified reads remain disabled without an enforced canonical writer boundary and independent witness configuration. The object-store witness uses conditional versions independent of PostgreSQL restore. Missing authority, mismatched versions, pending writes or poison cannot be repaired by replaying a database snapshot: reads fall back to the strong provider path and shared-mode mutations are rejected. A recovery controller must fence every old dispatcher and bypass writer, prove all accepted provider requests terminal, then rotate to a fresh writer binding and witness incarnation while retaining old history. Cancellation, key revocation and elapsed time alone are insufficient. The source adapter supplies checkpoint and frozen-root rotation primitives; production drain verification and initial provisioning are not available yet; gateway configuration only validates existing authority. No automatic seed/reset/reseed or runtime activation is provided. Analytics caches are replaceable projections and cannot supply this proof. The source-only certified-capture primitive can hold a bounded set of namespace fences around strong, separately metered reads. It groups physical advisory-lock collisions without merging logical namespace certificates. Any pending write, poison, witness mismatch or session loss invalidates the whole capture. This is not an immutable analytics snapshot API; consumers must hold all Function locks first and publish nothing after a failed capture. Service validation and production recovery/boundary configuration remain prerequisites. A source-only opaque capture receipt can bind completed strong-scan coverage to its canonical provider and namespaces, independent witness incarnation and readable generation. Comparison requires the original validated capture guards; missing proof or mismatched coverage cannot authorize skipping a scan. Receipts belong beside trusted, complete derived results and cannot be supplied as client proof. This API does not activate production capture or replace the required writer/drain enforcement. ### Administrative authority preparation The `layer-function-cache-authority` tool prepares a create-only, inactive witness root and an explicit empty PG policy. Preparation does not establish terminal provider drain or enable cached reads. Activation requires a real verifier bound to the persisted request; an existing root or correctness state is never reset. Turbopuffer read-only metadata is not a transactional write barrier, so it cannot resolve unknown old writes. The platform execution owner must obtain that evidence and enforce the all-writer boundary before adopting the optional configuration. The tool's `request` command retrieves the original inactive bootstrap manifest for a provider capability inquiry. The reviewed public Turbopuffer API does not establish a terminal legacy-write drain operation. Keep existing namespaces strongly uncached without that proof. An independent namespace branch can isolate later source commits, but migration still requires an enforced destination writer boundary, an approved data cut and spend; it is not an automatic bootstrap. ### Abandoned scan reservations The financial scan ledger has a separate operator-only recovery tool: `layer-scan-budget-recovery settle-dead PRIVATE_PLAN.json`. It requires retained attempt-to-owner linkage, operator-reviewed terminal runtime evidence, and a fresh Kubernetes inventory proving that the old pod UID is absent. An API object being absent alone is not proof of process death. Recovery charges every unknown page at its full reservation, without inventing observed billing or refunding spend. The audit is retained and a late higher provider receipt reopens debt. Completion starts a fresh 24-hour retention window; it does not certify scan completeness or publish field stats. Existing legacy attempts lack durable owner bindings, so startup cannot automatically recover them. New scan attempts bind to an immutable original PostgreSQL-session incarnation. On session loss, canonical ledger transactions revoke that incarnation before conservative settlement; live sessions remain protected. A revoked client cannot reserve or complete again, while late billing can still raise debt. This fences financial admission without proving process death or provider drain. See `docs/runbooks/scan-budget-recovery.md` for the private plan and direct-session requirements. An ownerless legacy scan can also be financially fenced with the administrative `layer-scan-budget-recovery fence-legacy` tool. This closes that exact attempt to new page admission without claiming that its old process died. Existing reservations remain fully charged, missing billing remains explicitly estimated, and late excess remains debt. It does not authorize cache activation or certify a source snapshot. See the scan-budget recovery runbook for the exact-match procedure. ### Bulk backfill `spec.backfill: true` runs the Function once over rows that already exist, reading inputs in paged scans of 9,999 rows and making no per-document provider read at claim, completion or write. It requires `triggers: [discovery]`, writes attributes only, and reports `backfill` progress and billed bytes in Function status. The `Function` CRD and the layer-operator must both be from layer-pro `3dcdeee6` or later: an older CRD prunes the field and an older operator drops it when registering the Function, which leaves the per-document path running. Declare the Function paused, confirm that `spec.backfill` reads back `true` from both Kubernetes and `GET /v2/udfs/{id}`, then unpause it. See [Backfill a Function over existing rows](/docs/pro/kubernetes/backfill-a-function). ### Bounded backfill runs For an expensive namespace, set `spec.discoveryOnce: true` and include both `discovery` and `write` in `spec.triggers`. The gateway traverses existing work once per Function definition using durable page cursors. Completed checkpoints stay complete when Function completion markers change. Subsequent gateway writes maintain the queue without another sweep. A definition or source-store change starts a new traversal. Unresolved gateway write intents or dirty revisions also reopen discovery, so failed write-trigger queue updates can recover. Writes outside the gateway are not covered; use periodic discovery for those workloads. This setting does not create a provider snapshot. Use `spec.attemptRun: story-probe-1` and `spec.attemptLimit: 40` to bound a measurement to forty representative claims. The gateway reserves attempts in Postgres before returning claims. Failures, lost responses and expired leases consume attempts; success does not refund them. Parent-group expansion belongs to the representative attempt. Concurrent workers share the ceiling. Zero blocks new claims. Already processing work can finish, so pause and drain it before starting a measurement. Reusing the run name retains its count even after a version change; use a new name for a new measurement. Raising the cap extends the same run. Removing the cap explicitly restores unlimited acquisition. `targetNamespaces` restricts already queued work even when `indexSelector` is also present. Narrow it before a probe; excluded queue entries remain pending. These controls bound work acquisition, not provider retries within one attempt; combine them with `queriesPerItem` and spend admission. Gateway API spellings are `discovery_once`, `attempt_run`, and `attempt_limit`. These are Function fields; no Pipeline field or application ingest change is needed for this backfill. ### Layer-only Function reads Set gateway `LAYER_FUNCTION_LAYER_ONLY_NAMESPACES` to a comma-separated list of physical namespaces whose Function work must dispatch no provider reads. For example, `LAYER_FUNCTION_LAYER_ONLY_NAMESPACES=pov-bcc-story-pages`. The gateway forces a zero provider-query budget for every Function input, claim-bound parent/group lookup, completion verification and write-trigger read in those namespaces, even when a Function declares a higher `queriesPerItem`. Metadata reads are covered too. Cache misses or unavailable certificates fail closed; they never silently hydrate from turbopuffer. Denied reads increment the Function's `rejected_queries`; `provider_queries` remains zero. Discovery in this mode seeds existing work from the complete Layer canonical copy once per Function definition and materialization incarnation, then consumes durable gateway write intents and dirty input revisions. It scans no provider namespace and reports `pages_scanned: 0`. Its local cursor survives gateway replacement. New Functions can discover previously hydrated rows without a source rewrite. Mixed-target Functions use local discovery when a protected namespace participates; hydrate all participating targets for complete backfills. Cold namespaces require a separate admitted bulk reconciliation. Configure `canonical_storage: "postgres"` in the gateway's existing `LAYER_FUNCTION_READ_CACHE_CONFIG` JSON. The Pro image includes `layer-function-bulk-hydrate AUTHORITY.json PLAN.json`: it uses the same original PG ledger, static writer identity and independent witness, scans strong ordered pages of 10,000 rows, and stores complete Function attributes plus durable `_hevlayer_parent_id` membership. It does not scan from an ordinary Function request. Vectors reserved by the wire are not part of Function canonical inputs. The hydration plan contains `namespace`, `max_pages` (1–1000 pages per invocation) and `admission`: `run_id`, `namespace_bytes` (reviewed billed bytes per read ceiling), `max_queries`, `max_queried_bytes`, `max_rows` and `max_storage_bytes` (JSON payload bytes; provision PG disk/index overhead separately). Every physical attempt, including the initial metadata request and failures, reserves its ceiling before dispatch. Unknown or over-ceiling successful row billing blocks that run. Row receipt totals and conservative reservations are distinct; rejected publication and failed/uncertain attempts still consume reservations. Retry the identical plan to resume the durable cursor; changing budgets requires a fresh run and a new full traversal. A completed identical run performs no provider reads. Pause source writers and Functions through initial hydration. A namespace revision/incarnation change between pages refuses resumption; it cannot certify an inconsistent traversal. Rows, membership and the page cursor commit together; only completion grants whole-namespace membership. Typed writes then maintain rows and membership under the shared fence. Repeating unchanged schema declarations preserves the copy. Unknown writes, changed schema/native generated attributes, failed publication or lost authority revoke eligibility; strict mode continues to refuse provider hydration. Retain the original ledger and witness through gateway replacement and follow the [cache authority runbook](https://github.com/hev/layer-pro/blob/main/docs/runbooks/function-cache-authority.md) for initial authority, sizing, hydration and bounded acceptance. Write-trigger preparation records durable uncertainty before dispatch and does not fetch a provider pre-image. Successful complete writes populate certified cache rows; row-only Functions then consume that written state. Patches need an existing complete certified row. A post-write cache/read failure can surface after the provider accepted the write; retain its intent and resolve it through Layer's guarded recovery. Do not infer that an HTTP failure rolled the write back. This policy is a financial admission boundary. It does not manufacture cache authority: activation still requires the independently verified writer boundary and original ledger/witness protocol. Bulk hydration removes per-document seeding cost and global group invalidation for the durable copy. Count its admitted provider costs separately, then require successful bounded Function completion with zero billed reads per attempted document. Refused misses alone are not successful backfill acceptance. # No Guarantees Source: https://hevlayer.com/docs/pro/guarantees import Edition from "../../components/docs/Edition.astro"; import Callout from "../../components/docs/Callout.astro"; import StoreMatrixLink from "../../components/docs/StoreMatrixLink.astro"; Layer can't offer guarantees. We try our best to provide secure, hands-off infrastructure that you are ultimately responsible for. While we can't offer guarantees, we make a set of promises in how we design, secure, and distribute our software that we believe make it easy to use and will stand the test of time. This page covers the specific status of those promises. ## Commitments - Your history is backed up to S3. Search history and namespace snapshots are written to the S3 bucket you specify. The format of this data may change. - Hot data served from cache. Customer document and chunk data is served from Layer's local document cache for price/performance. We try not to stray from this pattern, though some use cases may justify a smaller in-memory document cache. - This documentation is accurate and up to date. When it isn't, that's a bug in the software — report it. - Graceful degradation. We add graceful degradation support whenever possible — the gateway degrades rather than failing hard. The per-scenario behavior and recovery signals live in the [failure-mode runbook](/docs/pro/failure-modes). - Client compatibility. We will (almost) always stay client-compatible with the search systems we front. Where we diverge, it's a feature making an explicit tradeoff we believe is an improvement. - One consistency cut on backends with a stable watermark. When the gateway expands a supported query into multiple legs ([hybrid text fusion](/docs/pro/api/query#hybrid-text-fusion), [scatter/gather](/docs/pro/concepts#scattergather)), every leg is filtered at the same stable watermark, injected from a single read. Legs never see different cuts. Layer was developed by a [single person](https://hevmind.com/about) orchestrating agentic coding tools, leveraging open source and building automation. Not a single line of code was hand-written. That said, it was made with ❤️ by a human as much as it is built by AI. # Introduction Source: https://hevlayer.com/docs/pro import Edition from "../../components/docs/Edition.astro"; import Diagram from "../../components/docs/Diagram.astro"; import { layerMapDiagram, layerMapDiagramNarrow, ceGatewayDiagram, ceGatewayDiagramNarrow, ceGatewayDiagramLabel } from "../../lib/diagrams"; import StoreNote from "../../components/docs/StoreNote.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; Layer provides a **retrieval gateway** with one Layer API across pluggable stores, and a **function runtime** for per-row compute on supported backends. The Layer API includes a Turbopuffer-compatible subset as well as gateway query, pipeline and management operations. Use the generated Layer clients or HTTP; select the backend to see the supported shapes and feature gaps. {layerMapDiagram} You run two server components in your own cluster: a Rust **gateway** and a Kubernetes **operator**. The **retrieval gateway** routes requests to the configured vector store and serves Layer operations around caching, history, [pipelines](/docs/pro/api/pipelines), [state of your index](/docs/pro/api/namespace-metadata), [clickstream](/docs/pro/api/search-history), and [cost](/docs/pro/dashboard). The configured backend determines which origin operations are available; gateway-managed services have their own storage and worker dependencies. The **function runtime** is one primitive for every per-row job over an index. Embedding, classification, tagging, and attribute migration are all the same thing: a stateless [UDF](/docs/pro/functions) declared as a Kubernetes-native `Function`. The gateway discovers the work, leases it to worker pools, retries, and writes results back, with KEDA scaling each pool to zero between bursts. You write and declare the function, and Layer runs the worker fleet for you. You call the gateway four ways: the [Python client](/docs/pro/api/introduction#install), the [Go client](/docs/pro/api/introduction#install), the [TypeScript client](/docs/pro/api/introduction#install), or the REST API directly — the clients are generated from the same OpenAPI spec, and every endpoint page shows them side by side. Layer also ships an optional GUI [dashboard](/docs/pro/dashboard). The dashboard manages cluster configuration through CRDs; all other state is persisted in object storage (S3). No durable state lives in a Layer process, so the compute tier is stateless and fully elastic. Because indexing is bursty, especially GPU-bound work, our [Terraform](/docs/pro/install#terraform) installs [Karpenter](https://karpenter.sh) as a cluster autoscaler to provision and scale the nodes Layer's compute runs on. The remaining backing services are the document cache, the indexing-state store, and the metrics store. Every component Layer runs alongside is open source: - **[Karpenter](https://karpenter.sh)** — cluster autoscaler that provisions and scales nodes for Layer's bursty, GPU-bound compute (Apache-2.0). - **[Aerospike](https://aerospike.com)** — ephemeral document cache (AGPL-3.0). - **[PostgreSQL](https://www.postgresql.org)** — indexing-state store for the pipeline and embed queue (PostgreSQL License). - **[VictoriaMetrics](https://victoriametrics.com)** — metrics store (Apache-2.0). To get started, see the [install guide](/docs/pro/install). For more technical detail, see [Concepts](/docs/pro/concepts), [Guarantees](/docs/pro/guarantees), and [Tradeoffs](/docs/pro/tradeoffs). # Indexes Source: https://hevlayer.com/docs/pro/indexes import Edition from "../../components/docs/Edition.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; An `Index` represents one namespace exposed through the gateway. It declares which upstream namespace to use, snapshot policy, cache posture, and consistency mode. The backend connection itself lives in a [VectorStore](/docs/pro/api/vectorstores). ```yaml apiVersion: hevlayer.com/v1 kind: Index metadata: name: products namespace: layer spec: backend: storeRef: turbopuffer-default namespace: products distanceMetric: cosine_distance embedding: model: voyage-3-large@v1 outputDim: 1024 normalization: l2 metadata: title: Products description: Shop catalog, one row per product. labels: app: shop tags: - catalog fieldStats: fields: auto exclude: [blocks] valueCap: 10000 interval: 5m snapshot: interval: 5m retention: never facetFields: - category - brand search: fullText: true scan: threads: 8 cache: ttl: 24h capGiB: 64 mode: standard consistency: strong ``` ## Backend | Field | Purpose | | --- | --- | | `backend.storeRef` | Optional `VectorStore` name in the same namespace. The gateway routes requests for this upstream namespace to that store. Defaults to the namespace's default store. | | `backend.namespace` | Optional upstream namespace override. Defaults to the Index name. | | `backend.distanceMetric` | Vector metric, default `cosine_distance`. | For `kind: search` stores, the operator accepts only the metrics the backend serves directly today: L2-style metrics for single-vector namespaces and `cosine_distance` for multivector namespaces. Unsupported values put the Index in `Ready=False` with reason `MetricMismatch`. ## Field stats `spec.fieldStats` controls the [current stats returned by metadata](/docs/pro/api/namespace-metadata#field-stats). Omitting it defaults to `fields: auto`, covering filterable non-vector attributes. | Field | Default | Purpose | | --- | --- | --- | | `fieldStats.fields` | `auto` | All filterable attributes, an explicit list, or `[]` to disable stats. | | `fieldStats.exclude` | `[]` | Fields to omit from either selection mode. | | `fieldStats.valueCap` | `10000` | Positive exact-value cap. Above it `distinct` is a HyperLogLog estimate within about 2% and `top` is dropped. | | `fieldStats.interval` | `5m` | Positive duration between scheduled reconciles. | Auto follows schema changes. Enabling a field reads its attribute on every row at the next reconcile; until then unavailable coverage is explicit in `untracked`. With reviewed capture installed (see [how the gateway keeps stats current](/docs/pro/api/namespace-metadata#how-the-gateway-keeps-them-current)), the reconcile projects the union of snapshot facets and selected stats fields in one single-flight scan with the larger interval as its floor. Only complete persisted artifacts and an opaque receipt validated under retained source guards permit scan reuse. A false validation requires a scan; an error never permits skipping. Without it, no field is reported `exact`. Stats alone do not create snapshot history. Metadata reads never start scans or register work. Enabling fields costs a scan: see [scan cost of enabling fields](/docs/pro/api/namespace-metadata#scan-cost-of-enabling-fields). An explicit list tracks exactly the filters an application offers and keeps the scan to those attributes: ```yaml spec: fieldStats: fields: [doc_type, job, company, file_year] interval: 10m ``` Existing snapshot facets retain their scan projection and interval, including when stats are disabled. Legacy unchanged refreshes still incur full scan and aggregation cost. Timestamps and row counts do not prove content identity or silent external-write detection. ## Metadata | Field | Purpose | | --- | --- | | `metadata.title` | Optional human-readable name. Clients show it instead of the namespace name. | | `metadata.description` | Optional one-line summary shown beside the title. | | `metadata.labels`, `metadata.annotations`, `metadata.tags` | Free-form operator metadata. | Both display fields live on `spec.metadata` with the other descriptive fields; an Index that sets neither behaves exactly as before. Blank values count as unset. The gateway reads them with the rest of the Index config and refreshes them on the same interval as the other Index settings, so a `kubectl apply` shows up without a restart. Clients read them from the gateway, never from the cluster: - `GET /v2/namespaces/{namespace}/metadata` returns `layer.title` and `layer.description`. - `GET /v2/namespaces` returns `title` and `description` on each row. Each key is omitted when unset, so a client uses `title ?? name`. A `backend.namespace` pattern such as `wt-*` applies its title to every matching namespace. The fields are display text only; they do not change routing, caching or auth. ## Embedding `spec.embedding` declares the embedding identity of this namespace's vectors. It is optional for a single-namespace query, where distances are only ever compared within the namespace. It is **required** to include a namespace in a [federated vector query](/docs/pro/api/federated-query#vector-merge-requires-a-matching-embedding-space): the gateway merges those by distance, which is only meaningful when every namespace in the set shares one embedding space. | Field | Purpose | | --- | --- | | `embedding.model` | Model identity and version, e.g. `voyage-3-large@v1`. Treated as an opaque token compared for equality across a namespace set. | | `embedding.outputDim` | Vector dimensionality. Part of the identity because a model truncated to a smaller dimension (Matryoshka) is not comparable to its full-width output. Cross-checked against the namespace schema. | | `embedding.normalization` | Vector normalization, e.g. `l2` or `none`. | Together with `backend.distanceMetric`, these form the embedding profile the gateway compares across a fan-out. Two namespaces are distance-comparable only when all four match; otherwise a fused vector query over them falls back to rank-interleave (or is rejected under `strict`). `spec.embedding` declares an already-computed embedding's identity for comparison purposes; it does not compute anything. To have the gateway compute vectors for you, declare `embed:` on a schema attribute instead — see below. ## Schema-attribute embedding A schema attribute can declare `embed:` so its source value is embedded on write. This is Turbopuffer's native-embeddings wire (`embed` on a schema attribute, private beta at [turbopuffer.com/docs/embedding](https://turbopuffer.com/docs/embedding)). With native serving on a Turbopuffer store, Layer validates and transparently forwards the wire to Turbopuffer's managed embedding service. Autoscaler serving uses that same service through the gateway, then writes only concrete derived vectors to the active store. ```jsonc // simple form — tpuf-compatible "schema": { "text": { "type": "string", "embed": "voyage/voyage-4-lite" } } // extended form "schema": { "text": { "type": "string", "embed": { "model": "Snowflake/snowflake-arctic-embed-m-v1.5", "revision": "refs/pr/5", "dims": 768, "attribute": "text_vector", "instructions": { "document": "Represent this passage for retrieval: ", "query": "Represent this query for retrieving passages: " }, "chunk": { "strategy": "recursive", "unit": "characters", "size": 1200, "overlap": 120 }, "serving": { "prefer": "autoscaler" } } } } ``` | Field | tpuf-compatible | Purpose | | --- | --- | --- | | `embed` (string) | ✅ | Simple form. A provider-namespaced model id such as `voyage/voyage-4-lite`. | | `embed.model` | ✅ | Extended form's provider-namespaced model id. | | `embed.dims` | ✅ | Output dimensionality via Matryoshka truncation. | | `embed.attribute` | ✅ | Override the derived vector attribute name. Omitting it derives `embed_`. | | `embed.serving.prefer` | Layer extension | `native`, `autoscaler`, or `local` (`lattice` is an alias for local Lattice serving). Defaults to `native`. Layer consumes this field before forwarding the native wire. | | `embed.revision` | Layer extension | Pin a Hugging Face model revision. It participates in profile identity and cache keys. | | `embed.instructions` | Layer extension | Optional `document` and `query` prefixes for asymmetric embedding models. | | `embed.modality` | Layer extension | `text` (default) or `image`. Image values are URLs or base64 strings for a CLIP-family checkpoint; query-time `Embed` still uses its text tower. | | `embed.chunk` | Layer extension | Split or fan out the source before embedding. See [Chunking](#chunking). | Layer extensions require `prefer: autoscaler`, with one exception: a CLIP-family `modality: image` profile may use `prefer: local` without the revision, instruction, or chunking extensions (see [local CLIP](/docs/pro/api/embed#images)). Native and local serving otherwise reject extensions with `422`. A missing production embedding provider returns `503 service_unavailable`. The first row write that establishes an embedded schema must include `distance_metric`. Omitting it returns `422`. The default derived vector column is **`embed_`** — for example, `embed_title` for source attribute `title`. The source attribute is stored alongside the vector. Model ids must be provider-namespaced. Native mode uses Turbopuffer's stock model menu. Autoscaler mode accepts any Hugging Face repo id the configured provider can serve, including finetuned and mixed-case checkpoints. Provider load or model-support failures are returned as upstream embedding errors. ### Chunking For scalar text, `embed.chunk` reuses the Pipeline chunk shape: `strategy` is `none`, `fixed`, `recursive`, `sentence`, or `markdown`; `unit` is `characters` or `tokens`; and `size`, `overlap`, and `tokenizer` control the window. A split document remains as its original row, while each embedded chunk is written as `{id}#{i}` with `_hevlayer_parent_id` and `_hevlayer_chunk_index`. Structured fields use a two-level section composition: ```jsonc "schema": { "text": { "type": "string", "embed": { "model": "BAAI/bge-m3", "chunk": { "strategy": "section", "sectionSource": "jsonFields", "fields": ["boxed_warning", "drug_interactions"], "sectionAttribute": "section", "split": { "strategy": "recursive", "unit": "characters", "size": 1200, "overlap": 120 } }, "serving": { "prefer": "autoscaler" } }} } ``` Each configured non-empty string field on the document row fans out into the embedded attribute first; optional `split` then windows that section. Rows use ids `{id}#{section}#{i}`, retain the document attributes, and add the configured section attribute plus the standard parent/index attributes. Chunked writes require string document ids and `upsert_rows`; columnar writes return `422`. ### Serving mode Per model, `embed.serving.prefer` chooses how vectors are computed. | `prefer` | Behavior | | --- | --- | | `native` (default) | On Turbopuffer, forward the compatible wire to its managed service. | | `autoscaler` | Resolve through the configured inference provider and send only concrete vectors to the active store. Use this for BYO checkpoints and Layer extensions. | | `local` | Resolve through the locally configured embedder — the `layer-embed` service at `LAYER_EMBED_URL`, or the gateway's own Lattice or CLIP artifact — and send only concrete vectors to the active store. Every model change requires a full re-index. | | `lattice` | Alias for `local` with the `erikkaum/lattice-retrieval` model. | Layer does not switch modes after a provider failure. For the CPU text menu, point `LAYER_EMBED_URL` at a `layer-embed` service; the gateway discovers its models, applies each model's query prefix itself, and pins every profile to the artifact it was declared with (see [CPU models](/docs/pro/api/embed#cpu-models)). For Lattice, generate a deployment artifact with the upstream slicer, mount `model.safetensors` beside its `tokenizer.json`, and set `LAYER_LATTICE_MODEL_PATH` to the model file. The requested dimensions must match the artifact. The recommended int4-row, 512-dimensional artifact quantizes model weights; Layer stores its normalized output vectors as f32. For a CLIP-family model, set `LAYER_LOCAL_CLIP_MODEL_PATH` to the checkpoint directory — provisioning and the image-input contract are covered in [local CLIP](/docs/pro/api/embed#images). Layer durably records gateway-served attribute profiles in S3, so later writes do not need to repeat `schema`. Query vectors use a short in-memory TTL cache. When changing an embedded source attribute, upsert the full row; patching that attribute returns `422` because Layer cannot safely recompute a vector from a partial row. ## Snapshot policy | Field | Default | Purpose | | --- | --- | --- | | `snapshot.facetFields` | `[]` | Fields the gateway materializes into durable facet snapshots. Empty disables the automatic writer. | | `snapshot.interval` | `5m` | Minimum spacing between automatic snapshot writes after upstream-stable advances. | | `snapshot.retention` | `never` | `never` keeps all snapshot bodies; a duration such as `30d` prunes older bodies while keeping the latest. | ## Search backend policy `spec.search` applies when the Index targets a `kind: search` VectorStore. The operator uses it to drive the backend's explicit index lifecycle. | Field | Default | Purpose | | --- | --- | --- | | `search.fullText` | `false` | Build the backend's BM25 index for the namespace's `text` column. Enable this for lexical, FTS, or hybrid-text namespaces. | ## Scan policy `scan.threads` sets the per-namespace default for origin scan fan-out: the maximum concurrent upstream requests one scan may issue during scatter/gather. It defaults to `32` for pinned and ready namespaces and `8` otherwise. The gateway clamps it to `512` for pinned and ready namespaces and `32` otherwise, and to the active shard count. Request-level `threads` overrides this default for one scan. ## Cache policy Aerospike remains an ephemeral cache; durable snapshot history stays in S3. Cache warming uses the same scan fan-out policy as other origin scans. ## Status The operator reports observed generation, metadata sync state, and conditions. `status.snapshot.lastRun` and `lastSuccess` are reserved for the gateway history bridge. # Install Source: https://hevlayer.com/docs/pro/install import Edition from "../../components/docs/Edition.astro"; import Callout from "../../components/docs/Callout.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; ## AWS and Kubernetes An AWS install has two stages. **Terraform** provisions the required AWS resources: IAM, S3, ECR, networking, cost-read roles, and, for the recommended path, a fresh EKS cluster. **Helm** installs the gateway, operator, and document cache into that cluster and wires them to the AWS resources Terraform produced. One command runs both stages: `layer install`. It is the AWS provisioning entrypoint. You can skip Terraform if you already have the AWS resources hev layer needs. At minimum, provide an S3 bucket and gateway IRSA role for snapshots and history. For the full feature set, also provide gateway cost-read IAM, image registry locations, and cluster-level components equivalent to the Terraform outputs. ## layer install Prerequisites: `aws` (with credentials configured), `terraform`, `helm`, and `kubectl` on your PATH, plus your upstream Turbopuffer API key. Clone the `hev/layer` repository — it carries the Terraform configuration and the Helm chart the command drives — and run `layer install` from the checkout: ```sh git clone https://github.com/hev/layer cd layer go build -o layer ./apps/layer-cli export TURBOPUFFER_API_KEY="tpuf_..." ./layer install ``` AWS credentials come from the ambient environment by default; pass `--aws-profile` (or set `AWS_PROFILE`) to select a named credentials profile. Missing required inputs prompt interactively on a TTY. The command prints the resolved plan — profile, region, cluster name, system node shape — and asks for confirmation before provisioning anything. Then it runs both stages in one shot: Terraform provisions the opinionated AWS footprint — VPC, EKS, IAM/IRSA, S3, ECR — and Helm installs the release wired to those outputs, waiting for the gateway rollout before printing next steps. | Flag | Environment | Default | | --- | --- | --- | | `--profile` | `LAYER_INSTALL_PROFILE` | `demo` | | `--aws-profile` | `AWS_PROFILE` | ambient credentials | | `--region` | `AWS_REGION` | `us-east-1` | | `--cluster-name` | `CLUSTER_NAME` | `layer` | | `--namespace` | `NAMESPACE` | `layer` | | `--helm-release` | `HELM_RELEASE` | `layer` | | `--node-type` | `SYSTEM_NODE_INSTANCE_TYPE` | profile default (`m8g.large`) | | `--turbopuffer-api-key` | `TURBOPUFFER_API_KEY` | none (required) | | `--version` | `LAYER_VERSION` | `latest` | | `--license-token` | `LICENSE_TOKEN` | none | | `--dashboard-user` | `DASHBOARD_USER` | `admin` | | `--dashboard-password` | `DASHBOARD_PASSWORD` | generated and printed | | `--source` | `LAYER_SRC` | walk up from the working directory | | `--skip-terraform` | none | off | | `--yes` | none | off | | `--dry-run` | none | off | Non-interactive installs (CI, scripts) pass every value as a flag or environment variable and must add `--yes`; without a terminal the command refuses to provision unconfirmed. `--dry-run` prints the resolved plan and the exact commands it would run, then exits. `--skip-terraform` reuses existing Terraform outputs and reruns only the cluster components and the Helm release; on that path, the saved cluster name and namespace are authoritative. ### Profiles `--profile` selects the install footprint. `demo` is the default. **`demo`** is the lean evaluation footprint. The always-on system node is one arm64 `m8g.large` with an 80 GiB gp3 data volume. The document cache runs on that system pool, backed by a gp3 PVC (80 Gi) instead of NVMe instance store; no dedicated cache node pool is created. Every Pro feature — gateway, operator, dashboard, extractions, snapshots, keys — runs the same as on the larger footprint. Always-on cost is roughly $40–70/mo on current us-east-1 on-demand pricing. **`indexing`** is the design-partner footprint for installs with heavy indexing duty cycles. It adds a dedicated document-cache Karpenter pool on arm64 `i4g.large` NVMe instance-store nodes, so the cache gets local flash instead of a gp3 volume. Pass `--node-type` to override the system node instance type on either profile. The cache is ephemeral on both profiles (`resetOnStart: true`); losing the volume or the node is a non-event. ### install status `layer install status` reports the installed release and workload health: Helm release status, pod readiness, and document-cache node age, type, and utilization — so a forgotten instance-store node shows up instead of billing quietly. ```sh ./layer install status ``` It takes `--aws-profile`, `--region`, `--cluster-name`, `--namespace`, and `--helm-release`, with the same environment variables and defaults as `layer install`. ### install uninstall `layer install uninstall` removes the Helm release and cluster components and destroys the Terraform footprint. It confirms before destroying (pass `--yes` to skip) and is idempotent. With `--skip-terraform` it removes only the in-cluster install and retains the AWS footprint. ```sh ./layer install uninstall ``` The rest of this page is the reference for what the install sets up and the bring-your-own-cluster alternative. ## Install shape An install is one Helm release per environment with one S3 bucket for snapshot and history data. The chart renders a default [`VectorStore`](/docs/pro/api/vectorstores) from the credential you provide; an install can define additional `VectorStore` resources, each with its own upstream credential and inbound auth policy, and route namespaces between them with `Index.spec.backend.storeRef`. Scoped gateway-only bearer keys are available through the `keys` inbound auth mode described below. ## Terraform The Terraform configuration in `infra/terraform/` provisions the AWS resources that the gateway and operator need. It is opinionated about the resources hev layer needs to behave correctly and conservative about resources around it. Route53 hosted zones and ACM certificates are opt-in; most installs bring existing DNS and TLS. ### What it sets up | Resource | Purpose | | --- | --- | | S3 bucket | Durable storage for namespace snapshots, search history, and clickstream events. | | IAM roles + IRSA policies | Gateway S3 and Cost Explorer access, plus worker/operator AWS access. | | ECR repositories | Registry space for customer-built function and pipeline worker images. Layer-owned gateway, operator, and dashboard images are pulled from Docker Hub. | | EKS + VPC + node pools | Recommended fresh-cluster runtime. | | Route53 + ACM | Optional DNS zones, records, and TLS certificates when `manage_public_dns=true`. | ### Cluster: recommended Installs should use a fresh EKS cluster unless there is a specific reason to bind hev layer to an existing one. The cluster path provisions: - a VPC with the subnets and endpoints hev layer expects - an EKS control plane and one always-on `system` node group, defaulting to an arm64 `m8g.large` with an 80 GiB gp3 data volume - public worker subnets by default, with no NAT Gateway in the fresh cluster path - Karpenter for scale-from-zero `worker-cpu` and `worker-gpu` indexing capacity - the AWS EBS CSI add-on and a `gp3` StorageClass for the demo-profile document-cache PVC - the AWS Load Balancer Controller for ingress - EFS for shared persistent volumes If you already operate an EKS cluster, you can disable the cluster modules and point hev layer at the existing cluster. You are still responsible for the functional prerequisites: an S3 bucket for snapshots/history, gateway IRSA that can read/write that bucket and call AWS Cost Explorer for tag-scoped cost reads, Docker Hub pull access for Layer-owned images, registry access for worker images, Karpenter or equivalent node autoscaling for workers, and the AWS Load Balancer Controller if you use public ingress. Deploy hev layer to a fresh cluster. The baseline is one always-on ARM node for the gateway, dashboard, control loops, and document cache. CPU and GPU indexing workers scale from zero, so embedding and extraction cost follows indexing duty cycle instead of becoming a standing line item. The fresh path also avoids a NAT Gateway: workers run in public subnets, so the large data volumes that flow during indexing skip NAT's per-GB processing charge. Existing clusters typically route worker egress through NAT already, which turns every pipeline run into a metered transfer. ### Cost notes The Terraform is designed to deploy a cost-efficient AWS footprint with autoscaling for on-demand indexing work. At rest, the fixed costs are EKS, one small ARM `system` node, the shared ALB, and small storage lines — roughly $40–70/mo on current us-east-1 on-demand pricing for the demo profile, before variable traffic, object storage, and upstream vector-store usage. The indexing profile adds the dedicated document-cache node while it runs. Indexing bursts scale CPU or GPU worker nodes up through Karpenter and back down when queues drain. If you switch workers to private subnets, enabling NAT adds a standing hourly and egress cost. Heavier search use cases may need more read-side infrastructure: additional gateway replicas, larger always-on nodes, or a dedicated document-cache pool for steady cache pressure. Contact hev layer for help sizing read-heavy deployments. ### Outputs Terraform emits the values the Helm chart needs to install: the S3 bucket name, gateway IRSA role ARN, and cluster metadata. Runtime images are pulled from Docker Hub for the Layer-owned gateway, operator, and dashboard containers. Pass these into the Helm values file described below. The Terraform provider tags managed resources with `Project=hevlayer`; activate that tag as a cost-allocation tag in AWS Billing so the gateway can scope Cost Explorer reads to the Layer stack. ## Helm The Helm chart at `infra/helm/layer/` installs the gateway, operator, and document cache into a cluster that already has the AWS resources from [Terraform](#terraform) or equivalent resources you manage. `layer install` applies the chart with the profile overlay (`values-demo.yaml` or `values-indexing.yaml`) plus a generated values file; a bring-your-own-cluster install runs Helm directly with the same values. ### Local gateway development `docker compose` starts the gateway's local dependencies, not a replacement control plane. The gateway still resolves [`VectorStore`](/docs/pro/api/vectorstores) and [`Index`](/docs/pro/indexes) resources from Kubernetes at startup, so a compose-based gateway run needs a current kube context with Layer CRDs installed and the matching `VectorStore`, `Index`, and Secret objects applied. For a local search backend running on the compose network, use a local-only `VectorStore.endpoint.url` that the gateway container can reach; cluster Service DNS names only work inside Kubernetes. ### Required values Most of the chart is opinionated defaults. In a typical install the credential you bring from outside the cluster becomes the default `VectorStore` credential. | Value | Required | Notes | | --- | --- | --- | | `vectorStore.credential.apiKey` | yes | Upstream store credential. With the default `deriveFromStore` auth mode, clients also send this as the gateway bearer key. | | `vectorStore.endpoint.url` | yes | Upstream store API base URL. Defaults to turbopuffer's AWS us-east-1 endpoint. | | `vectorStore.endpoint.region` | yes | Region label for the rendered `VectorStore`. | | `vectorStore.inboundAuth.mode` | no | `deriveFromStore`, `keys`, or `open`. Defaults to `deriveFromStore`. | | `vectorStore.inboundAuth.keys` | for `keys` mode | Gateway-only bearer keys with `read`, `write`, and `admin` scopes. | | `search.enabled` | no | Installs the Layer-operated `search` backend in-cluster and, when `vectorStore.kind=search` and `vectorStore.endpoint.url` is blank, points the default `VectorStore` at that Service. | | `search.image` | for `search.enabled` | Container image for the `search` backend, distributed separately from the Layer repo images. The chart rejects `ghcr.io/hev/*`. | | `gateway.image` | yes | Gateway image URL. Customer installs pull the pro image from Docker Hub, `hevlayer/layer-gateway-pro:`. | | `operator.image` | for `operator.enabled` | Operator image URL. Customer installs pull `hevlayer/layer-operator:` from Docker Hub. | | `dashboard.image` | for `dashboard.enabled` | Dashboard image URL. Customer installs pull `hevlayer/layer-dashboard:` from Docker Hub. | | `license.token` | trial/commercial installs | Signed hev layer license key from the trial or commercial license email. The chart writes it to a Kubernetes Secret and surfaces it to the gateway. | | `license.existingSecret` / `license.secretKey` | optional | Existing Secret name and key containing the license key, for clusters where secret material is managed outside Helm. | | `s3.bucket` | yes | S3 bucket Terraform created for snapshots and history. | | `serviceAccount.roleArn` | yes | IRSA role ARN that grants the gateway access to the S3 bucket and Cost Explorer. | | `dashboard.serviceAccount.name` | yes | Dashboard ServiceAccount name from Terraform output `layer_dashboard_service_account_name`. | | `dashboard.serviceAccount.roleArn` | yes | Dashboard IRSA role ARN from Terraform output `layer_dashboard_role_arn`; this renders the EKS role annotation on the dashboard ServiceAccount at first boot. | | `documentCache.storage.pvc.enabled` | no | Back the document cache with a PVC (`size`, `storageClass`) instead of node-local instance store. The demo profile sets an 80 Gi `gp3` PVC. | | `documentCache.nodeRole` | no | `system` schedules the cache on the always-on system pool (demo profile); `document-cache` targets a dedicated pool (indexing profile). | | `documentCache.karpenter.enabled` | no | Provision a dedicated document-cache Karpenter pool. The indexing profile enables it with `instanceType: i4g.large`. | | `gateway.indexNamespace` | no | Namespace containing `Index` CRs. Blank follows `operator.discovery.indexNamespace`, then the Helm release namespace. | | `gateway.indexConfig.enabled` | no | Enables gateway reads of `Index` CR routing and policy such as `spec.backend.storeRef`, `spec.snapshot.facetFields`, and `spec.scan.threads`. | | `gateway.indexGc.enabled` | no | Enables namespace hard-delete cleanup of operator-discovered `Index` CRs. | | `gateway.consistency.stablePollIntervalMs` | no | Slow polling cadence for namespaces last observed stable. Defaults to `60000`; cold and updating namespaces keep the fast gateway default. | | `gateway.cost.tagKey` / `gateway.cost.tagValue` | no | Cost-allocation tag filter for AWS Cost Explorer. Defaults to `Project=hevlayer`. | | `gateway.embed.enabled` / `gateway.embed.image` | no | Run the bundled CPU embedding service as a gateway sidecar on the pod's loopback and set `LAYER_EMBED_URL`. Pull `hevlayer/layer-embed:`. The image bakes both [CPU text models](/docs/pro/api/embed#cpu-models); no mount, download or credential is needed. `gateway.embed.resources` sizes it (defaults request 1 CPU and 1 Gi). | | `gateway.localModels.enabled` | no | Mount an offline model bundle read-only into the embedding sidecar as the extension over the baked menu. Requires `gateway.embed.enabled`. `artifact.s3Uri` names the bundle prefix, `artifact.manifestSha256` pins its `manifest.json`; see [bringing your own weights](/docs/pro/api/embed#bringing-your-own-weights). | | `ingress.host` | optional | Set when you want a public ingress; use your DNS/TLS or enable Terraform-managed Route53/ACM. | Most other Helm inputs are wiring between resources the install process already produced. The store API key is the credential hev layer cannot generate for you. The chart stores it in a Kubernetes Secret, points the default `VectorStore` at that Secret, and the gateway derives its default inbound bearer from the same key. After you [start a trial](/#start-trial), hev layer emails a signed license key. Set it as `license.token` or store it in an existing Secret referenced by `license.existingSecret` and `license.secretKey`. The gateway exposes local license health at [`/v2/license`](/docs/pro/licensing#license-states), with `licensed`, `grace`, and `floor` states. ### Image Coordinates The customer pull path for the Layer-owned runtime images is Docker Hub: ```yaml gateway: image: hevlayer/layer-gateway-pro: embed: enabled: true image: hevlayer/layer-embed: operator: enabled: true image: hevlayer/layer-operator: dashboard: enabled: true image: hevlayer/layer-dashboard: ``` These pro images are public to pull, but licensed surfaces are only useful with a valid hevlayer license key installed through `license.token` or `license.existingSecret`. Without a valid key, the gateway, operator, and dashboard project the install to the license floor described in [Licensing](/docs/pro/licensing#license-states). The in-cluster `search` backend image is distributed separately from the Layer repo images. Demo worker images are not part of the first Docker Hub release lane; build customer Function and Pipeline images into the registry your cluster already pulls from. ### Layer-Operated Search Set `search.enabled=true` to run the `search` backend beside the gateway. The chart uses the existing service account/IRSA, stores backend data under `s3:///search` by default, mounts a node-local object cache, adds Prometheus scrape annotations for the bundled vmsingle, and restricts backend ingress to Layer's own components (gateway, operator, and metrics scraper). The in-chart backend takes no credential of its own and is reachable only through that internal connection. For a default `kind: search` VectorStore, set `vectorStore.inboundAuth.mode` to `keys` or `open`; `deriveFromStore` is only valid when the upstream store has its own credential. ### Gateway auth modes The default `deriveFromStore` mode is the single-tenant BYOC path: ```yaml vectorStore: credential: apiKey: tpuf_... inboundAuth: mode: deriveFromStore ``` For an install that needs a gateway-only bearer, use `keys` mode. The chart renders `apiKey` values into the release Secret and references them from the `VectorStore`; omit `apiKey` when pointing at a pre-created Secret. ```yaml vectorStore: credential: apiKey: tpuf_... inboundAuth: mode: keys workerSecretKey: layer-inbound-worker-api-key keys: - name: worker scopes: [read, write, admin] apiKey: layer_worker_... secretRef: key: layer-inbound-worker-api-key ``` In `keys` mode, operator workers, KEDA, and the dashboard use `workerSecretName` / `workerSecretKey` as their gateway bearer. Blank `workerSecretName` uses the release Secret; blank `workerSecretKey` uses `layer-inbound-worker-api-key`. ### Run the install On a bring-your-own cluster, run Helm directly with a profile overlay and your values: ```sh helm upgrade --install layer ./infra/helm/layer \ --namespace layer --create-namespace \ -f ./infra/helm/layer/values-demo.yaml \ -f values.customer.yaml ``` The chart is not published to a public Helm repository — install from the source path or from the chart artifact provided during onboarding. ### What gets installed - `layer-gateway` — Rust gateway for turbopuffer-compatible routes, fetch, scans, snapshots, warm jobs, and pipeline state. - `layer-operator` — reconciler for VectorStore, Index, InfraRules, Pipeline, and Function CRDs documented in [Kubernetes](/docs/pro/kubernetes/operator). - `layer-document-cache` — Aerospike-backed document cache, scale-to-zero by default. On the demo profile it runs on the system pool over a gp3 PVC; on the indexing profile it gets a dedicated instance-store node pool. - Optional Karpenter `NodePool` / `EC2NodeClass` resources for `worker-cpu` and `worker-gpu` indexing capacity when `workerKarpenter.enabled=true`, and the dedicated `document-cache` pool when the indexing profile (or `documentCache.karpenter.enabled=true`) is in effect. - Supporting resources: service accounts, IRSA bindings, ingress, and CRDs. ### Built-in compute pools The operator supplies built-in compute pools when no cluster-scoped `InfraRules/default` object exists. Helm does not create that object. If a workload omits `scaling.pool`, the operator maps `worker.computeClass: cpu` or `gpu` to the stock `cpu` or `gpu` pool. The default compute pools are: | Pool | Use | | --- | --- | | `cpu` | General CPU workers such as extraction, ingestion, and lightweight Functions. | | `cpu-large` | CPU workers that need local ephemeral-storage headroom for per-pod source caches. | | `gpu` | One-NVIDIA-GPU workers for embedding and model inference. | The built-in pools select `layer.hev.dev/node-role=worker-cpu` or `worker-gpu`, matching the chart's `workerKarpenter` NodePools. Override them by applying `InfraRules/default` when you need to tune resource requests, limits, node selectors, tolerations, GPU SKU hints, or per-workload replica ceilings for your cluster. See [Scaling](/docs/pro/scaling) for the full field shape. # Licensing Source: https://hevlayer.com/docs/pro/licensing import Edition from "../../components/docs/Edition.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; hev layer is licensed per environment. Design-partner and trial installs use the same license path as production: sign up, receive a key by email, install the key with Helm, and monitor the gateway's local license state. For tier prices and what each tier includes, see [pricing](/pricing). ## Start a Trial Use the [trial signup](/#start-trial) on the hev layer site. Submit the email address you want associated with the install; hev layer emails back a signed trial key and the current install instructions. The key is the only license material you need for a standard self-hosted install. Treat it like a deployment secret: store it in your secret manager, do not commit it to source control, and avoid pasting it into support tickets or docs. Self-hosted customer installs pull the Layer-owned pro images from Docker Hub: `hevlayer/layer-gateway-pro:`, `hevlayer/layer-operator:`, and `hevlayer/layer-dashboard:`. These images are only useful with a valid license key; without one, licensed gateway routes, operator actions, and dashboard write surfaces project to the license floor. ## Install the Key For Helm installs, pass the emailed key as `license.token`: ```sh helm upgrade --install layer ./infra/helm/layer \ --namespace layer --create-namespace \ --set license.token="$HEVLAYER_LICENSE" \ -f values.customer.yaml ``` If your cluster policy requires pre-created Kubernetes Secrets, create the Secret through your normal secret workflow and point Helm at it with `license.existingSecret` and `license.secretKey`. See the [install guide](/docs/pro/install#required-values) for the full values table. ## License Claims The license key is a signed PASETO `v4.public` value issued by hev layer and verified locally by the gateway, operator, and dashboard. Verification is offline: the cluster does not call a license server to answer license health. The license answers what the deployment is entitled to: `sub`, `tier`, `features`, `limits`, `iat`, and `exp`. API keys still answer who the caller is and what that caller may do. A request must pass both checks. The initial feature strings are: | Feature | Gates | | --- | --- | | `transform-runtime` | Pipeline, Function, UDF, worker dispatch, and scaling surfaces. | | `agents` | Agent management and invocation surfaces. | | `rbac` | Scoped key mint/manage routes and multi-tenant enforcement. | | `warehouses` | Warehouse APIs and data-supply verification. | | `doc-cache` | Managed document cache and cache-backed warm paths. | | `history` | Search history, clickstream, checkpoints, restore, and activity. | | `cost` | Cost and fin-ops APIs and dashboard panels. | Core gateway routes are not represented as license features. Queries, point reads, writes, scans, namespace metadata, snapshots, and backend routing remain available in every license state. ## License States The gateway evaluates license state locally from its configured key. It does not require a network callback to answer license health. | State | Meaning | |---|---| | `licensed` | The key is valid and the current time is before its expiration. Licensed Layer surfaces are available. | | `grace` | A commercial key has expired, but the install is still inside its grace window. Licensed surfaces continue to work while renewal is handled. Responses on gated routes include a grace signal. Trial keys do not enter this state. | | `floor` | No valid key is configured, a trial key has expired, or a commercial key is past expiration plus grace. Licensed surfaces are refused or degraded to the CE behavior. | Read the local state with: ```sh curl -H "Authorization: Bearer $LAYER_API_KEY" \ https:///v2/license ``` The response includes whether the key is valid, the licensed subject, tier, features, limits, expiration, and the gateway's `licensed` / `grace` / `floor` projection. Operator and dashboard enforcement use the same state model. ## End-to-End Runbook The full-fidelity license acceptance run is scripted: ```sh scripts/license-e2e-real.sh ``` The script uses a per-run plus-addressed inbox, stands up a disposable kind cluster from `infra/helm/layer`, installs the extracted key with `license.token`, checks `GET /v2/license`, and queries PostHog when a read key is available. Each leg is independently gated and the final report says exactly which credential or local tool unlocks the next assertion. It never runs the grace/floor walk against the shared AWS cluster. No-key and trial installs do not get post-expiration grace. For a trial, the trial period itself is the evaluation window, and licensed surfaces drop to the floor when the key expires. Commercial grace is a lapse cushion measured from a real `exp`. Read the current gateway projection with [`GET /v2/license`](/docs/pro/api/license). ## Metrics License health is exported with the gateway metrics and uses the `license_tier` and `license_sub` labels. Surface metrics also include `surface`; Phase 1 emits `surface="gateway"`. | Metric | Type | Meaning | | --- | --- | --- | | `hevlayer_license_valid` | Gauge | `1` when the configured key verifies; `0` for missing or invalid keys. | | `hevlayer_license_expiry_seconds` | Gauge | Seconds until `exp`; negative after expiry for a valid expired key. | | `hevlayer_license_grace_seconds_remaining` | Gauge | Seconds left before a surface drops to the floor; `0` outside grace. | | `hevlayer_license_degraded` | Gauge | `1` once a surface is on the floor. | | `hevlayer_license_grace_requests_total` | Counter | Gateway requests allowed while the license is in grace. | Useful alerts are "license expires in less than 14 days", any grace requests, and `hevlayer_license_degraded == 1`. ## Renewals When a trial or commercial license is close to expiration, request a renewal through your hev layer contact or reply to the license email. Replace the key in Helm or in the referenced Secret and roll the gateway release so it reads the new value. ## Governing Terms | Document | Governs | |---|---| | [`TRADEMARKS.md`](https://github.com/hev/layer/blob/main/TRADEMARKS.md) / [brand policy](/trademarks) | Use of the Layer and hevlayer names, marks, and product identity. | | [Hosted-service Terms of Service](/terms) | The operated Layer service at hevlayer.com, including acceptable use, billing, SLA, liability, and support terms. | | [Privacy Policy](/privacy) | Data handling for the hosted service, site, and product telemetry. | Self-hosting the gateway is governed by the gateway license and trademark policy. Using the hosted Layer service is governed by the hosted-service terms and privacy policy. ## Client SDKs The generated client SDKs stay Apache-2.0. They are pure onramps to the public API surface and are intended to be easy to adopt in any application. # Limits Source: https://hevlayer.com/docs/pro/limits import Edition from "../../components/docs/Edition.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; Layer is limited by certain constraints of the underlying components we ship with. We will lift these as demand increases. - **Single-node document cache.** We enforce this for simplicity and also believe that a single large local drive offers enough storage for almost every dataset. - **~4,090 turbopuffer namespaces.** We use Aerospike sets for logical separation of data, which are limited by the Aerospike Community Edition AGPL license. - **~3 TB cache size.** Another limitation of the Aerospike license. - **10,000 distinct values per scan facet field.** Pre-computed snapshot scans cap each facet field's cardinality. If a field exceeds the cap, it is noted in `fields_skipped[]` rather than `fields[]`, so readers can treat every emitted field as complete. See [snapshots](/docs/pro/api/snapshots). - **1,000,000 distinct values per values scan.** On-demand values scans accumulate their histogram in gateway memory. A job that crosses the cap completes with `truncated: true`: the cap applies after the full pass, keeping the top values by count — each with an exact count — and dropping the low-count tail. See [scans](/docs/pro/api/scans#values-mode). ## No limits These have no enforced ceiling, but practical limits exist and will show up under load — see [failure modes](/docs/pro/failure-modes) for how each surface degrades. - **CRD instances** (`Index`, `Function`, `Pipeline`, `Scaling`) — bounded only by the etcd and operator throughput of your Kubernetes cluster. - **Snapshot history per namespace** — durable in S3; bounded by `spec.snapshot.retention` when set, or by object storage cost under `retention: never`. - **Search history retention** — accumulates indefinitely in S3; no automatic expiry. - **Clickstream event volume** — accumulates indefinitely in S3; no automatic expiry. - **UDF concurrency per function** — KEDA scales replicas to match queue depth, bounded by your cluster's capacity. - **Pipeline queue depth** — pipeline queues, including chunked document queues, store document IDs and chunk ID lists in S3 manifests and keep only segment state and counters in Postgres. - **Document size and attribute count** — bounded by turbopuffer and Aerospike record limits, not by Layer. # Health and metrics Source: https://hevlayer.com/docs/pro/observability ## Health ```sh curl --fail http://localhost:8080/health ``` The health route requires no bearer token. Use Compose health checks and gateway logs to diagnose startup and database connectivity. ## Metrics `GET /metrics` exposes Prometheus-compatible gateway metrics without auth. The metrics catalog and proxy routes under `/v2/metrics` provide names and query access; proxy queries require a configured metrics service. The local Compose bundle does not include that service. ```sh curl --fail http://localhost:8080/metrics ``` ## Telemetry The standalone gateway sends anonymous telemetry to `https://telemetry.hevlayer.com`. It is fire-and-forget, off the request path, and never blocks startup. The local Compose bundle disables it by default. Disable it with either variable: ```sh LAYER_TELEMETRY=off DO_NOT_TRACK=1 ``` On Kubernetes, the Helm chart sets `LAYER_TELEMETRY=off` for you when `gateway.telemetry.enabled=false`. The gateway sends two events: - `gateway_started`, once at startup: gateway version, an anonymous instance ID, and the backend kinds it serves (`pgvector`, `turbopuffer` or `search`). - `gateway_heartbeat`, the same fields plus aggregate counts. Feature counters (automatic routing, hybrid/RRF, fuzzy surfacing, facets, scans, federated query, multi-store routing) are cumulative since startup. `usage` counts successful queries, writes, and rows upserted, patched and deleted since the previous heartbeat. The gateway checks hourly and sends a heartbeat when there is usage to report, or once a day when idle. A gateway running a valid license also sends the license's issuance ID (`licenseId`) and tier (`licenseTier`), which lets us see that a license we issued is running. It never sends the license key or the email it was issued to. A tool that starts the gateway can name itself with `LAYER_TELEMETRY_SOURCE` (lowercase letters, digits and `-`, up to 32 characters, for example `kit`) and `LAYER_TELEMETRY_SOURCE_VERSION` (up to 32 characters of letters, digits, `.`, `+` and `-`). Both are sent as `distribution` and `distributionVersion`; a value outside that shape is not sent. Telemetry never includes query text, vectors, namespace names, document IDs, bearer tokens, API keys, or document contents. See the generated [telemetry reference](https://github.com/hev/layer/blob/main/docs/telemetry.md). # PDF extraction Source: https://hevlayer.com/docs/pro/pdf-extraction `layer-extract` is the stock extract worker for a [Pipeline](/docs/pro/api/pipelines). Point it at an object-store prefix of PDFs or a [cloud drive](/docs/pro/api/warehouses#pdfs-from-a-drive) and it parses each document with [LiteParse](https://github.com/run-llama/liteparse) and stages one row per page into the target namespace. Each row carries the page's markdown, its tables, the page number, the parser that produced it, and a link back to the page in the source document. Reruns parse only documents whose bytes changed. The same image has a second command, `write`, which writes staged pages into the namespace. A PDF pipeline is two `Pipeline` resources that share one `pipelineId`: the extract stage stages pages, and the write stage writes them. The namespace's schema embeds each page's full text [natively](/docs/pro/api/embed#where-the-vector-comes-from), so no stage computes vectors and no page is truncated. ## Declare it The extract stage: ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Pipeline metadata: name: contracts-extract namespace: layer spec: pipelineId: contracts target: namespace: contracts-pages sourceRef: kind: objects uri: s3://your-bucket/contracts/ cacheUri: s3://your-bucket/layer-extract-cache/ parse: processes: 2 timeoutSeconds: 120 rescanSeconds: 900 worker: image: .dkr.ecr.us-east-1.amazonaws.com/mesh:layer-extract- computeClass: cpu podSpec: serviceAccountName: layer containers: - name: worker env: - name: HEVLAYER_SNAPSHOT_REGION value: us-east-1 resources: requests: cpu: 1500m memory: 3Gi limits: memory: 6Gi scaling: pool: cpu mode: fixed replicas: min: 1 max: 1 ``` {/* TODO(PDF extraction milestone 3): worker.image becomes optional and defaults to layer-extract pinned to the operator's release. Drop the image line from this example, and the "set worker.image" sentence below, when it lands. Also update the image location once release.yml publishes hevlayer/layer-extract to Docker Hub. */} The write stage runs the same image with `args: [write]`: ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Pipeline metadata: name: contracts-write namespace: layer spec: pipelineId: contracts target: namespace: contracts-pages worker: image: .dkr.ecr.us-east-1.amazonaws.com/mesh:layer-extract- computeClass: cpu podSpec: containers: - name: worker args: [write] resources: requests: cpu: 100m memory: 256Mi scaling: pool: cpu mode: fixed replicas: min: 1 max: 1 ``` Set `worker.image` on both resources. The image is published to the `mesh` repository with a `layer-extract-` tag in the mesh-account ECR registry, next to the other stock workers. Run the extract stage as one replica in `mode: fixed`. It sweeps the prefix on a timer rather than draining a queue, and sweeps do not coordinate across pods: two replicas would parse every document twice and overwrite each other's manifest. Scale it with `parse.processes` and the CPU request instead. The write stage claims documents with leases, so it can run more replicas. The worker reads S3 with the pod's AWS credentials (the default provider chain, so IRSA on EKS). The service account needs read access to `uri` and read and write access to the cache location. The S3 client takes its region, and an optional S3-compatible endpoint, from `HEVLAYER_SNAPSHOT_REGION` and `HEVLAYER_SNAPSHOT_ENDPOINT`. The operator injects both when the chart has a snapshot bucket; otherwise set the region on the container as above. ### Cache identity and unchanged pages Conversion identities are scoped to each file format's handler, Python and converter versions, font packages, and output libraries (PDF, image, font, XML and Office import libraries). The whole apt dependency closure is **not** identity: unrelated desktop, networking and multimedia package security patches leave every format's identity unchanged. Adding email, HTML or Box Notes support also leaves unchanged Office handlers valid. Changes to a used handler, LibreOffice component, output library or font still invalidate relevant results. The complete installed package inventory is available as image provenance at `/usr/local/lib/layer-extract/runtime-converter-profile.json`. Legacy global fingerprints cannot reveal their old package versions. Before rebuilding an image that used them, export its customer-data-free runtime profile with `converter_identity.py --export-profile` and supply the JSON as Docker build argument `LEGACY_CONVERTER_PROFILE` (the verification build and merged-image publishing workflows accept `legacy_converter_profile`). The extractor checks the historical handler, output dependencies and Python/library versions against this baseline before accepting its legacy identities. Unrelated OS updates therefore preserve those existing keys too. Keep this original profile in subsequent builds until the legacy receipts are retired; omitting it cannot recover old hashes after package updates. The [rollout recipe](https://github.com/hev/layer-pro/blob/main/docs/learnings/extract/story-format-coverage-handoff.md#cache-safe-format-rollout-follow-up) includes export, build and acceptance commands. When re-extraction produces identical page text, content hashes and all extractor-owned attributes, the extractor compares the gateway's stored pipeline chunks and skips staging. It counts the document as `skipped_unchanged`, leaving embedding state and write-triggered Functions untouched. These reads use the pipeline cache or S3 backing, without querying the target namespace. A comparison failure stops staging and reports `stage_error` rather than risking a rewrite. Downstream derived attributes and vectors are outside the staged-chunk receipt. ## Source `kind: objects` is an open source kind: the operator hands `spec.sourceRef` to the worker verbatim as `HEVLAYER_SOURCE_REF`, and `layer-extract` validates it at startup. | `sourceRef` field | Default | Purpose | | --- | --- | --- | | `kind` | required | Must be `objects`. | | `uri` | required | The prefix to extract, `s3://bucket/prefix/`. `file:///absolute/dir/` works for local runs. The prefix is listed recursively. | | `cacheUri` | see below | Where the manifest and parse cache live. The worker writes under `{cacheUri}/{pipelineId}/`. | | `suffixes` | `[".pdf"]` | Object key suffixes to extract, matched case-insensitively. Every match is parsed as a PDF. | | `parse.processes` | the pod's available cores | Parse processes in the pool. | | `parse.timeoutSeconds` | `120` | Hard limit per document. A parse over it is killed and named `timeout`. | | `parse.maxPages` | `2000` | Pages parsed per document. Later pages are dropped. | | `parse.maxBytes` | `536870912` (512 MiB) | Larger documents are named `too_large` without being downloaded. | | `rescanSeconds` | `3600` | Seconds between sweeps of the prefix. The worker stays up between sweeps. | Without `cacheUri`, the cache goes to `s3://$HEVLAYER_SNAPSHOT_BUCKET/pipelines/{pipelineId}/extract/`, the chart's snapshot bucket. If the operator injects no snapshot bucket, `cacheUri` is required and the worker exits at startup without it. ## Page rows Each page becomes one row with id `{doc_id}#p{n}`, where `n` is the 1-based page number. `doc_id` is the first 32 hex characters of the sha256 of the document's source URI, so it is stable across runs and the page id stays under the store's 64-byte id limit. A document that moves to a new key gets a new `doc_id`. | Attribute | Contents | | --- | --- | | `text` | The page as markdown. Full-text searchable (BM25) with fuzzy matching, not filterable. | | `_hevlayer_parent_id` | The document id, the same parent convention as [gateway chunking](/docs/pro/api/pipelines#chunking). | | `page` | Page number, 1-based. | | `page_count` | Pages in the source document, including any past `parse.maxPages`. | | `page_label` | The page's printed label (for example `iv`), when the PDF defines one. | | `tables` | The page's tables, each as a markdown table. Absent when the page has none. Not filterable. | | `blocks` | Compact layout blocks as a JSON string: `t` (block type), `b` (bounding box in 72-DPI points, top-left origin) and `s` (byte span of the block's text in `text`, when it can be located). Absent when the page has none, or when the JSON is over 32 KiB. Not filterable. | | `ocr` | `none`. | | `complexity` | LiteParse's complexity reasons for the page, comma-separated (for example `scanned,no-text`). Empty for a plain text page. | | `needs_ocr` | `true` when `complexity` is non-empty. | | `source_uri` | The document's URI with a page deep link, `s3://your-bucket/contracts/a/lease.pdf#page=3`. | | `content_hash` | sha256 of the document bytes. | | `parser` | The engine and version, `liteparse@2.14.7`. | | `path` | The object key relative to `uri`. | | `name` | The last segment of `path`. | | `source_bytes` | Document size in bytes. | | `source_modified` | The object's last-modified time, when the listing reports one. | {/* TODO(PDF extraction milestone 2): ocr records tesseract or the configured backend per page, scanned pages carry OCR'd text, and the extract.ocr settings join the sourceRef table. Update the ocr, complexity and needs_ocr rows then. */} Pages with no text layer, such as scans, are staged with empty `text`. LiteParse flags `sparse-text` and `embedded-images` as well as scans, so `needs_ocr` is much broader than "no text layer". Count the reasons in `complexity` rather than the flag. The worker accepts up to 1 KiB of junk before the `%PDF-` header, as PDF readers do, and strips it before parsing. Source metadata is copied onto every page so filters work per page: filter on `_hevlayer_parent_id` for one document's pages, or on `path` for a folder. ## Incremental runs Each sweep lists the prefix and compares it with a manifest, `manifest.json` under the cache location. The manifest maps each document id to the listing fingerprint (the S3 ETag and size), content hash and parser it was last staged or failed with. - **Unchanged documents** with the same fingerprint are skipped without being downloaded, parsed or restaged. A second run over an unchanged prefix parses nothing. - **Touched documents** (a new ETag, the same bytes) are downloaded and hashed, then skipped. - **Changed documents** are parsed and restaged. Restaging resets the document to `pending`, so the write stage writes it again and the namespace re-embeds its pages. - **Duplicate documents** parse once. The parse cache, under `parses/` in the cache location, is keyed by parser and content hash, so identical bytes under different keys share one parse. - **Removed documents** have all their page rows deleted from the namespace. - **Shorter documents** drop their trailing pages: when a new version has fewer pages than the staged one, rows past its last page are deleted. A new `layer-extract` release that changes the parser version re-parses every document. The manifest also records the parse limits each document was handled under, so raising a limit retries exactly the documents that hit it: - a larger `timeoutSeconds` retries `timeout` failures - a larger `maxBytes` retries `too_large` failures - a larger `maxPages` retries truncated documents Other unchanged documents and other failures stay skipped. Entries written by an older `layer-extract` that did not record limits get one retry to record them. To reprocess everything, point `cacheUri` at a new prefix. If a listing returns no documents at all while the manifest knows some, the worker keeps the existing rows and logs a warning. An empty listing is far more often a wrong prefix or a permissions change than a deliberate deletion. The manifest also records the pipeline id and source URI. If either changes, the worker starts a fresh manifest. ## Failures A document that cannot be turned into rows is recorded with a named reason. Each failure is written to the manifest with `status: failed`, its `reason` and a message, and counted in the sweep report. Parse failures are also logged with the document's key. | Reason | Meaning | Retried | | --- | --- | --- | | `encrypted` | Password-protected, or a security handler PDFium cannot open. | When the bytes change. | | `invalid_pdf` | Not a PDF, or damaged past what PDFium recovers. | When the bytes change. | | `no_pages` | The document has no pages. | When the bytes change. | | `incomplete_parse` | The parser omitted or duplicated pages within the requested range. Nothing is staged; prior rows stay searchable. | When the bytes change. | | `too_large` | Over `parse.maxBytes`. Not downloaded. | When the object changes. | | `timeout` | Over `parse.timeoutSeconds`. The parse process was killed. | When the bytes change. | | `crashed` | The parse process died, from a PDFium crash or an OOM kill. | Once on the next sweep, then when the bytes change. | | `parser_error` | Any other error the parser reported. | When the bytes change. | | `source_error` | Reading the document from the source failed. | Every sweep. | | `stage_error` | Staging the page rows through the gateway failed. | Every sweep. | Each parse runs in its own child process, so a document that hangs or crashes PDFium takes down only that process. Each child is reaped before another document takes its slot, and the sweep continues. On Linux each child has a 2 GiB address-space limit. If a changed document fails to parse, its previous version's page rows stay searchable. A later successful parse replaces them and removes any trailing pages the new version no longer has. Removing the document from the source deletes its rows, including rows kept from an earlier version, unless the whole listing came back empty. ### Crashes and conversion failures A `crashed` or `conversion_error` is usually the environment, not the document, so the next sweep retries each one exactly once (a persisted attempt count in the manifest; a second failure of the same bytes and parser is permanent until the bytes change). A process that cannot even start because the pod is out of processes, memory or descriptors is a transient `source_error` and does not use that retry. The report's `failure_causes` names why, as `reason:cause` with fixed tokens only: `crashed:exit_signal_9`, `crashed:exit_code_101`, `conversion_error:exit_code_1`, `conversion_error:output_invalid`, `source_error:spawn_resource`, `conversion_error:workspace_full`. The same `cause` appears on each `document failed` log line. The image runs the worker under `tini` so helper processes that LibreOffice leaves behind are reaped. ### OCR failure causes An OCR request that fails marks the document `ocr_error`. Invalid images (`http_400`), persistent size rejections (`http_413`) and `empty_text` settle without another sweep attempt. With a fallback, both failures must be permanent. Other OCR failures retry for at most three sweeps for the same content and parser. A changed document or OCR configuration starts a new attempt budget. Settled failures remain in `manifest.json` with their cause and attempt count; `settled_ocr` and `settled_ocr_causes` in the sweep log count them separately from `failed` and `skipped_failed`. `retry_exhausted` means the three-attempt budget was consumed; the manifest retains the final service cause. PDF rendering retains aspect ratio and caps page images below 16 megapixels before bitmap allocation. A size rejection retries once at half width and height. Known OCR server error bodies are logged without client pixels or text; arbitrary proxy bodies are suppressed. The report's `ocr_failure_causes` says why, with fixed tokens only (`connect_deadline`, `timeout`, `transport`, `http_`, `empty_text`, `wrong_backend`, `malformed_reply`, `body_limit`), and `,fallback=` when the fallback backend failed too. A page the backend reads successfully and finds no text on (a blank, decorative or chart-only page, common after an Office conversion) is not a failure: the page keeps its native content with `ocr: none` and its complexity reasons, and the document stages. With a fallback configured, both backends must answer empty; an empty answer next to a failed fallback is still `ocr_error`, because the page's text is then unknown. `empty_text` only appears in a cause as `empty_text,fallback=`. A reset or a 502/503/504 from a restarting backend is retried up to three times; a timeout is not, because the backend may still be working on it. A burst of concurrent pages queues behind the backend's single inference lock and counts that wait against `timeoutSeconds`. ### Process some documents first A sweep works through the listing in order. To get chosen documents ahead of a long backlog without narrowing the selection, name their provider item ids (for a cloud drive, the Graph item id): ```yaml sourceRef: extract: priority: itemIdsFile: /etc/layer/priority-items.txt # `` or ` ` per line, # comments # itemIds: [, ...] # inline ids rank first # items: [{driveId: , itemId: }] # drive-scoped, rank after itemIds ``` Only the order changes. Every selected document is still processed, in listing order after the prioritized ones, and ids, manifest, dedupe and OCR settings are untouched, so a prioritized sweep re-parses nothing an unprioritized one would skip. An id ranks by its position (`itemIds`, then `items`, then file lines; a repeat keeps its first rank), is matched on any location of a document, and applies to the current version of that item. An id not in the listing is counted, not an error. An unreadable `itemIdsFile` fails the sweep. Ids are as private as the documents: keep them in a mounted Secret or ConfigMap, never in git, and they are never logged. The report carries counts only: `priority_matched`, `priority_absent` (configured ids missing from this listing) and `priority_failed` (matched documents that failed this sweep). ## Sweep report Each sweep logs a `sweep completed` line with its counts: documents, staged, skipped unchanged, skipped known failures, failed, deleted, pages staged, pages parsed per second per busy core, and time spent parsing, downloading and staging. The full report adds failures by reason, the failed documents with their keys, truncated documents, and complexity reason counts. | Variable | Effect | | --- | --- | | `LAYER_EXTRACT_REPORT_PATH` | Write the full JSON report to this path after each sweep. | | `LAYER_EXTRACT_ONCE` | `1` or `true`: run one sweep, print the report to stdout, and exit. | | `LAYER_EXTRACT_DRY_RUN` | `1` or `true`: parse without staging, and imply `LAYER_EXTRACT_ONCE`. No gateway is needed. | | `RUST_LOG` | Log filter. Defaults to `layer_extract=info`. Logs are JSON on stderr. | `LAYER_EXTRACT_ONCE` and `LAYER_EXTRACT_DRY_RUN` are for local runs; on a Deployment, a worker that exits is restarted. A dry run can share `cacheUri` with a real run. It warms the parse cache, which is keyed by content, but does not write the manifest, so a later real run still stages every document. ## Write stage `layer-extract write` claims staged documents from the pipeline and writes their pages as rows through [`PUT /v2/pipelines/{id}/documents/{doc_id}/rows`](/docs/pro/api/pipelines#write-staged-rows). It sends no vectors. Once every batch of a document's pages is written, it marks the document `indexed`. - **Embedding.** Each write carries the page schema, which declares `text` with `embed: {model: "voyage/voyage-4"}` and no `serving`. So the namespace embeds natively: on Turbopuffer, Turbopuffer computes a 1,024-dimension vector from the page's full markdown. The model reads up to 32,000 tokens; the longest page in our test set was under 14,000. Query the namespace with [`Embed`](/docs/pro/api/embed#query-with-embed) and it uses the same model. - **Text.** Every page with text is written byte-for-byte, with no truncation. Native embedding rejects empty strings, so a page with no text is written as `{name}, page {n} (no text layer)`. - **Schema.** `text` declares `full_text_search: true` and `fuzzy: true` alongside native embedding. This supports BM25 and typo-tolerant `HybridText` queries. `text`, `tables` and `blocks` are declared non-filterable, because page content regularly exceeds Turbopuffer's 4 KiB limit on filterable values. The distance metric is `cosine_distance`. - **Failures.** A document whose write fails is released back to `pending` for a later claim. Page ids are stable, so a retried write overwrites what an earlier attempt left. Leases are renewed while a batch is in flight. {/* TODO(PDF extraction milestone 2): once scanned pages are OCR'd, the "(no text layer)" placeholder applies only to pages OCR could not read. */} | Variable | Default | Purpose | | --- | --- | --- | | `LAYER_EXTRACT_WRITE_MODEL` | `voyage/voyage-4` | The embedding model in the page schema. It must be one your store's native embedding offers. | | `LAYER_EXTRACT_WRITE_BATCH` | `8` | Pages per write request. | | `LAYER_EXTRACT_WRITE_DOCS` | `1` | Documents per write request. Above 1, consecutive claimed documents share one [bulk write](/docs/pro/api/pipelines#write-several-documents-rows-in-one-request), which spreads the fixed cost of a namespace write over them. Each document is completed only after the request that carries it succeeds; a failed request releases all its documents. | | `LAYER_EXTRACT_WRITE_MAX_ROWS` | `256` | Most rows in a packed request. A document with more rows is written on its own, in `LAYER_EXTRACT_WRITE_BATCH` pages per request. | | `LAYER_EXTRACT_WRITE_MAX_BYTES` | `4194304` | Most serialized bytes of page rows in one write request, for packed requests and for the batches of a single document alike (a batch also stops at `LAYER_EXTRACT_WRITE_BATCH` rows; one row over the cap goes alone). Requests of many long, dense pages can be refused by the store while smaller ones pass, and row count does not bound that: if writes to a namespace with large documents fail fast, lower this (for example 65536 to 131072) and read the refusal logs for the size that was refused. The default leaves the row-count split unchanged. | | `LAYER_EXTRACT_WRITE_CLAIM` | `16` | Documents per claim. | | `LAYER_EXTRACT_WRITE_LEASE_SECONDS` | `900` | Claim lease. | | `LAYER_EXTRACT_WRITE_POLL_SECONDS` | `5` | Wait between claims when nothing is pending. | | `LAYER_EXTRACT_WRITE_BACKOFF_SECONDS` | `5` | Pause before the next claim when more than half of the last claim was released, which means the store is refusing or losing writes. Doubles for each consecutive such claim, with up to 50% jitter, and resets as soon as a claim mostly succeeds. `0` turns it off. | | `LAYER_EXTRACT_WRITE_BACKOFF_MAX_SECONDS` | `60` | The longest pause. | The stage connects with the variables the operator injects: `HEVLAYER_BASE_URL`, `HEVLAYER_PIPELINE_ID`, `HEVLAYER_TARGET_NAMESPACE` and `LAYER_GATEWAY_API_KEY`. ## Enable fuzzy search on an existing page namespace For Turbopuffer-backed namespaces, enable `fuzzy: true` on the existing `text` attribute with a schema-only namespace write. Preserve its current type, full-text search settings, `filterable: false`, and embedding configuration; `fuzzy` is a sibling of `full_text_search`, not a setting inside it. [Turbopuffer supports this update in place](https://turbopuffer.com/docs/write#updating-attributes), without rewriting rows or rerunning the extraction pipeline. The new index builds asynchronously. Queries that require an index still being built can return HTTP 409; wait for indexing to finish before checking fuzzy or `HybridText` results. Changing full-text search parameters also rebuilds that index in the background, with queries using the previous settings during the rebuild. Adding fuzzy support does not change BM25's ranking expression. Verify the stored schema and both BM25 and `HybridText` queries after the build. The [Turbopuffer full-text search guide](https://turbopuffer.com/docs/fts#fuzzy-matching) explains how fuzzy matching contributes to ranking. ## Local embedding stage The image also retains `args: [embed]` for deployments using a `layer-embed` CPU sidecar. It discovers the model at the sidecar's `/v1/models` and computes vectors before writing them through the pipeline vector API. This stage uses `LAYER_EMBED_URL` (default `http://127.0.0.1:8081`) and `LAYER_EXTRACT_EMBED_MODEL` (default `BAAI/bge-small-en-v1.5`). `LAYER_EXTRACT_EMBED_MAX_CHARS` defaults to `1500`: only that prefix is sent to the model, while the row retains the full markdown. Run either this stage or the native write stage for a pipeline, with the same `pipelineId` as its extractor. ## Limits | Limit | Default | Behavior | | --- | --- | --- | | Document size | 512 MiB (`parse.maxBytes`) | Larger documents are named `too_large` and not downloaded. | | Pages per document | 2,000 (`parse.maxPages`) | Later pages are dropped. `page_count` still reports the full count, and the sweep report counts the document as truncated. | | Parse time | 120 s per document (`parse.timeoutSeconds`) | The parse process is killed and the document is named `timeout`. | | Layout blocks | 32 KiB of JSON per page | `blocks` is left off the row. | | Embedded text | The model's context: 32,000 tokens for `voyage/voyage-4` | A longer page can't be embedded. Choose a model whose context fits your longest pages. | Up to twice `parse.processes` documents are in flight at once, and each is held in memory while it downloads and parses. Size the memory limit for your largest documents. ## Files attached to REST rows Some APIs hand out documents row by row: `POST /api/invoices/{id}/showinvoice` returns the invoice PDF. A `rest` source with an `attachment` block, run by `layer-extract` instead of the REST source worker, lists the rows the way a [REST Warehouse](/docs/pro/api/warehouses) does, fetches each row's file with the Warehouse's own auth (static token or `login`), and stages one row per page. Nothing else in the Pipeline changes: the same parse cache, OCR, write stage and retained originals apply as for object-store and cloud-drive sources. ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Pipeline metadata: name: ki-invoices-extract spec: pipelineId: ki-invoices targetNamespace: ki-invoices worker: image: sourceRef: kind: rest warehouseRef: ki request: { path: /api/invoices } pagination: { kind: page, pageSize: 100, pageSizeParam: pageSize, pageParam: page, totalsHeader: X-Pagination } response: { items: "$[*]" } cursor: { field: id } mapping: id: "{{id}}" attributes: invoice_number: invoiceNumber customer_id: customerId hasFile: hasFile attachment: request: method: POST path: /api/invoices/{id}/showinvoice when: hasFile extract: ocr: { url: pp-ocrv5 } ``` | `sourceRef.attachment` field | Default | Purpose | | --- | --- | --- | | `request.path` | required | The file's path. `{name}` is replaced with that attribute of the mapped row (`{id}` is the row id), percent-encoded. | | `request.method` | `GET` | `GET` or `POST`. | | `request.body` | none | Optional JSON object sent with the request. String values may use `{name}`. | | `when` | every row | A mapped attribute that must be present and not `false`, `null` or empty for the row to have a file. Rows without one are listed but produce no pages. | | `extension` | `pdf` | The file type, used for the document key and, with `extract.normalize`, for conversion. | The rest of `sourceRef` is the REST source's own (`request`, `pagination`, `response`, `cursor`, `mapping`, `request.forEach`), and `extract`, `parse`, `cacheUri` and `rescanSeconds` are this page's. A listing that fails on any partition fails the sweep, so a partial listing never deletes pages. `layer-extract` is the only worker that runs an `attachment`; the REST source worker refuses a Pipeline that declares one. Each document is `rest://{warehouseRef}/{id}.{extension}`, so page ids are stable across sweeps and `doc_id` follows the parent row. Bytes are checked against the file type (a `pdf` response must start with `%PDF-`), so an HTML error page is a named failure, not a page of garbage. Pages carry the parent row's mapped attributes (`invoice_number`, `customer_id`, ...) next to the usual page attributes, plus `source_row_id`, the parent row id. A parent attribute never replaces a page attribute or a reserved `_hevlayer_*` or `original_*` attribute. When the row changes but the file does not, the pages are restaged from the cached parse, with no new download-and-parse or OCR. Identical bytes under two rows parse once and retain one original, keyed by content hash. ### One document per file across a drive and a REST source The same PDF often lives in two systems: an invoice attached to a REST row and a copy in a SharePoint library. Indexed twice, search returns both and neither knows about the other. Add the REST source to the cloud-drive Pipeline's `sourceRef.merge` and `layer-extract` collapses the two by content, so a file found in both is one document with a location per source. ```yaml sourceRef: kind: clouddrive warehouseRef: sharepoint drives: { sites: [{ site: "example.sharepoint.com:/sites/Finance", library: Docs }] } mapping: { id: id, version: file.hashes.quickXorHash } merge: - kind: rest warehouseRef: ki attributePrefix: ki_ request: { path: /api/invoices } # pagination, response, cursor and mapping as for a REST source attachment: request: { method: POST, path: /api/invoices/{id}/showinvoice } when: hasFile ``` | `sourceRef.merge[]` | Purpose | | --- | --- | | entries | Each is a `rest` sourceRef with an `attachment` of `extension: pdf`, as above, and its own `warehouseRef`. The primary must be `kind: clouddrive`. `extract`, `parse`, `cacheUri` and `request.forEach` belong to the primary and are rejected here. | | `attributePrefix` | Lowercase letters, digits and underscores that namespace the row's attributes on the pages. Default: the `warehouseRef` with other characters turned into `_`, plus a trailing `_` (`bcc-ki` becomes `bcc_ki_`). | The operator resolves each entry's Warehouse (it must be `Verified`) and gives the worker its connection as `HEVLAYER_WAREHOUSE_`, with the credential mounted under `/var/run/hevlayer/warehouse-/`. The key is the sha256 of the original bytes (`original_sha256` on the rows). A REST file has no listing hash, so the worker downloads each row's file once, hashes it, and remembers the result per row fingerprint in `rest-hashes.json` under the cache location. A settled listing downloads nothing for hashing. Then, per sweep: - A REST file whose sha256 matches a document the drive source has already processed joins that document. With `dedupe: item` (the default whenever `extract.normalize` is on) the drive source keeps one document per item, so when several items share the bytes, the REST copy joins the one with the lowest document id and the other items stay as they were. - REST rows that share a sha256 but have no drive copy form one document together. - A row whose file could not be fetched or hashed keeps its own document, which fails the usual way. **Document ids.** A drive document keeps its existing id (the provider hash, as before), so merging changes none of its page ids and re-embeds nothing. A document only REST sources have takes the first 32 hex characters of the content sha256. The page ids are `{doc_id}#p{n}` in both cases. A REST-only document that later gains a drive copy moves to the drive id: the sweep that sees the match deletes the REST-only pages and the drive document gains the REST location from its cached parse. An unhashable row keeps the per-source id `sha256(rest://{warehouseRef}/{id}.pdf)`, so existing REST-only Pipelines are unaffected and need no migration. **Locations.** The `locations` attribute is a JSON list with one entry per copy. On a document that has a REST copy, every entry names its `source` (`clouddrive` or `rest`); a document with only drive copies is written as before, without `source`. The drive copies come first, then REST copies ordered by warehouse and row id, so the same listing always produces the same rows. ```json [ {"source": "clouddrive", "path": "Invoices/a.pdf", "webUrl": "https://...", "folderUrl": "https://..."}, {"source": "rest", "path": "ki/17.pdf", "rest": {"warehouse": "ki", "rowId": "17", "url": "rest://ki/17.pdf", "attributePrefix": "ki_", "attributes": {"invoice_id": 17, "vendor": "..."}}} ] ``` **Attributes.** The page's links and provenance (`webUrl`, `folderUrl`, `path`, `name`, `pdf`, `source_uri`, `original_*`) come from the drive copy when there is one. The first REST row's attributes are added under the prefix (`ki_invoice_id`, `ki_vendor`, `ki_source_row_id`), so they never collide with the drive source's attributes; the other rows' attributes are only in `locations`. A REST-only document keeps its `rest://` links and has no `webUrl`. A change to a REST row's attributes patches the existing rows from the cached parse with no new parse or embedding. The sweep report counts `merged_documents` (found under both kinds of source) and `rest_only_documents`. ## Office, text and document images Set `sourceRef.extract.normalize: true` to convert originals before the stock LiteParse/PDFium extraction and OCR path. The stock image includes LibreOffice, img2pdf and pikepdf; consumers do not need a private parser. Existing PDF-only sources retain their selection, deduplication and parser behavior. For a cloud drive, listing a non-PDF type in `include.mimeTypes` (for example the `.docx`, `.xlsx` and `.pptx` types) turns `normalize` on. With no list, a normalized source selects every file in its roots and counts unknown formats; with a list, only the listed types are selected and the rest are filtered, not counted. Omit `include.maxBytes` and use `dedupe: item`. Set `parse.maxBytes` to count oversized originals as `too_large`. Object sources ignore `suffixes` in this mode. Unknown formats become `unsupported_format`, not filtered files. Use only verified source roots; personal drives require separate selection and are excluded from the Story contract. | Original | Conversion and page identity | | --- | --- | | PDF | Original PDF pages; existing parser and renderer | | DOC/DOCX/DOTX/ODT/RTF, PPT/PPTX/ODP | LibreOffice PDF export; converted PDF pages, with a fixed locale and font set | | XLS/XLSX/XLSM/XLSB/ODS | LibreOffice PDF export with its print pagination: each sheet starts on a new page, in workbook order, and a wide or long sheet continues onto further pages. Page numbers are not sheet numbers | | TXT/MD | Strict UTF-8 text, rendered literally by LibreOffice Writer; converted pages | | Box Notes (`.boxnote`) | Strict JSON attributed text (`atext.text`), rendered as text pages | | MSG/OFT/EML | Message headers and plain body (HTML body fallback), rendered as text pages; attachments excluded | | HTML/HTM | Visible UTF-8 text, excluding scripts, styles and document head; rendered as text pages | | MPP, Pages | `unsupported_format`: no bundled Project converter; Pages requires an exported rendition | | CSV | UTF-8, comma-separated, double-quote escaping; LibreOffice Calc print pages | | PNG/JPEG/TIFF/BMP/GIF/WebP | img2pdf; each supported raster frame becomes a page, then existing OCR routing | | Camera RAW, including DNG/CR2/NEF/ARW | Unsupported originals; no automatic developing, embedded-thumbnail substitution or photo classification | | Other formats | Counted `unsupported_format`; add stock converter support before processing | The run report counts `bytes_downloaded` and `bytes_converted` (original bytes sent to the converter). Both are `0` on a second sweep of unchanged files. Unsupported and failed files appear in `failures_by_reason`, never as silent drops. Generic `application/octet-stream` is admitted for extension-based conversion. The converter validates JSON, OLE, ZIP, RTF, RFC822 or HTML content; a suffix alone does not turn arbitrary bytes into a document. Add `text/html`, `application/vnd.ms-outlook`, `message/rfc822`, `application/vnd.openxmlformats-officedocument.wordprocessingml.template`, `application/vnd.ms-excel.sheet.macroEnabled.12`, `application/vnd.ms-excel.sheet.binary.macroEnabled.12`, `application/rtf`, and `application/octet-stream` to a drive MIME list to select these types. Add `application/vnd.ms-project` to account for unsupported MPP originals. Generic MIME can also select unrelated files; those count as unsupported. Email attachments are excluded because embedded files require independent identities and selection. MSG/OFT supports Unicode property streams or strict UTF-8 ANSI streams, plain body or UTF-8 HTML; RTF-only message bodies fail conversion rather than silently publishing only headers. HTML conversion never fetches external resources. Conversion errors, corrupt containers and undecodable text become `conversion_error`. Password-protected Office inputs that cannot export are conversion failures. PDF failures retain their existing reasons. The per-document process deadline covers conversion and parsing; its process group is killed and reaped, including converter descendants. Converted PDFs are bounded to 512 MiB. A raster is bounded to 100 million pixels. Converter diagnostics are suppressed. Original bytes remain the content-hash authority. Parent IDs are based on the original item URI, and page IDs stay `{parent}#p{number}` across versions. Page rows retain `original_uri` (view link), `original_source_uri` (stable fetch identity), `original_sha256`, `original_version`, provider drive/item/MIME/ETag and locations. For non-PDF originals, `source_uri` opens the original file; `page_basis: converted_pdf` explicitly names the page coordinate system. A `conversion` JSON string records original format, converter recipe and normalized PDF SHA256. Metadata and trailer IDs are canonicalized; reproducibility requires the same immutable image, libraries, locale and fonts. Converted page numbers must not be presented as Office source pagination or camera capture numbers. The parse cache separates format, declared MIME and converter stack fingerprint (script, installed package versions and fonts) as well as original byte hash. Identical bytes in two provider items can reuse parsing while retaining separate parents and provenance. Unsupported and failed originals remain in the manifest and report, with no page rows published for the failed version. A last complete prior version remains until removal or successful replacement. Turning on `normalize` or adding `imageMimeTypes` to a source that already extracted PDFs does not re-parse them: a native PDF keeps its parse identity, so settled PDFs (and their OCR) are reused, and only the newly selected formats are converted and parsed. Each converted format keeps its own identity. ### Scanned images from a drive Scans often sit in a SharePoint library as images, not PDFs. A `clouddrive` source can read selected image types as documents without normalizing every file. List them in `sourceRef.extract.imageMimeTypes`; the choice of which images are documents is the Pipeline author's, made by MIME type and folder. ```yaml sourceRef: kind: clouddrive warehouseRef: m365 drives: sites: - site: contoso.sharepoint.com:/sites/Projects library: Documents include: paths: [/Scans, /Field Reports/Scanned] # folders that hold documents extract: imageMimeTypes: [image/tiff, image/jpeg, image/png] mapping: id: id version: file.hashes.quickXorHash ``` `imageMimeTypes` accepts `image/png`, `image/jpeg`, `image/tiff`, `image/bmp`, `image/gif` and `image/webp`. `include.mimeTypes` defaults to `application/pdf` plus the listed types and may not name anything else. Deduplication stays `content` by default. Each image, or each frame of a multi-page TIFF, becomes one page row, converted by the stock converter described above and run through the existing OCR routing (PP-OCRv5, falling back to Tesseract). Rows carry `locations`, `webUrl`, `folderUrl`, `path` and `name` as PDF rows do. For an image, `source_uri` is the file's `webUrl` with no `#page=` fragment, there is no `pdf` link, and `page_basis` is `converted_pdf`. Failures use the reasons above and are counted in the sweep report. HEIC is not supported: `image/heic` is not an accepted `imageMimeTypes` value, so real HEIC files are filtered by `include.mimeTypes` and skipped, and a `.heic` name carrying a listed MIME type is `unsupported_format`. Any other file whose extension is not a supported raster is `unsupported_format`. A file whose bytes are not the format its extension and MIME type claim, or that cannot be decoded, is `conversion_error`. Files filtered out by `include.paths` or `include.mimeTypes` appear in the metadata worker's `skipped` counts, not as failures. This does not tell photos from documents. Images that should be searchable by content, not text, belong to a [`clouddrive-images`](/docs/pro/api/warehouses#photos-from-a-drive) source; choose between them with `include.paths`. Automatic classification of mixed folders is not part of this feature. ### Verified cached originals and sealed manifests For extensionless cached objects, use a manifest-backed `objects` source. The manifest supplies the original name and MIME; the fetch key remains unchanged. The worker makes no Graph requests and does not interpret a private SQLite ledger. An acquisition owner exports a complete JSONL selection joined to its current verified receipts, including pending and failed items, then seals the exact bytes with SHA256. Update the snapshot seal when receipts change. Never provide a success-only delta: absent items are reconciled as removals. ```yaml sourceRef: kind: objects uri: s3://example-bucket/selected/ cacheUri: s3://example-bucket/extract-cache/ extract: normalize: true dedupe: item manifest: uri: s3://example-bucket/manifests/selection.jsonl sha256: <64-lowercase-hex-SHA256-of-exact-JSONL-bytes> warehouseRef: example-sharepoint complete: true graphVersionKeys: true mapping: drive: drive_id item: item_id name: metadata.name mime: metadata.file.mimeType path: metadata.parentReference.path webUrl: metadata.webUrl etag: metadata.eTag size: metadata.size modifiedAt: metadata.lastModifiedDateTime bucket: artifact.bucket key: artifact.key sha256: artifact.sha256 receiptEtag: artifact.etag receiptBytes: artifact.bytes verified: artifact.s3_readback_verified ``` Mappings are dot-separated JSON object paths. Without a mapping, the same canonical field names are read directly. `driveMetadata`, `folderUrl` and `reuse` are optional additional fields. `path` can be a provider parent path or a full original path; the original filename is appended when needed. Preserve provider links verbatim. Supply original identity, name, ETag and size even when a receipt is pending. An absent MIME is unspecified and still undergoes stock byte/container validation. `bucket` must match the configured source bucket; `key` is the complete S3 key within its prefix. With `graphVersionKeys: true`, the relative key must equal `versions/SHA256(drive + "/" + item + "/" + ETag)` using UTF-8 bytes and lowercase hex. Receipt ETag and byte count must match original metadata, `verified` must be the JSON boolean `true`, and the receipt SHA256 must match fetched bytes. Missing, stale, corrupt or out-of-prefix receipts become counted `source_error` failures. Every sweep verifies bytes again, including settled/cache-hit items. Manifest SHA mismatch or duplicate item identity aborts the entire listing before removal reconciliation. The manifest is bounded to 512 MiB. The stable source identity is `clouddrive://{warehouseRef}/{drive}/{item}`; `original_source_uri` and document/page IDs use this identity rather than a versioned S3 key. `original_fetch_uri` records the cached object URI. ETag and byte SHA identify the original version. An unchanged-byte version or link update patches provenance through existing gateway APIs without reparsing. Cache state binds destination, mode, source prefix, warehouse and mapping; changing the snapshot URI or SHA retains reconciliation history. Use a distinct warehouse identity and cache for a separately owned selected-root contract. All staging, publication and queries use gateway APIs. #### Reusing native or OCR page outputs A manifest may bind `reuse` to an existing stock `ParsedDocument` JSON blob (`page_count`, ordered `pages`, optional `conversion`). Set `encoding: parse_worker_response` to reuse the stock parse-worker stdout envelope `{"ok": }` directly, including retained native or OCR responses. The default encoding is `parsed_document`. Error/image responses are rejected. Use `encoding: page_rows` for already-produced gateway page rows. `rowsPath` selects an array (empty means the root); default fields are `text` and `metadata.page`, `metadata.page_count`, `metadata.original_sha256`, optional `metadata.ocr`, `metadata.complexity`, `metadata.tables`, `metadata.blocks`, `metadata.page_label` and `metadata.conversion`. Stock comma-separated complexity and JSON-text blocks/conversion retain their existing wire encoding; native JSON arrays are also accepted. `pageMapping` overrides these with dot paths named `markdown`, `number`, `pageCount`, `originalSha256`, `ocr`, `complexity`, `tables`, `blocks`, `label` and `conversion`. For a flat retained projection, a mapping such as `{"number":"page","pageCount":"page_count","originalSha256":"content_sha256","complexity":"complexity"}` keeps `text` as the default markdown field. Every row needs a valid original byte SHA; only the receipt's matching hash is selected. Selected rows must agree on page count and conversion provenance, and sorted page numbers must form the required consecutive prefix. Old row IDs and vectors are ignored. Missing backend labels on a certified OCR recipe become `retained_unknown`; the importer does not infer a backend or pretend those pages are native. This reads existing text, not an original document parser or converter. Optional tables, blocks, complexity, labels and OCR flags absent from the projection are recorded in the published receipt's `projectionOmissions`; the importer does not recover missing layout or claim lossless metadata reuse. Its receipt is: ```json {"version":1,"uri":"s3://example-bucket/outputs/item-version.json", "sha256":"", "originalUri":"clouddrive://example-sharepoint/drive/item", "originalSha256":"","etag":"", "bytes":123,"format":"pdf","mime":"application/pdf", "parser":"","policy":"exact", "limits":{"timeoutSeconds":120,"maxPages":2000,"maxBytes":536870912}} ``` The receipt binds original identity, hash, ETag, size, format and MIME. `exact` binds the full active parser recipe including converter fingerprint, format, MIME hash and OCR routing fingerprint when applicable. Non-PDF outputs require matching stock conversion provenance and converted-PDF SHA256. `pdf_pass_through` permits unconverted PDF outputs from exactly `liteparse@2.14.7` across the stock normalization wrapper, which passes PDF bytes unchanged. Existing PDF OCR output is reusable only when its old `liteparse@2.14.7+ocr-v2-` recipe matches the active engine and exact OCR routing fingerprint after removing only the stock wrapper. An OCR page without that recipe is rejected. Converted outputs cannot use this policy. The output blob is bounded to 64 MiB, hash-checked on each sweep and validated for ordered page coverage under both recorded and current page limits. Missing or incompatible receipts fail the document; they do not silently trigger parsing. Changing an import receipt changes its cache identity. Accepted outputs enter the existing parse cache and per-document staging/publication path without a parser/OCR call. Each published page retains the verified `reuse` receipt as a JSON string, including the actual output engine, output hash and import policy; the active compatibility recipe does not replace this provenance. A partial old output cannot satisfy a larger current page limit. Retained outputs require a declared stock encoding or explicit page-row mapping and their actual engine, limits and original bindings. Unbound legacy payloads remain explicit intake gaps; a receipt does not certify a historical collection. ### Independent text namespace contract A Story text sibling uses a fresh Turbopuffer namespace, `pov-bcc-story-pages`, accessed through the gateway only. This name is a source contract, not evidence that the namespace exists. Use a distinct extractor/write pipeline ID and cache prefix, `dedupe: item`, the complete verified Story SharePoint roots, and the native `args: [write]` stage. Set `LAYER_EXTRACT_WRITE_MODEL=voyage/voyage-4` and `LAYER_EXTRACT_WRITE_DIMS=1024`. The schema is: ```json {"text":{"type":"string","full_text_search":true,"fuzzy":true,"filterable":false, "embed":{"model":"voyage/voyage-4","dims":1024}}} ``` The profile binds source `text` to target `embed_text`, text modality, native serving, 1024 dimensions and cosine distance. Native Voyage is provider-managed: there is no local weight artifact SHA to invent or inherit. Pin the declared model/schema and provider readback; a requirement for immutable weight artifacts would require a separately selected local profile. The writer keeps full nonblank page text and labels textless pages. Query through the same gateway namespace with `rank_by: ["text", "ANN", ["Embed", "question"]]`, BM25 on `text`, or `HybridText` on `text`; supplied vectors must match the exact selected space and dimensions. The legacy BCC worker uses BGE-small 384-dimensional local vectors and a 1500-character input prefix. Its model discovery, artifact and query prefix do not apply to the native Story sibling. CLIP image-profile approval also does not approve this text profile. There is no copy, migration or inherited schema. Before runtime activation, independently read back the gateway/store schema, profile binding, source selection, immutable extractor/writer images and in-image source versions. Test new writes and ANN/BM25/HybridText on synthetic rows, reject 384-dimensional vectors, and reconcile original/document/version/page identities and all failure counters. Retain the existing Function pauses and extract/OCR/embed holds until the owners explicitly authorize rollout. Per-document Function processing also requires LYR-261's warm-cache query budget proof (at most two Turbopuffer queries per document); source support alone proves no runtime cost or search acceptance. All claims, page reads and writes go through existing gateway Pipeline APIs, never direct store access. ## Retained originals Every source keeps each original file it ingests in the target namespace's private blob storage, so an authorized MCP reader can fetch the PDF itself (see [Original files](/docs/pro/api/mcp#original-files)). This is on by default. Rows gain `original_blob` and `original_bytes`, next to the `original_sha256`, version and `webUrl` they already carry, and nothing else on the row changes. ```yaml sourceRef: extract: retainOriginals: false # opt out # retainOriginals: {maxBytes: 5242880, backfill: true} # tune ``` `maxBytes` defaults to 10 MiB; larger originals are skipped and counted in the sweep report's `originals.oversize`, never truncated. Retention reuses bytes the sweep already downloaded, so it adds no provider traffic. Documents already settled before retention was on are filled by the `layer-extract originals` [Function](/docs/pro/functions#backfill-original-file-references), or, with `backfill: true`, from sources whose bytes Layer already holds; a cloud drive is never re-downloaded for this and its settled documents are counted in `originals.pending` until they next change. ### Validate a rollout against live receipts Set `LAYER_EXTRACT_VALIDATE_LIVE=1` on a one-shot extractor using the production Pipeline ID, source reference, gateway URL, target namespace and cache URI. This mode uses the production cache binding. It first audits **every** converted receipt, including documents outside the current selection, then runs a read-only sweep. `LAYER_EXTRACT_EXPECT_CONVERTED_RECEIPTS` optionally asserts the expected receipt count. A missing or differently bound manifest fails validation. The gateway sink compares stored pipeline pages through the same cache/object storage read path used by production. It counts `would_stage` separately from `would_rewrite` of documents already in the manifest, and reports `would_delete` and `would_patch`. It never ensures/creates the Pipeline, stages rows, deletes or patches rows, retains originals, or writes connector cursors, parse caches or manifests. Counts are printed as JSON; no document identifiers are included in the validation reports. A nonzero migration gap or existing-document mutation fails the command. The isolated `LAYER_EXTRACT_DRY_RUN` mode is mutually exclusive and continues to use its separate cache binding. The image build input `legacy_converter_profile` accepts either one exported runtime profile or an array of profiles from every historical immutable build represented in the live cache. Combine only package/version metadata and the platform normalization script; document content is unnecessary. Recipes and output dependencies must still match before an old fingerprint qualifies. LST, XLK and PPS remain unsupported; matching historical failure recipes retain their previous receipt identity. Use the runtime-only helper to combine profiles with a shared inventory and per-build overrides, keeping workflow inputs below GitHub's size limit: ```sh python3 apps/layer-extract/tools/converter_identity.py --combine-profiles \ /tmp/story-live-profile.json /tmp/story-history-one.json /tmp/story-history-two.json \ > /tmp/story-legacy-converter-profiles.json ``` Supply that file with `-F legacy_converter_profile=@/tmp/story-legacy-converter-profiles.json`. The original single-profile input and raw profile arrays remain supported. Fresh exports also include the old image's normalization source. It is platform code, never document content, and is parsed only for recipe comparison and historical hashing; it is never executed by migration. This reproduces hashes from earlier script revisions absent from the bundled history. Re-export all historical profiles with the current helper before combining them. Older package-only profiles remain accepted for bundled historical revisions. Compact bundles deduplicate both inventories and normalization sources. # Quickstart Source: https://hevlayer.com/docs/pro/quickstart import StoreNote from "../../components/docs/StoreNote.astro"; import Edition from "../../components/docs/Edition.astro"; import CodeTabs from "../../components/docs/CodeTabs.astro"; ## 1. Sign up for a 30-day trial [Start your free trial](/#start-trial) with your work email. You'll receive a trial license key and install instructions. Layer runs in your AWS account, in front of your vector store. ## 2. Install in your AWS account Have Go, the AWS CLI, Terraform, Helm, and kubectl installed, with AWS credentials configured. Set your Turbopuffer API key and the trial license key from your email, then run the installer: ```sh git clone https://github.com/hev/layer.git cd layer go build -o layer ./apps/layer-cli export TURBOPUFFER_API_KEY="tpuf_..." export LICENSE_TOKEN="your-trial-license-key" ./layer install ``` Review and confirm the plan. The installer provisions the AWS infrastructure and installs Layer in your cluster. The [install guide](/docs/pro/install#layer-install) covers prerequisites, configuration, and existing infrastructure. ## 3. Switch your client Point your Layer client at your gateway endpoint using its configured bearer key. Keep your Turbopuffer API key as the gateway bearer. Your data stays in Turbopuffer; requests now go through Layer. ```sh export LAYER_GATEWAY_URL="https://your-layer-gateway" export LAYER_GATEWAY_API_KEY="your-gateway-bearer-key" ``` Layer's [Python, Go, and TypeScript clients](/docs/pro/api/introduction#install) expose the Layer API: ```python import os from hevlayer import AsyncHevlayer client = AsyncHevlayer( base_url=os.environ["LAYER_GATEWAY_URL"], api_key=os.environ["LAYER_GATEWAY_API_KEY"], ) ``` ```go import ( "os" hevlayer "github.com/hev/layer-go" ) client := hevlayer.NewClient( hevlayer.WithBaseURL(os.Getenv("LAYER_GATEWAY_URL")), hevlayer.WithAPIKey(os.Getenv("LAYER_GATEWAY_API_KEY")), ) ``` ```typescript import { Hevlayer } from "hevlayer"; const client = new Hevlayer({ baseUrl: process.env.LAYER_GATEWAY_URL, apiKey: process.env.LAYER_GATEWAY_API_KEY, }); ``` ```bash curl --fail-with-body "$LAYER_GATEWAY_URL/health" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` Explore the [API docs](/docs/pro/api/introduction) or see Layer in action in the [demos](/demos). # Changelog Source: https://hevlayer.com/docs/pro/roadmap import FeatureGate from "../../components/docs/FeatureGate.astro"; The [published release history](https://github.com/hev/layer/releases) records released versions and their artifacts. Production documentation follows the released tag; development previews follow their version branch. Surfaces marked **in preview** are deliberately absent from this page. They are documented and usable, but they sit outside the release line and carry no compatibility promise, so they are not part of a version's contract. ## Edge `edge` is the opt-in development build. It tracks the `v0.7` branch and is republished as the branch moves; pin a released tag for anything you depend on. Landing in 0.7, on the `edge` image today: - 🐘 Postgres `VectorStore` backend (`kind: pgvector`) — the Community Edition default store, documented in the [store matrix](/docs/pro/stores); a keyless `docker compose up` is the whole stack - 🔢 [Ordered scans](/docs/pro/api/query#rank-expressions) on Postgres — `rank_by` on an attribute or `id`, filter-only queries in id order, and byte-order string range filters - ✅ [Conditional writes](/docs/pro/api/write) on Postgres — `upsert_condition` and `delete_condition`, including `$ref_new` - 📚 Several `full_text_search` attributes per namespace on Postgres — one `pg_search` BM25 index over all of them, ranked one attribute at a time; see the [schema limits](/docs/pro/stores#schema-limits) - 🧠 [CPU embedding service](/docs/pro/api/embed#cpu-models) — `layer-embed` beside the gateway, reached through `LAYER_EMBED_URL`; `prefer: local` needs no key, GPU, or download - 🏷️ Typed `feature` on `422 UnsupportedByStore` — match on the identifier, not the message ### Running edge ```sh git clone --branch v0.7 https://github.com/hev/layer.git cd layer export GATEWAY_IMAGE=hevlayer/layer-gateway:edge export TURBOPUFFER_API_KEY="" docker compose up -d --wait curl --fail http://localhost:8080/health ``` The [quickstart](/docs/pro/quickstart) covers the rest, including using your own Turbopuffer key instead of the bundled local database. ## 0.6 ### Community Edition - 📦 One-bundle Community Edition — a single Compose file and a public image, run with a Turbopuffer key (a keyless Postgres start shipped as a preview) - 🧑‍💻 [`layer` CLI](/docs/pro/cli) on Community Edition — keyless `index get`, `env add`, and inspection ### Gateway and pipelines - 🚦 Higher fan-out for pinned namespaces — 4× the default concurrency - 📄 REST source pagination — `searchAfter` over enumerated snapshots - 🦾 arm64 REST worker - 🗑️ Namespace deletion without S3 ### Fixes - 🔒 Scoped keys confined to their namespaces - 🧮 Document-cache scans agree with counts - 🪣 Standalone gateway runs without an object store ## 0.5 ### Embedding - 🧮 [Gateway-side query embedding](/docs/pro/api/embed) — `embed:` attributes resolved on the wire - 🎛️ [Serving preference](/docs/pro/api/embed) — per-attribute choice of where embedding runs - 🤝 Native Turbopuffer embeddings wired through the gateway - 🪟 Lattice embedding provider — pinned artifact mounted by Helm - 🖼️ Local CLIP provider — multimodal without a GPU worker - 🧰 BYO models — the blended embedding policy removed - 💸 Native embed cost attribution ### Install and CLI - 🧙 `layer install aws` — guided Terraform + Helm install wizard - ♻️ Profile-aware [`layer install`](/docs/pro/cli) lifecycle — the install script deleted - 🗄️ Document cache Helm profiles - 🚀 [Quickstart](/docs/pro/quickstart) from a clone — the bundled Compose flow ### Licensing - 🎟️ [Team tier gates](/docs/pro/licensing) — custom InfraRules and multiple vector stores - 🔏 Authenticated `/v2/license` probe - ⏳ Trial licenses skip post-expiry grace ### Pipelines - 📚 Enumerated REST full snapshots - 🧬 Idempotent sweeps — content hashes keep Function-written attributes ### Stores and gateway - 🗃️ Kubernetes vector stores preferred in pro - 🧊 Turbopuffer base64 vectors accepted - 🎯 Namespace delete routed by vector store - 🔓 Segment lock convoys resolved - 📴 Standalone gateway runs with no object store ## 0.4 ### API hardening - 🧩 Finalize CRDs - 🚆 Wire-compatible pass-through reads and writes - 🏷️ Naming things ### Lifecycle and operability - 🎚️ [Autoscaling compute](/docs/pro/scaling) for pipelines and UDFs - 🗄️ [Document cache endpoint](/docs/pro/api/query#fetch) for multi-stage pipelines - 📸 [Index snapshot history](/docs/pro/api/snapshots) - 🧨 Coordinated delete - ⛵ [Helm and Terraform install](/docs/pro/install) scripts - 🔐 [Scoped API keys](/docs/pro/api/keys) — minted [`ApiKey` resources](/docs/pro/api/keys) - 🔑 License key validation - 🧾 Key audit logs — lifecycle events to S3 - 🏭 [Warehouse CRD](/docs/pro/api/warehouses) — declared Snowflake and Hugging Face sources - 🏗️ Production cluster cutover — lean topology live in prod ### Surfaces - 🪟 [Dashboard MVP](/docs/pro/dashboard) — CRD management and observability - 📚 Documentation site - 🧰 Official Python, Go, and TypeScript clients ### Search - 🎯 [Stable reads](/docs/pro/api/query#stable-reads) during heavy writes - 🕰️ Temporal queries — `as_of` / `between` selector on reads - 🚦 [Ready signal](/docs/pro/api/namespace-metadata) — namespace reports when every row is indexed - 📜 Precomputed facet listings in [snapshots](/docs/pro/api/snapshots) - 🪙 Precomputed facet counts in [snapshots](/docs/pro/api/snapshots) - 🪃 [Scans](/docs/pro/api/scans) — row selection by filter, `fts`, `hybrid_text`, or `ann` - 🆔 Search by id via document-cached vector - 🪢 [Hybrid text fusion](/docs/pro/api/query#hybrid-text-fusion) - 🧭 [Query routing](/docs/pro/api/query#query-routing) - 📰 [Search history](/docs/pro/api/search-history) saved to S3 - 🔥 Trending searches - 🗂️ [Enhanced namespace metadata](/docs/pro/api/namespace-metadata) ## Proposals Unreleased design proposals are tracked separately from published release notes. A proposal does not establish store or edition support; use the selected edition's API reference and the [store matrix](/docs/pro/stores). # Scaling Source: https://hevlayer.com/docs/pro/scaling import Callout from "../../components/docs/Callout.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; Pipelines and Functions run on Layer's built-in `cpu`, `cpu-large`, and `gpu` pools when no `InfraRules/default` object exists. The built-in pools include the stock resource envelopes, placement rules, and per-workload replica ceilings shown below, so a fresh install needs no infrastructure policy object. `InfraRules` is the cluster-scoped policy object for Layer-managed runtime infrastructure. Apply the singleton `InfraRules/default` when you need to replace the built-in pools or declare document-cache policy. Pipelines and Functions do not reference a separate autoscaling resource. They set `spec.scaling` inline and choose a pool from the built-in pool set or `InfraRules/default.spec.computePools` when the custom object exists. ## InfraRules ```yaml apiVersion: hevlayer.com/v1alpha1 kind: InfraRules metadata: name: default spec: computePools: - name: cpu kind: cpu nodeSelector: layer.hev.dev/node-role: worker-cpu layer.hev.dev/compute: cpu tolerations: - key: layer.hev.dev/node-role operator: Equal value: worker-cpu effect: NoSchedule resources: requests: cpu: "1" memory: 2Gi limits: cpu: "2" memory: 4Gi maxReplicasPerWorkload: 32 - name: cpu-large kind: cpu nodeSelector: layer.hev.dev/node-role: worker-cpu layer.hev.dev/compute: cpu tolerations: - key: layer.hev.dev/node-role operator: Equal value: worker-cpu effect: NoSchedule resources: requests: cpu: "1" memory: 2Gi ephemeral-storage: 35Gi limits: cpu: "4" memory: 4Gi ephemeral-storage: 40Gi maxReplicasPerWorkload: 8 - name: gpu kind: gpu nodeSelector: layer.hev.dev/node-role: worker-gpu layer.hev.dev/compute: gpu tolerations: - key: layer.hev.dev/node-role operator: Equal value: worker-gpu effect: NoSchedule - key: nvidia.com/gpu operator: Exists effect: NoSchedule resources: requests: cpu: 250m memory: 4Gi nvidia.com/gpu: "1" limits: cpu: "2" memory: 10Gi nvidia.com/gpu: "1" maxReplicasPerWorkload: 4 documentCache: capGiB: 256 replicationFactor: 1 scaling: mode: autoscale nodes: min: 0 max: 1 ``` The operator validates that the object is named `default`. ## Compute pools Layer defines three built-in pools: | Pool | Use | | --- | --- | | `cpu` | General CPU workers. | | `cpu-large` | CPU workers that need local ephemeral-storage headroom. | | `gpu` | One-NVIDIA-GPU workers for embedding and inference. | The default pools select the Karpenter-backed worker nodes with `layer.hev.dev/node-role=worker-cpu` or `worker-gpu`. The default `gpu` pool also requests `nvidia.com/gpu: "1"` and includes the standard NVIDIA toleration. Apply `InfraRules/default` with different `nodeSelector`, `gpuType`, or resource envelopes when your cluster uses different worker pool names or specific SKUs. | Field | Purpose | | --- | --- | | `name` | Referenced by `spec.scaling.pool` on Pipeline and Function resources. | | `kind` | Pool class label such as `cpu` or `gpu`. | | `gpuType` | Optional descriptive GPU type for GPU pools. | | `nodeSelector` | Applied to worker pods that choose the pool. | | `tolerations` | Applied to worker pods that choose the pool. | | `resources` | Container resources applied to worker pods. | | `maxReplicasPerWorkload` | Hard ceiling for one Pipeline or Function. | If a workload names an unknown pool or asks for more replicas than the pool ceiling, the operator leaves the workload unready and records a condition on its status. ## Workload scaling ```yaml scaling: pool: gpu mode: autoscale warmWindowSeconds: 300 replicas: min: 0 max: 4 ``` | Mode | Behavior | | --- | --- | | `autoscale` | Emit a KEDA `ScaledObject` and let queue depth scale the Deployment between `min` and `max`. | | `fixed` | Set Deployment replicas to `replicas.min`; no KEDA object is emitted. | | `disabled` | Scale the Deployment to 0; no KEDA object is emitted. | | Field | Purpose | | --- | --- | | `pool` | Names a built-in pool, or a pool in `InfraRules/default.spec.computePools` when custom rules exist. When omitted, the operator maps `worker.computeClass` to the stock `cpu` or `gpu` pool. | | `mode` | `autoscale`, `fixed`, or `disabled` (see the table above). | | `replicas` | `min`/`max` bounds for the Deployment. `max` may not exceed the pool's `maxReplicasPerWorkload`. | | `warmWindowSeconds` | Cooldown that holds a workload warm after its last scaling trigger drains, before `autoscale` returns it to `replicas.min`. See below. | Paused workloads also scale to 0. To keep a cold-start-heavy worker warm, set `mode: autoscale` and `replicas.min: 1`. ### Warm window `warmWindowSeconds` maps to the KEDA `ScaledObject` `cooldownPeriod`: the operator waits this long after the last trigger fires before scaling the Deployment back to `replicas.min`. It defaults to `60` when unset. A non-zero value also annotates the worker pods with `karpenter.sh/do-not-disrupt`, so Karpenter retains the *node* — not just the replica — for the window rather than consolidating it away. This is aimed at scale-to-zero GPU pools, where each wake otherwise pays a full cold start (fresh nodeclaim, multi-GB image pull, model load). A warm window lets adjacent batches reuse one warm node, then lets the pool return to genuine scale-to-zero once the window elapses. It must be `>= 0` and requires `mode: autoscale`; the operator leaves the workload unready and records a condition otherwise. ## Document cache rules `documentCache` captures the operator-owned document cache settings: capacity, replication factor, and node count. Helm still renders the document-cache KEDA object directly; `InfraRules` is the declared policy shape the operator reports and validates against. # Store capability matrix Source: https://hevlayer.com/docs/pro/stores import Edition from "../../components/docs/Edition.astro"; import StoreMatrix from "../../components/docs/StoreMatrix.astro"; import DocsExamplesObserved from "../../components/docs/DocsExamplesObserved.astro"; Layer serves one API across its configured stores. Each cell below comes from the gateway's backend capability declaration. [Wire protocol matching](/docs/pro/concepts#wire-protocol-matching) explains the contract, and [how we validate it](#how-we-validate-it) is below the matrix. Every page offers a picker when more than one backend is documented for the edition, so the selection stays with you as you navigate. ## Matrix ## Reading the cells **Supported** means the declared feature is available. **Approximate** means it is served with the limits described in the cell. **Unsupported** means the store cannot serve that feature and returns `422 UnsupportedByStore`. Capabilities describe individual features, not every combination of options. The [schema limits](#schema-limits) table beneath the matrix gives the numeric bounds behind the multiple-fields row. Two request shapes express hybrid retrieval, and each has its own row. A store accepts Supported routes and Approximate routes within their stated limits. Unsupported routes reject. | Route | Request | Row | |---|---|---| | `HybridText` | `rank_by: [field, "HybridText", input]` — the gateway runs one ranked query per leg and fuses with RRF | [HybridText rank operator](#feature-hybrid) | | Multi-query | a `queries` body — independent legs, fused by the store when `rerank_by` is set | [Multi-query](#feature-multi_query) | On Postgres, `HybridText` is approximate — served only with the shape its cell states (`fuzziness: 0`, no cursor or temporal filter) — and a `queries` or `rerank_by` body returns `422 UnsupportedByStore` naming `multi_query`. ## How we validate it Matching the wire does not promise identical index internals, latency, scores, or ranking across stores. In particular, [full-text ranking](#fts-ranking) is backend-specific. Layer's additional request fields, routes, and response metadata are documented as gateway enhancements. - **API and client contracts.** The SDK harness compares the gateway OpenAPI operations and generated Python client with the upstream API, and checks captured HTTP requests against documented examples using a mock server. These checks catch route, field, and serialization drift; they do not prove that a real backend returns the right results. - **Backend acceptance.** Store-specific suites send requests through a real gateway and backend using generated clients. They check supported operations and explicit rejection of unsupported requests. The Postgres suite runs against the Compose database. - **Documented examples.** A committed selection of upstream examples runs against a real gateway and store. Each request is classified as **ok**, **unsupported**, **fail**, or **blocked** by a prerequisite. A baseline change fails the check for review; matching a baseline can still preserve known failures. This is a selected test corpus, not proof that every upstream request or combination works. The matrix is generated from backend declarations and checked for source drift. It states the contract; acceptance results are evidence of behavior. Both are needed to assess compatibility. ## The fail-fast contract Unsupported requests fail with `422 UnsupportedByStore`. The gateway does not silently drop unsupported predicates or substitute a different backend. The body names the store, the route, and the rejected feature: ```json { "error": "UnsupportedByStore", "store": "pgvector", "route": "/v2/namespaces/traces", "feature": "patch_rows", "message": "UnsupportedByStore: pgvector: patch_rows" } ``` `feature` is a stable identifier. Match on it; do not parse `message`. - A wire-feature id from the [matrix](#matrix) when one owns the request: `multi_query` for a `queries` or `rerank_by` body, `patch_rows`, `search_after` for `cursor` or `searchAfter`, `conditional_writes` for a `patch_condition` on Postgres. The same string appears in the matrix and the 422. - Otherwise the rejected wire key, dotted when nested: a second embedded attribute on Postgres is `schema.embed` and a chunked one is `embed.chunk`; a filter operator is its name, such as `Contains`. - Schema-count limits use the limit name, such as `max_vector_fields` for a second vector attribute on Postgres. `message` stays human-readable and starts with `UnsupportedByStore: {store}: {feature}`; any detail follows after a colon. `feature` is additive, so existing clients that read `error` and `message` are unaffected, and clients ignore values they do not know. A rejection that names no single feature carries no `feature` field. ```python from hevlayer import AsyncHevlayer, HevlayerError try: await client.write_namespace("traces", body) except HevlayerError as e: if e.error == "UnsupportedByStore" and e.feature == "multi_query": ... # choose the HybridText route instead ``` # Tradeoffs Source: https://hevlayer.com/docs/pro/tradeoffs import Edition from "../../components/docs/Edition.astro"; import StoreNote from "../../components/docs/StoreNote.astro"; import FeatureGate from "../../components/docs/FeatureGate.astro"; Layer makes a set of design tradeoffs we believe improve functionality of your search system. This page makes those tradeoffs explicit. As this list grows, we will offer configuration where possible to allow users to configure their preference. Layer adds latency to the query path in the following ways. - An additional network hop (not configurable). - A query plan that allows for [stable reads](/docs/pro/api/query#stable-reads) during heavy writes ([index configurable](/docs/pro/indexes)). - The same query plan also checks [RBAC entitlements](/docs/pro/api/keys#entitlements) on every request (not configurable). On a turbopuffer-backed namespace, Layer also increases index storage requirements via. - A secondary indexing for filtering by upsert time (not configurable). - A secondary indexing used for scatter gather sharding (not configurable). # Agentic search Source: https://hevlayer.com/docs/pro/api/agents import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import Preview from "../../../components/docs/Preview.astro"; import FeatureGate from "../../../components/docs/FeatureGate.astro"; import Callout from "../../../components/docs/Callout.astro"; **Agentic search** runs a configured reasoning loop over one or more namespaces and returns the same row shape as every other search endpoint. A model reads the query, fans out diverse phrasings in parallel via layer's [scatter/gather](/docs/pro/concepts#scattergather), ranks the candidates for relevance, and returns a ranking fused from both signals. It is a better-ranked result set, not a generated answer — the response is the [federated query](/docs/pro/api/federated-query#response) shape, so any client that reads `/v2/query` reads this with no changes. The endpoint names a configured [`Agent`](#configuration) in the path. The model, the turn budget, the indices, and the output shaping are bound on that resource, so the request body is just a query, an optional query embedding, and a result count. ``` POST /v2/agents/{name}/query ``` ## Request ```python response = await client.query_agent("support-search", { "query": "auth errors after the june upgrade", "top_k": 20, }) ``` ```go response, err := client.QueryAgent(ctx, "support-search", &hevlayer.AgentQueryRequest{ Query: "auth errors after the june upgrade", TopK: 20, }) ``` ```typescript const response = await client.queryAgent("support-search", { query: "auth errors after the june upgrade", top_k: 20, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/agents/support-search/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "auth errors after the june upgrade", "top_k": 20 }' ``` | Field | Purpose | | --- | --- | | `query` | The natural-language query. The agent reformulates it; you do not pre-shape it into a route expression. | | `vector` | Optional. The embedding of `query`, used for the agent's semantic recall leg. See [Bring your own embedding](#bring-your-own-embedding). | | `top_k` | Rows to return after fusion. | The model, fan-out, fusion weighting, and output are bound on the [`Agent`](#configuration), which keeps the request trivial and makes the agent the single source of truth for what a call costs and what it can read. `query` and `vector` are the only request inputs, and they are data, not config — there are no per-request overrides of the agent's configured behavior. ### Bring your own embedding The agent fans out for recall over both routes: a lexical leg on your query text and a semantic leg on a query vector. Layer never embeds query text — you supply the vector, the same bring-your-own-embedding contract as [`/v2/query`](/docs/pro/api/query). Pass it as `vector`: ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/agents/support-search/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "auth errors after the june upgrade", "vector": [0.0123, -0.0456, 0.0789], "top_k": 20 }' ``` `vector` is the embedding of `query`. The agent uses this one vector for every planned semantic leg — it does not embed the reformulated phrasings, so the vector carries the query's semantic intent while the text reformulations broaden lexical recall. Embed `query` with the same model your indices were embedded with, and match their dimensionality. Omit `vector` and the agent's semantic legs fall back to the lexical route: recall runs on the reformulations alone, which is the right behavior when an index has no vector column or you have no embedder on the client. ## Response The response is the [federated query](/docs/pro/api/federated-query#response) shape: `rows` each carrying `$namespace`, `$rank`, and a native `$score`/`$dist`, plus a `merge` block and a `namespaces` block. The merge names the dual-score fusion. ```json { "rows": [ { "id": "T-4821", "$namespace": "tickets", "$rank": 1, "$score": 9.7, "subject": "SSO login fails after upgrade" } ], "merge": { "method": "weighted-rrf", "route": "dual-score" }, "namespaces": [ { "namespace": "tickets", "stable_as_of": 1747300000123, "matched": 20 } ] } ``` By default the response is byte-shape-identical to a [federated query](/docs/pro/api/federated-query): a client cannot tell whether a reasoning loop produced it. Set [`output.provenance`](#output) on the agent to surface the scores. ### Provenance and trace With provenance on, the response gains an `agent` echo and each row carries a `$agent` field with both scores: ```json { "rows": [ { "id": "T-4821", "$namespace": "tickets", "$rank": 1, "$score": 9.7, "$agent": { "retrievalScore": 3, "relevanceScore": 0.92, "query": "authentication failure post-upgrade", "queryIndex": 0 } } ], "merge": { "method": "weighted-rrf", "route": "dual-score" }, "namespaces": [ { "namespace": "tickets", "stable_as_of": 1747300000123, "matched": 20 } ], "agent": { "turns": 2, "deadlineHit": false, "recallDepth": 50, "relevanceWeight": 0.6, "queries": [ { "namespaces": ["tickets"], "rankBy": "hybridText", "query": "authentication failure post-upgrade", "filters": { "created_at": { "$gte": "2026-06-01" } } } ] } } ``` | Field | Meaning | | --- | --- | | `$agent.retrievalScore` | The row's rank within the leg that first surfaced it — the per-query position (1-based; lower is better), not the fused-pool rank. With `fanout` > 1 a row can appear in several legs; this records the first leg's rank. The fused recall signal is computed separately (RRF over every leg the row appeared in). | | `$agent.relevanceScore` | The model's graded relevance for the row (the precision signal). | | `$agent.query` | The planned variant that first surfaced the row. | | `$agent.queryIndex` | Zero-based index of that planned variant in `agent.queries`. | | `agent.turns` | Model turns the call spent. | | `agent.deadlineHit` | True when the deadline ended the request early and returned the best ranking so far. | | `agent.queries` | The planned variants: route, reformulated text, and inferred filters. | With [`output.trace`](#output) the `agent` echo also carries the full reasoning trace. The trace is written to the [search-history](/docs/pro/api/search-history) record whether or not it is echoed, so agentic and plain searches share one surface for evaluation. ## Auth Auth follows the same model as the other API endpoints; multi-namespace queries follow [federated query](/docs/pro/api/federated-query#entitlements) auth behavior. A minted key additionally needs an `agent.` entitlement on its [ApiKey](/docs/pro/api/keys#entitlements) to invoke the agent. ## Validation | Condition | Status | | --- | --- | | `{name}` is not a known, `Ready` agent | 404 | | The minted key lacks the `agent.` entitlement | 403 | | A bound index is outside the key's namespace grant | 403 | | `query` is empty | 422 | | `vector` is present and its dimensionality does not match the bound indices' vector schema | 422 | | Deadline hit with `onDeadline: bestEffort` | 200, best ranking so far, `agent.deadlineHit: true` | | Deadline hit with `onDeadline: error` | 504 | | The provider is unreachable on both primary and fallback | 502 | ## Configuration An `Agent` is a saved agentic-search configuration as a resource. It binds an inference model, a turn budget, and a set of [indices](/docs/pro/indexes) to a name, so a caller searches with [`POST /v2/agents/{name}/query`](#request) and sends only a query string. Everything that decides what a call costs and what it can read lives on the resource, not in the request. An agent adds no retrieval primitive. Its only tool is the [federated query](/docs/pro/api/federated-query); it sits one level above the [`Auto`](/docs/pro/api/query#query-routing) router, using a model to reformulate the query, fan out for recall, and score the candidates for relevance. What it returns is the same row shape as every other search endpoint — a better-ranked result set, not a generated answer. An Agent has two authoring surfaces that round-trip through one schema: `kubectl get agent -o yaml` and `client.agent("support-search").apply()` are two spellings of the same object. ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Agent metadata: name: support-search namespace: layer spec: model: provider: openrouter name: anthropic/claude-haiku-4-5 fallback: anthropic/claude-sonnet-4-6 # optional; used on primary timeout/error apiKeySecretRef: name: openrouter key: credential budget: deadlineMs: 60000 onDeadline: bestEffort indices: [docs, tickets] retrieval: fanout: 8 recallDepth: 50 rankBy: auto relevanceWeight: 0.6 output: provenance: false trace: false status: phase: Ready conditions: - type: SecretResolved status: "True" - type: ModelReachable status: "True" - type: IndicesResolved status: "True" ``` Everything the request omits is bound on the `Agent` resource: the model and its credential, the deadline, the indices, the fan-out and fusion weighting, and the output shaping. Save the Agent manifest as `agent.yaml` and apply it to the gateway's Kubernetes namespace: ```bash kubectl apply -n layer -f agent.yaml kubectl get agent support-search -n layer -o yaml ``` The SDK query methods invoke an existing Agent; configure it through Kubernetes. ## Model | Field | Purpose | | --- | --- | | `provider` | Inference backend. `openrouter`. | | `name` | Model id passed to the provider, e.g. `anthropic/claude-haiku-4-5`. | | `fallback` | Optional model used when the primary times out or errors. | | `apiKeySecretRef` | Secret holding the provider credential (`name`, `key`). The token is never inline on the resource — the same rule the [ApiKey](/docs/pro/api/keys) and [VectorStore](/docs/pro/api/vectorstores) CRDs follow. | The gateway's in-memory agent spec also accepts a raw inline `model.apiKey` token. This exists only as a local dev-seed escape hatch for running an agent without provisioning a Secret. It is deliberately absent from the `Agent` CRD — the operator rejects anything but `apiKeySecretRef`, and RFC 0074 bans a raw token on the resource. Do not use it in any deployed configuration. ## Budget | Field | Purpose | | --- | --- | | `deadlineMs` | Wall-clock deadline for the whole request. | | `onDeadline` | `bestEffort` (default) returns the best ranking the agent has when the deadline hits; `error` fails the request instead. | ## Indices `indices` is the set of namespaces the agent searches, passed to the [federated query](/docs/pro/api/federated-query) as its `namespaces`. The operator checks that the list is present and every entry is a non-empty string, surfaced as the `IndicesResolved` condition; it does not currently confirm each entry names a namespace that exists. The agent reads each listed namespace under the caller's credential, so a caller only reaches the namespaces its own key grants. ## Retrieval All retrieval behavior is expressed against the [federated query](/docs/pro/api/federated-query) — nothing new reaches the upstream. | Field | Default | Purpose | | --- | --- | --- | | `fanout` | `8` | Query reformulations the agent issues, run in parallel via layer's [scatter/gather](/docs/pro/concepts#scattergather) and merged for recall. Higher fan-out trades latency for recall. | | `recallDepth` | `50` | Candidates gathered for ranking before `top_k`, at least `top_k`. Bounds how much the model reads. | | `rankBy` | `auto` | Default route per leg: `auto`, `hybridText`, or `semantic`. A `semantic` leg needs a query vector, which the caller supplies on the request (`vector`) — layer never embeds query text; see [Agentic search](#bring-your-own-embedding). Without a supplied vector, semantic legs fall back to the lexical route. | | `relevanceWeight` | `0.6` | Weight of the relevance score against the recall score in the final ranking. | ## Output | Field | Purpose | | --- | --- | | `provenance` | When true, each row carries a `$agent` field with its `retrievalScore` and `relevanceScore`, and the response gains a top-level `agent` echo. | | `trace` | When true, the `agent` echo also carries the full reasoning trace. | Default off, the response is byte-for-byte the [federated query](/docs/pro/api/federated-query#response) shape: a client cannot tell a reasoning loop produced it. ## Status `status` carries health and validation only; latency, turn counts, and spend go to metrics and history, never into etcd. | Condition | Meaning | | --- | --- | | `SecretResolved` | `model.apiKeySecretRef` exists and is readable. | | `ModelReachable` | the provider answered with the bound credential. | | `IndicesResolved` | `spec.indices` is non-empty and every entry is a non-empty string. (It does not yet verify each entry is a known namespace.) | | Phase | Meaning | | --- | --- | | `Ready` | Resolvable and callable. | | `Degraded` | Reachable but a condition is failing — calls may fall back or error. | | `Invalid` | A required field or reference does not resolve; calls are refused. | `kubectl get agent` print columns: `MODEL`, `INDICES`, `PHASE`. Edits are picked up shortly after they apply: the gateway resolves agents — spec, provider credential, and per-index schema — into memory and refreshes on a periodic tick, so a `kubectl apply` lands within the refresh interval rather than instantly. ## Naming Agent names are cluster-unique at the gateway because callers address agents by name only. If two namespaces define the same Agent name, the gateway sorts by agent name and namespace, keeps the lexicographically later namespace, and logs a warning naming both namespaces. Keep Agent names unique across the cluster. ## Observability Deadline-hit rate, provider latency, and token usage per agent export as `hevlayer_*` metrics. Token counts come back on the inference response, so they cost no extra call; dollar cost is derived from them downstream rather than fetched in the request path. The reasoning trace is written to the [search-history](/docs/pro/api/search-history) record alongside the query it belongs to, so agentic and plain searches share one history surface for evaluation. # Blobs Source: https://hevlayer.com/docs/pro/api/blobs import StoreNote from "../../../components/docs/StoreNote.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import Edition from "../../../components/docs/Edition.astro"; Blobs store opaque bytes under their sha256 and serve them through the gateway. A row never stores the bytes themselves. It stores an ordinary string attribute such as `image_blob: "blob://products/"`. Use blobs for media, source files or other binary payloads that rows point at but that should not ride the document write path. ## Routes | Route | Method | Behavior | | --- | --- | --- | | `PUT /v1/namespaces/{ns}/blobs` | PUT | Store raw bytes by sha256 and return a `blob://` reference. | | `GET /v1/namespaces/{ns}/blobs/{sha256}` | GET | Serve the bytes by sha256. | | `POST /v1/namespaces/{ns}/blobs/move_to_s3` | POST | Move a store-held blob set to S3. See [Move a blob set to S3](#move-a-blob-set-to-s3). | ## Where the bytes live Blobs go to the S3-compatible object store set by `S3_BUCKET`, or to the namespace's store when it has a native binary type. The routes and the `blob://` reference are the same whichever backend holds the bytes. `LAYER_BLOB_STORAGE` on the gateway chooses between them for stores that could hold the bytes themselves: | `LAYER_BLOB_STORAGE` | Blobs a native store could hold | | --- | --- | | `auto` (default) | S3 on turbopuffer namespaces when `S3_BUCKET` is set; the store otherwise. | | `s3` | S3 whenever `S3_BUCKET` is set. | | `store` | The store, up to its per-value cap. | turbopuffer namespaces default to S3 because turbopuffer bills each blob read as a query, by the size of the whole blob namespace rather than the blob returned. Reading many blobs from a large blob namespace multiplies that cost. An S3 read is billed per request and by the bytes it returns. Blobs larger than the store's per-value cap, and every blob on a store without native bytes, go to S3 under every setting. | Store | Backend | Largest blob in the store | | --- | --- | --- | | turbopuffer | A `{ns}__hevlayer_blobs` namespace, one row per sha256 with a `bytes` attribute | 6 MiB | | Postgres (pgvector) | The `layer_pgvector.blobs` table, one `bytea` row per sha256 | 10 MiB (the gateway cap) | | Stores without native bytes | S3 | None; every blob goes to S3 | The gateway accepts blobs up to 10 MiB. turbopuffer caps a `bytes` value at 8 MiB of base64 on the wire, which is 6 MiB of blob. A blob between 6 MiB and 10 MiB on turbopuffer goes to S3. With no `S3_BUCKET`, a blob that does not fit in the store is rejected rather than dropped: | Case | Status | `error` | | --- | --- | --- | | Blob over 10 MiB | 413 | `payload_too_large` | | Blob over the store's cap, no S3 | 413 | `blob_exceeds_store_cap` | | Store without native bytes, no S3 | 422 | `object_store_not_configured` | A turbopuffer blob namespace appears in namespace listings next to the namespace it serves, and deleting a namespace does not delete its blobs. The per-store values are in the [store capability matrix](/docs/pro/stores#blobs). An Index can set one namespace's blob storage, over the gateway's `LAYER_BLOB_STORAGE`: ```yaml spec: blobs: storage: s3 # or store; auto follows LAYER_BLOB_STORAGE ``` ## Move a blob set to S3 A namespace that switched to S3 still has the blobs it wrote to its turbopuffer blob namespace. Reads find them there until they move. A move copies the blob namespace to S3 in pages of up to 10,000 blobs, one turbopuffer query per page and never one per blob: ```bash curl -X POST "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs/move_to_s3" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"max_pages": 1}' ``` ```json { "namespace": "products", "done": false, "cursor": "7d1a54127b222502f5b79b5fb0803061...", "pages": 1, "moved": 9987, "already_in_s3": 13, "mismatched": 0, "moved_bytes": 2147483648, "scanned_bytes": 2150215680, "page_size": 10000, "billable_bytes_queried": 10737418240, "billable_bytes_returned": 2863311530, "updated_at": "2026-10-13T18:04:11Z", "run": { "pages": 1, "moved": 9987, "already_in_s3": 13, "mismatched": 0, "moved_bytes": 2147483648, "scanned_bytes": 2150215680, "billable_bytes_queried": 10737418240, "billable_bytes_returned": 2863311530 } } ``` Each call moves up to `max_pages` pages (default 1) and returns. Call it again until `done` is `true`. The top-level counters are totals for the whole move; `run` is what this call did. `billable_bytes_queried` and `billable_bytes_returned` are what turbopuffer billed for the move's queries. | Field | Default | Meaning | | --- | --- | --- | | `page_size` | Sized by bytes | Blobs per turbopuffer query, 1 to 10,000. Unset, the first page is 100 blobs and later pages hold about 256 MiB of blobs at the mean size seen so far, up to 10,000. The gateway holds a whole page in memory. | | `max_pages` | `1` | Pages this call moves before returning. | | `restart` | `false` | Start again from the first blob, even after the move finished. | A move is safe to rerun or interrupt. Progress is saved in the object store after every page, so the next call starts after the last page saved. A blob already in S3 is not rewritten. A row whose bytes do not hash to its sha256 is counted in `mismatched` and not copied. The move is `done` when a page comes back empty, so the last call makes one query that finds nothing. Once `done` is `true`, further calls return the totals without querying turbopuffer. A finished move changes reads. Before it finishes, a blob missing from S3 is looked up in the turbopuffer blob namespace. After it finishes, S3 is the only backend, and a blob missing from S3 is a 404 with no turbopuffer query. The move leaves the turbopuffer blob namespace in place. Delete it yourself once reads are served from S3. A blob written to the store after a move finished (because the namespace switched back to `store`, or a gateway had not picked up the switch yet) reopens the move: S3 misses ask the store again, and the next call copies the new blob. Deleting a namespace, or branching onto its name, clears its move. The namespace's blob storage must already resolve to S3: the route returns 409 when it resolves to the store, and 422 `object_store_not_configured` without `S3_BUCKET`. The move needs an admin-scoped key. ## Store ```python with open("image.jpg", "rb") as f: stored = await client.put_blob("products", f.read()) print(stored.ref) ``` ```go body, _ := os.ReadFile("image.jpg") stored, err := client.PutBlob(ctx, "products", body, nil) ``` ```typescript import fs from "node:fs/promises"; const bytes = await fs.readFile("image.jpg"); const stored = await client.putBlob("products", bytes); ``` ```bash curl -X PUT "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/octet-stream" \ --data-binary @image.jpg ``` Response: ```json { "ref": "blob://products/9f86d081884c7d659a2feaa0c55ad015...", "sha256": "9f86d081884c7d659a2feaa0c55ad015...", "size": 48213 } ``` The same bytes always return the same reference. The route rejects empty bodies and bodies over 10 MiB. Write the returned `ref` as a normal row attribute: ```json { "id": "B0123", "vector": [0.1, 0.2], "image_blob": "blob://products/9f86d081884c7d659a2feaa0c55ad015..." } ``` The removed document `blobs` payload shape is still rejected. Binary bytes do not traverse `/v2/namespaces/{ns}` writes. ## Fetch ```python image = await client.get_blob("products", stored.sha256) ``` ```go image, err := client.GetBlob(ctx, "products", stored.Sha256) ``` ```typescript const image = await client.getBlob("products", stored.sha256); ``` ```bash curl "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs/$SHA256" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -o image.jpg ``` Successful responses include immutable cache headers: ```http Cache-Control: public, max-age=31536000, immutable ETag: "" ``` The gateway sniffs common image types (`jpeg`, `png`, `gif`, `webp`) for `Content-Type`; otherwise it returns `application/octet-stream`. ## Warm Policy Aerospike is the hot cache in front of whichever backend holds the bytes. A cache miss reads the durable backends in the namespace's order (S3, then the store for blobs not yet [moved](#move-a-blob-set-to-s3), when it keeps blobs in S3; the store, then S3, otherwise) and backfills Aerospike best-effort. `PUT ...?warm=true` writes one object through to cache. For bulk warming, declare the row attributes containing blob references in the Index's `spec.blobs.referenceAttributes`, then call `hint_cache_warm` with `blobs=true` and a positive `blob_budget_bytes`: ```yaml spec: blobs: referenceAttributes: [image_blob] ``` ```bash curl "$LAYER_GATEWAY_URL/v1/namespaces/products/hint_cache_warm?turbopuffer=false&documents=false&snapshots=false&blobs=true&blob_budget_bytes=104857600" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` This example warms only blobs, up to 100 MiB of cached bytes. The gateway scans origin rows, follows same-namespace references in the declared attributes (strings or arrays), and deduplicates objects. It stops before caching the first object that would exceed the budget; the response reports `blobs.budget_exhausted`. The budget bounds bytes written to cache, not reads from the store or S3: concurrent reads may fetch beyond the cutoff. Missing objects and invalid references are counted and skipped. See [Warm cache](/docs/pro/api/warm-cache#blob-warming) for SDK examples and response fields. # Edge bundle Source: https://hevlayer.com/docs/pro/api/bundle `POST /v2/namespaces/{ns}/bundle` exports text rows for local BM25 search. The calling Layer key needs read scope on the namespace. Open access and upstream pass-through credentials cannot export a bundle. The route is available only in Pro. It does not run a ranked query or create a background job. ```json { "filters": ["division", "Eq", "north"], "include_attributes": ["title", "job"], "max_rows": 7500 } ``` All three fields are required. The filter selects the division; the attribute list selects the fields the browser may receive. IDs are always included. Vectors, vector fields and fields outside that list are never exported. The row cap must be between 1 and 7,500. Crossing it fails the entire request. The export also has a 32 MiB content limit and a 60 second time limit. The response contains `manifest` and `content`. Content is a JSON string in micropuffer 0.2.0 `fromStore` format. The manifest records the namespace, scan-start `stable_as_of` watermark, row count, exported schema, tokenizer and SHA-256 of the exact UTF-8 content string. It declares `consistency: "observed"`: this is a consistent-enough observation, **not a snapshot**. The gateway checks its watermark again after scanning. If it moved, the gateway retries once; another movement fails with 412. A missing watermark also fails with 412. This check cannot detect every external write or deletion. Export a quiescent division when the host needs reproducible rows. The pilot accepts full-text string fields configured with `word_v2` only. A `word_v4` field fails with a clear error, because micropuffer's `fromStore` would silently use a different tokenizer. Other tokenizers and text processing options are rejected too. Configure a separate `word_v2` text field for this pilot if the origin uses `word_v4`. The manifest names the tokenizer actually used; it does not claim origin-tokenizer parity. ## Load and suggest Keep gateway credentials on the host server. The host chooses where to publish the bundle and who can fetch it; every exported attribute is readable by anyone with access to those bytes. The loader accepts that URL or the response object. ```ts import { loadBundle } from 'hevlayer/edge'; const edge = await loadBundle('/bundles/north.json'); const result = edge.suggest('elec', { collapseBy: 'job', topK: 10, candidates: 100, }); console.log(result.rows); ``` The loader verifies the hash before loading, then prewarms the lazy BM25 index. It uses `last_as_prefix` on the bundle's full-text field. For multiple text fields, pass `field` to `loadBundle` as its second argument. Collapse follows the gateway's ordered fallback rules: `collapseBy` can be an attribute or a list of up to four attributes. Each row uses the first present value; absent, null and empty strings count as missing. `document` resolves to `_hevlayer_parent_id` when exported, otherwise to the row ID. The optional `missing` setting accepts `own` (default), `together`, `drop`, or an object with `mode` and scalar placeholder `values`. Groups keep candidate rank order, retain all candidate members, and expose their best row in `rows`. `topK` limits groups, while `candidates` limits the ranked window before collapse. More candidates can expose more jobs. A bundle does not refresh itself; the host decides when to export and replace it. Micropuffer ships WASM imports. Browser builds need a bundler with WASM module support. Node can use `--experimental-wasm-modules`. Call `edge.free()` when replacing a loaded bundle to release its WASM allocation. # Checkpoints Source: https://hevlayer.com/docs/pro/api/checkpoints import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import Preview from "../../../components/docs/Preview.astro"; Checkpoints give an application a stable name for a known-good namespace cut. Creating a checkpoint labels the newest durable [snapshot](/docs/pro/api/snapshots) body for the namespace and stores that small label record in S3. It does not run a scan or write rows. Use checkpoints when a downstream app needs to browse, diff, or drop data by a named catalog cut instead of by an ad hoc `catalog_run_id` filter. The checkpoint response includes the snapshot `watermark_ms`, content `sha`, and `row_count` added since the previous checkpoint. ## Routes | Route | Method | Behavior | | --- | --- | --- | | `POST /v2/namespaces/{ns}/checkpoints` | POST | Create or return an immutable checkpoint label. | | `GET /v2/namespaces/{ns}/checkpoints` | GET | List checkpoints newest first. | | `GET /v2/namespaces/{ns}/checkpoints/{label}` | GET | Resolve one checkpoint label. | ## Create ```python checkpoint = await client.create_checkpoint("products", { "label": "catalog-2026-06-15", }) ``` ```go checkpoint, err := client.CreateCheckpoint(ctx, "products", &hevlayer.CreateCheckpointRequest{Label: "catalog-2026-06-15"}) ``` ```typescript const checkpoint = await client.createCheckpoint("products", { label: "catalog-2026-06-15", }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/checkpoints" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"label": "catalog-2026-06-15"}' ``` Response: ```json { "namespace": "products", "label": "catalog-2026-06-15", "watermark_ms": 1749513600000, "sha": "3f9e8b21...", "row_count": 10000 } ``` Re-posting the same `label` returns the existing checkpoint unchanged, even if newer snapshots have landed. Labels are namespace-local and may contain ASCII letters, numbers, `-`, `_`, `.`, and `:`. The namespace must already have at least one durable snapshot. If no snapshot body exists yet, creation returns `412 precondition_failed`. ## List ```python page = await client.list_checkpoints("products", limit=20) ``` ```go page, err := client.ListCheckpoints(ctx, "products", &hevlayer.ListCheckpointsParams{Limit: 20}) ``` ```typescript const page = await client.listCheckpoints("products", { limit: 20 }); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/products/checkpoints?limit=20" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` ```json { "checkpoints": [ { "namespace": "products", "label": "catalog-2026-06-15", "watermark_ms": 1749513600000, "sha": "3f9e8b21...", "row_count": 10000 } ], "next_cursor": null } ``` | Query param | Default | Purpose | | --- | --- | --- | | `limit` | 50 | Maximum entries returned. Capped at 500. | | `before` | none | Opaque cursor from the previous page's `next_cursor`. | ## Resolve ```python checkpoint = await client.get_checkpoint("products", "catalog-2026-06-15") ``` ```go checkpoint, err := client.GetCheckpoint(ctx, "products", "catalog-2026-06-15") ``` ```typescript const checkpoint = await client.getCheckpoint("products", "catalog-2026-06-15"); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/products/checkpoints/catalog-2026-06-15" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` Resolve returns `404 not_found` when the label does not exist in that namespace. # Data connections Source: https://hevlayer.com/docs/pro/api/data-supply ## VectorStores See [VectorStores](/docs/pro/api/vectorstores) to list and inspect serving connections. ## Warehouses See [Warehouses](/docs/pro/api/warehouses) to list and inspect upstream source connections. # Embed Source: https://hevlayer.com/docs/pro/api/embed import Edition from "../../../components/docs/Edition.astro"; import Callout from "../../../components/docs/Callout.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; import Upstream from "../../../components/docs/Upstream.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; The `embed` schema block and the `Embed` rank expression use Turbopuffer's request shapes. Availability depends on the selected store's capabilities. The Layer API also includes gateway-managed operations; support depends on the selected backend. Add an `embed` block to a string attribute in your schema and Layer computes that attribute's vector on every write. Put `["Embed", "..."]` in `rank_by` and Layer computes the query vector with the same model. Your application never loads a model or sends a vector of its own. ## Example This write creates the `articles` namespace with an embedded `text` attribute and upserts two rows. Layer embeds each row's `text` with MiniLM, one of the [CPU models](#cpu-models), before storing it. ```python await client.write_namespace("articles", { "upsert_rows": [ {"id": "planet-1", "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System."}, {"id": "photo-1", "title": "Photosynthesis", "text": "Plants turn sunlight, water, and carbon dioxide into food."}, ], "distance_metric": "cosine_distance", "schema": { "text": { "type": "string", "embed": { "model": "sentence-transformers/all-MiniLM-L6-v2", "dims": 384, "serving": {"prefer": "local"}, }, }, }, }) ``` ```go _, err := client.WriteNamespace(ctx, "articles", hevlayer.TurbopufferWriteRequest{ "upsert_rows": []map[string]any{ {"id": "planet-1", "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System."}, {"id": "photo-1", "title": "Photosynthesis", "text": "Plants turn sunlight, water, and carbon dioxide into food."}, }, "distance_metric": "cosine_distance", "schema": map[string]any{ "text": map[string]any{ "type": "string", "embed": map[string]any{ "model": "sentence-transformers/all-MiniLM-L6-v2", "dims": 384, "serving": map[string]any{"prefer": "local"}, }, }, }, }) ``` ```typescript await client.writeNamespace("articles", { upsert_rows: [ { id: "planet-1", title: "Planet", text: "Jupiter is the biggest planet in the Solar System." }, { id: "photo-1", title: "Photosynthesis", text: "Plants turn sunlight, water, and carbon dioxide into food." }, ], distance_metric: "cosine_distance", schema: { text: { type: "string", embed: { model: "sentence-transformers/all-MiniLM-L6-v2", dims: 384, serving: { prefer: "local" }, }, }, }, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/articles" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "upsert_rows": [ {"id": "planet-1", "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System."}, {"id": "photo-1", "title": "Photosynthesis", "text": "Plants turn sunlight, water, and carbon dioxide into food."} ], "distance_metric": "cosine_distance", "schema": { "text": { "type": "string", "embed": { "model": "sentence-transformers/all-MiniLM-L6-v2", "dims": 384, "serving": { "prefer": "local" } } } } }' ``` Query by meaning with `Embed`. Layer reads the model from the `text` schema and embeds the query string with it: ```python response = await client.query_namespace("articles", { "rank_by": ["text", "ANN", ["Embed", "largest planet in the solar system"]], "top_k": 3, "include_attributes": ["title", "text"], }) print(response.rows, response.performance) ``` ```go response, err := client.QueryNamespace(ctx, "articles", &hevlayer.QueryRequest{ RankBy: []any{"text", "ANN", []any{"Embed", "largest planet in the solar system"}}, TopK: 3, IncludeAttributes: []string{"title", "text"}, }) ``` ```typescript const response = await client.queryNamespace("articles", { rank_by: ["text", "ANN", ["Embed", "largest planet in the solar system"]], top_k: 3, include_attributes: ["title", "text"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/articles/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["text", "ANN", ["Embed", "largest planet in the solar system"]], "top_k": 3, "include_attributes": ["title", "text"] }' ``` ```jsonc { "rows": [ { "id": "planet-1", "$dist": 0.137, "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System." } ], "performance": { "embedding_tokens": 7, "embedding_ms": 9 } } ``` ## Where the vector comes from `embed.serving.prefer` picks the provider that runs the model: | `embed.serving.prefer` | Who computes the vector | | --- | --- | | `native` | The default. On Turbopuffer, Layer forwards the declaration and Turbopuffer embeds. On a store that cannot embed, Layer picks a provider once; see [per-store resolution](#per-store-resolution). | | `turbopuffer` | Turbopuffer's hosted embedding service, from its managed model menu. Layer calls it on any store and writes the vector itself. It supports `instructions`, `revision`, and `chunk`. | | `local` | The locally configured embedder: the `layer-embed` CPU service the gateway reaches at `LAYER_EMBED_URL`, or an in-process Lattice or CLIP artifact. No key, no GPU, no account. | | `worker` | A GPU container in your own cluster, serving an open-weights model you declared. | The provider is independent of the store the vector is written to, so any store that accepts `embed` can use any configured provider. Layer never substitutes one provider for another. A profile that selects a provider the deployment has not configured fails validation at write time, and a provider that fails at request time returns an error instead of falling back to a different model. `autoscaler` is accepted as an alias for `turbopuffer`, and `lattice` for `local` on a [Lattice](/docs/pro/api/lattice) profile. `native` is its own value, not an alias. All four values are available. Layer validates a `native` profile and forwards it unchanged to the upstream `POST /v2/namespaces/{ns}` and `POST /v2/namespaces/{ns}/query`, so Turbopuffer computes the vector and it never passes through Layer. `turbopuffer`, `local`, and `worker` compute the vector in Layer and write an ordinary ANN column. A `turbopuffer` profile gets its vector from the same hosted service a `native` one does, but through Layer, which is what lets it take the Layer extensions. ### Per-store resolution Omitting `serving` selects `native`. Only Turbopuffer can compute that vector itself. On a store that cannot embed, Layer computes the vector and the store receives an ordinary vector column. The rule depends on the store, never on the request: | Store | `native` (the default) | `turbopuffer`, `local`, `worker` | | --- | --- | --- | | Turbopuffer | Forwarded unchanged, and Turbopuffer embeds. | Layer embeds and writes an ANN column. | | hev search | Gateway-resolved through the configured Turbopuffer embedding provider. | Layer embeds. | | Postgres | Gateway-resolved: the `local` embedder if it lists the model, otherwise the configured Turbopuffer embedding provider. | Layer embeds. | Layer resolves a gateway-resolved profile once, on the write that declares the attribute, and the namespace keeps that provider for good. If neither provider serves the model, the write returns `503` and nothing is stored. Postgres takes the same `embed` declaration as Turbopuffer. On Community Edition Compose, which runs the CPU service and has no Turbopuffer key, a profile naming one of the [CPU models](#cpu-models) needs no `serving` block: ```json { "schema": { "text": {"type": "string", "full_text_search": true, "embed": {"model": "BAAI/bge-small-en-v1.5"}} }, "upsert_rows": [ {"id": "planet-1", "text": "Jupiter is the biggest planet in the Solar System."} ] } ``` Layer embeds each row before the write's transaction, so if the provider fails, no rows or schema are written. The vector lands in `embed_` (or `embed.attribute`), declared `[N]f32` on the first write that carries rows; a schema-only write declares no vector column yet. `distance_metric` is optional and defaults to `cosine_distance`. `rank_by: [attr, "ANN", ["Embed", text]]` and `Auto` both embed the query text. Multi-query bodies are not supported on Postgres; use `Auto` or `HybridText`. The profile is stored in Postgres next to the namespace schema, so it survives a gateway restart with no object store configured, and `GET .../schema` returns the `embed` block you wrote. These return `422 UnsupportedByStore`, and nothing is written: | Request | `feature` | | --- | --- | | A second attribute declares `embed` | `schema.embed` | | A later write changes `model`, `dims` or the provider | `schema.embed` | | A client `vector` in the same namespace | `max_vector_fields` | | `embed.chunk` on a profile other than `turbopuffer` or `worker` | `embed.chunk` | A Postgres namespace holds either gateway-embedded vectors or client vectors, never both. To change models, write to a new namespace. [Chunking](#chunking-long-documents) works on Postgres when the profile sets `serving.prefer: turbopuffer` or `worker`. ## CPU models Layer serves three CPU embedding models, small enough to run on a laptop under Docker. They use the same schema as the larger GPU and hosted models, with lower retrieval quality. Community Edition Compose starts the text service next to the gateway, and the Helm chart runs it as a gateway sidecar; neither needs a key, GPU or download after the image pull, and the service has no route out of the machine. | Model | Dims | Modality | Use it for | | --- | --- | --- | --- | | `sentence-transformers/all-MiniLM-L6-v2` | 384 | text | Start here. 22M parameters, symmetric, no prefixes. | | `BAAI/bge-small-en-v1.5` | 384 | text | Better retrieval for 50% more parameters. Try it when MiniLM's results are weak. | | `openai/clip-vit-base-patch32` | 512 | image | Text-to-image search. Both towers run on CPU. | The two text models are served by `layer-embed`, a separate CPU process whose image contains both sets of weights. The gateway reaches it over a private network at `LAYER_EMBED_URL` (for example `http://embed:8081`); nothing is downloaded at startup, and the gateway image carries no model weights. Set the variable and `prefer: local` resolves any model the service advertises. Leave it unset and a `prefer: local` write that names a text model returns `422`. CLIP runs inside the gateway process from the directory named by `LAYER_LOCAL_CLIP_MODEL_PATH`; see [Images](#images). There is no default model: a `prefer: local` profile always names one. Omit `dims` and Layer uses the dimension the service advertises for that model. Every model here pins a fixed artifact; `revision`, `instructions`, and `chunk` are not accepted on a local profile. Query embedding has a 10 second budget and each write 60 seconds, shared by every batch of that write. Set `LAYER_EMBED_QUERY_TIMEOUT_MS` and `LAYER_EMBED_WRITE_TIMEOUT_MS` to change them. A service that is still loading returns `503` for local profiles only; native and GPU profiles are unaffected. ### Text The two text models are embedded differently, and Layer handles both; the [example](#example) at the top of this page uses MiniLM. bge expects an instruction prefix on queries and MiniLM expects none, so the gateway adds the prefix exactly once, only when it embeds a query, and never on the text it stores. bge uses CLS pooling and MiniLM uses mean pooling. Both outputs are L2-normalized, so `cosine_distance` scores are comparable across rows. Switching a namespace between them requires a [full re-index](#changing-models-requires-a-re-index). ### Images With `modality: image` on a CLIP model, Layer embeds each attribute value with the image tower at write time and embeds `Embed` query text with the text tower, against the same vector column. Both towers run on CPU beside the gateway. ```python await client.write_namespace("photos", { "upsert_rows": [ {"id": "commons-131", "title": "Sunset at Porto Covo", "image_url": "https://upload.wikimedia.org/…/640px-Porto_Covo.jpg"}, ], "distance_metric": "cosine_distance", "schema": { "image_url": { "type": "string", "embed": { "model": "openai/clip-vit-base-patch32", "dims": 512, "modality": "image", "serving": {"prefer": "local"}, }, }, }, }) ``` ```go _, err := client.WriteNamespace(ctx, "photos", hevlayer.TurbopufferWriteRequest{ "upsert_rows": []map[string]any{ {"id": "commons-131", "title": "Sunset at Porto Covo", "image_url": "https://upload.wikimedia.org/…/640px-Porto_Covo.jpg"}, }, "distance_metric": "cosine_distance", "schema": map[string]any{ "image_url": map[string]any{ "type": "string", "embed": map[string]any{ "model": "openai/clip-vit-base-patch32", "dims": 512, "modality": "image", "serving": map[string]any{"prefer": "local"}, }, }, }, }) ``` ```typescript await client.writeNamespace("photos", { upsert_rows: [ { id: "commons-131", title: "Sunset at Porto Covo", image_url: "https://upload.wikimedia.org/…/640px-Porto_Covo.jpg" }, ], distance_metric: "cosine_distance", schema: { image_url: { type: "string", embed: { model: "openai/clip-vit-base-patch32", dims: 512, modality: "image", serving: { prefer: "local" }, }, }, }, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/photos" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "upsert_rows": [ {"id": "commons-131", "title": "Sunset at Porto Covo", "image_url": "https://upload.wikimedia.org/…/640px-Porto_Covo.jpg"} ], "distance_metric": "cosine_distance", "schema": { "image_url": { "type": "string", "embed": { "model": "openai/clip-vit-base-patch32", "dims": 512, "modality": "image", "serving": { "prefer": "local" } } } } }' ``` Query the image column with text: ```python response = await client.query_namespace("photos", { "rank_by": ["image_url", "ANN", ["Embed", "sunset over water"]], "top_k": 16, "include_attributes": ["title", "image_url"], }) ``` ```go response, err := client.QueryNamespace(ctx, "photos", &hevlayer.QueryRequest{ RankBy: []any{"image_url", "ANN", []any{"Embed", "sunset over water"}}, TopK: 16, IncludeAttributes: []string{"title", "image_url"}, }) ``` ```typescript const response = await client.queryNamespace("photos", { rank_by: ["image_url", "ANN", ["Embed", "sunset over water"]], top_k: 16, include_attributes: ["title", "image_url"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/photos/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["image_url", "ANN", ["Embed", "sunset over water"]], "top_k": 16, "include_attributes": ["title", "image_url"] }' ``` ```jsonc { "rows": [ { "id": "commons-131", "$dist": 0.412, "title": "Sunset at Porto Covo", "image_url": "https://upload.wikimedia.org/…/640px-Porto_Covo.jpg" } ], "performance": { "embedding_tokens": 5, "embedding_ms": 134 } } ``` Write responses report `embedding_images` instead of `embedding_tokens`. CPU CLIP handles query-time text embeds and write-time image embeds for small and medium corpora. A bulk image backfill needs a GPU. The provider is set per namespace, so a backfill can run on a GPU while queries embed on CPU. Declare the checkpoint for that backfill under [open-weights models on your own GPU](#open-weights-models-on-your-own-gpu). The [lens demo](https://lens.hevlayer.com) runs this setup: Wikimedia Commons Quality images embedded and searched on CPU, with the gateway's `performance` echo shown beside each result. Source at [github.com/hev/lens](https://github.com/hev/lens). ### Image input An image profile accepts an HTTP(S) URL or a base64 image string and fetches at most 20 MiB per URL. A rate-limited image host returns HTTP `429` with error `upstream_error`; a server error from the image host returns HTTP `503` with error `service_unavailable`. Layer preserves the host's `Retry-After` response header for both. Malformed URLs or base64, non-retryable 4xx responses, oversize content, and undecodable images return `422 validation_error`. ### Changing models requires a re-index The two text models both emit 384 dimensions, so switching between them leaves the namespace schema valid. It still requires re-embedding every row. Different models place vectors in unrelated spaces. If documents were embedded with MiniLM and queries are embedded with bge, the distances compare incompatible vectors and the ranking is meaningless. The same applies when moving from a CPU model to a hosted or GPU model, and when the weights, tokenizer, pooling, or prefixes behind a model id change: equal dimensions never make two vector spaces compatible. Layer records the artifact fingerprint a local profile was declared with. A write that names a different model for an attribute that already has rows, or a write or `Embed` query after the service starts serving a different artifact for the pinned model, returns `422` and names the pinned model. Re-index from your source data into a fresh namespace so every vector comes from the new model, verify it, then switch your application to it. ### Bringing your own weights You can mount a model directory into the `layer-embed` service to add or override text checkpoints. Set `LAYER_EMBED_MODELS_DIR` on that service to a read-only bundle in the same manifest format as its baked models: a directory with a `manifest.json` listing each model's files with their sizes and SHA-256 hashes. Mounted entries override baked entries by model id. The service verifies every listed file, loads and warms the whole menu, and only then reports ready; the gateway discovers the merged menu at startup. Only BERT text models with 384-dimensional CLS or mean pooling are accepted. The mount is optional and the bundled models need none. Overriding a model id with different weights changes its artifact fingerprint, so existing namespaces on that id need a [full re-index](#changing-models-requires-a-re-index) after the restart. Under Compose, mount the directory into the `embed` service read-only and set `LAYER_EMBED_MODELS_DIR` to its path in a Compose override file. The stock Compose file mounts nothing. On Kubernetes, the Helm chart provisions a mounted bundle with the opt-in `gateway.localModels` values. An init container copies the bundle from a private S3 prefix, verifies `manifest.json` against a pinned SHA-256 before the pod starts, and mounts the directory read-only into the embedding sidecar; the sidecar then verifies each file against the manifest. A checksum mismatch fails the pod instead of serving unverified weights. The gateway container itself never downloads a checkpoint. For a single static retriever that trades retrieval quality for microsecond embeds and a few megabytes of deployment size, see [Lattice](/docs/pro/api/lattice). ## Open-weights models on your own GPU To use a stronger retrieval model than the CPU menu offers, run it on a GPU in your own cluster. You build a container with the model's weights inside it, declare it in `InfraRules`, and name the model in a schema with `serving.prefer: worker`. Layer runs the container on a GPU pool, scales it to zero when idle, and sends it every embedding for that model. ### Build the container The container must serve two endpoints on its declared port: `GET /health`, which succeeds once the model is loaded, and the OpenAI-compatible `POST /v1/embeddings`. The weights go inside the image. Layer downloads no weights and passes the container no model arguments, so the image's own entrypoint starts the server with the model configured. The server must reject an input longer than the model's limit, not truncate it. Hugging Face [text-embeddings-inference](https://github.com/huggingface/text-embeddings-inference) (TEI) serves both endpoints. Download the checkpoint at a pinned revision, keeping only the files TEI loads: ```sh hf download BAAI/bge-m3 \ --revision 5617a9f61b028005a4858fdac845db406aefb181 \ --include "*.json" pytorch_model.bin sentencepiece.bpe.model \ --exclude "onnx/*" \ --local-dir ./bge-m3 ``` Then copy it into the TEI image for your GPU: ```dockerfile # The tag prefix is the GPU's compute capability: 86 for A10G, 89 for L4. FROM ghcr.io/huggingface/text-embeddings-inference:86-1.9 COPY bge-m3/ /model/ ENV HF_HUB_OFFLINE=1 \ PORT=8080 CMD ["--model-id", "/model", "--served-model-name", "BAAI/bge-m3"] ``` `--served-model-name` must equal the `model` you declare. Layer rejects a response whose `model` field names anything else. `HF_HUB_OFFLINE=1` makes a missing file fail at startup instead of triggering a download. Push the image to a registry your cluster can pull from. The image is the model's identity; see [image identity](#image-identity). ### Declare it Each container is declared once, under `spec.embedders` in `InfraRules/default`. Add it to the existing object and keep its `computePools`: the object must list at least one pool, and the embedder's pool must be one of them. See [compute pools](/docs/pro/scaling#compute-pools) for the pool definitions. ```yaml apiVersion: hevlayer.com/v1alpha1 kind: InfraRules metadata: name: default spec: # computePools: unchanged. It must include the pool named below. embedders: - name: bge-m3 model: BAAI/bge-m3 image: /embed-bge-m3:2026-09-29 port: 8080 dims: 1024 maxInputTokens: 8192 scaling: pool: gpu mode: autoscale warmWindowSeconds: 300 replicas: min: 0 max: 2 ``` | Field | Purpose | | --- | --- | | `name` | Required. A DNS label of at most 48 characters, unique among embedders. The operator creates a Deployment and Service named `layer-embedder-`. | | `model` | Required. The model id a schema names in `embed.model`. Each model may be declared by only one embedder. | | `image` | Required. Your container, by tag or digest. See [image identity](#image-identity). | | `port` | The port serving `/health` and `/v1/embeddings`. Defaults to `8080`. | | `dims` | Required, positive. The dimension the model emits. A schema declaring a different `embed.dims` fails validation, and so does a response of the wrong length. | | `maxInputTokens` | Required, positive. The model's input limit. See [input limit](#input-limit). | | `scaling` | Required. The same [workload scaling](/docs/pro/scaling#workload-scaling) block Pipelines and Functions use. | | `scaling` field | Default | | --- | --- | | `pool` | `gpu` | | `mode` | `autoscale` | | `replicas.min` | `0` | | `replicas.max` | None. Required, at least `1` under `autoscale`, and at most the pool's `maxReplicasPerWorkload`. | | `warmWindowSeconds` | `60` | `fixed` mode requires `replicas.min` to equal `replicas.max`. `disabled` removes the scaler and sets replicas to zero. Adding or changing an embedder is an edit to this object. The gateway reads the declaration on demand, so it needs no gateway image rebuild or redeploy. Removing an entry deletes the Deployment, Service, and scaler the operator created for it. ### Use it Name the model in the schema. Compared with the CPU example, only `model`, `dims`, and `serving.prefer` change: ```python await client.write_namespace("articles", { "upsert_rows": [ {"id": "planet-1", "text": "Jupiter is the biggest planet in the Solar System."}, ], "distance_metric": "cosine_distance", "schema": { "text": { "type": "string", "embed": { "model": "BAAI/bge-m3", "dims": 1024, "serving": {"prefer": "worker"}, }, }, }, }) ``` ```go _, err := client.WriteNamespace(ctx, "articles", hevlayer.TurbopufferWriteRequest{ "upsert_rows": []map[string]any{ {"id": "planet-1", "text": "Jupiter is the biggest planet in the Solar System."}, }, "distance_metric": "cosine_distance", "schema": map[string]any{ "text": map[string]any{ "type": "string", "embed": map[string]any{ "model": "BAAI/bge-m3", "dims": 1024, "serving": map[string]any{"prefer": "worker"}, }, }, }, }) ``` ```typescript await client.writeNamespace("articles", { upsert_rows: [ { id: "planet-1", text: "Jupiter is the biggest planet in the Solar System." }, ], distance_metric: "cosine_distance", schema: { text: { type: "string", embed: { model: "BAAI/bge-m3", dims: 1024, serving: { prefer: "worker" }, }, }, }, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/articles" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "upsert_rows": [ {"id": "planet-1", "text": "Jupiter is the biggest planet in the Solar System."} ], "distance_metric": "cosine_distance", "schema": { "text": { "type": "string", "embed": { "model": "BAAI/bge-m3", "dims": 1024, "serving": { "prefer": "worker" } } } } }' ``` Queries are unchanged. Moving a namespace from a CPU model to a GPU model means editing those fields and [re-indexing](#changing-models-requires-a-re-index). ### Status The operator reports each embedder under `status.embedders`: ```yaml status: embedders: - name: bge-m3 model: BAAI/bge-m3 image: /embed-bge-m3:2026-09-29 imageDigest: sha256: endpoint: http://layer-embedder-bge-m3..svc.cluster.local:8080 conditions: - type: Ready status: "True" reason: Ready ``` `Ready` is always present. When it is `False`, a second condition says why: | Condition | Meaning | | --- | --- | | `Ready` | The container passes `GET /health` and its image digest is recorded. | | `Waking` | Replicas are requested. Waiting for scheduling, image pull, and readiness. | | `Sleeping` | Scaled to zero. The next request wakes it. | | `ImagePullFailed` | Kubernetes could not pull the declared image. | | `CrashLooping` | The container keeps exiting. | ### Image identity A declared `@sha256:` digest is recorded at once. For a tag, the operator records the digest Kubernetes pulled on the first wake and pins the Deployment to it. The digest survives scale to zero and operator restarts, so moving the tag in your registry does not change the model an embedder runs. Changing `image` in the declaration resets it. The first write to a namespace stores the image digest and `dims` in its embedding profile. A later write or query that finds a different digest, dimension, model, or instructions returns `422`, even when a vector for the input is cached. A rebuilt image is a new model: re-index into a fresh namespace, as in [changing models](#changing-models-requires-a-re-index). ### Input limit OpenAI-compatible servers expose no tokenizer endpoint, so the gateway cannot count tokens the way the model does. It checks a conservative budget instead: each prepared input's length in UTF-8 bytes, plus two special tokens, must not exceed `maxInputTokens`. The budget includes any instruction prefix. A longer input returns `422` before anything is sent to the container. A token is almost always longer than one byte, so this check rejects some inputs the model would accept. With `maxInputTokens: 8192`, an input may be at most 8,190 bytes. Set `maxInputTokens` to the model's real limit, not a lower safety margin, and split long text with [chunking](#chunking-long-documents). ### Cold starts With `replicas.min: 0`, the first embedding after an idle period waits for Kubernetes to provision a GPU node, pull your image (often several gigabytes) and load the model. Expect **several minutes** before the first vector. One observed figure: on Layer's production cluster on 2026-09-29, a bge-m3 embedder at zero replicas took about 3 min 54 s (233.6 s) from the first write to an HTTP 200 on retry. That included provisioning a g5.xlarge node and pulling the image. The first response was a `503`, returned in 233 ms. Your cluster's time depends on its node provisioning, registry, and image size; this is not a guarantee. While an embedder is waking, Layer returns `503` with `Retry-After: 5` and does not hold the request open. It returns the `503` even when the vector for that input is cached. Retry after the `Retry-After` interval. Every request for an embedder, including one that gets a `503`, counts toward `hevlayer_embedder_demand{embedder}`, the number of requests in the last 60 seconds. KEDA polls it every 15 seconds, targets one replica per counted request between `replicas.min` and `replicas.max`, and wakes a sleeping embedder when it rises above zero. After demand falls to zero, the embedder stays up for `warmWindowSeconds`, KEDA's cooldown, before it returns to `replicas.min`. For interactive search, keep one replica warm with `replicas.min: 1`, or set `warmWindowSeconds` long enough to cover the gaps between your bursts of traffic. ### A bigger model Bigger dense models follow the same steps. Build the container with the new weights, declare it on a pool with enough GPU memory, and re-index. For example, a 4B-parameter model that expects an instruction before each query: ```yaml - name: qwen3-embed-4b model: Qwen/Qwen3-Embedding-4B image: /embed-qwen3-4b:2026-09-29 dims: 2560 maxInputTokens: 32768 scaling: pool: gpu-48g # not built in; declared under spec.computePools mode: autoscale warmWindowSeconds: 300 replicas: { min: 0, max: 2 } ``` The built-in pools are `cpu`, `cpu-large`, and `gpu`. A pool with larger GPUs, here `gpu-48g`, is one you add under `spec.computePools` in the same object, with its own `nodeSelector`, `gpuType`, and resources. See [compute pools](/docs/pro/scaling#compute-pools). ```json "text": { "type": "string", "embed": { "model": "Qwen/Qwen3-Embedding-4B", "dims": 2560, "serving": { "prefer": "worker" }, "instructions": { "query": "Instruct: Given a question, retrieve passages that answer it\nQuery: " } } } ``` Layer applies the instruction to queries only, once, before sending them to the container. The container never adds its own. ### Errors | Condition | Response | | --- | --- | | No `InfraRules` embedder declares the model, or more than one does | `422 validation_error`, naming the model | | `embed.dims` differs from the declared `dims`, or the profile sets `revision` or `modality: image` | `422 validation_error` | | The namespace's stored digest, dimension, model, or instructions differ from the current declaration | `422 validation_error`; re-index into a fresh namespace | | A prepared input exceeds the [input limit](#input-limit) | `422 validation_error` | | Embedder asleep, waking, not yet digest-pinned, or unreachable, or the container returns `503` | `503 service_unavailable`, with `Retry-After: 5`, even when the vector is cached | | Container does not answer within 60 seconds | `504 gateway_timeout` | | Response with the wrong count, a missing or repeated index, the wrong dimension, a non-finite value, a norm more than 1e-4 from 1, or a different `model` | `502 upstream_error`; nothing is cached or written | | Container returns any other error status | `502 upstream_error` | Layer never falls back to another provider. An undeclared model is a configuration error you can fix, so Layer returns a `4xx` that names the model. `503` means a declared model is not serving yet; the embedder's condition on the `InfraRules` status says why. ## Query with `Embed` `["Embed", text]` computes a query vector from `text` using the model settings of the attribute being ranked. When `rank_by` names the source attribute, Layer reads the model from its schema: ```python response = await client.query_namespace("clinical-notes", { "rank_by": ["text", "ANN", ["Embed", "chest pain radiating to left arm"]], "top_k": 10, }) print(response.rows) ``` ```go response, err := client.QueryNamespace(ctx, "clinical-notes", &hevlayer.QueryRequest{ RankBy: []any{"text", "ANN", []any{"Embed", "chest pain radiating to left arm"}}, TopK: 10, }) ``` ```typescript const response = await client.queryNamespace("clinical-notes", { rank_by: ["text", "ANN", ["Embed", "chest pain radiating to left arm"]], top_k: 10, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/clinical-notes/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["text", "ANN", ["Embed", "chest pain radiating to left arm"]], "top_k": 10 }' ``` When `rank_by` names the derived vector attribute `embed_`, pass the model explicitly. Omitting it returns `422` with `a model name must be provided`. ```python response = await client.query_namespace("clinical-notes", { "rank_by": ["embed_text", "ANN", ["Embed", "chest pain radiating to left arm", { "model": "acme/clinical-retrieval-v3", }]], "top_k": 10, }) ``` ```go response, err := client.QueryNamespace(ctx, "clinical-notes", &hevlayer.QueryRequest{ RankBy: []any{"embed_text", "ANN", []any{"Embed", "chest pain radiating to left arm", map[string]any{"model": "acme/clinical-retrieval-v3"}}}, TopK: 10, }) ``` ```typescript const response = await client.queryNamespace("clinical-notes", { rank_by: ["embed_text", "ANN", ["Embed", "chest pain radiating to left arm", { model: "acme/clinical-retrieval-v3", }]], top_k: 10, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/clinical-notes/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["embed_text", "ANN", ["Embed", "chest pain radiating to left arm", { "model": "acme/clinical-retrieval-v3" }]], "top_k": 10 }' ``` Query vectors are cached for 60 seconds by default. Set `LAYER_EMBED_CACHE_TTL_MS` to change the TTL. Instruction prefixes are part of the cache key. A missing provider returns `503 service_unavailable`. ### `Embed` with `Auto` An inline `Embed` lets [query routing](/docs/pro/api/query#query-routing) run a semantic or fused query in one request: ```python response = await client.query_namespace("articles", { "rank_by": ["title", "Auto", "how plants turn sunlight into food", { "vector": ["Embed", "how plants turn sunlight into food", {"field": "text"}], }], "top_k": 10, }) ``` ```go response, err := client.QueryNamespace(ctx, "articles", &hevlayer.QueryRequest{ RankBy: []any{"title", "Auto", "how plants turn sunlight into food", map[string]any{ "vector": []any{"Embed", "how plants turn sunlight into food", map[string]any{"field": "text"}}, }}, TopK: 10, }) ``` ```typescript const response = await client.queryNamespace("articles", { rank_by: ["title", "Auto", "how plants turn sunlight into food", { vector: ["Embed", "how plants turn sunlight into food", { field: "text" }], }], top_k: 10, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/articles/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["title", "Auto", "how plants turn sunlight into food", { "vector": ["Embed", "how plants turn sunlight into food", {"field": "text"}] }], "top_k": 10 }' ``` The first tuple field (`title`) is the attribute for the lexical legs. `field` inside `Embed` names the attribute whose schema supplies the embedding profile and vector column (`text` above). Omit `field` when both are the same attribute. Layer chooses the route before it resolves `Embed`, so a short input routed to `hybrid_text` never calls the embedding provider. A semantic or fused response reports the routing decision (`routing.policy: "v1"`, `routing.executed: true`) and merges embedding measurements into `performance`. Without a vector or an inline `Embed`, the router returns `routing.executed: false` and leaves embedding to the caller. ## Model settings The full `embed` block: | Field | Meaning | | --- | --- | | `model` | A provider-namespaced Hugging Face repo id. | | `dims` | The vector dimension. Must match what the model emits. | | `attribute` | Where the vector is stored. Defaults to `embed_`, and may not be the source attribute. | | `serving.prefer` | `native` (the default), `turbopuffer` (alias `autoscaler`), `local` (alias `lattice`), or `worker`. See [where the vector comes from](#where-the-vector-comes-from). | | `modality` | `text` (the default) or `image`. `image` requires a CLIP-family model. | | `revision` | Pins a checkpoint revision, including a finetuned one. | | `instructions.document` / `instructions.query` | Prefixes for asymmetric retrieval models. Both affect the query-cache key. | | `chunk` | Splits the attribute into one row per chunk on write. See [chunking long documents](#chunking-long-documents). | The first write that establishes an embedded schema must also set `distance_metric`. `revision`, `instructions`, `chunk`, and `modality` are Layer extensions. Layer applies them itself and never forwards them to the store. A profile that sets any of them must use `serving.prefer: turbopuffer` or `worker`, or the write returns `422`. The one exception is a CLIP profile with `modality: image` on `local`, which may not set the other three. A `turbopuffer` profile with an extension also needs a configured Turbopuffer embedding provider, and returns `503` without one. A `worker` profile embeds text only and rejects `revision`; bake the revision into the container image instead. ## Chunking long documents A single vector for a long document matches queries poorly, so long sources should be split into chunks and each chunk embedded as its own row. Add `embed.chunk` to the schema and Layer splits the attribute on write. The split runs on the gateway's CPU during the write request, and only the chunks go to the embedding provider. This profile splits `text` into windows of up to 512 tokens that overlap by 64: ```python await client.write_namespace("manuals", { "upsert_rows": [ {"id": "pump-manual", "title": "Pump maintenance", "text": long_text}, ], "distance_metric": "cosine_distance", "schema": { "text": { "type": "string", "embed": { "model": "BAAI/bge-m3", "dims": 1024, "serving": {"prefer": "worker"}, "chunk": { "strategy": "recursive", "unit": "tokens", "tokenizer": "BAAI/bge-m3", "size": 512, "overlap": 64, }, }, }, }, }) ``` ```go _, err := client.WriteNamespace(ctx, "manuals", hevlayer.TurbopufferWriteRequest{ "upsert_rows": []map[string]any{ {"id": "pump-manual", "title": "Pump maintenance", "text": longText}, }, "distance_metric": "cosine_distance", "schema": map[string]any{ "text": map[string]any{ "type": "string", "embed": map[string]any{ "model": "BAAI/bge-m3", "dims": 1024, "serving": map[string]any{"prefer": "worker"}, "chunk": map[string]any{ "strategy": "recursive", "unit": "tokens", "tokenizer": "BAAI/bge-m3", "size": 512, "overlap": 64, }, }, }, }, }) ``` ```typescript await client.writeNamespace("manuals", { upsert_rows: [ { id: "pump-manual", title: "Pump maintenance", text: longText }, ], distance_metric: "cosine_distance", schema: { text: { type: "string", embed: { model: "BAAI/bge-m3", dims: 1024, serving: { prefer: "worker" }, chunk: { strategy: "recursive", unit: "tokens", tokenizer: "BAAI/bge-m3", size: 512, overlap: 64, }, }, }, }, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/manuals" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "upsert_rows": [ {"id": "pump-manual", "title": "Pump maintenance", "text": "..."} ], "distance_metric": "cosine_distance", "schema": { "text": { "type": "string", "embed": { "model": "BAAI/bge-m3", "dims": 1024, "serving": { "prefer": "worker" }, "chunk": { "strategy": "recursive", "unit": "tokens", "tokenizer": "BAAI/bge-m3", "size": 512, "overlap": 64 } } } } }' ``` Layer keeps the original row and adds one row per chunk. A chunk row copies the original's attributes, replaces the embedded attribute with the chunk text, and gets the id `{id}#{n}` plus two generated attributes: `_hevlayer_parent_id` (the original id) and `_hevlayer_chunk_index` (`n`). A query returns chunk rows, and `_hevlayer_parent_id` groups them back to their document. The original row has no chunk-sized text of its own. On Turbopuffer, which requires a vector on every row, it is stored with the vector of its first chunk, so it can also match a query. Postgres stores it without a vector, so queries return only chunk rows. Only chunk rows have `_hevlayer_parent_id` set. A row whose text fits in one chunk is written unchanged. A chunked `serving.prefer: turbopuffer` profile is embedded on the Layer side, like an unchunked one, and needs a configured Turbopuffer embedding provider. Without one the write returns `503` and nothing is stored. | `embed.chunk` field | Meaning | | --- | --- | | `strategy` | `fixed`, `recursive`, `sentence`, `markdown`, `section`, or `none`. | | `unit` | `characters` (the default) or `tokens`. | | `size` | Maximum chunk length in `unit`s. Required unless `strategy` is `none`. | | `overlap` | Units repeated between adjacent chunks. Must be smaller than `size`. | | `tokenizer` | The Hugging Face tokenizer that counts tokens. Required when `unit: tokens`. | The `section` strategy splits a structured row by field before windowing. Set `sectionSource: jsonFields` and list the source attributes in `fields`, and each field becomes its own section. The chunk id becomes `{id}#{field}#{n}` and the field name is stored in `sectionAttribute` (default `section`). Put the window settings for each section in a nested `split` block. Chunking has these limits: - The profile must use `serving.prefer: worker` or `turbopuffer`, on any store. - A namespace can have one chunked embedding attribute. - Chunking cannot be combined with `modality: image`. - Writes must use `upsert_rows` with a string `id` on every row. Columnar `upsert_columns` writes are rejected. Because the split runs inside the write request, a large document makes that write slower and uses gateway CPU that is also serving queries. For bulk ingest, or for sources that need extraction first (PDFs, scans), use a [two-stage pipeline](/docs/pro/api/pipelines). Its CPU worker extracts and chunks outside the gateway and scales separately, and its GPU stage embeds the staged chunks. The pipeline takes the same chunk settings; see [chunking](/docs/pro/api/pipelines#chunking). ## Performance accounting Write and query responses report embedding work under `performance`: ```jsonc { "rows": [ /* ... */ ], "performance": { "embedding_tokens": 8, "embedding_ms": 42 } } ``` Queries omit `embedding_tokens` on a cache hit. Layer merges provider measurements into the same object and exports the work as `hevlayer_embed_tokens_total` and `hevlayer_embed_compute_seconds_total`, labeled by namespace, store kind, model, and serving mode. # Federated query Source: https://hevlayer.com/docs/pro/api/federated-query import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; A **federated query** runs one query across a **set** of namespaces. `POST /v2/query` is namespace-less: the per-namespace [query endpoint](/docs/pro/api/query) names its namespace in the path, while this endpoint takes the set in the body, so the namespace is no longer a path parameter. It has no upstream equivalent — turbopuffer's multi-query is single-namespace, so the fan-out and the merge are a Layer composition over per-namespace reads. One ranking is spread across every namespace in the set and merged into a single ranked list — "search my whole feed / my whole library." For several **independent** rankings instead, query each namespace separately; a federated query always returns one fused list. ## Fan-out and fuse Supply one ranking and a `namespaces` set. The gateway runs the ranking against each namespace and returns one merged `rows` list, each row tagged with the namespace it came from. ```python response = await client.query({ "namespaces": ["moment-pod-changelog", "moment-pod-latent-space", "moment-pod-no-priors"], "rank_by": ["text", "Auto", "evaluating RAG systems"], "top_k": 12, "filters": ["published_at", "Gte", 1740000000], "include_attributes": ["text", "show", "source_url", "start_sec"], }) ``` ```go response, err := client.Query(ctx, &hevlayer.FederatedQueryRequest{ Namespaces: []string{"moment-pod-changelog", "moment-pod-latent-space", "moment-pod-no-priors"}, RankBy: []any{"text", "Auto", "evaluating RAG systems"}, TopK: 12, Filters: []any{"published_at", "Gte", 1740000000}, IncludeAttributes: []string{"text", "show", "source_url", "start_sec"}, }) ``` ```typescript const response = await client.query({ namespaces: ["moment-pod-changelog", "moment-pod-latent-space", "moment-pod-no-priors"], rank_by: ["text", "Auto", "evaluating RAG systems"], top_k: 12, filters: ["published_at", "Gte", 1740000000], include_attributes: ["text", "show", "source_url", "start_sec"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "namespaces": ["moment-pod-changelog", "moment-pod-latent-space", "moment-pod-no-priors"], "rank_by": ["text", "Auto", "evaluating RAG systems"], "top_k": 12, "filters": ["published_at", "Gte", 1740000000], "include_attributes": ["text", "show", "source_url", "start_sec"] }' ``` `rank_by` accepts the same vocabulary as a single-namespace query, including the Layer [`HybridText`](/docs/pro/api/query#hybrid-text-fusion) and [`Auto`](/docs/pro/api/query#query-routing) expressions. The query text is identical across the fan-out, so `Auto` routes **once** and the chosen route runs against every namespace — a single `routing` block is echoed. `filters` apply to every namespace. Name the explicit set to search, or pass `namespaces: ["*"]` to expand the authenticated key's namespace allowlist at request time. Omitting `namespaces` is equivalent to `["*"]`. ### Response One fused list. Each row carries `$namespace` (its origin) and `$rank` (its position within that namespace's results — the key the merge orders on). A `merge` block names the strategy, and a `namespaces` block reports each namespace's freshness and how many rows it returned to the merge. ```json { "rows": [ { "id": "ep123#7", "$namespace": "moment-pod-latent-space", "$rank": 1, "$score": 11.4, "show": "Latent Space", "text": "..." } ], "merge": { "method": "rank-interleave", "route": "fused" }, "routing": { "route": "fused", "policy": "v1", "tokens": 3, "executed": true }, "hybrid": { "tokens": ["evaluating", "rag", "systems"], "tokens_dropped": 0, "fuzziness": "auto", "rank_constant": 60, "legs": 4, "per_leg_limit": 60 }, "namespaces": [ { "namespace": "moment-pod-latent-space", "stable_as_of": 1747300000123, "matched": 14 }, { "namespace": "moment-pod-changelog", "stable_as_of": 1747299999001, "matched": 9 }, { "namespace": "moment-pod-no-priors", "stable_as_of": 1747300000050, "matched": 0 } ] } ``` | Field | Meaning | | --- | --- | | `$namespace` | The namespace this row came from. | | `$rank` | The row's 1-based rank **within its namespace**. The merge orders on this on a text route; see [Merge](#merge). | | `$score` / `$dist` | The row's native score within its namespace, carried as provenance. Comparable **within** a namespace, not across the fused list — do not threshold on it. On a vector route `$dist` is the merged ordering key and *is* globally comparable. | | `merge` | The merge applied. `method` is `"distance"` on a vector route, `"rank-interleave"` on a text route; `route` is currently always `"fused"` (reserved for future route-specific provenance). | | `namespaces` | Per-namespace echo: each reached namespace's `stable_as_of` watermark and `matched` — the rows it **returned to the merge** (up to [`per_namespace_limit`](#fusion-options)), not its surviving share of the final `top_k`. A namespace that matched nothing reports `matched: 0`. | `routing` and `hybrid` echo exactly as they do for a single-namespace [`Auto`](/docs/pro/api/query#query-routing) / [`HybridText`](/docs/pro/api/query#response) query, since the route runs once for the whole fan-out. ### Fusion options | Option | Default | Meaning | | --- | --- | --- | | `fusion.per_namespace_limit` | `clamp(2 × top_k, 10, 100)` | How many rows each namespace returns to the merge. Shallower than the single-namespace `per_leg_limit`: across a wide fan-out most namespaces contribute nothing to the final `top_k`, so deep per-namespace retrieval is wasted. Integer > 0. | | `fusion.rank_constant` | `60` | Reserved for route-dependent fusion tuning. It is accepted for forward compatibility and is inert in the current gateway. | ## Entitlements The `namespaces` set uses the same store-derived authentication path as individual namespace queries: the upstream key must be able to read each listed namespace. A namespace read failure is reported under [partial failure](#partial-failure) unless `strict` is enabled. For minted scoped keys, explicitly named namespaces must be inside a `vectorstore.` read grant. Naming a namespace outside the grant is a hard `403`, not a partial result. `namespaces: ["*"]` expands by listing upstream namespaces and filtering them through the key's vectorstore namespace globs, so the key is the feed/library set and the client does not enumerate it. ## Filters One `filters` expression is applied to **every** namespace in the set, and each namespace evaluates it independently against its own schema. So a filter only behaves uniformly when the attributes it references are part of a **shared filterable schema** across the set — present in every namespace, with the same type, and declared filterable. This is the filter analog of the [shared embedding space](#vector-merge-requires-a-matching-embedding-space) a vector merge needs: a federated query is only as coherent as the contract its namespaces share. A namespace that cannot evaluate the filter — the attribute is absent, has an incompatible type, or is not filterable there — is reported as `filter_schema_mismatch`, not as `matched: 0`. This is a per-namespace validity failure: the namespace did not participate in the filtered ranking because it could not evaluate the predicate. In default best-effort mode, the namespace is omitted from `rows`, listed in `errors`, and the response carries `x-layer-partial: true`. With `strict: true`, the whole request fails with `422`. This docs contract selects `422` for the strict-mode failure because the namespace set and filter expression are valid only if every required namespace can evaluate the filter. Keep filters to attributes declared filterable in every namespace in the set. Using the same embedding model does not imply a shared filterable schema; scope the set to namespaces that support the filters you send. ## Merge A federated query merges by the quantity the route makes comparable across namespaces: - **Vector route** (`ANN` over a query vector) — distances are comparable across namespaces that share an embedding space, so the gateway merges by `$dist` and the fused order is the exact global nearest-neighbor ranking. `merge.method` is `"distance"`. - **Text route** (`BM25`, `HybridText`, or a routed `fused`/`hybrid_text`) — BM25 and hybrid scores are **not** comparable across corpora (different term statistics; a hybrid `$score` is already a per-namespace fusion). The gateway merges by **rank-interleave**: rows are ordered by their `$rank` within their namespace, ties broken by `id`. `merge.method` is `"rank-interleave"`. No single pod dominates the head of the list; each contributes its best matches in rank order. The fused list exposes `$rank` as the ordering key and carries each row's native `$score` only as provenance. `top_k` truncates the merged list. Each namespace returns [`fusion.per_namespace_limit`](#fusion-options) rows to the merge. ### Vector merge requires a matching embedding space Merging by distance is only meaningful when every namespace in the set embeds into the same geometry: model, output dimension, normalization, and distance metric must match. A standalone namespace without an embedding profile uses the best-effort downgrade below, or fails when `strict: true`. Declare the profile through [`spec.embedding`](/docs/pro/indexes#embedding) and `spec.backend.distanceMetric` on the namespace's `Index`. The gateway compares those profiles before a vector fan-out uses distance merge: - All reached namespaces have the same profile → `merge.method: "distance"`. - A profile is missing or differs → best-effort responses downgrade to `merge.method: "rank-interleave"` and include `merge.downgraded_reason` (`"missing_embedding_profile"` or `"embedding_profile_mismatch"`). - With `strict: true`, a missing or mismatched profile is a `422`. A text fan-out is rank-interleave regardless, so embedding profiles do not affect it. ## Consistency Each namespace has its own [stable-read](/docs/pro/api/query#stable-reads) watermark; there is no single consistent cut across independent namespaces. The `namespaces` block reports each namespace's `stable_as_of`, and the response header `x-layer-stable-as-of` carries the **minimum** across the reached namespaces — the most conservative answer to "as of when." Layer does not manufacture a global watermark. ## Partial failure Across many namespaces, one may be deleted, time out, error upstream, or fail to evaluate the [filter](#filters). Unlike single-namespace [multi-query](/docs/pro/api/query#batch-query), the fan-out defaults to **best-effort**: it returns the namespaces that succeeded, lists the rest in an `errors` block, and sets `x-layer-partial: true`. A feed search does not blank because one pod is briefly unavailable. ```json { "rows": [ "..." ], "errors": [ { "namespace": "moment-pod-no-priors", "error": "Upstream error: namespace not found" } ] } ``` Availability failures carry a human-readable upstream message in `error`, not a stable machine code — match on `namespace`, not on the `error` string. Filter schema mismatches are the exception: they carry the stable `code: "filter_schema_mismatch"` plus a human-readable `detail` so clients can separate "namespace unavailable" from "namespace cannot evaluate this filter." ```json { "rows": [ { "id": "ep123#7", "$namespace": "moment-pod-latent-space", "$rank": 1 } ], "errors": [ { "namespace": "moment-pod-no-priors", "code": "filter_schema_mismatch", "error": "filter schema mismatch", "detail": "filter attribute published_at is absent, not filterable, or has an incompatible type" } ] } ``` Set `"strict": true` to opt into fail-fast: any namespace error fails the whole request. Reach for it when a missing namespace would make the result misleading rather than merely thinner. ## Pinned namespace concurrency Federated queries run up to `64` pinned and ready namespace legs concurrently, configured with `LAYER_PINNED_FEDERATED_QUERY_NAMESPACE_THREADS` (clamped to `1..512`). Other namespace legs retain `LAYER_FEDERATED_QUERY_NAMESPACE_THREADS` (default `16`). Mixed requests use separate lane limits with an overall ceiling of `512` when a pinned lane is present. Readiness uses the same [metadata observations and expiry as scans](/docs/pro/api/scans#pinning-and-fan-out). HybridText retains its `15`-token expansion limit regardless of pinning. Pinning changes scheduling capacity, not token selection or ranking semantics. ## Limits | Limit | Value | | --- | --- | | Namespaces per request | 512. Over the cap → `422` naming the excess. | | Pagination | Not supported. `cursor` is rejected — a fused cursor across independent namespaces does not form the monotone bands pagination relies on. | ## Validation | Condition | Status | | --- | --- | | A named namespace is outside the minted key's namespace grant | 403 | | A namespace read fails upstream | 200 with `errors`, or 502 with `strict: true` | | A namespace cannot evaluate `filters` (attribute absent, wrong type, or not filterable) | 200 with `errors[].code: "filter_schema_mismatch"` and `x-layer-partial: true`, or 422 with `strict: true` | | `namespaces` is empty | 422 | | `namespaces: ["*"]` mixed with other names | 422 | | `namespaces` set exceeds the cap | 422 | | Vector route missing/mismatched embedding profiles with `strict: true` | 422 | | `cursor` present | 422 | | `rank_by` expression invalid | 422 (same checks as a single-namespace query) | # Introduction Source: https://hevlayer.com/docs/pro/api/introduction import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; Layer supports migration through a Turbopuffer-compatible wire subset and adds gateway features across its backends, including agentic search. Compatibility is the entry point; the full Layer API is available through the Layer clients for Community Edition and Pro. The gateway selects the backend. Each API page describes the current integration's supported features and gaps. ## Install There are four ways to call Layer: the Python client, the Go client, the TypeScript client, and the REST API itself. The clients are generated from `apps/layer-gateway/openapi.yaml`, so all four expose the same operations — every endpoint page on this site shows them side by side. Anything the clients can do, plain HTTP can do. ```sh pip install hevlayer # Python 3.11+ go get github.com/hev/layer-go # Go 1.22+ npm install hevlayer # Node 18+ ``` Point a client at the gateway: ```python import os from hevlayer import AsyncHevlayer client = AsyncHevlayer( base_url=os.environ["LAYER_GATEWAY_URL"], api_key=os.environ.get("LAYER_GATEWAY_API_KEY", ""), ) ``` ```go import ( "os" hevlayer "github.com/hev/layer-go" ) client := hevlayer.NewClient( hevlayer.WithBaseURL(os.Getenv("LAYER_GATEWAY_URL")), hevlayer.WithAPIKey(os.Getenv("LAYER_GATEWAY_API_KEY")), ) ``` ```typescript import { Hevlayer } from "hevlayer"; const client = new Hevlayer({ baseUrl: process.env.LAYER_GATEWAY_URL, apiKey: process.env.LAYER_GATEWAY_API_KEY, }); ``` ```bash curl "$LAYER_GATEWAY_URL/health" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` Code examples across these pages assume this `client` — and in Go, a `ctx context.Context`. The cURL tab on each page is the bare REST contract; any HTTP stack works the same way. ## Authentication Set `LAYER_GATEWAY_URL` to the gateway origin, without `/v2`. Set `LAYER_GATEWAY_API_KEY` to the inbound bearer token when the gateway requires one. An isolated local gateway configured with `inboundAuth.mode: open` needs no token. Engine credentials belong on the gateway, not in application code. With `deriveFromStore`, the configured Turbopuffer store key is accepted as an admin bearer. Pro also supports minted namespace-scoped keys. See [API keys](/docs/pro/api/keys). Routes enforce read, write or admin authorization and namespace grants. A request outside a key's scope returns 403. Configure the gateway's inbound authentication separately from its engine connection. ## Gateway failures Clients return connection errors when the gateway is unreachable. They do not retry directly against a backing store. Unsupported store features return `422 UnsupportedByStore`; malformed input and gateway validation errors are reported separately. A client method's existence does not establish support for every backend. ## Gateway operations Start with [write](/docs/pro/api/write) and [query](/docs/pro/api/query). Other routes include [scans](/docs/pro/api/scans), [embedding](/docs/pro/api/embed), [federation](/docs/pro/api/federated-query) and [metadata](/docs/pro/api/namespace-metadata). The same clients call the entire Layer API, including operations outside the compatible subset. Backend-specific notes remain beside the affected examples. ## Response metadata Layer returns retrieval metadata in [response headers](/docs/pro/api/response-headers). Use the generated clients' performance response options where needed. Stable-watermark and pagination headers depend on the backend and query mode; they are not guarantees made by every store. # API keys Source: https://hevlayer.com/docs/pro/api/keys import StoreNote from "../../../components/docs/StoreNote.astro"; import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; Layer mints its own API keys. An `ApiKey` is a minted credential as a resource. Layer owns the credential lifecycle — mint, verify, revoke, expire — and what the key opens is declared per resource: each entitlement names a [`VectorStore`](/docs/pro/api/vectorstores), a [`Warehouse`](/docs/pro/api/warehouses), an [`Agent`](/docs/pro/api/agents), or Layer itself, and carries the scopes and claims for that target. Claims are opaque to Layer — an external system can use Layer as its key store and keep authorization decisions to itself. Keys have two authoring surfaces that round-trip through one schema: `kubectl get apikey -o yaml` and `GET /v2/keys/{keyId}` are two spellings of the same object. ```yaml apiVersion: hevlayer.com/v1alpha1 kind: ApiKey metadata: name: cohort-reader namespace: layer spec: owner: acme description: cohort read access entitlements: vectorstore.prod-turbopuffer: scopes: [read] namespaces: ["cohort-*"] warehouse.prod-snowflake: claims: - "notes:cohort:*:read" expiresAfter: 365d status: keyId: 0a1b2c3d-… phase: Active lookupHash: sha256:… createdAt: "2026-06-10T00:00:00Z" expiresAt: "2027-06-10T00:00:00Z" secretRef: name: apikey-cohort-reader ``` You don't have to mint anything. The default `VectorStore` credential (the store key you already own) is accepted as an admin bearer. Minting starts from it: admins can also delegate the prefix mint authority below. ## Key model - Tokens look like `hvl_iqGFsDD2PNkyhCqr59jjvKuKL47vqXMz`. The raw token is returned **once**, in the mint response. Layer stores only one-way hashes; a lost token is revoked and re-minted, never recovered. - Every key is an `ApiKey` resource in the cluster — `kubectl get apikeys` is the audit trail, and `kubectl apply` is an equal authoring surface (the operator mints and delivers the token via Secret). - Revoke is the default end-of-life action. It disables the credential while keeping its owner, entitlements, and lifecycle record available for audit. Propagation takes seconds, not milliseconds. - Hard delete is a rare, deliberate cleanup action. A key must be `Revoked` first; deletion then permanently removes its audit record. ## Spec | Field | Purpose | | --- | --- | | `owner` | Optional free-form owner label, echoed in list and authenticate responses. | | `description` | Optional free-form description. | | `entitlements` | Map keyed by target resource. Each entry carries `scopes`, `namespaces`, and `claims` for that target. | | `expiresAfter` | Duration or `never`. Defaults to `365d`; `status.expiresAt` is computed at mint. | ## Entitlements | Key | Target | | --- | --- | | `vectorstore.` | Data-plane access through the named store. `scopes` (`read`, `write`) gate routes whose `Index` resolves to that store; `namespaces` globs constrain which upstream namespaces. | | `warehouse.` | A list of opaque `claims` strings bound to the source system. Layer stores and echoes them; the application routes on them. No client route reaches a source — clients touch indexes, not warehouses — so the entitlement grants nothing in Layer and inerts when the warehouse is deleted. | | `agent.` | Invocation of the named [`Agent`](/docs/pro/api/agents): the right to call `POST /v2/agents//query`. Data access is separate — the agent's reads run under the caller's own `vectorstore` grants — so this governs *who may invoke*, not *what it reads*. Inerts when the agent is deleted. | | `mcp.` | Calling the named [MCP server](/docs/pro/api/mcp) at `/mcp/`. Like `agent.`, it governs *who may call*, not *what it reads*: each tool call reads under the key's own `vectorstore` grants. Inerts when the server is deleted. | | `layer` | The control plane itself. `scopes: [admin]` covers key management and Pipeline/Function create/delete/control routes, and satisfies `read` and `write` everywhere. | Scope meanings match [inbound auth](/docs/pro/api/vectorstores#inbound-auth): `read` covers query, fetch, scans, and metrics; `write` covers namespace writes and worker routes. `claims` is a list of opaque strings, allowed on any entitlement and the only field on a warehouse entitlement. Layer stores them, returns them from list, get, and authenticate, and never interprets them — an existing permission grammar (`service:resource_type:resource_id:action` strings, a legacy entitlement vocabulary) drops in verbatim, and the consuming application maps them to its own authorization. An entitlement whose target does not exist grants nothing and surfaces as a status condition (`EntitlementTargetMissing`) — not an admission error, so keys and their targets can be applied in either order. Check the condition after applying: a typo in a target name looks the same as a missing target. A key whose entitlements carry only claims — no scopes — is a pure external-store key: it authenticates, but opens no Layer route. ## Routes | Route | Method | Auth | Behavior | | --- | --- | --- | --- | | `/v2/keys` | POST | admin or mint | Mint a key. The only response that contains the raw token. | | `/v2/keys` | GET | admin or mint | List keys — metadata, never material. `?includeRevoked` adds revoked and expired keys. | | `/v2/keys/{keyId}` | GET | admin or mint | One key's metadata. | | `/v2/keys/{keyId}/revoke` | POST | admin or mint | Revoke. Idempotent; the record stays. | | `/v2/keys/{keyId}` | DELETE | admin or mint | Hard-delete a `Revoked` key and permanently remove its audit record. | | `/v2/keys/authenticate` | POST | none | Exchange a raw token for identity and entitlements. | Admin here means a key with the `layer` entitlement at `admin` scope, or the bootstrap gateway key. Prefix mint authority permits the constrained operations described below. `authenticate` is unauthenticated by construction — the token is the credential. ## Prefix mint authority An admin can give a service a narrow credential for issuing per-machine keys: ```json { "vectorstore.prod-turbopuffer": { "scopes": ["mint"], "namespaces": ["kit-*"] } } ``` `mint` requires a nonempty literal prefix followed by `*` on a vectorstore entitlement. It permits minting read and/or write grants on that same store, with every namespace an exact name or trailing-star prefix contained in the issuer's prefix. For example, `kit-alice-traces` and `kit-alice-*` fit `kit-*`. Empty namespace lists, `*`, other stores, admin or mint grants, warehouse claims, and agent/MCP invocation grants are refused with HTTP 403. The issuer can list only keys it minted. Getting, revoking, or hard-deleting a key additionally requires that its current entitlements still fit the issuer's authority. Ownership uses the issuer's immutable key ID, persisted in the ApiKey annotation `hevlayer.com/issuer-key-id`; the request's `owner` field is descriptive and grants no authority. Older keys without that annotation remain manageable by admins, but cannot be claimed by a minter. Recreating an issuer with the same resource name does not inherit ownership. The same credential can `DELETE /v2/namespaces/kit-alice-traces` when that namespace resolves to the granted store. It cannot delete outside its prefix. Mint alone grants no namespace reads or writes and no Pipeline/Function control. Admin credentials keep their existing access. ### Provision and use a kit minter Use the actual `VectorStore` resource name in the entitlement, rather than its backend type or hostname. An admin provisions the service credential: ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/keys" \ -H "Authorization: Bearer $ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"kit-invite-minter","entitlements":{ "vectorstore.prod-turbopuffer":{"scopes":["mint"],"namespaces":["kit-*"]} },"expiresAfter":"90d"}' ``` Save the returned token as the service's `KIT_MINTER_KEY`. The service can issue a machine key for an exact namespace or a narrower prefix: ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/keys" \ -H "Authorization: Bearer $KIT_MINTER_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"kit-alice-laptop","owner":"alice","entitlements":{ "vectorstore.prod-turbopuffer":{"scopes":["read","write"],"namespaces":["kit-alice-*"]} },"expiresAfter":"30d"}' curl "$LAYER_GATEWAY_URL/v2/keys?includeRevoked=true" \ -H "Authorization: Bearer $KIT_MINTER_KEY" curl -X POST "$LAYER_GATEWAY_URL/v2/keys/$MACHINE_KEY_ID/revoke" \ -H "Authorization: Bearer $KIT_MINTER_KEY" curl -X DELETE "$LAYER_GATEWAY_URL/v2/namespaces/kit-alice-traces" \ -H "Authorization: Bearer $KIT_MINTER_KEY" ``` `MACHINE_KEY_ID` is the child's returned `keyId`. Use `["read"]` or `["write"]` for a single-scope child. A request containing both `kit-alice-traces` and `orders` is refused in full, as are `orders` alone, `kit*`, and a child with `admin` or `mint` scope. An attempt to revoke a key minted by another issuer returns `403` with `{"error":"key was not minted by this issuer"}`, even if its namespace is inside `kit-*` and its `owner` is `alice`. Deleting `orders` also returns `403`. Revoking a machine key does not delete its archive; namespace deletion is a separate request. Mint authority is shared across the granted prefix: this credential can issue keys for every `kit-*` tenant and delete their namespaces. Keep it in the invite service; give each machine only its child token. If the service needs namespace discovery, an admin must explicitly add `read` to the minter's store grant. That also grants data reads within the prefix. ## Mint ```python key = await client.mint_key({ "name": "cohort-reader", "owner": "acme", "entitlements": { "vectorstore.prod-turbopuffer": { "scopes": ["read"], "namespaces": ["cohort-*"], }, "warehouse.prod-snowflake": { "claims": ["notes:cohort:*:read"], }, }, "expiresAfter": "365d", }) print(key.token) # shown once, never again ``` ```go key, err := client.MintKey(ctx, &hevlayer.MintKeyRequest{ Name: "cohort-reader", Owner: "acme", Entitlements: hevlayer.ApiKeyEntitlements{ "vectorstore.prod-turbopuffer": { Scopes: []string{"read"}, Namespaces: []string{"cohort-*"}, }, "warehouse.prod-snowflake": { Claims: []string{"notes:cohort:*:read"}, }, }, ExpiresAfter: "365d", }) fmt.Println(key.Token) // shown once, never again ``` ```typescript const key = await client.mintKey({ name: "cohort-reader", owner: "acme", entitlements: { "vectorstore.prod-turbopuffer": { scopes: ["read"], namespaces: ["cohort-*"], }, "warehouse.prod-snowflake": { claims: ["notes:cohort:*:read"], }, }, expiresAfter: "365d", }); console.log(key.token); // shown once, never again ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/keys" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "cohort-reader", "owner": "acme", "entitlements": { "vectorstore.prod-turbopuffer": {"scopes": ["read"], "namespaces": ["cohort-*"]}, "warehouse.prod-snowflake": {"claims": ["notes:cohort:*:read"]} }, "expiresAfter": "365d" }' ``` The response is `201 Created`: ```json { "keyId": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", "name": "cohort-reader", "owner": "acme", "entitlements": { "vectorstore.prod-turbopuffer": {"scopes": ["read"], "namespaces": ["cohort-*"]}, "warehouse.prod-snowflake": {"claims": ["notes:cohort:*:read"]} }, "phase": "Active", "createdAt": "2026-06-10T00:00:00Z", "expiresAt": "2027-06-10T00:00:00Z", "token": "hvl_iqGFsDD2PNkyhCqr59jjvKuKL47vqXMz" } ``` | Field | Required | Behavior | | --- | --- | --- | | `name` | yes | Unique per install. Becomes the `ApiKey` resource name. | | `owner` | no | Free-form owner label, echoed in list and authenticate responses. | | `description` | no | Free text, shown in listings. | | `entitlements` | no | Map keyed by target — `vectorstore.`, `warehouse.`, `agent.`, or `layer`. Unknown prefixes are 400s. See the [entitlement model](#entitlements). | | `expiresAfter` | no | Duration or `never`. Defaults to `365d`; `expiresAt` is computed at mint. | An agent invocation entitlement is `"agent.": {}`. It does not accept scopes or namespace restrictions. Validation failures are 400s with field-level messages; nothing is created on a failed mint. An entitlement whose target does not exist is accepted — it grants nothing and surfaces as an `EntitlementTargetMissing` condition on the resource, so keys and targets can be applied in either order. Mint an admin key the same way — `"entitlements": {"layer": {"scopes": ["admin"]}}`. An admin key can mint further keys; key custody is the only hierarchy. ## List and get ```python keys = await client.list_keys() key = await client.get_key("0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9") ``` ```go keys, err := client.ListKeys(ctx, nil) key, err := client.GetKey(ctx, "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9") ``` ```typescript const keys = await client.listKeys(); const key = await client.getKey("0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/keys" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` ```json { "keys": [ { "keyId": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", "name": "cohort-reader", "owner": "acme", "entitlements": { "vectorstore.prod-turbopuffer": {"scopes": ["read"], "namespaces": ["cohort-*"]}, "warehouse.prod-snowflake": {"claims": ["notes:cohort:*:read"]} }, "phase": "Active", "createdAt": "2026-06-10T00:00:00Z", "expiresAt": "2027-06-10T00:00:00Z", "lastSeenAt": "2026-06-10T12:00:00Z" } ] } ``` Listings carry metadata only — never tokens or recoverable hashes. `lastSeenAt` advances at most once per five minutes per key. ## Authenticate External systems present a raw token and get back `keyId` — a stable actor id — plus the full entitlements map, then make their own authorization decisions from the claims. This is the verb that makes Layer a key store for applications that keep authorization to themselves: Layer never interprets a claim string. ```python identity = await client.authenticate_key({"token": presented}) claims = identity.entitlements["warehouse.prod-snowflake"].claims ``` ```go identity, err := client.AuthenticateKey(ctx, &hevlayer.AuthenticateKeyRequest{ Token: presented, }) claims := identity.Entitlements["warehouse.prod-snowflake"].Claims ``` ```typescript const identity = await client.authenticateKey({ token: presented }); const claims = identity.entitlements["warehouse.prod-snowflake"].claims; ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/keys/authenticate" \ -H "Content-Type: application/json" \ -d '{"token": "hvl_iqGFsDD2PNkyhCqr59jjvKuKL47vqXMz"}' ``` `200` returns `{keyId, name, owner, entitlements, expiresAt}`. Invalid, revoked, and expired tokens all answer `401` indistinguishably, in constant time on a miss. The route is rate-limited. ## Verification External systems present the raw token to `POST /v2/keys/authenticate` and get back `keyId` (a stable actor id) plus the full `entitlements` map, then make their own authorization decisions from the claims. The gateway also accepts any `Active` key's token as a bearer on its own routes, enforcing the entitlement for the store or control-plane surface the route resolves to. Verification is one indexed lookup plus one hash check against a watch-fed in-memory map — the hot path never reads the control plane per request. `status.lastSeenAt` advances at most once per five minutes per key. | Phase | Meaning | | --- | --- | | `Pending` | CRD-authored key awaiting mint. | | `Active` | Verifiable; token works. | | `Revoked` | `POST /v2/keys/{keyId}/revoke` was called; token refused. | | `Expired` | `status.expiresAt` passed; token refused. | Revoke is the default lifecycle end-state. Revoked `ApiKey` resources are retained indefinitely for audit and are not automatically garbage-collected. Operators should set a retention policy appropriate to their obligations. Hard delete is rare and deliberate: `DELETE /v2/keys/{keyId}` accepts only a `Revoked` key and permanently removes the resource, its lifecycle record, and any owned token Secret. Other phases return `409 Conflict`; revoke first, then delete only after the retention period has passed or policy requires the record itself to be forgotten. Deleting a `VectorStore` or `Warehouse` inerts every entitlement that names it: the keys stay `Active` for their other entitlements, and the deletion is finalizer-guarded on the target's side while keys still reference it. ## Revoke by default ```python await client.revoke_key("0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9") ``` ```go _, err := client.RevokeKey(ctx, "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9") ``` ```typescript await client.revokeKey("0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/keys/$KEY_ID/revoke" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` The gateway stops accepting a revoked key within seconds. Rotation is mint-new, deploy, revoke-old — there is no in-place rotation. Revoked keys remain in `?includeRevoked` listings indefinitely: Layer does not automatically garbage-collect them. Set an external retention policy that meets your audit requirements. Hard delete is the explicit exception for records that must be forgotten. It is accepted only after the key is `Revoked`; deleting an `Active`, `Pending`, or `Expired` key returns `409 Conflict`: ```bash curl -X DELETE "$LAYER_GATEWAY_URL/v2/keys/$KEY_ID" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` Deleting removes the `ApiKey` resource and its audit history permanently. For CRD-authored keys, Kubernetes also garbage-collects the owned token Secret. Use deletion only after the applicable retention period has passed or when policy requires the record itself to be removed. ## Using a minted key A minted key works anywhere its entitlements reach. A `vectorstore.` entitlement opens data-plane routes against that store, inside its namespace globs: ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/cohort-7/query" \ -X POST \ -H "Authorization: Bearer hvl_iqGFsDD2PNkyhCqr59jjvKuKL47vqXMz" \ -H "Content-Type: application/json" \ -d '{"rank_by": ["text", "BM25", "acme"], "top_k": 10}' ``` Outside the grant, the gateway answers 403: ```json {"error": "namespace not in key grant", "namespace": "orders"} ``` A request that needs a higher scope answers 403 with the scope named: ```json {"error": "insufficient API key scope", "required_scope": "admin"} ``` A key whose entitlements carry only claims — no scopes — is a pure external-store key: it authenticates, but opens no Layer route. On a `deriveFromStore` gateway, minted keys are verified by Layer with the same entitlements, scopes, and namespace globs as in `keys` mode. Requests use the selected store's configured credential upstream. Invalid, expired, or revoked minted tokens are rejected with 403; they never fall back to upstream pass-through. Other bearers retain their existing pass-through behavior. For `/mcp/demo`, grant `"mcp.demo": {}` together with a read entitlement for the server's namespaces, for example `"vectorstore.default": {"scopes": ["read"], "namespaces": ["docs"]}`. The MCP entitlement opens the server; the store grant controls which tools and data the caller can access. A minted key is gateway-only and cannot authenticate directly to the upstream store. SDKs return connection errors when the gateway is unreachable; see [client failure behavior](/docs/pro/failure-modes#client-failures). ## kubectl The resource is the other authoring surface — apply an `ApiKey` with no credential and the operator mints the token into a Secret named by `status.secretRef`: ```bash kubectl apply -f key.yaml kubectl get apikeys -n layer kubectl get secret apikey-cohort-reader -n layer -o jsonpath='{.data.token}' | base64 -d ``` The operator writes the token under the Secret's `token` key and moves `phase` from `Pending` to `Active`. The Secret is the token delivery; it is owned by the `ApiKey` and garbage-collected with it. Rotate by minting a replacement, deploying it, and revoking the old key. ## Kubernetes RBAC CRD authoring makes kubectl a minting surface, so the chart ships roles to delegate key administration without cluster-admin: | ClusterRole | Grants | | --- | --- | | `hevlayer-key-admin` | Full verbs on `apikeys`, plus `get` on delivered token Secrets. Can mint, revoke, and collect tokens. | | `hevlayer-key-viewer` | `get`/`list`/`watch` on `apikeys`. No Secret access — status hashes are one-way, so viewing is audit, not credential access. | Neither role aggregates into the built-in `view`/`edit`/`admin` ClusterRoles: namespace viewer never silently means key viewer. Bindings are the cluster operator's explicit act; set `rbac.keyRoleBindings` in Helm values to render them for the single-team case. ## Bootstrapping `LAYER_GATEWAY_API_KEY` is the bootstrap credential: it mints the first admin key — ```yaml spec: entitlements: layer: scopes: [admin] ``` — after which routine minting uses minted admin keys. Cluster operators can equally bootstrap by applying an `ApiKey` resource, since CRD authoring needs only kubectl access. ## Backup and migration Use a full namespace Velero backup, or an equivalent etcd snapshot, for portable `ApiKey` migration. Do not export and apply YAML: the verifier lives in `status`, and apply paths do not preserve that subresource. 1. Back up these Layer namespace objects: - `ApiKey` resources with `status`: `status.keyId`, `phase`, `lookupHash`, and `tokenHash` are the credential record; the raw token is not recoverable. - The chart's `*-keys` Secret: it holds `LAYER_KEY_PEPPER`, which must match every restored verifier. - Token-delivery Secrets referenced by CRD-authored keys through `status.secretRef`. 2. Restore `ApiKey` status explicitly. Velero restores specs by default and drops `status` unless the restore uses `--status-include-resources apikeys.hevlayer.com`, or the `Restore` sets `spec.restoreStatus.includedResources: ["apikeys.hevlayer.com"]`. 3. Restore the namespace, including the pepper Secret, before running Helm on the target release. If Helm touches an empty namespace first, it can generate a new random pepper, making restored argon2id verifiers unusable. 4. If source and target Velero servers share object storage, wait until the target server lists the completed source backup before creating the restore; otherwise its local `Backup` object may not have synced. 5. Refresh source Velero discovery before backup. Install Layer CRDs before Velero discovers resources, or restart the source Velero deployment after CRD installation and wait for readiness; stale discovery can produce a `Completed` backup that omits `ApiKey`. 6. Quiesce the Layer operator before backup and keep it quiesced through initial restore. Velero restores `ApiKey` spec and `status` separately; an operator that starts in that window can mark a transient spec-only key `MintBlocked` or `Pending`. 7. Inspect the completed backup from inside the Velero server's cluster. Its item list must include `apikeys.hevlayer.com`, expected `ApiKey` objects, the chart's `*-keys` pepper Secret, and token-delivery Secrets referenced by `status.secretRef`. 8. Restart gateway/operator discovery after restore so the watch-fed key map rebuilds from restored `ApiKey` status and Secrets before traffic depends on the migrated tokens. 9. Treat `MintBlocked` as a failed restore, not a key rotation. The operator refuses to mint a replacement when `status.keyId` is empty but the deterministic delivery Secret exists; it writes a `MintBlocked` condition and warning Event instead. A new CRD-authored `ApiKey` with no delivery Secret still mints normally. Validate the key store before accepting traffic: ```sh SOURCE_CONTEXT=layer-a \ TARGET_CONTEXT=layer-b \ LAYER_NAMESPACE=layer \ SOURCE_BASE_URL=https://source.example.com \ TARGET_BASE_URL=https://target.example.com \ SOURCE_ADMIN_KEY=hvl_... \ scripts/apikey-velero-migration.sh ``` The script mints REST and CRD-authored keys, backs up the namespace, waits for the target Velero server to sync the completed backup, restores with `--status-include-resources apikeys.hevlayer.com`, and asserts: - original active tokens still authenticate - revoked and expired tokens remain refused - the completed backup contains expected `ApiKey` resources and Secrets - the source operator was quiesced and the expired control key reached `Expired` before backup - the restored pepper Secret matches the source - restored key status fields are present, so spec-only, incomplete, racing, or misordered restores fail loudly Local reproduction currently needs the temporary test-license setup from the `/v2/keys` test `kid` keygen work in PR #199. Velero backups are credential-bearing: they contain one-way verifiers, the pepper, and any remaining CRD-authored delivery Secrets. Store and encrypt them like other credential material. ## CLI The same operations from the [CLI](/docs/pro/cli#keys): ```bash layer keys mint cohort-reader --owner acme \ --entitle vectorstore.prod-turbopuffer=read \ --namespaces "cohort-*" \ --claim warehouse.prod-snowflake="notes:cohort:*:read" layer keys ls layer keys revoke cohort-reader # Rare cleanup after the retention period: layer keys rm cohort-reader ``` `layer keys mint` prints the token once, alone on stdout for piping; the metadata table goes to stderr. # Latest-row aggregates Source: https://hevlayer.com/docs/pro/api/latest-relation import Edition from "../../../components/docs/Edition.astro"; `POST /v2/namespaces/{namespace}/aggregate` accepts `latest_by` for an exact Count/Sum over the winning revisions observed by a complete source read. This is separate from ranked collapse. It returns aggregates, never revisions. The same strict request is accepted on `/query` when `latest_by` is present. Generated clients expose `aggregateLatest` / `aggregate_latest` with typed `LatestRelationRequest` and `LatestRelationResponse` models. ```json { "latest_by": { "key": ["entity"], "order_by": [ {"field":"version", "direction":"desc", "nulls":"last"}, {"field":"id", "direction":"desc", "nulls":"last", "canonical_key":"entity"} ] }, "scan": {"max_rows":100000, "max_bytes":67108864, "consistency":"observed"}, "filters": ["status", "Eq", "active"], "aggregate_by": {"entities":["Count"], "amount":["Sum","amount"]} } ``` The relation selects one complete row per key, then evaluates `filters`, then aggregates. An older active revision cannot reappear when the winning row is inactive. Ordering is lexicographic in the supplied terms. `canonical_key` compares whether the physical `id` equals that row's named key attribute; `desc` prefers the canonical ID. It is allowed only on `field: "id"`. A descending physical-ID tie breaker is always appended. IDs must be uniformly strings or unsigned integers; strings compare by UTF-8 bytes. Attribute types must be consistent across nonnull values. An ordering attribute cannot mix floating-point values with integers outside the exact binary64 integer range (−2^53 through 2^53); that whole source domain is rejected before returning winners. Integer-only ordering preserves the full int64/uint64 range. Null and missing compare together, with placement explicit for each term. Logical keys must contain nonempty strings, numbers or booleans; null, missing and empty string keys fail the request rather than merging unrelated rows. Count counts selected matching rows. Sum ignores null/missing values and returns zero for an empty set. Non-numeric values and numeric overflow fail. Integer sums remain integers; fractional sums use compensated binary64 arithmetic. A fractional sum with an integer subtotal outside ±2^53 is rejected. JSON integers remain exact on the wire; consumers must preserve integer precision when decoding large totals. Filters support Eq, NotEq, Gt, Gte, Lt, Lte, In, NotIn, Contains, NotContains, ContainsAny, NotContainsAny, And, Or and Not. Eq null matches missing as well as explicit null; range comparisons never match null. Unknown operators and options fail before source reads. Attribute references are literal flat names, including names containing dots; JSON paths and nested object traversal are not supported. ## Cost and consistency Turbopuffer reads all revisions in strong ID-ordered pages inside Layer. For a namespace with active sharding, Layer scans independent shards concurrently using the existing namespace scan thread default and readiness maximum (`Index.spec.scan.threads`; see [scan concurrency](/docs/pro/api/scans)). Each shard advances its own ordered cursor to exhaustion. A disjoint remainder partition reads rows whose shard tag is missing, null or outside that topology. Cached readiness is a partitioning hint, never proof that every row is tagged. The remainder is read on every request, under the same concurrency and global budgets; it is not a second full scan. Layer then selects winners once across all partitions, before applying mutable predicates. Invalid manifest namespace identity or completion metadata fails the request, as do topology auth, throttle, transport and server errors. Absent or unsupported metadata permits a complete unpartitioned read. Counts of zero or above 4,096 use one complete serial cursor before partition dispatch rather than constructing an unbounded filter. Unsharded namespaces retain a single complete cursor. No physical shards are created or resized by this request. If sharding is not ready, the complete unpartitioned scan includes tagged and untagged data and excludes the namespace metadata marker. Every partition excludes the marker after decoding, without sending a string-ID filter to unsigned-integer ID namespaces. Marker response bytes and pages still count. For scalar requests without named grouped plans, the HTTP adapter requests only attributes referenced by the logical key, winner ordering, every filter branch and Sum metrics. Physical IDs are retained for ordering and ties. This avoids transferring and decoding unused attributes such as large payloads. All revisions are still read; predicates run in Layer after winner selection. If a partition's first projected query returns Turbopuffer's explicit HTTP 400 error for an attribute absent from its schema, Layer retries that partition once with all attributes. This preserves missing-value semantics, including Sum of an absent field and null filters. The failed read adds a source query and its response bytes count against the byte budget; any reported billing is retained. It may add backend cost. Other errors propagate. After any successful page in that partition, schema loss fails the request without restarting the scan. Projection is attempted afresh on each request, so later schema additions remain visible. Grouped requests read all nonvector attributes, including attributes not referenced by any plan, to preserve complete prefilter winner binding on continuations. Existing adapters without explicit projection support retain their full-source implementation. Layer processes every observed revision, and gateway memory scales with revision count and returned row size. Each page is a separate source query; the backend may rescan data on every page. Physical source work, billed bytes and latency are unmeasured and may grow with namespace size times page count. Attribute projection does not establish fewer physical rows read, fewer storage bytes scanned or lower billing. This is not a native latest-row pushdown. No rows are transported to the API consumer. The scan does not use the cache. Each page is a separate read: concurrent writes, updates or deletes may change the observed relation. `observed` does **not** mean a point-in-time snapshot, and can miss inserts behind the cursor or observe a winner that is later replaced. `snapshot` is rejected on this backend. Both scan budgets are required. Exceeding either fails the whole request; there is no partial total or silent 10,000-row cap. Limits bound accepted source rows and decoded JSON bytes; up to the configured scan width of source pages can be in flight before a byte overrun is detected. The row and byte limits apply to the whole request, not separately to each shard. A failed shard fails the request and cancels pending sibling reads; caller cancellation also drops those reads. These are gateway transfer budgets, not limits on backend scanned or billable bytes. Source byte counts measure decoded row-page response bytes. Metadata and sharding checks can make additional source calls outside these counters. Billing and end-to-end latency must be measured separately. Response `relation` reports the actual source rows, pages, bytes, selected and matching rows, the declared consistency and the exhausted-source flag. `complete` means the scan reached its end under that consistency contract. It does not certify a snapshot under concurrent writes. Only explicitly supported adapters may execute this plan. Other query options, ranked queries, joins and native group_by are rejected in this initial slice. Postgres support and cross-namespace join isolation require separate validation; native physical-row Aggregate support does not imply latest-row support. There is no persistent analytics store and no reuse of a previous request's selected rows. ## Named grouped plans Add `grouped` to evaluate up to 16 named plans on the same complete selected, filtered relation. Scalar `aggregate_by` remains required. For example: ```json {"grouped":{"projects":{"group_by":[{"kind":"field","field":"project"}],"metrics":{"sessions":{"kind":"count"},"spend":{"kind":"sum","field":"amount"}},"page_size":100}}} ``` Each plan accepts literal flat field dimensions (`field`, `utc_day`) and an optional expansion: `timestamps` with `field` and IANA `timezone`, or `models` with an object field mapping model names to numeric metric objects. Timestamp expansion enables `weekday` (Monday=0) and `hour`; model expansion enables `model` and `model_sum`. UTC dates consume integer epoch milliseconds. Repeated DST hours merge while counting every instance. Empty expansions produce no instances. Missing scalar dimensions form a typed null group; empty strings remain distinct. No absent groups are filled with zero. Row `sum` under model expansion rejects; per-model amounts are never inferred from session totals. Timestamp expansion repeats a row sum per instance. Response `grouped[name]` contains typed keys, aggregates, `next_cursor`, `total_groups`, and `complete` for the group page. Outer `relation.complete` certifies the observed source scan; it is distinct from page completeness. Pass a page's cursor in that named plan's `cursor`. Every continuation repeats the complete scan and grouping. It rejects changed selected content, selection or filter plan, named plan, page size, namespace, store or authenticated caller. It does not pin an upstream snapshot. Client scope/boundary fields reject. All plans share finite ceilings: 10 million expanded instances and 100,000 accumulated groups, divided equally among the submitted plans. Each plan has at most 10,000 output groups per page. Any budget or numeric failure rejects the entire request, including scalar totals and earlier plans. Work scales with revisions plus selected rows times plans plus expanded instances; named plans share one source scan and one latest/filter selection, not grouping work. Integer sums are exact; mixing any integer outside ±2^53 with floats rejects. Unsupported grade/tool/cell predicates and joins require consumer fallback. The grade semi/anti-join kernel has no authorized HTTP surface here. # Lattice Source: https://hevlayer.com/docs/pro/api/lattice import Edition from "../../../components/docs/Edition.astro"; import Preview from "../../../components/docs/Preview.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro";

Lattice is in preview. It is documented and usable, but it sits outside the release line: it is not listed in the changelog, its configuration can change without a deprecation cycle, and it carries no compatibility promise. The supported way to embed text on CPU is the [bundled model menu](/docs/pro/api/embed#cpu-models); Lattice is for workloads that have measured that menu and need something smaller.

Lattice is a static retriever — a token lookup table rather than a transformer. It embeds text in microseconds on a CPU and adds a few megabytes to a deployment. It scores materially below a real dense embedder on retrieval, which is the trade it exists to make. It is an explicit serving leg. A namespace that selects Lattice never falls back to another leg, and an unconfigured artifact is a validation error rather than a silent substitution. ## Provisioning Generate a deployment artifact with the upstream [Lattice slicer](https://github.com/ErikKaum/lattice/tree/main/slicer), place its `model.safetensors` and `tokenizer.json` together, and set `LAYER_LATTICE_MODEL_PATH` to the model file before starting the gateway. The supported model id is `erikkaum/lattice-retrieval`; the requested `embed.dims` must match the loaded artifact, and only text modality is supported. ```bash uv run slicer slice \ --dim 512 \ --quant int4_row \ --output-dir /var/lib/hevlayer/lattice export LAYER_LATTICE_MODEL_PATH=/var/lib/hevlayer/lattice/model.safetensors ``` ```jsonc "text": { "type": "string", "embed": { "model": "erikkaum/lattice-retrieval", "dims": 512, "serving": { "prefer": "lattice" } } } ``` `prefer: lattice` selects the Lattice artifact. `prefer: local` also resolves to it when the declared model is `erikkaum/lattice-retrieval`. The recommended operating point is an int4-per-row, 512-dimensional artifact. Int4 quantizes the model's lookup-table weights only. Layer writes the resulting normalized vectors as `[512]f32`; Turbopuffer's int8 minimum for quantized vector storage is a separate choice and is not used by this path. ## End-to-end example Declare the Lattice profile on a string attribute, write rows, and query with `Embed`. The gateway embeds both sides in-process — no external inference provider is involved. Write two rows into a namespace whose `text` attribute carries the profile above: ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/articles" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "upsert_rows": [ {"id": "planet-1", "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System."}, {"id": "photo-1", "title": "Photosynthesis", "text": "Plants turn sunlight, water, and carbon dioxide into food."} ], "schema": { "text": { "type": "string", "embed": { "model": "erikkaum/lattice-retrieval", "dims": 512, "serving": { "prefer": "lattice" } } } } }' ``` Query by meaning rather than exact phrase: ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/articles/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["text", "ANN", ["Embed", "largest planet in the solar system"]], "top_k": 3, "include_attributes": ["title", "text"] }' ``` ```jsonc { "rows": [ { "id": "planet-1", "$dist": 0.137, "title": "Planet", "text": "Jupiter is the biggest planet in the Solar System." } ], "performance": { "embedding_tokens": 7, "embedding_ms": 1 // in-process lookup — no network hop to a provider } } ``` A live example of exactly this contract is the [Wikipedia × Lattice demo](https://wiki.hevlayer.com): all 283,997 Simple English Wikipedia articles (1.74M paragraph rows) embedded through Lattice and searched on Turbopuffer, with the `performance` echo displayed beside each result. Source at [github.com/hev/wiki](https://github.com/hev/wiki). ## Limits - Text only. An image modality on a Lattice profile is a validation error. - No [revision pins or instructions](/docs/pro/api/embed#model-settings). Those extensions require a GPU-served profile. - `embed.dims` must equal the sliced artifact's dimension. A mismatch is a validation error at write time, not a silent reshape. - A directory that fails to load stops the gateway at startup rather than serving a namespace that cannot embed. # License Source: https://hevlayer.com/docs/pro/api/license import CodeTabs from "../../../components/docs/CodeTabs.astro"; `GET /v2/license` returns the gateway's local license projection. It is an operator and dashboard oracle: it never phones home, and it derives state from the configured license key plus the gateway's codified grace cushion. ```bash curl "$LAYER_GATEWAY_URL/v2/license" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` ## Response A valid key that is still within its licensed or grace window returns `valid: true` and the key claims the gateway is using: ```json { "valid": true, "sub": "acme-corp", "tier": "design-partner", "features": ["transform-runtime", "agents", "rbac", "warehouses", "doc-cache", "history", "cost"], "limits": {"namespaces": 50, "udf_workers": 20}, "exp": "2026-12-14T00:00:00Z", "gateway": { "state": "licensed", "seconds_to_deadline": 1209600, "grace_seconds_remaining": 0 } } ``` When the key is expired but still inside the gateway grace window, `gateway.state` is `grace`, `valid` remains `true`, and `grace_seconds_remaining` counts down to the floor. Missing, invalid, or fully degraded licenses return `valid: false`: ```json { "valid": false, "state": "floor", "reason": "missing", "gateway": { "state": "floor", "seconds_to_deadline": 0, "grace_seconds_remaining": 0 } } ``` Invalid keys use `reason` to report the verifier failure. Missing keys use `reason: "missing"`. ## Fields | Field | Present when | Meaning | | --- | --- | --- | | `valid` | Always | Whether the gateway currently treats the deployment as licensed or in grace. | | `state` | Missing/invalid/floor key | Top-level floor marker for a missing or invalid license. | | `reason` | Missing/invalid key | Human-readable verifier reason, or `missing`. | | `sub` | Valid key | Licensed account or trial subject. | | `tier` | Valid key | License tier label such as `trial` or `design-partner`. | | `features` | Valid key | Feature strings the key enables. Absence denies the gated feature. | | `limits` | Valid key | Numeric entitlement limits. Missing keys are unlimited for that dimension. | | `exp` | Valid key | Key expiration timestamp. | | `gateway.state` | Always | `licensed`, `grace`, or `floor` for the gateway surface. | | `gateway.seconds_to_deadline` | Always | Seconds to `exp` while licensed, seconds to `exp + grace` while in grace, otherwise `0`. | | `gateway.grace_seconds_remaining` | Always | Seconds left in gateway grace, otherwise `0`. | Phase 1 reports the gateway surface. Operator and dashboard states use the same `licensed` / `grace` / `floor` model, but they are not included in this response until their enforcement surfaces ship. ## State Effects | Gateway state | Gated route behavior | CE route behavior | | --- | --- | --- | | `licensed` | Feature-gated routes work if `features` includes the route's feature. | Always works. | | `grace` | Feature-gated routes work and responses include `x-hevlayer-license-grace: true`. | Always works. | | `floor` | Feature-gated routes return `402` with `error: "license_required"`. | Always works. | The CE floor includes query, point read, write, scan, namespace metadata, snapshot, and backend routing routes. ## Client Call ```python state = await client.get_license() print(state.gateway.state) ``` ```go state, err := client.GetLicense(ctx) if err != nil { return err } fmt.Println(state.Gateway.State) ``` ```typescript const state = await client.getLicense(); console.log(state.gateway.state); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/license" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` # MCP server Source: https://hevlayer.com/docs/pro/api/mcp import Edition from "../../../components/docs/Edition.astro"; import Callout from "../../../components/docs/Callout.astro"; The gateway can serve [Model Context Protocol](https://modelcontextprotocol.io) servers at `POST /mcp/`. Each server exposes a fixed set of namespaces to an AI assistant such as Claude Desktop. The gateway generates the tools, their parameters and their descriptions from each namespace's schema, so you write no server code and no tool definitions. A server is read-only. Every tool call runs as the caller's API key, through the same query and document paths as `/v2`. ## Enable it The capability is off by default. Enable it on the gateway and declare servers in `LAYER_MCP_JSON`: ```sh LAYER_MCP_ENABLED=true LAYER_MCP_ALLOWED_HOSTS=layer.example.com LAYER_MCP_JSON='{ "field-records": { "namespaces": [ { "name": "site-photos", "toolName": "photos", "description": "Job-site photos, linked to the job they were taken on.", "filters": ["job_id", "photo_class", "taken_at"], "link": "source_url" }, { "name": "invoices", "link": "source_url" } ] } }' ``` | Variable | Purpose | | --- | --- | | `LAYER_MCP_ENABLED` | `true` serves the declared servers. Defaults to `false`. | | `LAYER_MCP_JSON` | The servers, keyed by name. The gateway validates it at startup, and a change needs a gateway restart. | | `LAYER_MCP_ALLOWED_HOSTS` | Comma-separated hostnames the gateway answers MCP requests on. Defaults to `localhost,127.0.0.1,::1`, so a remote URL needs its hostname here. Keep the `Host` header intact through your proxy. | | `LAYER_MCP_ALLOWED_ORIGINS` | Exact origins, including the port, for browser-based clients, for example `https://app.example.com:443`. Native clients such as Claude Desktop send no `Origin` and need none. | Wildcards and empty allowlists are refused. A request from an unlisted host or origin is rejected. ### Server fields Each server has one to 512 namespaces. Names are exact, not globs. Set `genericTools: true` on the server to use generic tools even with eight or fewer namespaces. More than eight configured namespaces selects generic tools automatically. An optional `host` serves the same handler at `/` on that dedicated hostname; include it in the host allowlist. The Desktop extension (0.1.3 and later) accepts either form: `https:///mcp/`, or `https:///` for a server's dedicated host. | Field | Default | Purpose | | --- | --- | --- | | `name` | required | The namespace to read. | | `toolName` | derived from the namespace name | The stem of the tool names: `photos` gives `search_photos` and `get_photos`. Tool names must be unique within a server. | | `description` | none | What a row is. The model reads it when choosing a tool, so say what someone would search this data for. | | `filters` | every supported filterable attribute | The attributes offered as search filters. `[]` offers none. | | `where` | none | A pinned filter: an object of attribute to exact value (string, boolean or number), for example `{"company":"story"}`. See [Pinned filters](#pinned-filters). | | `link` | none | An attribute holding a link to the source record. It is placed next to each hit so the model can cite it. | | `originals` | off | Serve the retained original file on demand with `fetch_original`. See [Original files](#original-files). | ## Tools Tool discovery returns `ttlMs: 0` and `cacheScope: "private"`. A tool list belongs to the caller's permissions and must not be shared across users. These cache hints let clients using MCP `2026-07-28`, including current Claude Code, load the available tools. Every server exposes `list_namespaces`, which takes no arguments and returns only namespaces the caller can read. Each entry includes `name`, `toolName`, `description`, `rowCount` (the upstream approximate count), `searchKind`, `filterableAttributes` (types and input schemas), `groupableAttributes`, `summableAttributes` and `extremaAttributes` (the fields [`aggregate`](#aggregate) takes), `aggregateToolName`, and `linkAttribute`. Counts can lag ingestion; discovery does not scan or fetch rows. With up to eight configured namespaces, each readable namespace also gets three tools. | Tool | What it does | | --- | --- | | `search_` | Searches with a `query`, optional `filters`, a `limit` (default 10, at most 50) and an optional `cursor` for the next [page](#paging). | | `get_` | Fetches one row by `id`. | | `aggregate_` | Counts, sums, mins and maxes, optionally [grouped](#aggregate). | In generic mode, the server exposes just `list_namespaces`, `search`, `get` and `aggregate`. `search` takes `namespace` alongside the usual `query`, `filters`, `limit` and `cursor`; `get` takes `namespace` and `id`; `aggregate` takes `namespace` and the arguments below. Call `list_namespaces` first to choose a namespace and its filter types. Namespace-specific tool names are unavailable in this mode. Guessed namespace names still require membership in the server and a read grant. A caller with no readable namespaces gets an empty discovery list. Filters take their types from the schema. Numbers take `min` and `max`, dates take `after` and `before`, and strings and booleans take an exact value. Search reads the live namespace schema and the gateway's persisted embedding configuration, including declared CLIP image profiles. A vector column alone is not an embedding declaration: declare its source/model through the [embedding configuration](/docs/pro/api/embed). CLIP profiles let a text query search photos in the declared embedding space without adding a full-text field. | Schema | `searchKind` and ranking | | --- | --- | | Full-text only, with fuzzy search enabled | `HybridText` | | Full-text only, without fuzzy search | `BM25` | | Declared embedding only | `ANN/Embed`, using inline `Embed` | | Full-text and a declared embedding | `Auto`, using inline `Embed`; without fuzzy search, lexical expansion is disabled with `fuzziness: 0` | When both search paths are available, MCP questions keep lexical and semantic ranking together at every query length. Short phrases such as “change order” can retrieve an embedded page whose extracted text contains only a signature; long questions retain lexical evidence for job names and document titles. Text-only and embedding-only namespaces keep their respective search paths. If the schema declares a string `title`, `name`, or `filename`, search checks that label on the first 50 ranked candidates. Title matches add a bounded contribution to the store's relevance. `$mcp_score` is the normalized store relevance plus at most 0.25 for the fraction of query words matched in the label. Results follow that combined score, while `$score` and `$dist` keep their store values. ANN distance uses inverse-distance relevance. Ties retain the store order. Compound label words such as “floorplan” match “floor plan” in a question. Every cursor page uses the same candidate bound. If the declared label fields change, restart at page one; a previous cursor is rejected. Use the declared job filter when the answer needs a particular project. Document-type filters select stored labels, which may not cover every page about that topic. ### Scope a request before ranking `query` ranks document content. A job, project or company name in the question does not become an attribute filter. For a request about one project, resolve its stored filter value first, then pass that value in `filters`. A display name and a stored job code may differ. Search the name alone and inspect the matching records, or use `aggregate` to discover the declared field's values; do not guess an identifier. Keep the user's question as the query when adding the constraint. For example, if discovery declares `job` and `doc_type`, and the records identify North Wing as job `24-021`, a drawing request can use: ```json {"query":"North Wing drawing revision","filters":{"job":"24-021","doc_type":"drawing"},"limit":5} ``` Search returns `applied_filters`, the caller's validated typed filters, and states the requested scope in its text. `{}` means the caller supplied no attribute constraint; server-pinned filters still apply and are not disclosed there. Query words alone are not evidence that a hit belongs to the requested project. If the scoped search has no matches, report that result rather than substituting documents from other projects. A missing result does not establish whether the source file exists upstream or has been indexed. A document-type label is not a content guarantee. If an optional `doc_type` filter returns no matches, retry the question without that filter while keeping the exact job, project, company and other constraints. Report that the type filter was removed; the tool does not relax filters automatically. Inspect the returned content before calling it a match: a contract clause describing how change orders work is not an actual change order. An empty classified search alone does not establish that no relevant unclassified page is indexed. Keep an explicitly requested exact-classification constraint unless the user agrees to a broader search. Retaining an original and ranking a document are separate. An indexed original PDF page can have a source page link before `original_blob` is attached. Converted DOCX, spreadsheet and Markdown sources do not gain original-PDF page links just because they rank highly. Search and get include a structured `links` list as well as rendered links, so clients that consume structured content receive the same source evidence. Each entry identifies the returned row and its `sourceLink`; `pageLink` is a PDF page URL or `null`. A converted-page basis or a non-PDF original MIME prevents a PDF page link. Legacy rows without either field need a PDF source URL. If the schema has more than one candidate field, the canonical `text` field wins; otherwise the tool returns an error naming the ambiguity. Both tools return the rows as structured content, plus a short text rendering with each hit's `link`. ## Aggregate `aggregate` answers "how many" and "how much" without reading rows: a count, or the sum, minimum or maximum of a numeric field, optionally grouped by one or two fields, over the rows the same typed `filters` as `search` select. It uses the store's native `aggregate_by` and `group_by`. | Argument | Meaning | | --- | --- | | `group_by` | One or two fields from the namespace's `groupableAttributes` (strings, booleans and integers). Omit for a single total. | | `aggregates` | Up to four of `{"fn":"count"}`, `{"fn":"sum","field":…}`, `{"fn":"min","field":…}`, `{"fn":"max","field":…}`. Default: one count. `sum` takes a field from `summableAttributes`; `min` and `max` take one from `extremaAttributes`, which adds dates. | | `filters` | The same typed filters as `search`. | | `limit` | Most groups returned, 1 to 1,000, default 100. | ```json {"name":"aggregate_documents","arguments":{"group_by":["doc_type"], "filters":{"company":"Story Renovations"}}} ``` The structured content holds `groups` (one object per group, with the group fields and `count`, `sum_`, `min_` or `max_`; an ungrouped call has one object), `truncated`, `limit` and `billing`. The store returns groups in key order, so `truncated: true` means later groups were cut off, not that these are the largest: add filters or raise `limit`. A namespace that [collapses](#collapsed-document-search) still counts its rows (pages), not documents, and `counts` says `rows`. `min` and `max` are not available with `group_by`, because the store aggregates only count and sum per group; the call fails with `unsupported_aggregate`. Filter to one group and call again without `group_by`. A float or timestamp field cannot be grouped by, since it would make a group per row. ### Cost The store bills a query at the size of the namespace it reads, whatever the filters: about 2 GB for each query against the BCC pages, and never less than 1.28 GB. So one call is bounded and cheap to reason about. Count and sum, grouped or not, are one store query. Each `min` or `max` is one more, and a call takes at most two. A call makes at most three queries, and a call that is refused (an unknown field, too many aggregates) makes none. Each call returns the bytes the store billed, as `billing.queries`, `billing.billable_logical_bytes_queried` and `billing.billable_logical_bytes_returned`, ends its text with the same figure, and logs it with the server and namespace. The gateway's `billable_logical_bytes_queried` metric counts them with every other query. ## Paging A search returns one page. Besides `rows` (or `groups` when the namespace [collapses](#collapsed-document-search)), the structured content carries: | Field | Meaning | | --- | --- | | `total` | How many results the whole search has: rows, or groups when collapsed. Counted up to 1,000. | | `total_exact` | `false` when the search has more than 1,000 results and `total` is that bound. | | `next_cursor` | An opaque string for the next page, or `null` on the last page. | | `offset` | Where this page starts in the result list. | | `snapshot` | `true` when the page walk is pinned to a snapshot (see below). | | `applied_filters` | The caller's validated typed filters, or `{}` if none were requested. Server pins still apply and are not included. | | `links` | Each returned row's `id`, `sourceLink` and eligible PDF `pageLink`. A source need not have a retained original for its page link to be present. | To read on, call the same search again with the same `query` and `filters` and `cursor` set to `next_cursor`. `limit` may change between pages. The text rendering ends with `Showing 8-14 of 31.` and the cursor, so a model that reads only the text can continue. A walk reaches at most the first 1,000 results. Every page ranks the same 1,000-result window, so approximate (vector) ranking cannot reorder results between pages; only the page's own rows are then read. ```json {"name":"search_photos","arguments":{"query":"kitchen cabinets","limit":10, "cursor":"eyJ2IjoxLCJxIjoi..."}} ``` The first page records when it ran, and the cursor carries that time. Every later page excludes rows the gateway wrote after it, so rows landing during a walk neither repeat a result nor push one off a page: a walk returns each of the first page's results once, in order, however much is written meanwhile. A fresh search, without a cursor, sees the new rows. Two limits apply. A row *overwritten* after the first page carries a new write time and drops out of the later pages of that walk. And a namespace whose schema has no `_hevlayer_upserted_at` (rows written around the gateway) has no write time to pin to, so `snapshot` is `false` and a walk there can shift when rows are added. A cursor belongs to the namespace, query, filters and collapse configuration it came from. Sending it with anything else fails with `cursor_mismatch`; a cursor that does not decode fails with `invalid_cursor`. ## Errors A failed call returns `isError: true`. The structured content holds `error.code`, `error.message` and the details that name the fix, and the text starts with the code. | `error.code` | When | Extra fields | | --- | --- | --- | | `unknown_argument` | An argument name the tool does not take, such as `q` for `query` or `page_size` for `limit`. | `unknown`, `allowed`, `did_you_mean` (the nearest accepted name, or `null`) | | `invalid_cursor` | `cursor` is not a cursor this server issued, or is out of range. | `reason` | | `cursor_mismatch` | `cursor` came from a different namespace, query or filters. | | | `invalid_argument` | Any other bad value: an empty `query`, `limit` outside 1 to 50, a filter of the wrong type, a `group_by` or `sum` field the namespace does not offer. | `field`, for an aggregate field | | `unsupported_aggregate` | `min` or `max` with `group_by`, or more than two of them. | | | `tool_failed` | The store or gateway failed. | | Errors are returned before any query runs, so a wrong name costs no store work. ## Auth A client connects with a Layer [API key](/docs/pro/api/keys) as a bearer token minted through the Pro key workflow. The key needs two entitlements: ```json { "mcp.field-records": {}, "vectorstore.default": { "scopes": ["read"], "namespaces": ["site-photos", "invoices"] } } ``` `mcp.` lets the key call that server. It grants no data access, so the key also needs `read` on each namespace through its `vectorstore` entitlement. An admin key still needs `mcp.` explicitly. | Request | Result | | --- | --- | | Key without `mcp.` | `403` | | Key with `mcp.` but no grant on a namespace | That namespace's tools are left out of `tools/list`, and calling one by name is denied. | Minted keys work in both `keys` and pass-through `deriveFromStore` inbound auth modes. Minted verification runs before upstream bearer pass-through: the caller keeps their namespace grants, and the configured store credential is used upstream. Invalid, expired or revoked minted keys are rejected without falling back to pass-through. Plain upstream bearers and declared bootstrap keys do not grant `mcp.`. `open` mode skips verification and cannot authorize MCP, even when a minted token is supplied. Give each person their own key, scoped to the namespaces they should see, and revoke it through the usual [key workflow](/docs/pro/api/keys). ## Transport The server speaks MCP's Streamable HTTP transport in stateless mode, with JSON responses. It keeps no session between requests, so any gateway replica can answer any call. You can drive it with plain HTTP: ```sh curl -sS "$LAYER_GATEWAY_URL/mcp/field-records" \ -H "Authorization: Bearer $LAYER_MCP_TOKEN" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'MCP-Protocol-Version: 2025-06-18' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", "params":{"name":"search_photos", "arguments":{"query":"kitchen cabinets","filters":{"job_id":"2231"},"limit":10}}}' ``` Each tool call is written to [search history](/docs/pro/api/search-history), tagged `mcp.`. ## Connect Claude Desktop Claude Desktop's remote connector settings take a URL and OAuth, not a bearer token. Layer ships a Desktop Extension, `hevlayer-mcp.mcpb`, that asks for the server URL and your token and forwards Desktop's calls to the gateway. The operator gives each person three things: the extension file, the full URL (`https:///mcp/`, or `https:///` for a dedicated host), and their own API key, sent through a secure channel. Each person then sets it up without a terminal: 1. In Claude Desktop, open **Settings → Extensions → Advanced settings → Install Extension…** and choose `hevlayer-mcp.mcpb`. 2. Paste the **Server URL** and **Access token**. The token field takes the token only, without `Bearer`. 3. Save, enable the extension, and start a new chat. ### On a Team or Enterprise account On a Team or Enterprise plan, an Owner adds the extension to the organization once, and each member installs it from inside Claude Desktop. The upload holds only the extension: the server URL and token are not part of it. Each member enters their own when they install, so one upload serves the whole team and every member reads with their own key. The Owner, once: 1. In Claude Desktop, click your name in the lower left, then open **Organization settings → Connectors → Desktop**. 2. Click **Add custom extension** and choose `hevlayer-mcp.mcpb`. It appears under **Custom team extensions** as **hev layer search**. 3. Click **…** on it, then **Add to team**. Each member then installs **hev layer search** from the organization's extensions under **Settings → Extensions**, and pastes the **Server URL** and their own **Access token** as in steps 2 and 3 above. - The organization's **Allowlist** toggle is on the same Desktop tab. Turning it on stops members installing extensions from a file and removes the ones they already installed that way. Anyone who installed `hevlayer-mcp.mcpb` by hand installs it again from the organization's list. - Members need Claude Desktop 0.13.91 or later. - On Enterprise, device policy overrides these settings: `isDesktopExtensionEnabled` and `isDesktopExtensionDirectoryEnabled` must not be `false`. See Anthropic's [desktop extension allowlist](https://support.claude.com/en/articles/12592343-enabling-and-using-the-desktop-extension-allowlist) guide. Operators build the extension from the reviewed source commit: ```sh npm ci --ignore-scripts --prefix tools/mcp-desktop npm test --prefix tools/mcp-desktop npm run pack --prefix tools/mcp-desktop ``` Distribute `tools/mcp-desktop/dist/hevlayer-mcp.mcpb` (version `0.1.3`) and its SHA-256 through a secure channel. The bundle is built in CI but is not published as a download. Claude Desktop supplies the Node runtime; users need no separate Node installation. Remote server URLs must use HTTPS; HTTP is accepted only on loopback. If the URL or token is invalid, the extension stays running and answers Desktop's initialize request with an error that says what to fix. A server is read-only, authenticates with bearer tokens only, and is declared in gateway configuration. Clients that require OAuth, such as Claude.ai and ChatGPT connectors, can't connect yet. {/* TODO(MCP server milestones 2-4): the McpServer CRD (live reload, status), facets and enum filters, inline image thumbnails, and `layer mcp` CLI and /v2/mcp authoring are not in 0.8. The design is in the Linear RFC: MCP server. */} ## Pinned filters Set `where` on a namespace seed to serve one slice of a shared namespace, such as one company's rows. The gateway ANDs the pin into every `search`, `aggregate`, `get` and `fetch_original` call on that tool, and into the internal lookups behind them, so a client cannot widen or override it: - A pinned attribute is not offered as a filter. A client that sends it gets `unknown filter`, so it cannot ask for another value. - `get` and `fetch_original` answer a record outside the pin exactly like a missing one (not found), even when the id is known. - Counts, totals and groups from `aggregate` cover the pinned rows only. ```json {"name":"pov-bcc-photos","toolName":"story_photos","where":{"company":"story"}, "filters":["job","folder_1"],"link":"webUrl"} ``` The seed is rejected at startup if `where` is empty or holds anything but a string, boolean or number. Discovery fails, as for `filters`, if a pinned attribute is missing from the namespace, not filterable, or its value does not match the attribute's type. Pinned attributes cannot also be listed in `filters`. A gateway that predates `where` rejects the seed, so roll the gateway first. ## Page-anchored source links Set `"pageLink": true` on a namespace seed whose rows are PDF pages with an integer `page` attribute. Each hit then shows its source link and a second link with `#page=` appended, so a result opens the matching page. Links that already carry a fragment, and rows without `page`, show the source link only. ## Documents with several copies When one document has more than one copy (a SharePoint file and the same file attached to a REST row, merged by `layer-extract` [`sourceRef.merge`](/docs/pro/pdf-extraction)), its rows carry a `locations` attribute listing every copy. A hit and a `get_*` result then end with `[also: clouddrive ; rest ]`, and the structured result has the full list. ## Original files A selected source original can have incorrect provider MIME metadata. A retained PNG is returned whole with actual `mimeType: "image/png"`, its provider `declaredMimeType`, `sourceFormatMismatch: true` and the original `sourceLink`. The attached bytes are unchanged. `pages` is only valid for actual PDF originals; a PNG request with `pages` fails explicitly. Typed catalog references use the same namespace grants, pins and source/version/hash binding as PDF references. Search and `get` stay text-only. When a namespace seed sets `originals` and its rows point at a retained original, the server adds `fetch_original_` (`fetch_original` with `namespace` in generic mode). It takes the `id` of a row from search or `get`, and optionally `pages`. [Warehouse original retention](/docs/pro/guides/warehouse-originals) also supplies stable `original:` catalog IDs for unindexed selected originals. These use the same arguments and namespace read grant. A catalog cannot bypass a pinned filter: missing pinned attributes are denied. For an indexed row without `original_blob`, the gateway can lazily bind a catalog receipt by its exact content hash or source drive/item/version, without scanning or patching rows. Source drive/item/version/ETag are included in the returned `sourceIdentity`; unknown legacy identity remains an explicit error. ```json {"name":"pov-bcc-story-pages","toolName":"story_documents","link":"webUrl","pageLink":true, "originals":{"maxBytes":700000,"maxPages":3,"maxWidth":1100}} ``` | Field | Default | Purpose | | --- | --- | --- | | `maxBytes` | 700000 | Largest original returned whole. Hosts cap a tool result near 1 MB and base64 adds a third. | | `maxPages` | 3 | Page images per call (at most 8). | | `maxWidth` | 1100 | Page image width in pixels (200 to 2000). | Without `pages`, the result is the whole original as an embedded resource with its actual MIME. With `pages`, it is those pages of the **original** PDF rendered by the gateway as PNG `image` content, which a vision model can read directly. Use page images for what OCR text misses: drawings, stamps, handwriting, colored annotations, charts. Both forms put the source link and the applicable PDF page link in the text, separately and unchanged, and `structuredContent` carries the metadata only (`sha256`, `bytes`, `mimeType`, `declaredMimeType`, `sourceFormatMismatch`, `version`, `sourceLink`, `pageLink`), never the bytes. Legacy entries without declared MIME or mismatch provenance return null for those optional fields. An embedded PDF blob is valid MCP, but hosts differ in what they do with it. Anthropic's hosted connectors have rejected binary embedded resources (`-32602 invalid_union`, [claude-ai-mcp#1086](https://github.com/anthropics/claude-ai-mcp/issues/1086)), and Claude Desktop is reported to take embedded resources without showing them or giving them to the vision model ([claude-ai-mcp#287](https://github.com/anthropics/claude-ai-mcp/issues/287)). `image` content is the form hosts pass to the model's vision. Ask for `pages` when the model must see the document. ### Retention For a tool with `originals` enabled, `get` and `fetch_original` read the current document metadata from the store. A cached row from before an original was attached cannot hide it, and a cached reference cannot serve an original that the current row no longer retains. `get` still returns metadata and text only; it does not load the original bytes. If the store cannot provide the current row, the call reports that failure instead of claiming the original is absent. If no original is retained, `fetch_original` returns a tool error with the record's source link so you can open the file there. A retained reference whose file is missing from storage returns the source link too. The bytes are in the namespace's private blob storage, content-addressed by SHA-256. Rows carry `original_blob` (`blob:///`), `original_sha256` and `original_bytes`. Neither the bucket, the key nor a URL is exposed, and the tool accepts no location from the caller. Two paths write the reference, with one format and one set of checks: - **The Pipeline.** A `layer-extract` Pipeline retains by default: every new or re-ingested document stores its original (up to `maxBytes`, 10 MiB) and stages the reference with its page rows. Nothing else has to run. Set `extract.retainOriginals: false` to opt out, or an object (`{maxBytes, backfill}`) to tune it. Originals over `maxBytes` are skipped and counted, never truncated. - **A Function for existing rows.** The `layer-extract originals` worker runs under a [Function](/docs/pro/functions) (`groupByParent: true`) and fills the reference for rows ingested earlier. It gets the bytes from a sealed manifest of cached originals when one is declared, otherwise through Layer's `clouddrive` connector with the pipeline's Warehouse mounted on the pod. Its completion patch holds only `original_blob` and `original_bytes`, so text, vectors, other metadata and the source and page links are untouched, and a row that already has the reference completes with the same patch (idempotent). The original is attached only when its hash, size and provider version, item and drive match what the row was indexed from (rows indexed before those attributes existed are matched by `webUrl` and proven by hashing against their `content_hash`); a file that changed since is refused (`ambiguous`) and left for the pipeline to re-ingest, and a filename never selects an object. ### Authorization Every fetch passes the same checks as a read, in order, and the bytes are not read until all of them pass: 1. A valid key with the `mcp.` entitlement. A missing, malformed, unknown or revoked key is refused before the tool runs. 2. A read grant on the row's namespace. 3. The row itself, read through the normal document path. An id that is missing or deleted gives a not-found error. 4. The row's stored reference must equal the address the gateway derives from the row's own indexed hash and the granted namespace. A reference edited to name another namespace, other bytes, a bucket or a URL is refused. 5. The stored bytes are hashed and must match the hash the row was indexed from, and the size and PDF header must match. Otherwise the call fails with a version mismatch. Errors are explicit: no original retained, original missing from storage, over `maxBytes` (with the advice to request `pages` and the source link), page out of range, version mismatch, and render timeout (20 seconds). ## Collapsed document search Set `collapse` on an MCP namespace seed to use the gateway's ranked-query Collapse API. For example, the BCC document seed uses: ```json {"name":"pov-bcc-pages","toolName":"documents","link":"webUrl","collapse":{"by":"document"}} ``` `by`, `expansion`, and `missing` have the same meaning as on [ranked queries](/docs/pro/api/query). `by: "document"` requires a parent ID or a schema attribute marked `document: true`; an absent document-identity schema declaration fails the search. Rows with missing values follow the gateway's `missing` policy. The gateway's expansion default is tunable: larger windows can expose more groups at increased store work and response size, up to the 10,000-row candidate cap. `limit` counts distinct groups within that expanded candidate window; it does not guarantee a complete document search. Collapsed searches return `structuredContent.groups`, in relevance order. Each group contains `key`, `by`, and `best` (its best matching row, including row ID, score, attributes and configured source link). Only that row is sent; matching continuation pages and the gateway's duplicate top-level `rows` are omitted. The text content shows each best row's excerpt and source link. `structuredContent.collapse` retains the gateway's `read` and `exhausted` metadata. [Paging](#paging) walks groups: `total` counts groups and each `cursor` continues at the next group. Cite the best row's source link and use its ID with `get_documents` for full details. `get` keeps its existing record response. `list_namespaces` advertises the configured collapse object. Namespace seeds without `collapse` retain flat search responses, including photo searches. The same configuration applies when using generic `search` tools. Callers cannot override collapse via tool arguments; authorization and filters still run through the existing gateway paths. ## Unavailable-source snapshots `fetch_original` also accepts immutable inventory-snapshot `original:` IDs. These references retain the exact inventoried revision after authenticated source metadata returned 404 or 410. The response explicitly reports `retentionBasis=inventorySnapshot`, `sourceAvailability=unavailable`, the recorded observation status/time and `deletionStatus=unknown`. This is historical evidence, not a live source check. SourceLink is labelled as an inventory citation. Whole-file resources retain their actual PDF or PNG MIME type. PDF page requests work for retained PDFs; PNG page requests are rejected. Authorization, namespace binding and configured pins apply before bytes are returned. Internal S3 and proof locations are not exposed. Existing schema 1 originals remain readable; older gateway readers fail closed on snapshot schema 2. No new tool arguments or HTTP registration route are required. ### Unclassified original bytes An explicitly opted-in source original can be retained without an established format. `fetch_original` returns `application/octet-stream`, declared MIME, `formatValidation=unverified`, exact SHA/size/version and its source citation. `sourceFormatMismatch` is unknown. This is a byte-fidelity proof, not a valid container or PDF claim. Whole-file size limits, namespace grants and pins still apply; requesting pages fails. Known corruption and error-response signatures are refused even if an opaque catalog record was saved earlier. # Namespace metadata Source: https://hevlayer.com/docs/pro/api/namespace-metadata import Edition from "../../../components/docs/Edition.astro"; import Upstream from "../../../components/docs/Upstream.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; Metadata requests are wire-compatible with the upstream `GET /v2/namespaces/{ns}/metadata` endpoint and forwarded as-is after validation. The Layer API includes this compatible subset and the gateway operations documented below; backend support is called out at each feature. ## Request ```python metadata = await client.get_namespace_metadata("products") ``` ```go metadata, err := client.GetNamespaceMetadata(ctx, "products") ``` ```typescript const metadata = await client.getNamespaceMetadata("products"); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/products/metadata" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` ```jsonc { // Proxied from turbopuffer verbatim "schema": { }, "approx_row_count": 12500, "approx_logical_bytes": 48800000, "created_at": "2026-03-15T10:30:45Z", "updated_at": "2026-05-12T18:49:00Z", "last_write_at": "2026-05-12T18:48:30Z", "index": { "status": "up-to-date" }, // Layer enhancement "layer": { "stable_as_of": 1715600400000, "is_stable": true, "indexed": true, "index_lag_rows": 0 } } ``` ## Source sync health `layer.sync` records the most recent completed source sweep. A key with `read` access to the namespace can read it through this endpoint. REST and clouddrive workers persist the signal in the snapshot bucket, including sweeps that change zero rows. Scheduled REST sweeps publish the same signal as continuous sync. ```json { "last_success_at": "2026-11-03T18:00:53Z", "last_attempt_at": "2026-11-03T18:00:53Z", "last_status": "succeeded", "rows_seen": 42, "source_kind": "rest", "pipeline": "ki-projects" } ``` Read the last successful sync time at `layer.sync.last_success_at`. Times are UTC completion times. A failed or partial sweep advances `last_attempt_at` and preserves `last_success_at`; before the first success, `last_success_at` is null. `rows_seen` counts rows observed in that sweep, not upserts. The object is omitted when no source has reported or the gateway cannot read or parse its health record. Other namespace metadata remains available. Treat an absent object or null success time as unknown, and reject stale, malformed or future times when enforcing freshness. `last_write_at` still describes data writes. Pipeline `Ready` means reconciled and does not prove a successful sweep. The signal survives worker and gateway restarts. Reading it does not require Pipeline, Kubernetes or bucket access. If several Pipelines target one namespace, this object describes the latest published sweep, not the health of every source. A change of Pipeline or source kind starts a new success history. ## The `layer` block | Field | Meaning | | --- | --- | | `stable_as_of` | Epoch-ms watermark from the most recent stable poll. Null on cold start before the watcher has observed a stable namespace. | | `is_stable` | Whether the most recent poll observed `index.status == "up-to-date"`. False on cold start, true once the watcher catches up. | | `indexed` | Whether every row in the namespace carries an indexed vector. True once the snapshot's indexed-vector row count has caught up to the namespace row count; false while rows are still awaiting their first index, as during a bulk load or a [pipeline](/docs/pro/api/pipelines) mid-flight. Null for FTS-only namespaces, which have no vector column to reconcile. | | `index_lag_rows` | Count of rows present in the namespace that do not yet have an indexed vector. Zero when `indexed` is true. Reconciled from the most recent [snapshot](/docs/pro/api/snapshots), so it trails live writes by the snapshot cadence. | | `title` | The namespace's [Index](/docs/pro/indexes#metadata) `spec.metadata.title`. Omitted when unset; use `layer.title ?? id`. | | `description` | The Index's `spec.metadata.description`. Omitted when unset. | A read for a namespace that does not exist returns upstream's 404, matching turbopuffer's own metadata endpoint. `is_stable` is the *current* signal — it drives the per-query filter-skip decision on the query path. `stable_as_of` is the *historical* watermark — the cut a filtered query would apply. After a namespace is observed stable, the watcher refreshes this watermark on the stable-tier cadence (`CONSISTENCY_STABLE_POLL_INTERVAL_MS`, default 60000 ms). Writes re-arm the fast tier, so active namespaces are polled on `CONSISTENCY_POLL_INTERVAL_MS`. `indexed` answers a different question than `is_stable`. `is_stable` reports whether the upstream index has caught up on the rows it has *seen*, which is what read-after-write depends on. `indexed` reports whether every row that *should* be present is present and queryable, which is what a bulk load or a [pipeline](/docs/pro/api/pipelines) needs to know it has finished: rows can be staged and counted before their vectors are indexed, so a namespace can read `is_stable: true` while `indexed: false` with a non-zero `index_lag_rows`. The reconciliation runs against the latest [snapshot](/docs/pro/api/snapshots), so `indexed` advances on the snapshot cadence rather than per write. For snapshot history derived from these freshness signals, see [Snapshots](/docs/pro/api/snapshots). ## Field stats `layer.field_stats` describes what is in each attribute of `schema`: how many distinct values it has, how many rows lack it, its range, and its most common values. It sits next to the schema it describes, keyed by the same attribute names, so one metadata read tells a client both what a field is and what is in it. The gateway keeps the stats current as rows are written, so the read is a few cache lookups, not a scan and not a query against your store. Field stats are opt-in. Ask for them with `?field_stats=true`. Without it the metadata response is exactly what it is today, so existing clients see no change. ```python metadata = await client.get_namespace_metadata("bcc-pages", field_stats=True) ``` ```go metadata, err := client.GetNamespaceMetadata(ctx, "bcc-pages", &hevlayer.GetNamespaceMetadataParams{ FieldStats: true, }) ``` ```typescript const metadata = await client.getNamespaceMetadata("bcc-pages", { fieldStats: true }); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/bcc-pages/metadata?field_stats=true" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` A search UI uses them to build the right filter for each field without being told. A field with one value has nothing to filter on. A field with five values wants checkboxes, one with 3,000 wants a search box, and a numeric field wants a slider over its real range. [Layer UI](https://github.com/hev/layer-ui) reads `field_stats` and makes those choices itself. ```jsonc { // Proxied from turbopuffer verbatim "schema": { "doc_type": { "type": "string", "filterable": true }, "company": { "type": "string", "filterable": true }, "file_year": { "type": "uint", "filterable": true }, "path": { "type": "string", "filterable": true } }, "approx_row_count": 48211, // Layer enhancement "layer": { "stable_as_of": 1791035112000, "is_stable": true, // Only with ?field_stats=true "field_stats": { "as_of": 1791035112000, "rows": 48211, "fields": { "doc_type": { "missing": 231, "distinct": { "value": 14, "quality": "exact" }, "state": "exact" }, "company": { "missing": 0, "distinct": { "value": 1, "quality": "exact" }, "state": "exact" }, "file_year": { "missing": 1209, "distinct": { "value": 19, "quality": "exact" }, "min": 2007, "max": 2026, "state": "approximate", "approximate_since": 1791034990000 }, "path": { "missing": 0, "distinct": { "value": 48102, "quality": "estimate" }, "state": "approximate" } } } } } ``` Metadata reads use cached field statistics; they do not trigger a namespace scan. Reconciliation refreshes the cache separately. The performance acceptance fixture checks that both `field_stats=true` and `field_stats_top=20` add at most 20 ms at p95 over plain metadata on a quiet host. This is a fixture target, not a staging latency SLA; network and store latency still affect your requests. Add `field_stats_top=N` (up to 1,000) to include each field's `N` most common values with their counts. It implies `field_stats=true`: ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/bcc-pages/metadata?field_stats_top=20" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` ```jsonc "doc_type": { "missing": 231, "distinct": { "value": 14, "quality": "exact" }, "top": [ { "v": "invoice", "n": 18233 }, { "v": "drawing", "n": 9120 }, { "v": "change_order", "n": 2210 } ], "state": "exact" } ``` To page through every value of a field, or count values within a filter, use a [values scan](/docs/pro/api/scans). | Field | Meaning | | --- | --- | | `as_of` | Epoch ms on the same clock as `stable_as_of`: the stable watermark of the last completed reconcile scan, which is the base the counts were verified against. Tracked writes after it update counts but never advance it, so `as_of` is the age of the last full verification, not of the last write. `null` until the first reconcile (see [What `as_of` and `state` promise](#what-as_of-and-state-promise)). | | `rows` | Rows the stats describe, counted by the gateway, not upstream's `approx_row_count`. | | `fields` | One entry per tracked attribute, keyed like `schema`. A filterable attribute that isn't tracked is absent (see [Which fields are tracked](#which-fields-are-tracked)). | | `fields.*.missing` | Rows with no value, `null`, an empty string or an empty array. | | `fields.*.distinct.value` | Distinct non-missing values. For array attributes, distinct elements. | | `fields.*.distinct.quality` | How the count was computed: `exact`; `estimate` (a HyperLogLog sketch, within about 2%) once the field passes the value cap; or `lower_bound` for a field stored before sketches existed, until its next reconcile. Says nothing about whether the count is current: that is `state`. | | `fields.*.min`, `max` | For numeric attributes, including datetimes stored as epoch numbers. | | `fields.*.top` | With `field_stats_top=N` only: the most common values (as strings) with their counts. Omitted once a field passes the value cap. | | `fields.*.state` | Maintenance state: `exact` only when the counts are certified current as of `as_of` plus every tracked write; otherwise `approximate`. Independent of `distinct.quality`: an `estimate` can be `exact` state, and an `exact` count can be `approximate` state. | ### How the gateway keeps them current Every write through the gateway updates the stats as it lands. - **Writes** add counts and widen ranges. Overwrites, patches and deletes subtract cached old values where available; missing old values, filtered writes and invalidated extrema leave approximate aggregates. - **Freshness.** With reviewed capture installed, metadata reads validate the complete persisted bundle under retained source guards before reporting exact counts. A stored receipt alone is insufficient. Missing coverage or unavailable guards leave historical counts `approximate`. Reads never scan, admit a scan attempt, schedule reconciliation or register a namespace. - **Reconcile.** A gated source integration uses an opaque capture receipt validated under the original retained guards. Complete persisted artifacts with matching coverage may be reused while guarded. A false validation requires one strong, exhaustive scan projecting the union of snapshot facets and declared stats fields; an error never permits skipping. Concurrent triggers share one flight and the larger configured interval sets the floor. Pro startup can install this integration from an explicit `LAYER_FIELD_STATS_CAPTURE_CONFIG` file (the Helm chart mounts it from an existing ConfigMap named by `gateway.fieldStatsCapture.configMapName`) containing reviewed client/account, pricing/calibration, schema/projection and validity inputs, plus an existing witness identity and fence quotas. No policy defaults or witness bootstrap are supplied. All configured Turbopuffer aliases must share the reviewed writer identity. Missing configuration preserves legacy snapshots. Source installation requires independent all-writer and terminal-drain evidence; it is not evidence that a live namespace has accepted current stats. - **Legacy snapshots** continue their periodic facet scans at their snapshot interval. Declared stats do not broaden those scans. Repeated unchanged refreshes still scan and reaggregate; new stats-only coverage is unavailable without the capture contract. Turbopuffer timestamps and row counts do not certify content identity or silent external writes. The live stats sit in Aerospike, one record per field, updated with compare-and-set so concurrent writers never lose a delta. Each reconcile also writes them to S3. A gateway that starts cold, or loses Aerospike, reloads from S3 rather than rescanning. A field passes the configured value cap (default 10,000 distinct values) when it has more distinct values than that. Past it the gateway stops counting values one by one: `top` is dropped and `distinct` becomes an `estimate` from a HyperLogLog sketch that the same writes and reconcile scan feed. No extra scan, accounting or scan bytes are involved. #### Estimate accuracy The sketch has 16,384 one-byte registers (16 KiB per field, about 22 KiB stored) and Ertl's improved estimator. Its relative standard error is about 0.8%: typically the estimate is within 1%, about 95% of estimates are within 1.6%, and about 98.7% are within 2%. Layer documents **about 2%**, not a hard bound. Its tests measure 10,001 to 2,000,000 distinct values over repeated independent key sets and require the mean error under 1.2%, at least 95% of estimates within 2% and none beyond 3%. The estimate never drops below the exact count it had at the cap. Sketches only add values: a delete or an overwrite of a value in an overflowed field leaves the estimate possibly too high, so the field's `state` becomes `approximate` until the next reconcile rebuilds the sketch from the scan. Use a [values scan](/docs/pro/api/scans) when you need the exact distinct count of a high-cardinality field. #### What `as_of` and `state` promise `quality` and `state` answer different questions. `quality` says how a count was computed; `state` says whether it is current. | Situation | `as_of` | `state` | | --- | --- | --- | | No reconcile has finished (incomplete initial coverage) | `null`; fields absent and listed in `untracked` | none | | Reconcile just finished, source guards certify nothing changed | That reconcile's watermark | `exact` | | Tracked appends since | Unchanged | `approximate` unless guards certify the cut | | Tracked deletes, overwrites and patches | Unchanged | `approximate` unless old values were subtracted exactly and guards certify | | Writes that bypass the gateway, or a row-count mismatch | Unchanged | `approximate` | | Cold start or lost Aerospike, reloaded from S3 | The stored watermark | `approximate` | | Stats saved before a field was tracked | Other fields unaffected | field absent, listed in `untracked` | `exact` is never reported for stale or incomplete data. Without reviewed capture installed (the default), nothing can certify a cut, so every returned field is `approximate`, even on an append-only namespace. Counts then only err high and ranges only wide. After deletes, the next reconcile within one `interval` replaces them with exact scan results, and the field returns to `exact` state wherever the capture contract certifies it. ### Which fields are tracked Every filterable, non-vector, non-reserved attribute of an Index's namespace, by default. Configure coverage on the `Index`: ```yaml spec: backend: namespace: bcc-pages fieldStats: fields: auto # default; or [doc_type, year] exclude: [blocks] valueCap: 10000 # default interval: 5m # default; positive duration ``` An explicit list selects only those filterable fields. `fields: []` disables stats, even when snapshot facets remain enabled. Exclusions apply to either mode. Invalid field names, a zero value cap or a nonpositive interval are rejected. Auto follows schema changes at the next scheduled reconciliation. Enabling a new field requires reading that attribute for every row once at the next reconcile. Wider projections increase upstream scan bytes; stats add no second scan. Until initial coverage completes, absent stats remain in `field_stats.untracked`; a client must keep their controls visible rather than treat them as exact zero. Excluded, nonselected and unavailable filterable fields are also listed there. Layer without Aerospike returns empty `fields`. A namespace without a declarative Index keeps the legacy snapshot-facet coverage. #### Scan cost of enabling fields A reconcile that has to scan reads the selected attributes of every row, in pages of 1,000 rows, and the provider bills each page as a query. Each page query is billed over the namespace's queried bytes, not just the page's rows, so the cost of one traversal grows with rows times namespace size, not with the number of fields alone. One measured traversal of a 208,000-row, 1.9 GB namespace projecting 14 attributes was 208 pages, 62 MB of response bytes and about 4.4e11 billed queried bytes. Three things keep that cost down: - **List fields instead of `auto`.** `auto` adds every filterable attribute, including high-cardinality ones such as paths and content hashes, and widens every row the scan returns. List the filters people actually use. - **Enabling a field is a scan.** A new field is `untracked` until the next reconcile reads it. Expanding coverage consumes the same scan allocation as any other reconcile, so enable fields in one change, not one at a time. - **Unchanged namespaces cost nothing, where capture certifies it.** With reviewed capture installed, a reconcile whose guards certify no change reuses the stored bundle and reads no rows. Without it, every reconcile at the `interval` rescans, so choose the interval for the cost you accept. Metadata reads never scan, whatever the configuration. #### Capping the snapshot scan Without guarded capture, the snapshot scan (`snapshot.facetFields`) is the only scan that fills field stats, and its stats are always `approximate`. A namespace can be put under the same spend ledger as guarded capture by listing it in the file named by `LAYER_FIELD_STATS_SNAPSHOT_BUDGET_CONFIG`: a reviewed store, and per namespace the account and pricing review, the page calibration and the static metadata estimate. The gateway refuses to start if that file is invalid or the shared financial store is missing. A listed namespace's scan then: - reserves one attempt, and every page at its reviewed ceiling, before the page is sent, against 144 attempts and 963,000 micro-USD of reserved spend per rolling 24 hours, keyed by provider and namespace (capture and snapshot scans share one allowance); - settles each page's billed bytes before the next page, and never refunds a reservation, a failed attempt or a refused page; - is refused, with no provider request, when the window is exhausted, another replica has an unsettled page, the listed projection differs from the namespace's, the size metadata is missing, a projected attribute's schema entry is missing or changed (the review pins those entries only, so an unrelated new attribute does not stop the scan), or the review has expired. A lapsed review does not stop the gateway from starting; the scan simply refuses until the file is re-rendered and the gateway restarts. The previous stats and `as_of` stay, `approximate`. A listed namespace the provider reports as written in the last 15 seconds is not scanned that interval at all: no attempt, no reservation, no provider page. This only avoids billing a scan that would be discarded; `as_of` and the cadence are unchanged, and a write that lands during a scan still discards its result. A namespace that is not listed scans as before, uncapped. At the reviewed 2x page ceiling the window admits fewer than 144 scans a day for a namespace of about 209 pages: see the scan cost estimate above. The spend is an estimate at public rates, not an invoice. The value cap is configured per Index; `LAYER_FIELD_STATS_VALUE_CAP` remains the fallback for legacy facet coverage. Above the cap this increment retains the sketch described above. ### How Layer UI uses them Layer UI's live runtime reads metadata with `field_stats=true` at startup and on each refresh, and picks each filter's control from the field's stats: | Field stats | Filter | | --- | --- | | `distinct` 0 or 1, `exact` | Hidden. Nothing to choose between. | | `distinct` 2–8 | Checkboxes | | `distinct` 9–1,000 | Searchable picker with counts | | More than 1,000, or `estimate` | Search-first picker | | Number or datetime with `min` and `max` | Range slider over `[min, max]` | | `missing` > 0 | A "None" option, counted | An `approximate` field keeps its control. Only an `exact` count of 0 or 1 hides it, so a filter never disappears because of a stale count. ### Field stats and snapshots [Snapshots](/docs/pro/api/snapshots) are history: a body per watermark, kept for `retention`, for comparing a namespace over time. Field stats are the current state, for building a UI now. They share the reconcile scan. Snapshot facets stay independent of stats coverage. Their projection is included in the same reconcile, even with field stats disabled. ### Limits - Field stats describe the whole namespace. They take no filter; for counts within a filter use a [values scan](/docs/pro/api/scans). - `GET /v1/namespaces/{ns}/schema` stays a verbatim proxy. Stats are on metadata only. - Field stats need Aerospike and S3, so they are a Pro feature. ## List namespaces `GET /v2/namespaces` is a Layer-only augmented listing. It pages the upstream namespace list and enriches each row with stability and cache signals. It is the endpoint the dashboard's inventory view reads. ```python namespaces = await client.list_namespaces(prefix="prod", page_size=100) ``` ```go namespaces, err := client.ListNamespaces(ctx, &hevlayer.ListNamespacesParams{ Prefix: "prod", PageSize: 100, }) ``` ```typescript const namespaces = await client.listNamespaces({ prefix: "prod", pageSize: 100, }); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces?prefix=prod&page_size=100" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` ```jsonc { "namespaces": [ { "name": "products", "row_count": 12500, "size_bytes": 48800000, "stable_as_of_ms": 1715600400000, "is_stable": true, "index": { "status": "up-to-date" }, "cache_state": {"state": "warm", "warm_inflight": false}, "last_write_ms": 1715600399000, "shadow": false, "labels": {}, "title": "Products", "description": "Shop catalog, one row per product." } ], "next_cursor": "..." } ``` `title` and `description` come from the namespace's [Index](/docs/pro/indexes#metadata) and are omitted when unset, so a client shows `title ?? name`. They are present even when `metadata_error` is. Each row carries freshness signals derived from that row's metadata fetch. `is_stable` is true when `index.status` is `"up-to-date"`, false when it is `"updating"`, and omitted when metadata has no index signal or the fetch failed. `stable_as_of_ms` is set to the metadata observation time for rows reported up to date. `indexed` and `index_lag_rows` live on `GET /v2/namespaces/{namespace}/metadata`, where the gateway can do the snapshot lookup for one namespace without adding object-store reads to the high-fanout list path. `index` is turbopuffer's indexing state, passed through verbatim: | Field | Meaning | | --- | --- | | `index.status` | `"updating"` or `"up-to-date"`. | | `index.unindexed_bytes` | Write-ahead-log bytes not yet indexed. Present only while `updating` (omitted once caught up). Unindexed data is still searched by queries, so a non-zero value means *behind, but serving* — watch it fall to confirm indexing is draining rather than wedged. | Listing is read-only and does not register namespaces with the consistency watcher. Write traffic and snapshot facet configuration register the namespaces that need durable watermarks. | Query param | Purpose | | --- | --- | | `prefix` | Restrict to namespaces whose name starts with this string. | | `cursor` | Pagination cursor from a prior `next_cursor`. | | `page_size` | Page size; the upstream list page is capped at 1000. | A per-row metadata failure degrades to a row with `metadata_error` set rather than dropping the namespace, so the list stays complete even when a single namespace's metadata call fails. Responses are served from a short-TTL cache (`NAMESPACE_LIST_CACHE_TTL_MS`, default `10000`) so dashboard polling does not fan out a metadata call per namespace per refresh. # Pipelines Source: https://hevlayer.com/docs/pro/api/pipelines import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; The pipeline API keeps the code you need to index data simple and organized. A typical pipeline has two stages: extraction and chunking on CPU, followed by embedding on GPU. This guide walks through a best-practice layout for that pipeline; the concepts expand to N stages. A `Pipeline` resource declares the scaling characteristics you want for ingesting data. Ingestion typically runs in stages: a CPU stage for chunking and extraction, followed by a GPU stage for embedding. You can declare the spec in YAML, from code through the pipeline API, or a combination of both — it is recommended you declare your pipeline scaling characteristics in YAML while setting your namespace via the client. `spec.sourceRef` lets you declare your pipeline's upstream details as well — the operator hands it to the worker as an environment variable, so the worker reads its source from config instead of hardcoding it. ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Pipeline metadata: name: product-images namespace: layer spec: target: namespace: products sourceRef: kind: sqs queueUrl: https://sqs.us-east-1.amazonaws.com/123456789/product-images schedule: cron: "0 2 * * *" leaseSeconds: 600 worker: image: .dkr.ecr.us-east-1.amazonaws.com/hev-product-image-worker:latest computeClass: cpu batchSize: 64 timeoutSeconds: 60 scaling: pool: cpu mode: autoscale replicas: min: 0 max: 8 ``` ## Document lifecycle ``` put chunks put vectors (new doc) ──────────► pending ──────────────► indexed ▲ │ re-stage (idempotent) ``` - **pending** — chunks stored, waiting for embedding. - **indexed** — vectors written to the namespace's configured VectorStore. `embedding` is a claim stage: documents sit in it only while leased to a worker, and recover to `pending` when a lease expires. Re-staging a document resets it to `pending` with new chunks, which is how you reprocess after source data changes. ## File tree ``` indexer/ ├── pipelines/ │ ├── extract-chunk.yaml # CPU stage — Pipeline resource │ └── embed.yaml # GPU stage — Pipeline resource ├── extract_chunk.py # read the source, stage chunks ├── embed.py # claim pending docs, write vectors └── app.py # REST API: trigger a run, wait for completion ``` The two YAML files declare the worker images, pools, and scaling — see [Target](#target) through [Scaling](#scaling) below for the fields. Both set `pipelineId: products` so the two workers share one queue. The rest of this page is the worker code — shown in Python and Go; every call is also a plain REST endpoint (see [Write & Stage](/docs/pro/api/write)). ## Extract and chunk The CPU worker reads the source, splits text into chunks, and stages them. Staging chunks stores them durably (S3, cached in the document cache) and marks the document `pending`. The worker hardcodes nothing: the operator injects the pipeline id, the gateway URL, and `spec.sourceRef` as environment variables — see the [worker variables](#worker) below. The queue URL below comes from the `sourceRef` declared in `pipelines/extract-chunk.yaml`. ```python # extract_chunk.py import asyncio import json import os import boto3 from hevlayer import AsyncHevlayer PIPELINE = os.environ["HEVLAYER_PIPELINE_ID"] SOURCE = json.loads(os.environ["HEVLAYER_SOURCE_REF"]) sqs = boto3.client("sqs") def chunks(text: str, size: int = 800) -> list[str]: return [text[i : i + size] for i in range(0, len(text), size)] async def main() -> None: async with AsyncHevlayer( base_url=os.environ["HEVLAYER_BASE_URL"], api_key=os.environ.get("LAYER_GATEWAY_API_KEY"), ) as layer: while True: batch = sqs.receive_message( QueueUrl=SOURCE["queueUrl"], MaxNumberOfMessages=10, ).get("Messages", []) for m in batch: doc = json.loads(m["Body"]) await layer.put_pipeline_document_chunks(PIPELINE, doc["id"], { "chunks": [ {"id": f"{doc['id']}-{i}", "text": t} for i, t in enumerate(chunks(doc["text"])) ], }) sqs.delete_message(QueueUrl=SOURCE["queueUrl"], ReceiptHandle=m["ReceiptHandle"]) asyncio.run(main()) ``` ```go // extract_chunk.go package main import ( "context" "encoding/json" "fmt" "os" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sqs" hevlayer "github.com/hev/layer-go" ) func chunks(text string, size int) []string { var out []string for i := 0; i < len(text); i += size { out = append(out, text[i:min(i+size, len(text))]) } return out } func main() { ctx := context.Background() pipeline := os.Getenv("HEVLAYER_PIPELINE_ID") var source struct { QueueURL string `json:"queueUrl"` } json.Unmarshal([]byte(os.Getenv("HEVLAYER_SOURCE_REF")), &source) cfg, _ := config.LoadDefaultConfig(ctx) queue := sqs.NewFromConfig(cfg) layer := hevlayer.NewClient( hevlayer.WithBaseURL(os.Getenv("HEVLAYER_BASE_URL")), hevlayer.WithAPIKey(os.Getenv("LAYER_GATEWAY_API_KEY")), ) for { batch, err := queue.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: &source.QueueURL, MaxNumberOfMessages: 10, }) if err != nil { continue } for _, m := range batch.Messages { var doc struct { ID string `json:"id"` Text string `json:"text"` } json.Unmarshal([]byte(*m.Body), &doc) var staged []hevlayer.Chunk for i, t := range chunks(doc.Text, 800) { staged = append(staged, hevlayer.Chunk{ID: fmt.Sprintf("%s-%d", doc.ID, i), Text: t}) } layer.PutPipelineDocumentChunks(ctx, pipeline, doc.ID, &hevlayer.PutChunksRequest{Chunks: staged}) queue.DeleteMessage(ctx, &sqs.DeleteMessageInput{ QueueUrl: &source.QueueURL, ReceiptHandle: m.ReceiptHandle, }) } } } ``` ```typescript // extract_chunk.ts import { DeleteMessageCommand, ReceiveMessageCommand, SQSClient, } from "@aws-sdk/client-sqs"; import { Hevlayer } from "hevlayer"; const PIPELINE = process.env.HEVLAYER_PIPELINE_ID!; const SOURCE = JSON.parse(process.env.HEVLAYER_SOURCE_REF!); const sqs = new SQSClient({}); const layer = new Hevlayer({ baseUrl: process.env.HEVLAYER_BASE_URL, apiKey: process.env.LAYER_GATEWAY_API_KEY, }); function chunks(text: string, size = 800): string[] { const out: string[] = []; for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size)); return out; } while (true) { const batch = await sqs.send(new ReceiveMessageCommand({ QueueUrl: SOURCE.queueUrl, MaxNumberOfMessages: 10, })); for (const message of batch.Messages ?? []) { const doc = JSON.parse(message.Body ?? "{}"); await layer.putPipelineDocumentChunks(PIPELINE, doc.id, { chunks: chunks(doc.text).map((text, i) => ({ id: `${doc.id}-${i}`, text })), }); await sqs.send(new DeleteMessageCommand({ QueueUrl: SOURCE.queueUrl, ReceiptHandle: message.ReceiptHandle, })); } } ``` ## Embed The GPU worker claims pending documents, reads their chunks back, and writes vectors. Writing vectors upserts to the namespace's configured VectorStore and marks the document `indexed`. Claims are leased, so a worker that crashes loses nothing. For multivector namespaces on a `kind: search` store, send `vectors: [[...], [...]]` on an entry instead of `vector: [...]`; the gateway forwards the bag and caches the first inner vector for `nearest_to_id` lookup. ```python # embed.py import asyncio import os from hevlayer import AsyncHevlayer from sentence_transformers import SentenceTransformer PIPELINE = os.environ["HEVLAYER_PIPELINE_ID"] model = SentenceTransformer("all-MiniLM-L6-v2") async def main() -> None: async with AsyncHevlayer( base_url=os.environ["HEVLAYER_BASE_URL"], api_key=os.environ.get("LAYER_GATEWAY_API_KEY"), ) as layer: while True: claimed = await layer.claim_documents(PIPELINE, { "stage": "pending", "claim_stage": "embedding", "limit": 16, "worker_id": "embed-0", }) for doc_id in claimed.documents: doc_chunks = await layer.get_pipeline_document_chunks(PIPELINE, doc_id) vectors = model.encode([c.text for c in doc_chunks]) await layer.put_pipeline_document_vectors(PIPELINE, doc_id, { "vectors": [ {"id": c.id, "vector": v.tolist(), "attributes": {"text": c.text}} for c, v in zip(doc_chunks, vectors) ], }) asyncio.run(main()) ``` ```go // embed.go package main import ( "context" "os" hevlayer "github.com/hev/layer-go" ) func main() { ctx := context.Background() pipeline := os.Getenv("HEVLAYER_PIPELINE_ID") layer := hevlayer.NewClient( hevlayer.WithBaseURL(os.Getenv("HEVLAYER_BASE_URL")), hevlayer.WithAPIKey(os.Getenv("LAYER_GATEWAY_API_KEY")), ) for { claimed, err := layer.ClaimDocuments(ctx, pipeline, &hevlayer.ClaimDocumentsRequest{ Stage: "pending", ClaimStage: "embedding", Limit: 16, WorkerID: "embed-0", }) if err != nil { continue } for _, docID := range claimed.Documents { docChunks, err := layer.GetPipelineDocumentChunks(ctx, pipeline, docID) if err != nil { continue } texts := make([]string, len(*docChunks)) for i, c := range *docChunks { texts[i] = c.Text } vectors := embed(texts) // your embedding model or service entries := make([]hevlayer.VectorEntry, len(*docChunks)) for i, c := range *docChunks { entries[i] = hevlayer.VectorEntry{ ID: c.ID, Vector: vectors[i], Attributes: map[string]interface{}{"text": c.Text}, } } layer.PutPipelineDocumentVectors(ctx, pipeline, docID, &hevlayer.PutVectorsRequest{Vectors: entries}) } } } ``` ```typescript // embed.ts import { Hevlayer } from "hevlayer"; const PIPELINE = process.env.HEVLAYER_PIPELINE_ID!; const layer = new Hevlayer({ baseUrl: process.env.HEVLAYER_BASE_URL, apiKey: process.env.LAYER_GATEWAY_API_KEY, }); while (true) { const claimed = await layer.claimDocuments(PIPELINE, { stage: "pending", claim_stage: "embedding", limit: 16, worker_id: "embed-0", }); for (const docId of claimed.documents) { const docChunks = await layer.getPipelineDocumentChunks(PIPELINE, docId); const vectors = await embed(docChunks.map((chunk) => chunk.text)); await layer.putPipelineDocumentVectors(PIPELINE, docId, { vectors: docChunks.map((chunk, i) => ({ id: chunk.id, vector: vectors[i], attributes: { text: chunk.text }, })), }); } } ``` ### Write staged rows A stage that doesn't compute vectors can instead write a document's staged rows as ordinary namespace rows, and let the namespace's schema embed them [natively](/docs/pro/api/embed#where-the-vector-comes-from). The stock [PDF extraction](/docs/pro/pdf-extraction) write stage works this way. ```bash curl -X PUT "$LAYER_GATEWAY_URL/v2/pipelines/$PIPELINE/documents/$DOC_ID/rows" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "upsert_rows": [ {"id": "'"$DOC_ID"'#p1", "_hevlayer_parent_id": "'"$DOC_ID"'", "text": "Page one."} ], "distance_metric": "cosine_distance", "schema": {"text": {"type": "string", "full_text_search": true, "embed": {"model": "voyage/voyage-4"}}} }' ``` `upsert_rows` may contain only the document's staged row ids, and each row's `_hevlayer_parent_id` must be the document id. `schema` and `distance_metric` are optional and follow the [write API](/docs/pro/api/write). The usual reserved-attribute rules apply. The route does not mark the document `indexed`. The worker completes its claim afterwards through the stage API, so a crash between the two leaves the document to be claimed again. | Condition | Response | | --- | --- | | A row id that isn't one of the document's staged rows, a mismatched `_hevlayer_parent_id`, a reserved attribute, or an unknown request field | `422` | | The pipeline doesn't exist | `404` | The generated Go, Python and TypeScript clients include this route. ### Write several documents' rows in one request Every namespace write takes the namespace's write path one at a time, and each write carries a fixed cost (the Function write guard, the store round trip and the embedding call) that does not shrink with the number of rows. A stage that finishes many small documents can share that cost: ```bash curl -X PUT "$LAYER_GATEWAY_URL/v2/pipelines/$PIPELINE/rows" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "documents": [ {"doc_id": "a", "upsert_rows": [{"id": "a#p1", "_hevlayer_parent_id": "a", "text": "Page one."}]}, {"doc_id": "b", "upsert_rows": [{"id": "b#p1", "_hevlayer_parent_id": "b", "text": "Page one."}]} ], "distance_metric": "cosine_distance", "schema": {"text": {"type": "string", "embed": {"model": "voyage/voyage-4"}}} }' ``` Up to 64 documents and 2,048 rows. Each document is validated as in the per-document route, and one invalid document rejects the whole request before anything is written. The route does not mark any document `indexed`: complete each claim only after the request succeeds. After an error or an unknown outcome, release the documents and write them again; page ids are stable, so the rewrite is idempotent. Page patches stay per document. Build the two workers into the images your YAML references and push them to a registry your cluster can pull — Layer does not build images. Then apply the resources: ```sh kubectl apply -f pipelines/ ``` The operator creates one Deployment per resource and the embed pool's KEDA object. Order doesn't matter here: the app creates the gateway pipeline before it enqueues a batch (staging into a pipeline id that doesn't exist returns 404), so workers never see a missing pipeline. Nothing else to wire: the CRD [types themselves](/docs/pro/install#helm) install with the Helm chart. ## Trigger a run The app exposes the pipeline to the rest of your system as one endpoint: `POST /index-runs` sends a batch to the source queue, then waits for the run to complete and returns the snapshot it produced. The pipeline is created on first use — this is where the target namespace is set in code. Layer deduplicates `create_missing` staging by pipeline and document id. Large staging calls publish queue segments incrementally, so downstream workers can claim committed rows and KEDA can observe their depth while the same call is still adding later segments. ```python # app.py import asyncio import json import os import time import boto3 from fastapi import FastAPI from hevlayer import AsyncHevlayer, HevlayerError QUEUE = "https://sqs.us-east-1.amazonaws.com/123456789/product-updates" sqs = boto3.client("sqs") app = FastAPI() layer = AsyncHevlayer( base_url=os.environ["HEVLAYER_BASE_URL"], api_key=os.environ.get("LAYER_GATEWAY_API_KEY"), ) @app.post("/index-runs") async def index_run(documents: list[dict]) -> dict: started_ms = int(time.time() * 1000) try: await layer.create_pipeline({"id": "products", "target_namespace": "products"}) except HevlayerError as e: if e.status_code != 409: # 409: already exists raise for doc in documents: sqs.send_message(QueueUrl=QUEUE, MessageBody=json.dumps(doc)) await drain() sha = await next_snapshot(after_ms=started_ms) return {"documents": len(documents), "snapshot": sha} ``` ```go // app.go var ( queueURL = "https://sqs.us-east-1.amazonaws.com/123456789/product-updates" queue *sqs.Client // sqs.NewFromConfig in main layer = hevlayer.NewClient( hevlayer.WithBaseURL(os.Getenv("HEVLAYER_BASE_URL")), hevlayer.WithAPIKey(os.Getenv("LAYER_GATEWAY_API_KEY")), ) ) func indexRun(w http.ResponseWriter, r *http.Request) { ctx := r.Context() startedMs := time.Now().UnixMilli() var documents []map[string]interface{} json.NewDecoder(r.Body).Decode(&documents) _, err := layer.CreatePipeline(ctx, &hevlayer.CreatePipelineRequest{ ID: "products", TargetNamespace: "products", }) var herr *hevlayer.HevlayerError if err != nil && !(errors.As(err, &herr) && herr.StatusCode == 409) { // 409: already exists http.Error(w, err.Error(), http.StatusBadGateway) return } for _, doc := range documents { body, _ := json.Marshal(doc) mb := string(body) queue.SendMessage(ctx, &sqs.SendMessageInput{QueueUrl: &queueURL, MessageBody: &mb}) } drain(ctx) sha := nextSnapshot(ctx, startedMs) json.NewEncoder(w).Encode(map[string]interface{}{ "documents": len(documents), "snapshot": sha, }) } ``` ```typescript // app.ts import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; import { Hevlayer } from "hevlayer"; const queueUrl = "https://sqs.us-east-1.amazonaws.com/123456789/product-updates"; const queue = new SQSClient({}); const layer = new Hevlayer({ baseUrl: process.env.HEVLAYER_BASE_URL, apiKey: process.env.LAYER_GATEWAY_API_KEY, }); async function indexRun(documents: Record[]) { const startedMs = Date.now(); await layer.ensurePipeline({ id: "products", target_namespace: "products" }); for (const doc of documents) { await queue.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: JSON.stringify(doc), })); } await drain(); return { documents: documents.length, snapshot: await nextSnapshot(startedMs) }; } ``` ## Wait for completion A run is complete in two steps: the queue drains, then the consistency watcher observes the namespace stable and writes a [snapshot](/docs/pro/api/snapshots) past the run's watermark. `pending_count` is the same signal KEDA scales on — when it reaches zero, the embed pool scales back to zero. `status` is `waiting_on_upstream` when a downstream worker has no pending rows to claim while upstream stages are still active. If a worker cannot load a document's durable chunk payload after the configured retry budget, Layer moves that document to `failed` and adds the dead-letter reason to `failed_reasons`, such as `{"chunks_unavailable": 2}`. The snapshot SHA addresses facet listings and counts exact at that watermark; flip your application to it. ```python # app.py async def drain() -> None: while True: status = await layer.get_pipeline_status("products") if status.pending_count == 0: # status.counts: {"pending": 0, "indexed": 8530} return await asyncio.sleep(10) async def next_snapshot(after_ms: int) -> str: while True: history = await layer.list_namespace_history("products", limit=1) if history and history[0].watermark_ms >= after_ms: return history[0].sha await asyncio.sleep(30) ``` ```go // app.go func drain(ctx context.Context) { for { status, err := layer.GetPipelineStatus(ctx, "products") if err == nil && status.PendingCount == 0 { // status.Counts: {"pending": 0, "indexed": 8530} return } time.Sleep(10 * time.Second) } } func nextSnapshot(ctx context.Context, afterMs int64) string { for { history, err := layer.ListNamespaceHistory(ctx, "products", &hevlayer.ListNamespaceHistoryParams{Limit: 1}) if err == nil && len(history) > 0 && history[0].WatermarkMs >= afterMs { return history[0].Sha } time.Sleep(30 * time.Second) } } ``` ```typescript // app.ts const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function drain() { while (true) { const status = await layer.getPipelineStatus("products"); if (status.pending_count === 0) return; await sleep(10_000); } } async function nextSnapshot(afterMs: number): Promise { while (true) { const history = await layer.listNamespaceHistory("products", { limit: 1 }); if (history.length > 0 && history[0].watermark_ms >= afterMs) { return history[0].sha; } await sleep(30_000); } } ``` Once vectors are indexed, query and fetch them through the namespace API — see [Query & Fetch](/docs/pro/api/query). ## Target `spec.target.namespace` is the turbopuffer namespace the pipeline writes. The gateway pipeline API owns document state, chunks, and vector writes for that target namespace. ## Pipeline id `spec.pipelineId` names the gateway pipeline (the queue) the worker stages into and scales on. It defaults to the resource name. Set it when multiple worker resources share one queue: the extract and embed stages of a [two-stage pipeline](#file-tree) both set `pipelineId: products`. ## Source `spec.sourceRef` declares the external source that feeds the worker. Its `kind` selects how the operator treats it. For **open kinds** — SQS, Kafka, S3 events, a partner API, a one-off migration — `sourceRef` is arbitrary JSON injected into the worker pod verbatim as `HEVLAYER_SOURCE_REF`; the worker image owns source-specific behavior. See [Extract and chunk](#extract-and-chunk) for a worker reading it. For PDFs in object storage, the stock `layer-extract` worker reads `kind: objects`; see [PDF extraction](/docs/pro/pdf-extraction). ### Typed sources For **warehouse-backed kinds** — `snowflake`, `huggingface`, and `rest` — `kind` selects a typed shape the operator validates. The source names a [`Warehouse`](/docs/pro/api/warehouses) with `warehouseRef`; the operator resolves it (it must be `Verified`), mounts its credential Secret, and injects connection details as `HEVLAYER_WAREHOUSE` with no credential material. Set `spec.worker.image` to the stock worker for that kind from the mesh-account ECR registry (for example, `.dkr.ecr.us-east-1.amazonaws.com/hev-huggingface-source` or `.dkr.ecr.us-east-1.amazonaws.com/hev-rest-source`), so a typed source needs no custom code, or to your own image. `worker.image` is required on every Pipeline; the operator does not fill it in. The per-kind source fields are on the [Warehouses](/docs/pro/api/warehouses#pipeline-source) page. A REST source can enumerate an incremental JSON projection without a `request.forEach` block. Use `pagination.kind: searchAfter`, name the query parameter that receives the prior cursor, and point `cursorPath` at the next cursor echoed by each response. The stock worker stores the last cursor in S3 after its row writes succeed and resumes there on the next scheduled refresh. See the [REST search-after example](/docs/pro/api/warehouses#rest). REST attribute mappings can cast numeric strings with `{ path: $.total, cast: float }`. Declare the target attribute as a filterable `float` to use inclusive numeric ranges through MCP search and aggregate. Nested API fan-out also supports list/detail endpoints; see the [REST mapping and fan-out contract](/docs/pro/api/warehouses#rest). ### Chunking A source's text column is often a whole document that must be split before it is embedded. An optional `chunk` block declares how, with no code for the common strategies. It applies to any source whose worker honors it — the stock workers do. ```yaml spec: sourceRef: kind: huggingface warehouseRef: hf-public dataset: wikimedia/wikipedia config: 20231101.en split: train mapping: text: text attributes: [title, url] chunk: strategy: recursive # none | fixed | recursive | sentence | markdown unit: tokens # tokens | characters size: 512 overlap: 64 tokenizer: cl100k_base # when unit: tokens ``` | `chunk` field | Purpose | | --- | --- | | `strategy` | `none` (default — one document per row), `fixed`, `recursive` (a paragraph→line→sentence→word ladder kept under `size`), `sentence`, or `markdown` (split on headings). | | `unit` | `tokens` or `characters` — what `size` and `overlap` count in. | | `size` | Target maximum chunk length. | | `overlap` | Units repeated between adjacent chunks for context. | | `tokenizer` | Token model when `unit: tokens`. Pinned so chunk boundaries stay reproducible. | Each row maps to one document; `text` splits into chunks. The chunk is the unit indexed and embedded — a row with id `{documentId}#{i}` carrying the document's attributes plus reserved `_hevlayer_parent_id` and `_hevlayer_chunk_index`. For splits the stock strategies can't express, set `spec.worker.image` to your own chunker. ## Schedule `spec.schedule` is optional. When it is set, the operator wakes the Pipeline worker on a KEDA cron window instead of scaling it on pending pipeline queue depth: ```yaml schedule: cron: "0 2 * * *" # 5-field UTC cron; minute must be a single integer leaseSeconds: 600 # sizes the cron window ``` The worker still owns source semantics: what to pull on wake, how to advance cursors, and how to stage rows. The schedule only controls when the worker runs. Scheduled Pipelines must use `scaling.replicas.min: 0`; the cron window is the wake trigger. A `rest` source can instead keep one worker running and re-enumerate on an interval with `sourceRef.sync.interval`, measured from the end of each sweep. Do not set both; see [Staying in sync](/docs/pro/api/warehouses#staying-in-sync). Each sweep writes a report to the snapshot bucket with its counts and errors. ## Worker | Field | Purpose | | --- | --- | | `image` | Worker image. Required. For [typed sources](#typed-sources), use the stock worker for the source kind or your own. | | `computeClass` | `cpu` or `gpu`. Defaults to `cpu`; when `scaling.pool` is omitted, the operator maps this to the stock `cpu` or `gpu` pool. | | `batchSize` | Work items per batch. | | `timeoutSeconds` | Worker call timeout. | | `podSpec` | Optional pod-level merge patch. | The operator creates one Deployment per Pipeline and injects: | Variable | Value | | --- | --- | | `HEVLAYER_PIPELINE_ID` | `spec.pipelineId`, defaulting to the resource name. | | `HEVLAYER_TARGET_NAMESPACE` | `spec.target.namespace`. | | `HEVLAYER_BASE_URL` | The gateway base URL. | | `HEVLAYER_SOURCE_REF` | `spec.sourceRef` as JSON, when set. | | `HEVLAYER_FOR_EACH_VALUES_PATH` | Read-only file projected from `request.forEach.values.ref` when a typed REST source uses `from: configMap`; absent for a single REST enumeration stream. | | `HEVLAYER_SNAPSHOT_BUCKET` | The chart's S3 bucket, injected for REST full-snapshot manifests. Region and optional S3-compatible endpoint are provided as `HEVLAYER_SNAPSHOT_REGION` and `HEVLAYER_SNAPSHOT_ENDPOINT`. | | `HEVLAYER_PIPELINE_SCHEDULE` | `1` when `spec.schedule` is set. | | `HEVLAYER_WAREHOUSE` | Resolved `Warehouse` connection JSON (no credential material), for [typed sources](#typed-sources). The credential Secret is mounted separately. | | `LAYER_GATEWAY_API_KEY` | Gateway bearer token. In `deriveFromStore` mode this is the default `VectorStore` credential; in `keys` mode it is the configured inbound worker key. | ## Scaling ```yaml scaling: pool: cpu mode: autoscale replicas: min: 0 max: 8 ``` `spec.scaling.pool`, when set, must name a pool in the [built-in set or `InfraRules/default`](/docs/pro/scaling). When omitted, the operator uses `worker.computeClass` to choose the stock `cpu` or `gpu` pool. Layer provides the well-known `cpu`, `cpu-large`, and `gpu` pools without requiring an `InfraRules` object. `mode: autoscale` creates a KEDA `ScaledObject` backed by pipeline queue depth, or by the cron window when `spec.schedule` is set. `mode: fixed` pins the Deployment to `replicas.min`; `mode: disabled` scales it to zero. `spec.scaling.warmWindowSeconds` sets a cooldown (and node retention) that holds the worker warm after its queue drains — see [Workload scaling](/docs/pro/scaling#warm-window). `spec.paused: true` also scales the worker to zero. ## Status Use the [pipeline status API](#wait-for-completion) for status: queue counts, stage progress, and worker state. The resource itself reports only managed object references and readiness conditions. ## Failure model - VectorStore write failures are hard: the vectors route returns 502 and the document stays in `embedding` for re-claim. - Aerospike cache failures do not block chunk reads when S3 backing is present; PostgreSQL connectivity failures return 500 and should be retried with backoff. The stop-writes recovery path and the metrics to watch live in the [failure-mode runbook](/docs/pro/failure-modes#pipeline-stop-writes). - Lease expiry is handled server-side. A worker that crashes mid-embedding has its documents recovered on the next claim sweep. # Query & Fetch Source: https://hevlayer.com/docs/pro/api/query import PostgresWarning from "../../../components/docs/PostgresWarning.astro"; import Edition from "../../../components/docs/Edition.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; import Upstream from "../../../components/docs/Upstream.astro"; import FeatureGate from "../../../components/docs/FeatureGate.astro"; import Callout from "../../../components/docs/Callout.astro"; Native queries are wire-compatible with the upstream `POST /v2/namespaces/{ns}/query` endpoint and forwarded as-is after validation. The Layer API includes this compatible subset and the gateway operations documented below; backend support is called out at each feature. This is Layer's query API. Layer reports its own metadata in `x-layer-*` response headers. ## Stable reads Layer tracks a stable watermark for Turbopuffer-backed namespaces. When one is available, `x-layer-stable-as-of` reports that observation. Store capabilities and watcher state determine whether a watermark filter applies; a cold-start gateway can return a query without this header. This is achieved by querying Turbopuffer with `consistency=eventual` and watching each namespace's `index.status`. While the index is updating, Layer injects a hidden `_hevlayer_upserted_at <= watermark` predicate so the read does not include partially indexed rows. Stable or unknown status runs without that predicate; a 429 can trigger one retry with the watermark filter forced on. Responses report `x-layer-stable-as-of` in epoch milliseconds when the watcher has a watermark. The header is omitted on a cold-start gateway that has not yet observed a stable poll. Query responses carry `next_cursor` and, when another page is available, `x-layer-next-cursor`. Cursor behavior depends on the selected store and query mode; unsupported pagination features return `422 UnsupportedByStore`. ## Query by id Pass `nearest_to_id` in place of `vector` to rank by stored document vectors instead of a raw query vector — exactly one of the two is required. `nearest_to_id` takes an **array of document ids**: the gateway resolves each id's vector (document cache first, the namespace's configured VectorStore on miss with a cache backfill) and averages them component-wise into a single centroid, then ranks nearest neighbors to that centroid. Pass one id to rank by a single document; pass several to get "more like these" over a set of seeds. ```python response = await client.query_namespace("products", { "nearest_to_id": ["asin-B08N5WRWNW", "asin-B07PXGQC1Q"], "top_k": 10, "include_attributes": ["title", "category"], }) ``` ```go response, err := client.QueryNamespace(ctx, "products", &hevlayer.QueryRequest{ NearestToID: []string{"asin-B08N5WRWNW", "asin-B07PXGQC1Q"}, TopK: 10, IncludeAttributes: []string{"title", "category"}, }) ``` ```typescript const response = await client.queryNamespace("products", { nearest_to_id: ["asin-B08N5WRWNW", "asin-B07PXGQC1Q"], top_k: 10, include_attributes: ["title", "category"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "nearest_to_id": ["asin-B08N5WRWNW", "asin-B07PXGQC1Q"], "top_k": 10, "include_attributes": ["title", "category"] }' ``` | Outcome | Status | | --- | --- | | Every id resolved (cache or origin) | 200, ranked results | | Any id has no stored vector anywhere | 404 (names the missing ids) | | `nearest_to_id` empty, or both/neither of `vector` / `nearest_to_id` | 422 | The centroid is an unweighted mean, so seed ids contribute equally regardless of how many you pass. All resolved vectors share the namespace's dimensionality, so no reconciliation is needed across seeds. This fuses the seeds into one ranking; to run several *independent* rankings in a single request, see [batch query](#batch-query). ## Rank expressions The Go typed `QueryNamespace` request currently serializes an empty `collapse` object when unset, causing a validation error. The shared Go example uses the Layer client's raw `QueryTurbopufferNamespace` method instead. After the [write example](/docs/pro/api/write#upsert-and-delete): ```python result = await client.query_namespace("preview_docs", { "rank_by": ["vector", "ANN", [1, 0, 0]], "top_k": 5, "filters": ["And", [["category", "Eq", "space"], ["year", "Gte", 2025]]], "include_attributes": ["body", "category", "year"], }) ``` ```go result, err := client.QueryTurbopufferNamespace(ctx, "preview_docs", hevlayer.TurbopufferQueryRequest{ "rank_by": []interface{}{"vector", "ANN", []float64{1, 0, 0}}, "top_k": 5, "filters": []interface{}{"And", []interface{}{ []interface{}{"category", "Eq", "space"}, []interface{}{"year", "Gte", 2025}, }}, "include_attributes": []string{"body", "category", "year"}, }) ``` ```typescript const result = await client.queryNamespace("preview_docs", { rank_by: ["vector", "ANN", [1, 0, 0]], top_k: 5, filters: ["And", [["category", "Eq", "space"], ["year", "Gte", 2025]]], include_attributes: ["body", "category", "year"], }); ``` ```bash curl --fail-with-body "$LAYER_GATEWAY_URL/v2/namespaces/preview_docs/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"rank_by":["vector","ANN",[1,0,0]],"top_k":5,"filters":["And",[["category","Eq","space"],["year","Gte",2025]]],"include_attributes":["body","category","year"]}' ``` ```python text = await client.query_namespace("preview_docs", { "rank_by": ["body", "BM25", "moon"], "top_k": 5, "include_attributes": ["body"], }) ``` ```typescript const text = await client.queryNamespace("preview_docs", { rank_by: ["body", "BM25", "moon"], top_k: 5, include_attributes: ["body"], }); ``` ```bash curl --fail-with-body "$LAYER_GATEWAY_URL/v2/namespaces/preview_docs/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"rank_by":["body","BM25","moon"],"top_k":5,"include_attributes":["body"]}' ``` Pass `rank_by` with `top_k` when you need an explicit ranking operator instead of the top-level `vector` / `nearest_to_id` shape. Supported ranks, pagination and consistency depend on the selected backend, as described in this section. On Turbopuffer, native query bodies that omit `top_k` remain pass-through. `rank_by` is mutually exclusive with `vector` and `nearest_to_id`. On a Postgres-backed namespace, `rank_by: ["", "asc"|"desc"]`, `rank_by: ["id", "asc"|"desc"]`, and a list of up to 8 such pairs return rows in that order with `top_k` or `limit`, `filters`, and `include_attributes`. A query with neither `rank_by` nor `vector` is ordered by id ascending. - Missing and `null` values sort first ascending and last descending; id ascending breaks remaining ties. Rows carry no `$dist`. - Strings order and compare (`Gt`, `Lt`, and so on) in byte order. In a namespace holding both id types, unsigned-integer ids sort before string ids. - There is no cursor for ordered queries. Page by advancing a filter on the order attribute, for example `["id", "Gt", last_id]`. A `cursor` returns `422 UnsupportedByStore`. - Ordering by id or by an `int`, `uint`, `float`, or `bool` attribute reads a B-tree index. String attributes have no B-tree index, so ordering by one sorts every row that matches the filter. On a Postgres-backed namespace, `exclude_attributes` returns everything `include_attributes: true` returns except the listed attributes, on ranked, ordered and filter-only queries. `id` is always returned. Vector attributes are left out of both unless named in `include_attributes`. Sending `include_attributes` and `exclude_attributes` together is a `400`. On a Postgres-backed namespace, `Contains`, `NotContains`, `ContainsAny` and `NotContainsAny` filter an array attribute (`[]string`, `[]int`, `[]uint`, `[]float` or `[]bool`), for example `["tool_names", "ContainsAny", ["Bash"]]`. - `Contains` matches rows whose array holds the value. `ContainsAny` matches rows whose array shares at least one element with the list. - A missing, `null` or empty array contains nothing, so `ContainsAny` with an empty list matches no rows. Each `Not` form matches exactly the rows its positive form does not. - Values must have the array's element type. A value of another type, or an array operator on a non-array attribute, is a `400`. - Array attributes take only these four operators. `Eq`, `In`, the ordering comparisons and `rank_by` on an array attribute return `422 UnsupportedByStore`. ## Batch query `nearest_to_id` fuses several seeds into a **single** ranking. To run several **independent** queries in one round trip, each with its own ranking, post a `queries` array. The response is a parallel `results` array — one ranked result set per query, in request order: `{ "results": [{ "rows": ... }] }`. Layer holds every leg on the same stable cut, so a batch reads one consistent view of the index. (The method is `batch_query_namespace`. It is named apart from turbopuffer's own upstream multi-query — a `rerank_by` body, which Layer passes through unchanged, as noted at the end of this section — to keep the two distinct.) ```python batch = await client.batch_query_namespace("products", { "queries": [ {"rank_by": ["vector", "ANN", [0.1, 0.2, 0.3]], "top_k": 10}, {"rank_by": ["title", "BM25", "wireless earbuds"], "top_k": 10}, ], }) # batch.results[0].rows ranked by vector; batch.results[1].rows by text ``` ```go batch, err := client.BatchQueryNamespace(ctx, "products", &hevlayer.BatchQueryRequest{ Queries: []hevlayer.TurbopufferQueryRequest{ {"rank_by": []any{"vector", "ANN", []float64{0.1, 0.2, 0.3}}, "top_k": 10}, {"rank_by": []any{"title", "BM25", "wireless earbuds"}, "top_k": 10}, }, }) ``` ```typescript const batch = await client.batchQueryNamespace("products", { queries: [ { rank_by: ["vector", "ANN", [0.1, 0.2, 0.3]], top_k: 10 }, { rank_by: ["title", "BM25", "wireless earbuds"], top_k: 10 }, ], }); // batch.results[0].rows ranked by vector; batch.results[1].rows by text ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "queries": [ {"rank_by": ["vector", "ANN", [0.1, 0.2, 0.3]], "top_k": 10}, {"rank_by": ["title", "BM25", "wireless earbuds"], "top_k": 10} ] }' ``` All legs in a non-fused batch share one `x-layer-stable-as-of` value. A leg may use native `rank_by`, or the Layer `vector` / `nearest_to_id` single-query shape; `nearest_to_id` is resolved before the leg is sent upstream. Batches must contain 2 to 16 legs. `cursor` is rejected at the top level and per leg because pagination is single-query only. When `rerank_by` is present, Layer treats the request as an upstream fused query and passes the body through unchanged. Reach for a batch query when you genuinely need N rankings — distinct user queries batched into one round trip, or hybrid retrieval fused upstream with RRF. Reach for `nearest_to_id` when many seeds should collapse into one "more like these" ranking. To get typo-tolerant text search without building the fused query yourself, see [hybrid text fusion](#hybrid-text-fusion). Every leg here targets the namespace in the path. To fan one query across a **set** of namespaces — and merge them into a single ranked list — see [federated query](/docs/pro/api/federated-query). ## Type-ahead text queries Use the last token as a literal prefix while the user is typing: ```json { "rank_by": ["text", "BM25", "Murp", {"last_as_prefix": true}], "top_k": 8, "collapse": {"by": "project_record_id", "expansion": 3}, "include_attributes": ["job_name", "job_number", "links"] } ``` `last_as_prefix` defaults to `false`. Earlier tokens keep their ordinary BM25 matching; only the last token accepts completions. Prefix matches score `1.0`. The field must have `full_text_search: true`. Collapse ranks the widened candidate window before grouping, so several matching facts can lead to one job. A prefix is not typo correction. Postgres supports this BM25 option using the existing full-text index. It uses the same default tokenizer as that index. This does not enable fuzzy filters or the fuzzy legs of `HybridText` on Postgres. ## Hybrid text fusion BM25 misses typos and morphological variants; fuzzy matching alone loses the relevance signal BM25 provides. `HybridText` runs both in one request: the gateway tokenizes your input string, expands it into one BM25 leg plus one fuzzy leg per token, and the effective legs are RRF-fused into one ranking. One expression in, typo-tolerant ranked results out. On a Postgres-backed namespace `HybridText` is the hybrid route, served with the limits in its capability cell: `fuzziness: 0` only, no `cursor` or temporal filter; other shapes return `422 UnsupportedByStore`. A `queries` or `rerank_by` body returns `422 UnsupportedByStore` naming `multi_query`. `HybridText` is a Layer-only `rank_by` spelling on the existing query route — no new endpoint, no client changes beyond the expression. The gateway tokenizes with [`alyze`](https://github.com/turbopuffer/alyze), turbopuffer's own open-source tokenizer and the same code that segmented your text at index time, so query tokens match index terms by construction. The ranked field must be indexed for both full-text and fuzzy matching — declare it `{"type": "string", "full_text_search": true, "fuzzy": true}` in the namespace schema. The BM25 leg uses the full-text index; the per-token fuzzy legs use the fuzzy index. ```python response = await client.query_namespace("support-tickets", { "rank_by": ["content", "HybridText", "conection timout kubernets"], "top_k": 10, "filters": ["tenant", "Eq", "t-42"], "include_attributes": ["content", "title"], }) ``` ```go response, err := client.QueryNamespace(ctx, "support-tickets", &hevlayer.QueryRequest{ RankBy: []any{"content", "HybridText", "conection timout kubernets"}, TopK: 10, Filters: []any{"tenant", "Eq", "t-42"}, IncludeAttributes: []string{"content", "title"}, }) ``` ```typescript const response = await client.queryNamespace("support-tickets", { rank_by: ["content", "HybridText", "conection timout kubernets"], top_k: 10, filters: ["tenant", "Eq", "t-42"], include_attributes: ["content", "title"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/support-tickets/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["content", "HybridText", "conection timout kubernets"], "top_k": 10, "filters": ["tenant", "Eq", "t-42"], "include_attributes": ["content", "title"] }' ``` An optional fourth tuple element tunes the expansion. Defaults: ```json ["content", "HybridText", "conection timout kubernets", { "fuzziness": "auto", "stopwords": "en", "rank_constant": 60, "per_leg_limit": null }] ``` | Option | Default | Meaning | | --- | --- | --- | | `fuzziness` | `"auto"` | Edit-distance tolerance for the fuzzy legs, keyed to each token's length (turbopuffer requires at least 3 query characters per edit). `"auto"` permits up to distance 2: exact for tokens of 3–5 characters, distance 1 for 6–8, distance 2 for 9 or more. Fixed `0`, `1`, or `2` caps the ladder, so `0` is exact-only. | | `stopwords` | `"en"` | Tokens that do not get a fuzzy leg. `"en"` is the built-in English list, `false` gives every token a fuzzy leg, and an array of strings replaces the built-in list (entries are lowercased). The BM25 leg always ranks the full input and is unaffected. | | `rank_constant` | `60` | turbopuffer's RRF constant, passed through verbatim. Integer > 0. | | `per_leg_limit` | `clamp(5 × top_k, 50, 200)` | How deep each leg retrieves before fusion. Integer > 0. | | `threads` | `8` unless configured per namespace | Maximum concurrent upstream requests when the gateway scatter/gathers the expansion across a [sharded](/docs/pro/concepts#scattergather) namespace — the same fan-out control as [scans](/docs/pro/api/scans). Clamped to active shards. No effect on unsharded namespaces, where the expansion is a single fused upstream call. | Set top-level `include_leg_breakdown: true` to return per-row `$fused.legs` attribution. Each leg entry reports the leg label, that row's 1-based rank within the leg, and the leg's raw score or distance. `rank` and `score` are `null` when the row fell outside that leg's `per_leg_limit` cut. Labels are `bm25`, `fuzzy:`, and `semantic` on routed fused queries. ### Tokenization The input string becomes tokens under a fixed, documented policy: 1. Split on Unicode (UAX #29) word boundaries and lowercase, using `alyze` — the code behind turbopuffer's production `word_v4` tokenizer. Punctuation-only tokens never survive the split. 2. Drop tokens shorter than 2 characters. 3. Dedupe. 4. Remove stop words under the `stopwords` option (English by default). Removed tokens are reported in `stopwords_dropped` and do not count against the cap, so on long queries the cap spends its legs on content tokens. 5. Cap at 15 tokens (15 fuzzy legs + 1 BM25 leg = 16, the upstream subquery limit). Tokens cut by the cap are counted in `tokens_dropped`. Stop words only suppress fuzzy legs: the BM25 leg ranks the full input string. Stemming and language detection are not applied. The input must yield at least one token before stop-word removal. An input made only of stop words is valid: it has no fuzzy legs and ranks on the BM25 leg alone. ### Response Results are the RRF-fused list. A `hybrid` block echoes the effective expansion so defaults are never invisible: ```json { "rows": [ { "id": "ticket-4117", "$score": 0.0639, "content": "...", "title": "Connection timeout on Kubernetes ingress" } ], "hybrid": { "tokens": ["conection", "timout", "kubernets"], "tokens_dropped": 0, "stopwords": "en", "stopwords_dropped": [], "fuzziness": "auto", "rank_constant": 60, "legs": 4, "per_leg_limit": 50 }, "next_cursor": null } ``` | Field | Meaning | | --- | --- | | `$score` | RRF score. Comparable **within** a response, not across requests — do not threshold on it. | | `$fused.legs` | Present only when `include_leg_breakdown: true`. Per-leg attribution in effective leg order; each item has `leg`, `rank`, and `score`. | | `tokens` | Tokens that produced fuzzy legs, post-policy. | | `tokens_dropped` | Tokens removed by the 15-token cap (not by the length or punctuation rules, or as stop words). | | `stopwords` | The effective `stopwords` option: `"en"`, `false`, or the caller's list. | | `stopwords_dropped` | Tokens removed as stop words, in input order. Empty when `stopwords` is `false`. | | `legs` | Total effective subqueries in the fused expansion. Normally the fuzzy legs + 1 BM25 leg (plus 1 ANN leg on routed fused queries). On the `surfaced` fallback there is no BM25 leg, so `legs` equals the token count (one fuzzy leg per token). | | `surfaced` | Present and `true` only when the empty-result fallback fired (see [Surfacing fallback](#surfacing-fallback)). Absent on the normal path. | | `next_cursor` | Top-level field (not inside `hybrid`), always present in the body: the next page token, or `null` on the last page. Mirrors the `x-layer-next-cursor` header. Pass a non-null value back as `cursor`. | The `hybrid` block appears only on `HybridText` responses. On sharded namespaces it also reports the effective `threads` fan-out width. Requests without a `HybridText` expression, including native turbopuffer multi-query + `rerank_by` bodies, keep their upstream-shaped responses byte-for-byte. ### Surfacing fallback Every primary leg ranks by BM25 over the full input, which upstream scores at zero — and drops — when no token matches a stored term exactly. A fully-misspelled query therefore fuses to zero rows. When the primary expansion returns nothing, Layer re-runs one fuzzy leg per token, reorders each leg by field/token edit distance, and fuses those instead, so a typo-heavy query still surfaces near matches. The response then carries `"surfaced": true` in the `hybrid` block, and `legs` reflects the surfacing expansion — one fuzzy leg per token, with no BM25 leg. Working queries never reach this path; the fallback is purely additive and absent (`surfaced` omitted) on the normal path. ### Semantics - **Fusion.** RRF uses the effective leg order: BM25 first, then one fuzzy leg per token, then the semantic ANN leg on routed fused queries. `include_leg_breakdown: true` can require one upstream query per leg on unsharded namespaces so Layer can report per-leg ranks. - **One consistency cut.** Request-level `filters` are replicated to every leg, and the [stable-read](#stable-reads) watermark predicate is injected into every leg from a single read — all legs see the same cut. Responses carry `x-layer-stable-as-of` as usual. - **All-or-nothing.** Any leg failure fails the request; Layer does not return a partial fusion over surviving legs. - **Replay as a unit.** The query logs to [search history](/docs/pro/api/search-history) as one entry carrying the `HybridText` expression, so replaying it reproduces the whole expansion. ### Validation All return `422`: | Condition | Why | | --- | --- | | Input yields zero tokens under the policy before stop-word removal | Nothing to expand. An input made only of stop words is not rejected. | | `HybridText` inside a `queries` array | The expansion is already one batch deep by construction. | | `fuzziness` not in `"auto" \| 0 \| 1 \| 2`; `stopwords` not `"en"`, `false`, or an array of strings; `rank_constant` ≤ 0; `per_leg_limit` ≤ 0; `threads` < 1 | Out of range. | To let the gateway pick between hybrid text and semantic retrieval per query, see [query routing](#query-routing). ## Query routing Real search boxes receive both `"timout"` and `"why do pods lose their connection during deploys"`. The first wants [hybrid text fusion](#hybrid-text-fusion); the second wants semantic retrieval — lexical legs add noise on long conversational input, and ANN underperforms on short identifier-shaped tokens. `Auto` is a Layer-only `rank_by` spelling that makes that call per query, so the branch doesn't live ad hoc in your application code. The route is chosen from the shape of the input alone. Supply an inline `Embed` as the vector source and Layer resolves it only after the policy selects `semantic` or `fused`; a `hybrid_text` route never calls the embedding provider. Without either an inline `Embed` or a numeric vector, a vector-needing route returns the routing decision instead of results so the application can embed and re-issue. Short keyword traffic executes immediately and never pays for an embedding. ```python response = await client.query_namespace("support-tickets", { "rank_by": ["title", "Auto", user_input, { "vector": ["Embed", user_input, {"field": "content"}], }], "top_k": 10, "filters": ["tenant", "Eq", "t-42"], }) ``` ```go response, err := client.QueryNamespace(ctx, "support-tickets", &hevlayer.QueryRequest{ RankBy: []any{"title", "Auto", userInput, map[string]any{ "vector": []any{"Embed", userInput, map[string]any{"field": "content"}}, }}, TopK: 10, Filters: []any{"tenant", "Eq", "t-42"}, }) ``` ```typescript const response = await client.queryNamespace("support-tickets", { rank_by: ["title", "Auto", userInput, { vector: ["Embed", userInput, { field: "content" }], }], top_k: 10, filters: ["tenant", "Eq", "t-42"], }); ``` ```bash # One request: title is lexical; content selects the embedding profile. curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/support-tickets/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["title", "Auto", "why do pods lose their connection during deploys", { "vector": ["Embed", "why do pods lose their connection during deploys", { "field": "content" }] }], "top_k": 10, "filters": ["tenant", "Eq", "t-42"] }' ``` ### Routing policy The v1 policy reads the token count of the input under the same [tokenizer policy](#tokenization) as hybrid text fusion: | Tokens | Route | Runs | | --- | --- | --- | | ≤ 2 | `hybrid_text` | The [hybrid text fusion](#hybrid-text-fusion) expansion. | | ≥ 8 | `semantic` | ANN over the supplied query vector. | | 3 – 7 | `fused` | Both, merged upstream by RRF. | Vector availability never changes which route is chosen — only whether it executes in this request. `hybrid_text` always executes and does not resolve an inline `Embed`; `semantic` and `fused` resolve it after routing, execute with a numeric `vector`, and defer only when neither is present. The policy is versioned (`"policy": "v1"`) in the response. Threshold changes are also visible in [search history](/docs/pro/api/search-history). ### Options The optional fourth tuple element: | Option | Default | Meaning | | --- | --- | --- | | `route` | `"auto"` | Force `"hybrid_text"`, `"semantic"`, or `"fused"` instead of applying the policy. Used on re-issue after a deferral, and for A/B comparison of strategies on the same input. | | `vector` | — | A numeric query vector, or `["Embed", input, {field?, model?}?]`. `field` selects the source or derived attribute whose embedding profile supplies the model and vector target; it defaults to the Auto lexical field. Layer resolves `Embed` only for `semantic` and `fused`. A derived `embed_` field requires `model`. | | `fuzziness` | `"auto"` | Forwarded to the `HybridText` expansion on the `hybrid_text` and `fused` routes: `"auto"`, `0`, `1`, or `2`. `0` forces exact-only matching. No effect on the `semantic` route. | | `stopwords` | `"en"` | Forwarded to the `HybridText` expansion on the `hybrid_text` and `fused` routes: `"en"`, `false`, or an array of strings. Does not change the token count the routing policy reads. | When the chosen route expands hybrid-text legs, the hybrid defaults apply and the [`hybrid` echo block](#response) appears alongside `routing`. Set top-level `include_leg_breakdown: true` to add `$fused.legs` to each fused row; the fused route includes a final `semantic` leg after the BM25 and fuzzy-token legs. ### Response Every `Auto` response carries a `routing` block: ```json { "rows": [{"id": "ticket-4117", "$score": 0.0639, "title": "..."}], "routing": { "route": "hybrid_text", "policy": "v1", "tokens": 1, "executed": true }, "hybrid": {"tokens": ["timout"], "tokens_dropped": 0, "stopwords": "en", "stopwords_dropped": [], "fuzziness": "auto", "rank_constant": 60, "legs": 2, "per_leg_limit": 50} } ``` | Field | Meaning | | --- | --- | | `route` | The strategy chosen (or forced). | | `policy` | Routing policy version that made the decision. `"forced"` when `route` was supplied. | | `tokens` | Token count the policy read, post tokenizer policy. | | `executed` | `false` on a deferral: the route needs a vector the request didn't supply. `rows` is empty; embed and re-issue with the route forced. | Routed queries follow the same semantics as their underlying strategy: one consistency cut across all legs and all-or-nothing leg failure. A single [search history](/docs/pro/api/search-history) entry carries the `Auto` expression and the decision. When an inline `Embed` is resolved, its `embedding_tokens` and `embedding_ms` measurements are merged into the normal top-level `performance` object. ### Validation All return `422`: | Condition | Why | | --- | --- | | Forced `"semantic"` or `"fused"` without `vector` | Forcing asserts you have the vector; only auto-routing defers. | | Input yields zero tokens under the policy | Nothing to route. | | `vector` dimensionality mismatch | Same check as a plain vector query. | | `Auto` inside a `queries` array | Inherited from [hybrid text fusion](#validation). | ## Collapse Add `collapse` to a ranked query to get one result per group instead of one per row. This is helpful when, for example, your index is built out per page but your users are searching for a specific document. ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/bcc-pages/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rank_by": ["text", "BM25", "kitchen remodel"], "top_k": 10, "collapse": {"by": "document"}, "include_attributes": ["name", "page", "job", "webUrl"] }' ``` ```python response = await client.query_namespace("bcc-pages", { "rank_by": ["text", "BM25", "kitchen remodel"], "top_k": 10, "collapse": {"by": "document"}, "include_attributes": ["name", "page", "job", "webUrl"], }) for group in response["groups"]: print(group["key"], [row["page"] for row in group["rows"]]) ``` ```typescript const response = await client.queryNamespace("bcc-pages", { rank_by: ["text", "BM25", "kitchen remodel"], top_k: 10, collapse: { by: "document" }, include_attributes: ["name", "page", "job", "webUrl"], }); ``` `top_k` still sets how many results come back; with `collapse`, each result is a group. To find them, the gateway reads `top_k × expansion` ranked rows, groups them, and returns the best `top_k` groups. This request reads 100 pages and returns up to ten documents. ### Collapse options | Field | Default | Meaning | | --- | --- | --- | | `by` | required | The attribute to group on, or `"document"`. A list such as `["job", "folder"]` groups each row on the first attribute it has a value for. Up to four. | | `expansion` | `10` | How many rows to read per result: the gateway reads `top_k × expansion` rows, up to 10,000. Raise it when one group can fill the top of the ranking, like a 400-page spec that matches every query about it. | | `missing` | `"own"` | Rows with no value for `by`. `"own"` makes each its own group, `"together"` puts them in one group with key `null`, `"drop"` leaves them out. Null, absent and empty-string values count as missing. Pass `{"mode": "own", "values": ["No job"]}` to also treat listed values as missing. | `expansion` is a tunable candidate-window multiplier, with a default of 10. Higher values read more rows and can improve group diversity when the top rows are dominated by one document, job or folder. Store work, response size and latency can grow with expansion; lower it to reduce that cost. The default is not a claim of an optimal setting for every workload. The read window is capped at 10,000 rows, so increasing expansion past that cap has no further effect. `"document"` means the row's source document. Rows written by a Layer pipeline (layer-extract pages, Function outputs) already carry it in `_hevlayer_parent_id`. Any other namespace names its document attribute in the schema: ```json { "schema": { "doc_id": { "type": "string", "filterable": true, "document": true } } } ``` `"document"` on a namespace with neither returns `400`. A schema attribute marked `document: true` wins over `_hevlayer_parent_id`. A list tries each attribute in turn. `["job", "folder"]` groups by job, and a row without a job is grouped with the other rows from its folder. The group's `by` says which attribute it was formed on. Some data stores a placeholder instead of leaving the value out, such as the string `"No job"`. List it in `missing.values` so the row falls through to the next attribute: ```json { "by": ["job", "folder"], "missing": { "values": ["No job"] } } ``` `missing.mode` defaults to `"own"`. A row that has none of the listed attributes follows the mode. ### Collapsed response ```json { "rows": [ { "id": "doc-41#p7", "$dist": 12.4, "name": "Kitchen spec.pdf", "page": 7 }, { "id": "doc-18#p1", "$dist": 11.9, "name": "Change order 3.pdf", "page": 1 } ], "groups": [ { "key": "doc-41", "by": "document", "rows": [ { "id": "doc-41#p7", "$dist": 12.4, "page": 7 }, { "id": "doc-41#p2", "$dist": 9.8, "page": 2 }, { "id": "doc-41#p9", "$dist": 7.1, "page": 9 } ] }, { "key": "doc-18", "by": "document", "rows": [{ "id": "doc-18#p1", "$dist": 11.9, "page": 1 }] } ], "collapse": { "read": 100, "exhausted": false } } ``` | Field | Meaning | | --- | --- | | `rows` | Each group's best row, best group first. A client that ignores `groups` gets one row per group. | | `groups` | The same groups, each with every row of the group among the rows read, best first. Groups are ordered by their best row, in the order the store ranked it. | | `groups[].key` | The value the group shares, or `null` for the `missing: "together"` group. | | `collapse.read` | Rows the gateway read: `top_k × expansion`, or fewer if the query ran out. | | `collapse.exhausted` | `true` when the store returned fewer rows than requested, so no more groups exist. | A group's `rows` covers only the rows read, so it shows which pages matched best, not every page in the document that matches. Fewer than `top_k` groups can come back when the candidate window contains too few distinct groups, even if more rows exist beyond that window. Increase `expansion` to look further down the ranking. ### Where it runs The gateway groups on every store. Neither turbopuffer nor Postgres can rank whole groups natively (turbopuffer's `group_by` exists only beside `aggregate_by`), so both receive an ordinary query for `top_k × expansion` rows and the response is identical. A schema `document: true` is stored differently per store. On a Postgres-backed namespace, `document: true` is stored in the namespace schema and returned by namespace metadata. On turbopuffer the gateway removes the key before the write, keeps it in object storage, and adds it back when you read the schema, so object storage must be configured. At most one attribute may be `document: true`, and it must be a string. ### Collapse: not yet - **Exact match counts per group.** A group lists the matching rows that were read, not all of them. Exact counts take a second aggregation per query. - **Collapsing a browse.** An empty-query browse would need a distinct listing, which is closer to an aggregation than to ranking. - **Paging.** `cursor` returns `400` with `collapse`. Raise `top_k` or `expansion` instead. - **Multi-query bodies.** `queries` returns `400` with `collapse`. - **Across namespaces.** [Federated queries](/docs/pro/api/federated-query) don't collapse. ## Counting matches To count how many rows match a full-text or vector query, use [scan](/docs/pro/api/scans) count mode with the `fts` or `ann` selector. Ranked counts share the single `/scans` endpoint with filter counts — `fts` is exact, `ann` is a radius scan flagged `approximate`, and both honor the `exhaustive` flag and the count deadline. ## Fetch Fetch is a Layer-only endpoint with no upstream equivalent. The document cache is checked first; on miss or error the gateway falls through to the backing store and backfills the cache best-effort. ### Single fetch ```python doc = await client.fetch_document( "products", "asin-B08N5WRWNW", include_attributes=["title", "category"], ) ``` ```go doc, err := client.FetchDocument(ctx, "products", "asin-B08N5WRWNW", &hevlayer.FetchDocumentParams{ IncludeAttributes: []string{"title", "category"}, }) ``` ```typescript const doc = await client.fetchDocument("products", "asin-B08N5WRWNW", { includeAttributes: ["title", "category"], }); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/products/documents/asin-B08N5WRWNW?include_attributes=title,category" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` | Outcome | Status | Header | | --- | --- | --- | | Cached hit | 200 | `x-layer-cache: hit` | | Cache miss, upstream hit, cache backfilled | 200 | `x-layer-cache: miss` | | Cache unavailable, upstream hit | 200 | `x-layer-cache: miss-on-error` | | Missing from both layers | 404 | — | ### Batch fetch ```python batch = await client.fetch_documents("products", { "ids": ["asin-1", "asin-2", "asin-3"], "include_attributes": ["title"], }) ``` ```go batch, err := client.FetchDocuments(ctx, "products", &hevlayer.FetchDocumentsRequest{ Ids: []interface{}{"asin-1", "asin-2", "asin-3"}, IncludeAttributes: []string{"title"}, }) ``` ```typescript const batch = await client.fetchDocuments("products", { ids: ["asin-1", "asin-2", "asin-3"], include_attributes: ["title"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/documents" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "ids": ["asin-1", "asin-2", "asin-3"], "include_attributes": ["title"] }' ``` ```json { "documents": [ {"id": "asin-1", "attributes": {"title": "..."}}, {"id": "asin-3", "attributes": {"title": "..."}} ], "missing": ["asin-2"] } ``` Batch fetch returns found documents and missing ids inline instead of a partial 404. `documents` preserves request order; ids the gateway could not find anywhere land in `missing`. Because order is preserved, batch fetch is a convenient way to reassemble a [pipeline](/docs/pro/api/pipelines)'s chunks back into their original document — request the chunk ids in sequence and concatenate the results. ### Behavior matrix | Cache state | Single fetch | Batch fetch | | --- | --- | --- | | Hit | cache | cache | | Miss, upstream present | upstream + backfill | upstream + backfill | | Miss, upstream absent | 404 | inline `missing` | | Cache unavailable | upstream, `miss-on-error` | upstream, `miss-on-error` | # Record versions Source: https://hevlayer.com/docs/pro/api/record-versions import Edition from "../../../components/docs/Edition.astro"; Record versioning is opt-in on **pgvector** namespaces. Each logical record keeps its usual string `id` (numeric IDs are not supported in this slice). Each accepted full upsert creates an immutable revision with a unique `version_id`. One revision is active at a time. Search, scans and normal reads use only the active record; inactive revisions never enter the candidate set. Other stores return `422` for this configuration and these endpoints. Enable versioning on an empty namespace with a write containing: ```json {"_hevlayer":{"versioning":{"enabled":true,"activation":"explicit"}}} ``` The setting persists across restarts. `automatic` activates every accepted write; `explicit` retains the revision without publishing it. Once enabled, versioning cannot be disabled or its mode changed. Omit the configuration on later writes. Namespaces without versioning keep their existing write and search behavior. ## Stage, publish and restore Use `POST /v2/namespaces/{namespace}` for full row or column upserts. The response includes `versions`, with logical `id`, immutable `version_id` and `active`. Supply `_hevlayer.version_id` for a single-row upsert when retries must return the same revision. Reusing that ID with different content or a different logical ID returns `409`. A successful retry never reactivates a superseded revision. Without a supplied ID, each retry creates another revision. ```sh NS=scratch-lyr319-example BASE="${LAYER_URL}/v2/namespaces/${NS}" curl --fail-with-body "$BASE" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"_hevlayer":{"versioning":{"enabled":true,"activation":"explicit"},"version_id":"v1"},"upsert_rows":[{"id":"guide","text":"First edition"}]}' curl --fail-with-body "$BASE/records/guide/activate" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"version_id":"v1","expected_current_version":null}' curl --fail-with-body "$BASE" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"_hevlayer":{"version_id":"v2"},"upsert_rows":[{"id":"guide","text":"Second edition"}]}' # Normal queries still return First edition. curl --fail-with-body "$BASE/query" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"rank_by":["id","asc"],"top_k":10,"include_attributes":true}' curl --fail-with-body "$BASE/records/guide/activate" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"version_id":"v2","expected_current_version":"v1"}' # Restore v1 without changing either immutable revision. curl --fail-with-body "$BASE/records/guide/activate" -H "Authorization: Bearer $LAYER_API_KEY" \ -H 'Content-Type: application/json' -d '{"version_id":"v1","expected_current_version":"v2"}' for SUFFIX in versions versions/v1 versions/v2 activations; do curl --fail-with-body "$BASE/records/guide/$SUFFIX" -H "Authorization: Bearer $LAYER_API_KEY" done curl --fail-with-body -X DELETE "$BASE" -H "Authorization: Bearer $LAYER_API_KEY" ``` `expected_current_version` is required on activation. Use `null` for no active revision. A stale expectation returns `409` and changes nothing. Repeating an activation whose target is already active succeeds without adding an event when the expectation matches. Activation and its separate activation event commit in one transaction with the searchable row. SQL indexing errors roll back the transaction, leaving the previous revision searchable. Pgvector indexing is synchronous: there is no pending revision exposed before a successful commit. Automatic writes use this same transaction; explicit activation works in both modes. Restoring is an activation, not a new content revision. ## History, retention and deletion `GET /v2/namespaces/{namespace}/records/{id}/versions` returns `versions` and `active_version_id`; each entry includes `id`, `version_id`, `row`, and `created_at`. `GET .../versions/{version_id}` retrieves one immutable revision. `GET .../activations` returns the separate ordered `activations` log with `previous_version_id`, `version_id`, `sequence`, and `created_at`. List endpoints accept `after` (exclusive version ID for versions, integer sequence for activations) and `limit` (1–100, default 100), returning `next_cursor` when more results exist. Version IDs sort lexicographically; activation sequences increase. Full upserts and named `deletes` are supported. A delete clears the active pointer and removes the searchable row, logging a null target; retained revisions remain available for restoration. Patches, conditional writes, filtered writes and branch/copy operations are refused on versioned namespaces. Do not write directly to the backing database. Enable versioning before adding records; adopting existing unversioned rows is refused. Revisions and activation events are retained indefinitely. There is no TTL or per-version deletion in this slice. Deleting the namespace removes its current rows, revisions, activation log and versioning configuration together. Use namespace deletion for complete erasure. A failed transaction retains neither a new revision nor an activation event. Embeddings computed by the gateway are part of the retained row; restoring a revision does not rerun an embedding model. # Response Headers Source: https://hevlayer.com/docs/pro/api/response-headers import Edition from "../../../components/docs/Edition.astro"; Layer keeps turbopuffer-compatible read bodies in the upstream shape and returns Layer-specific metadata in response headers. | Header | Values | Returned by | | --- | --- | --- | | `x-layer-stable-as-of` | Epoch milliseconds | Query, multi-query, scan counts | | `x-layer-next-cursor` | Opaque cursor token | Single-query pagination | | `x-layer-cache` | `hit`, `miss`, `miss-on-error` | Fetch | | `x-layer-warning` | `vector_attribute_dropped`, `rerank_degraded` | Query, fetch, [search](/docs/pro/api/search#degrade) | | `x-hevlayer-license-grace` | `true` | Licensed gateway routes allowed while the license is in grace | | `traceparent` | W3C trace context | Query, multi-query | SDKs expose these headers as fields where that is more convenient. For example, `query_namespace` returns upstream `rows` and also sets `stable_as_of` / `next_cursor` on the parsed response object when the headers are present. # Scan Source: https://hevlayer.com/docs/pro/api/scans import PostgresWarning from "../../../components/docs/PostgresWarning.astro"; import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import FeatureGate from "../../../components/docs/FeatureGate.astro"; ## Complete projected source rows `POST /v2/namespaces/{ns}/scans/rows` returns a bounded complete observed source as projected rows. It requires read authorization for the same namespace and store as query. No job, cache warm, cursor continuation, or snapshot is created. The request is: ```json {"include_attributes":["session_id","end","cost"],"scan":{"max_rows":1000000,"max_bytes":268435456,"consistency":"observed"}} ``` The response contains `rows` and `source` counters (`rows`, `pages`, `bytes`, `complete: true`, `consistency: "observed"`, `threads`). Physical IDs are always included. Rows are returned in physical ID order after complete acquisition. The gateway uses the shared namespace shard fanout and scan width resolver: ordinary default 8, capped by active shard count and namespace limits. An unready or unsharded namespace uses one complete cursor including tagged and untagged rows. With active sharding a disjoint residual cursor also reads null, missing and out-of-range shard tags; cached readiness alone cannot prove source coverage. It shares the same fanout width and budgets. A foreign-namespace manifest is rejected. Topology auth, throttle, transport and malformed-data errors fail the request. Only absent metadata or an unconfigured object store allows compatibility resolution; no readiness drain scan or cache promotion is needed for this route. The reserved namespace marker is excluded locally without string-ID predicates, including on uint stores. Zero or more than 4,096 advertised shards use one complete unpartitioned cursor to bound topology metadata expansion. Only explicitly supported projected source adapters may serve this route; others return structured 422 `UnsupportedByStore` / `projected_source_rows` before source reads. Projection errors never retry with full attributes. Rows and decoded source response bytes share one request budget across shards; all provider calls retain query admission. Any source, budget, identity or cursor failure rejects the whole response. Disconnect or deadline drops pending work; already accepted provider requests can finish remotely. No partial result is returned. Source counters exclude readiness/metadata calls and HTTP framing. This is parallel projected source acquisition, not backend-native aggregates or compact source reduction. Every revision is transferred to the gateway and the projected result to the caller. Consumers must choose latest before mutable filters and retain their existing complete folds and joins. Concurrent writes can cross observed pages; completeness does not imply a transactional snapshot. A scan is on-demand row selection over a namespace. It picks rows by one of four **selectors** and returns their IDs (`mode: ids`, an asynchronous job), their count (`mode: count`, synchronous), or the distinct values of one attribute field (`mode: values`, an asynchronous job): | Input | Field | Meaning | Notes | | --- | --- | --- | --- | | Filter selector | `filters` | An attribute predicate, or all rows when omitted. | Exact | | Full-text selector | `fts` | A BM25 predicate against a text field. | Exact | | Hybrid-text selector | `hybrid_text` | The BM25 leg, the per-token fuzzy legs, and the per-token surfacing legs — a superset of the `hybrid_text` query route (see [Hybrid text count](#hybrid-text-count)). | Exact | | Radius selector | `ann` | Rows within `radius` of a query vector. | Approximate (ANN recall) | | Fan-out control | `threads` | Maximum concurrent upstream requests for origin scatter/gather. | Origin only; defaults from `Index.spec.scan.threads`, then `32` for pinned and ready namespaces or `8` otherwise. | Origin scatter/gather is enabled only for namespaces whose shard backfill is complete. For adopted namespaces initialized through `POST /v2/namespaces/{ns}/init`, scans stay on the single-namespace origin path while `layer.shard_lag_rows` is greater than `0`; this keeps count, ID, and values scans from missing rows that have not yet been stamped with `_hevlayer_shard`. A request carries **at most one** ranked selector (`fts`, `hybrid_text`, or `ann`). `filters` is always optional and, when present alongside a ranked selector, is ANDed onto the match set as an extra constraint. A request with more than one ranked selector is a `422`. At cutover, `mode: ids` is filter-only (ranked IDs are a defined fast-follow), while `mode: count` and `mode: values` support all four selectors. Use scans for bulk exports, manual inspection, UDF discovery debugging, cache/origin consistency checks, exact or approximate counts, and field value discovery. ## Routes | Route | Method | Behavior | | --- | --- | --- | | `POST /v2/namespaces/{ns}/scans` | POST | Create an ID or values scan job, or return a count. | | `GET /v2/namespaces/{ns}/scans` | GET | List scan jobs for the namespace. | | `GET /v2/namespaces/{ns}/scans/{id}` | GET | Read one scan job. | | `GET /v2/namespaces/{ns}/scans/{id}/results` | GET | Read completed scan IDs or values. | | `DELETE /v2/namespaces/{ns}/scans/{id}` | DELETE | Drop the in-memory scan job. | ## ID Mode ```python job = await client.create_scan("products", { "source": "auto", "mode": "ids", "filters": ["category", "Eq", "Electronics"], "threads": 8, "page_size": 1000, }) ``` ```go job, err := client.CreateScan(ctx, "products", &hevlayer.CreateScanRequest{ Source: "auto", Mode: "ids", Filters: []interface{}{"category", "Eq", "Electronics"}, Threads: 8, PageSize: 1000, }) ``` ```typescript const job = await client.createScan("products", { source: "auto", mode: "ids", filters: ["category", "Eq", "Electronics"], threads: 8, page_size: 1000, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/scans" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": "auto", "mode": "ids", "filters": ["category", "Eq", "Electronics"], "threads": 8, "page_size": 1000 }' ``` `mode` defaults to `ids`. Valid ID-mode sources are `auto`, `cache`, and `origin`. Every client also ships a helper that creates the job and polls until it finishes (`status` is `completed` or `failed`): `scan(...)` in Python and TypeScript, `Scan` in Go. To wait on a job you already created, use `wait_for_scan` / `waitForScan` / `WaitForScan`. The create response is `202 Accepted`: ```json { "id": "scan-uuid", "namespace": "products", "source": "auto", "effective_source": "origin", "status": "running", "progress": 0, "documents_scanned": 0, "threads": 8, "created_at": "2026-05-26T10:00:00Z" } ``` Read IDs after `status` is `completed`: ```python results = await client.get_scan_results("products", job.id, limit=1000, offset=0) ``` ```go results, err := client.GetScanResults(ctx, "products", scanID, &hevlayer.GetScanResultsParams{Limit: 1000, Offset: 0}) ``` ```typescript const results = await client.getScanResults("products", job.id, { limit: 1000, offset: 0, }); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/products/scans/scan-uuid/results?limit=1000&offset=0" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` ```json { "ids": ["doc-1", "doc-2"], "total": 2 } ``` ## Count Mode ```python count = await client.create_scan("products", { "mode": "count", "source": "auto", "filters": ["category", "Eq", "Electronics"], "threads": 8, "timeout_seconds": 30, }) ``` ```go count, err := client.CreateScan(ctx, "products", &hevlayer.CreateScanRequest{ Mode: "count", Source: "auto", Filters: []interface{}{"category", "Eq", "Electronics"}, Threads: 8, TimeoutSeconds: 30, }) ``` ```typescript const count = await client.createScan("products", { mode: "count", source: "auto", filters: ["category", "Eq", "Electronics"], threads: 8, timeout_seconds: 30, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/scans" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "count", "source": "auto", "filters": ["category", "Eq", "Electronics"], "threads": 8, "timeout_seconds": 30 }' ``` ```json { "count": 4210, "served_by": "snapshot", "snapshot_sha": "3f9e8b21", "watermark_ms": 1747300000123, "elapsed_ms": 3 } ``` When `watermark_ms` is present, the response also includes `x-layer-stable-as-of` with the same epoch-ms value. Count-mode sources are `auto`, `snapshot`, `cache`, and `origin`. Snapshot reads are eligible only for a single leaf `Eq` or `In` filter on a field present in the latest snapshot `fields[]`. `And`, `Or`, `Not`, range operators, fields absent from the snapshot, and skipped fields fall through under `auto` and fail with `412 precondition_failed` under `source: snapshot`. All scan modes accept the same temporal selectors as query: `as_of` conjoins `_hevlayer_upserted_at <= as_of`; `between: [lo, hi]` conjoins `lo < _hevlayer_upserted_at <= hi`. The temporal predicate is ANDed with `filters` and with any ranked selector (`fts`, `hybrid_text`, or `ann`). Snapshot-served scans cannot evaluate temporal windows from a pre-aggregated body, so `source: auto` falls through to cache/origin when a temporal selector is present and `source: snapshot` fails with `412 precondition_failed`. Live count responses include: ```json { "count": 4210, "served_by": "origin", "bounded": false, "timed_out": false, "shards_saturated": 0, "shards_total": 1, "threads": 1, "elapsed_ms": 42 } ``` ## Values Mode A values scan enumerates the distinct values of one attribute `field` over the rows the selector picks, each with its document count. Use it to discover a field's value set — what product categories exist, what tags appear on rows matching a query — instead of confirming values you already know with counts. `field` is required for `mode: values` (and rejected on other modes with `422`). It must name a scalar string or integer attribute, or an array of strings — each array element counts once per containing document. Vector fields are a `422`. ```python job = await client.create_scan("products", { "mode": "values", "field": "category", "source": "auto", "filters": ["in_stock", "Eq", True], }) ``` ```go job, err := client.CreateScan(ctx, "products", &hevlayer.CreateScanRequest{ Mode: "values", Field: "category", Source: "auto", Filters: []interface{}{"in_stock", "Eq", true}, }) ``` ```typescript const job = await client.createScan("products", { mode: "values", field: "category", source: "auto", filters: ["in_stock", "Eq", true], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/scans" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "values", "field": "category", "source": "auto", "filters": ["in_stock", "Eq", true] }' ``` Like ID mode, the create response is a `202 Accepted` job, and the `scan(...)` SDK helpers poll it to completion: ```json { "id": "scan-uuid", "namespace": "products", "mode": "values", "field": "category", "source": "auto", "effective_source": "origin", "status": "running", "progress": 0, "documents_scanned": 0, "threads": 8, "created_at": "2026-05-26T10:00:00Z" } ``` Read values from the same results route after `status` is `completed`, with the same `limit`/`offset` pagination as scan IDs: ```json { "values": [ {"v": "electronics", "n": 4210}, {"v": "books", "n": 1240} ], "total": 2, "truncated": false } ``` `v`/`n` is the same vocabulary [snapshot](/docs/pro/api/snapshots) facet histograms use: `v` is the value, `n` its document count. Ordering is deterministic — `n` descending, then `v` ascending. Counts are exact for filter-selector scans; on a ranked scan with a saturated shard the job carries `bounded: true` and each `n` is a `>=` lower bound. ### Precomputed serving An unfiltered values scan (no `filters`, no ranked selector) on a field present in the latest snapshot `fields[]` is answered straight from the snapshot's facet histogram: the job completes during the create call — the `202` body already shows `status: completed` — and carries `effective_source: snapshot` with `snapshot_sha` and `watermark_ms`. Fields in `fields_skipped[]` or absent from the snapshot fall through to cache/origin under `auto` and fail with `412 precondition_failed` under explicit `source: snapshot`, as do scans carrying any selector. ### High cardinality Snapshot facet histograms cap each field at 10,000 distinct values and skip fields beyond it; values scans are the enumeration path for exactly those fields. A values job accumulates its histogram in gateway memory and caps the listing at **1,000,000 distinct values**. A scan that crosses the cap completes rather than failing: - The cap applies after the full pass, so every emitted `n` stays exact. - The listing truncates deterministically to the top 1,000,000 values by count (value-ascending tiebreak); the low-count tail is dropped. - The job and its results carry `truncated: true`, meaning the listing is incomplete. `truncated`, `bounded`, and `approximate` are independent flags: `truncated` is a gateway memory bound on the listing, `bounded` is upstream `top_k` saturation on a ranked scan's counts, and `approximate` is ANN recall fuzz on a radius ball's membership. ## Fan-out width Origin scans fan out one upstream request per active shard. `threads` sets the maximum number of those upstream requests a single scan may have in flight at once. It means concurrent requests, not operating-system threads; the gateway is async. Resolution order: 1. `threads` on the scan request. 2. `spec.scan.threads` on the namespace's `Index` resource. 3. The gateway default: `32` for pinned and ready namespaces, `8` otherwise. The effective value is clamped to the active shard count and the server cap (`512` for pinned and ready namespaces, `32` otherwise), then echoed as `threads` on origin responses and completed scan jobs. Snapshot and cache reads do not fan out, so they ignore this field and omit the echo. ## Full-text count Count rows matching a BM25 query with the `fts` selector. Full-text counts are exact and always run origin scatter/gather, so `source` must be omitted, `auto`, or `origin`. A `filters` array, when present, is ANDed on as an extra constraint. ```python count = await client.create_scan("products", { "mode": "count", "fts": {"field": "title", "query": "wireless headphones"}, "filters": ["category", "Eq", "Electronics"], "exhaustive": True, }) ``` ```go count, err := client.CreateScan(ctx, "products", &hevlayer.CreateScanRequest{ Mode: "count", Fts: hevlayer.FtsScan{Field: "title", Query: "wireless headphones"}, Filters: []interface{}{"category", "Eq", "Electronics"}, Exhaustive: true, }) ``` ```typescript const count = await client.createScan("products", { mode: "count", fts: { field: "title", query: "wireless headphones" }, filters: ["category", "Eq", "Electronics"], exhaustive: true, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/scans" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "count", "fts": {"field": "title", "query": "wireless headphones"}, "filters": ["category", "Eq", "Electronics"], "exhaustive": true }' ``` ## Hybrid text count Count rows in the keyword/fuzzy neighborhood of a `HybridText` query with the `hybrid_text` selector. The scan tokenizes `query` with the HybridText policy, then evaluates the BM25 leg, one fuzzy leg per token, **and** one surfacing leg per token (the RFC 0057 empty-result fallback's legs), and counts the de-duplicated union of returned row ids. This count is a **superset** of the `hybrid_text` query route's deduped rows: the scan always includes the surfacing legs, whereas the query route only adds them when its primary legs (BM25 + fuzzy) return nothing. On a partial-typo query whose primary legs do match, the scan can therefore count more rows than the route returns. Use this selector for a generous live count next to `hybrid_text` or `auto` results that routed to `hybrid_text`; plain `fts` counts exact BM25 only. ```python count = await client.create_scan("products", { "mode": "count", "hybrid_text": {"field": "title", "query": "wireles headphones"}, "filters": ["category", "Eq", "Electronics"], }) ``` ```go count, err := client.CreateScan(ctx, "products", &hevlayer.CreateScanRequest{ Mode: "count", HybridText: hevlayer.HybridTextScan{Field: "title", Query: "wireles headphones"}, Filters: []interface{}{"category", "Eq", "Electronics"}, }) ``` ```typescript const count = await client.createScan("products", { mode: "count", hybrid_text: { field: "title", query: "wireles headphones" }, filters: ["category", "Eq", "Electronics"], }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/scans" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "count", "hybrid_text": {"field": "title", "query": "wireles headphones"}, "filters": ["category", "Eq", "Electronics"] }' ``` ## Radius count Count rows within `radius` of a query vector with the `ann` selector — a distance-ball scan. `radius` is required and finite (without an upper bound every row is in the ball); `field` defaults to `vector`. Like `fts`, radius counts always run origin scatter/gather. The count is **approximate**: ANN recall means the index's membership of the ball may differ from the true set, independent of saturation, so the response carries `approximate: true`. The radius bound is applied by the gateway to the `$dist` returned by the ranked query. It is not sent upstream as a filter. ```python count = await client.create_scan("products", { "mode": "count", "ann": {"field": "vector", "vector": [0.12, -0.3, 0.88], "radius": 0.25}, }) ``` ```go count, err := client.CreateScan(ctx, "products", &hevlayer.CreateScanRequest{ Mode: "count", Ann: hevlayer.AnnScan{Field: "vector", Vector: []float64{0.12, -0.3, 0.88}, Radius: 0.25}, }) ``` ```typescript const count = await client.createScan("products", { mode: "count", ann: { field: "vector", vector: [0.12, -0.3, 0.88], radius: 0.25 }, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/scans" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "count", "ann": {"field": "vector", "vector": [0.12, -0.3, 0.88], "radius": 0.25} }' ``` ```json { "count": 980, "served_by": "origin", "approximate": true, "bounded": false, "timed_out": false, "shards_saturated": 0, "shards_total": 1, "threads": 1, "elapsed_ms": 51 } ``` ### Bounding ranked scans Ranked selectors fan out one turbopuffer query per shard, each capped at `top_k = 10_000`. `threads` bounds fan-out width: how many shard requests can run at once. `exhaustive` and `timeout_seconds` bound depth: what happens when a shard hits that cap and how long recursion can run. - `exhaustive: false` (default) — one scatter/gather. A saturated shard contributes its cap as a lower bound; the response carries `bounded: true` with `shards_saturated > 0`. - `exhaustive: true` — for BM25, recurse on each saturated shard via score-band pagination (`$score < last` with an `id` tiebreak) until every page is short or `timeout_seconds` elapses. ANN radius scans do not push `$dist` filters upstream; the gateway counts returned rows whose `$dist <= radius` and marks the shard exhausted when the first over-radius row appears. If the full page is still inside the radius, the shard remains `bounded`. The same `threads` value applies to the initial round and every exhaustive round over the remaining saturated shards. `bounded` and `approximate` are independent. `bounded` means a shard saturated and the count is a `>=` lower bound for the rows the index returned; `approximate` means the distance ball's membership is itself fuzzy. An `ann` count can be `bounded: false` yet still `approximate: true`. ## Sources | Source | ID mode | Count mode | Values mode | | --- | --- | --- | --- | | `auto` | Cache when fresh enough, otherwise origin | Snapshot first, then cache/origin. | Snapshot when eligible, then cache/origin. | | `snapshot` | Not supported | Latest snapshot only; requires eligible `Eq` or `In`. | Latest snapshot facet listing; requires an unfiltered scan on a field in `fields[]`. | | `cache` | Aerospike document cache only | Aerospike document cache only | Aerospike document cache only. | | `origin` | turbopuffer paginated scan | turbopuffer paginated scan | turbopuffer paginated scan with gateway-side dedupe. | This table covers the filter selector. The `fts`, `hybrid_text`, and `ann` selectors have no snapshot or cache evaluator, so they always run origin scatter/gather: omitted, `auto`, and `origin` all resolve to origin, and `snapshot` or `cache` returns `422`. ## Filters Scans accept the same turbopuffer filter array as [query](/docs/pro/api/query). On origin scans, the filter is pushed to turbopuffer. On cache scans, the gateway evaluates it against cached document attributes. Supported cache operators are `Eq`, `NotEq`, `Gt`, `Gte`, `Lt`, `Lte`, `In`, `NotIn`, `And`, `Or`, and `Not`. If `auto` sees a filter the cache cannot evaluate, it uses origin. Explicit `source: cache` with an unsupported filter fails rather than returning partial results. ## Auto-Mode Policy Auto ties cache freshness to the same consistency watermark used by [stable reads](/docs/pro/api/query#stable-reads). The gateway tracks per-namespace `cache_warmed_through`, the watermark observed at the end of the last successful origin warm. | Cache state | Watermark state | Action | | --- | --- | --- | | Empty | any | Run origin and stamp `cache_warmed_through`. | | Populated, `cache_warmed_through >= watermark` | observed | Serve cache. | | Populated, `cache_warmed_through < watermark` | observed | Serve cache and start a background origin warm. | | Populated, no `cache_warmed_through` yet | observed | Serve cache and start a background origin warm. | | Populated | not yet observed | Serve cache. | When cache is used, `_hevlayer_upserted_at <= cache_warmed_through` is added before the user filter so the scan is a stable warmed view. ## Operational notes - ID and values scan state is in-memory and ephemeral; it resets on gateway restart. - Count scans have a deadline, default 30s and maximum 300s. - Values jobs cap at 1,000,000 distinct values per scan and set `truncated: true` when crossed; the listing keeps the top values by count, each with an exact count. - Origin scan fan-out uses the [pinning-aware defaults and caps](#pinning-and-fan-out) unless the request or `Index.spec.scan.threads` sets a different default. - Snapshot-served count scans are exact at the snapshot `watermark_ms`. ## Pinning and fan-out Origin fan-out defaults to `32` requests for namespaces whose latest upstream metadata reports `pinning.status.ready_replicas > 0`; its maximum is `512`. Other namespaces default to `8` with a maximum of `32`. Request-level `threads` overrides `Index.spec.scan.threads`; both remain bounded by the applicable maximum and active shard count. The gateway observes readiness through metadata reads and background consistency polling. Missing or zero ready replicas, metadata failures, and observations older than two minutes use the unpinned limits. Cold namespaces use those limits until readiness is observed. These controls do not enable pinning. # Query History Source: https://hevlayer.com/docs/pro/api/search-history import StoreNote from "../../../components/docs/StoreNote.astro"; import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; Layer logs every query the gateway serves into a durable JSONL trail in S3, mirrored into Layer's hot cache for fast recent reads. Fetch events that downstream consumers tag back to a query land in a sibling clickstream feed. Together they make a search session reconstructable after the fact — for relevance tuning, A/B comparison, or incident review. Both feeds are Layer-only. ## Routes | Route | Behavior | | --- | --- | | `GET /v2/namespaces/{ns}/search-history` | Per-namespace query log, newest first. | | `GET /v2/namespaces/{ns}/clickstream` | Fetch events correlated to a search, newest first. | The `/v1/` versions of both routes are identical aliases held for client compatibility. ## Search history entry ```json { "entries": [ { "timestamp": "2026-05-22T08:00:00.000Z", "timestamp_nanos": 1747900800000000000, "namespace": "products", "trace_id": "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "raw_query": "wireless headphones", "stable_as_of": 1747900700000, "query": {"vector": "[…]", "top_k": 10, "filters": "[…]"}, "top_result_ids": ["asin-B08N5WRWNW", "asin-B07PXGQC1Q"], "tags": ["app:hev-shop", "route:search", "surface:storefront"] } ], "next_cursor": "1747900799000000000" } ``` | Field | Meaning | | --- | --- | | `timestamp` / `timestamp_nanos` | Wall-clock and nanosecond timestamps. `timestamp_nanos` is the pagination cursor. | | `trace_id` | Trace context propagated or generated for the query. Joins to the clickstream feed. | | `raw_query` | Caller-supplied query string from the `x-hevlayer-search-query` header (e.g. the BM25 input). Omitted when the header is absent. | | `stable_as_of` | Epoch-ms namespace watermark used by the served response. Omitted on cold-start gateways before the namespace has a watermark. | | `query` | Structured query summary — vector shape, filters, ranking. | | `top_result_ids` | IDs from the served response, in rank order. | | `tags` | Caller-supplied labels propagated through request headers. Used for ad-hoc segmentation. | [Hybrid text](/docs/pro/api/query#hybrid-text-fusion) queries log as a single entry whose `query` carries the `HybridText` expression, not the expanded legs, so re-issuing the logged query reproduces the whole expansion (tokenization, fuzzy legs, fusion) as a unit. [Routed](/docs/pro/api/query#query-routing) queries additionally carry the routing decision (route, policy version, executed), so per-route engagement can be measured against the clickstream and a logged query can be replayed under a forced route. ### Writing metadata Set `x-hevlayer-search-query` on query requests to capture the human input, and set `x-hevlayer-tags` to a comma-separated list of segmentation tags. The Python client exposes these as the `raw_query` and `tags` keyword arguments; the Go client as the `WithSearchQuery` and `WithSearchTags` request options: ```python query = await client.query_namespace( "products", {"vector": embedding, "top_k": 10, "include_attributes": ["title"]}, raw_query="wireless headphones", tags=["app:hev-shop", "surface:storefront", "route:search", "page:first"], ) history = await client.list_search_history( "products", tags=["app:hev-shop", "route:search", "page:first"], limit=20, ) ``` ```go query, err := client.QueryNamespace(ctx, "products", &hevlayer.QueryRequest{Vector: embedding, TopK: 10, IncludeAttributes: []string{"title"}}, hevlayer.WithSearchQuery("wireless headphones"), hevlayer.WithSearchTags([]string{"app:hev-shop", "surface:storefront", "route:search", "page:first"}), ) history, err := client.ListSearchHistory(ctx, "products", &hevlayer.ListSearchHistoryParams{ Tag: []string{"app:hev-shop", "route:search", "page:first"}, Limit: 20, }) ``` ```typescript const query = await client.queryNamespace( "products", { vector: embedding, top_k: 10, include_attributes: ["title"] }, { searchQuery: "wireless headphones", tags: ["app:hev-shop", "surface:storefront", "route:search", "page:first"], }, ); const history = await client.listSearchHistory("products", { tags: ["app:hev-shop", "route:search", "page:first"], limit: 20, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/query" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -H "x-hevlayer-search-query: wireless headphones" \ -H "x-hevlayer-tags: app:hev-shop,surface:storefront,route:search,page:first" \ -d '{"vector": [0.0012, -0.043], "top_k": 10, "include_attributes": ["title"]}' curl "$LAYER_GATEWAY_URL/v2/namespaces/products/search-history?tag=app:hev-shop,route:search,page:first&limit=20" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` Keep the query text in `raw_query`; use tags for segmentation, not for duplicating the query string. ### Tag contract Layer splits `x-hevlayer-tags` and `?tag=` on commas, trims whitespace, drops empty values, then sorts and dedupes tags before storing or matching them. Commas are separators and cannot be escaped. Limits: | Limit | Value | | --- | --- | | Max tags | 32 unique tags per request or filter | | Max tag length | 128 bytes | | Allowed characters | ASCII letters, digits, `:`, `_`, `-`, `.`, `/`, `=`, `+` | The list filter uses AND semantics: `?tag=a,b` returns only entries that carry both `a` and `b`. ### Query parameters | Param | Purpose | | --- | --- | | `tag` | Comma-separated tag filter. AND semantics — every tag must match. | | `from` / `to` | RFC3339 time bounds. | | `before` | Pagination cursor; return entries strictly older than the given `timestamp_nanos`. | | `limit` | Cap 500, default 50. | ## Clickstream entry ```json { "events": [ { "timestamp": "2026-05-22T08:00:02.143Z", "timestamp_nanos": 1747900802143000000, "trace_id": "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "namespace": "products", "doc_id": "asin-B08N5WRWNW", "tags": ["session:abc123"], "source": "fetch", "served_from": "cache" } ], "next_cursor": "1747900802142000000" } ``` `trace_id` joins to the search-history entry that produced the result; `served_from` distinguishes a cache hit from an upstream fetch. `trace_id` is also a supported query parameter so you can pull every event for a single search session: ```python events = await client.list_clickstream( "products", trace_id="f81d4fae-7dec-11d0-a765-00a0c91e6bf6", ) ``` ```go events, err := client.ListClickstream(ctx, "products", &hevlayer.ListClickstreamParams{ TraceID: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", }) ``` ```typescript const events = await client.listClickstream("products", { traceId: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", }); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/products/clickstream?trace_id=f81d4fae-7dec-11d0-a765-00a0c91e6bf6" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` ## Storage ```text search-history/{namespace}/{YYYY-MM-DD}/{timestamp_nanos}.jsonl ``` Writes are best-effort and never block the query response. Aerospike holds a recent window for fast reads; S3 is the durable store. A cache outage degrades read latency but not durability — list calls walk the S3 prefix and merge inline. # Search Source: https://hevlayer.com/docs/pro/api/search import Edition from "../../../components/docs/Edition.astro"; import Preview from "../../../components/docs/Preview.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; `POST /v2/namespaces/{namespace}/search` takes a query string and returns ranked, scored rows. In one request, inside one namespace, the gateway: 1. **Embeds** `query` from the schema's `embed:` attribute, through that attribute's [serving preference](/docs/pro/api/embed). 2. **Expands text legs** over every `full_text_search` attribute: one BM25 leg per attribute, then fuzzy legs per token, under a 16-leg budget. 3. **Adds the ANN leg** over the derived vector column. 4. **Scatters** every leg across the namespace's shards under one stable-read cut, with `filters` replicated to every leg. 5. **Fuses** with reciprocal rank fusion (`rank_constant` 60), deduplicating by id. 6. **Cuts** the fused list to `pool` candidates (the L1 stage). 7. **Reranks** the pool with [Jev](https://docs.typesafe.ai): one yes/no relevance question per document, a calibrated probability back. 8. **Prunes** below `rerank.threshold` and returns `top_k` rows with the probability as `score`. [`/query`](/docs/pro/api/query) is unchanged and stays the turbopuffer-shaped wire. `/search` is Layer-native: text in, scored rows out. The namespace must declare an `embed:` attribute and at least one `full_text_search` attribute, and the gateway must hold a reranker key (see [Provider key](#provider-key)). ```python response = await client.search_namespace("papers", { "query": "does vitamin D improve bone density in older adults", "top_k": 10, }) ``` ```go response, err := client.SearchNamespace(ctx, "papers", &hevlayer.SearchRequest{ Query: "does vitamin D improve bone density in older adults", TopK: 10, }) ``` ```typescript const response = await client.searchNamespace("papers", { query: "does vitamin D improve bone density in older adults", top_k: 10, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/papers/search" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"query": "does vitamin D improve bone density in older adults", "top_k": 10}' ``` ## Request Only `query` is required. Unknown fields are rejected with `422`. ```json { "query": "does vitamin D improve bone density in older adults", "top_k": 10, "filters": ["year", "Gte", 2015], "include_attributes": ["title", "text", "year"], "pool": 50, "embed": { "attribute": "text" }, "text": { "fuzziness": "auto", "stopwords": "en" }, "rerank": { "provider": "jev", "threshold": 0.0, "attributes": ["title", "text"], "docs_per_call": 30, "max_chars": 2000, "question": "generic-1", "required": false }, "explain": false } ``` | Field | Default | Meaning | | --- | --- | --- | | `query` | required | The search text. Embedded for the ANN leg, ranked as-is by the BM25 legs, tokenized for the fuzzy legs. | | `top_k` | `10` | Rows returned. `1..100`, and at most `pool`. | | `filters` | none | Filter expression in the [query](/docs/pro/api/query) grammar. A hard filter, replicated to every leg. | | `include_attributes` | `true` | `true`, `false`, or a list of attribute names to return under each row's `attributes`. Reserved `_hevlayer_*` attributes are returned only when named; vector columns are never returned. | | `pool` | `50` | Candidates handed to the reranker after the L1 cut. `50..200`. Also sets per-leg depth: `clamp(5 × pool, 50, 200)`. | | `embed.attribute` | the only one | Which `embed:` attribute to embed `query` with. Required when the schema declares several. | | `text.fuzziness` | `"auto"` | `"auto"` or `0..2`, as in [`HybridText`](/docs/pro/api/query#hybrid-text-fusion). | | `text.stopwords` | `"en"` | `"en"`, `false`, or a list. Suppresses fuzzy legs for stop words; BM25 legs are unaffected. | | `rerank` | on | `false` disables the stage. Otherwise an object; every key is optional. | | `rerank.provider` | `"jev"` | The only provider today. | | `rerank.threshold` | `0.0` | Drop rows whose probability is below this, before `top_k`. `0.1` is the recommended prune: on BEIR shortlists rows under `0.1` were judged relevant about 0.5% of the time. | | `rerank.attributes` | every full-text attribute | Attributes whose text the reranker reads. An attribute named `title` goes first, then schema order. `_hevlayer_*` names are rejected. | | `rerank.docs_per_call` | `30` | Documents per provider call, `1..50`. Calls run concurrently. | | `rerank.max_chars` | `2000` | Per-document character budget for reranker text, `1..20000`. | | `rerank.question` | `"generic-1"` | Named question version. `generic-1` is the only one. | | `rerank.required` | `false` | `true` turns a provider failure into `503` instead of a degrade. | | `explain` | `false` | `true` adds the L1 feature vector to every row. | | `collapse` | none | `{by, expansion?, missing?}`: one result per group, such as per document. See [Collapse](#collapse). | There is no pagination: a reranked page does not compose with a fused cursor. Ask for a larger `top_k`, up to `pool`. ## Response ```json { "rows": [ { "id": "PMC5461234", "score": 0.93, "attributes": { "title": "...", "text": "...", "year": 2019 } } ], "routing": { "route": "fused", "policy": "v1", "tokens": 10, "executed": true, "advisory": true }, "plan": { "executed": false, "reason": "unconfigured" }, "hybrid": { "tokens": ["vitamin", "improve", "bone", "density", "older", "adults"], "tokens_dropped": 0, "stopwords": "en", "stopwords_dropped": ["does", "in"], "fuzziness": "auto", "rank_constant": 60, "per_leg_limit": 200, "legs": [ { "label": "ann", "kind": "ann", "attribute": "text", "rows": 200 }, { "label": "bm25:text", "kind": "bm25", "attribute": "text", "rows": 143 }, { "label": "bm25:title", "kind": "bm25", "attribute": "title", "rows": 61 }, { "label": "fuzzy:text:vitamin", "kind": "fuzzy", "attribute": "text", "rows": 88 } ], "dropped_legs": 0, "surfaced": false }, "rerank": { "provider": "jev", "model": "jev-1.13.0", "question": "generic-1", "executed": true, "pool": 50, "calls": 2, "docs_per_call": 30, "threshold": 0.0, "pruned": 0, "input_tokens": 41200, "latency_ms": 231 }, "performance": { "embedding_tokens": 17, "embedding_ms": 4, "legs_ms": 88, "fuse_ms": 1, "l1_ms": 0, "rerank_ms": 231, "total_ms": 340 } } ``` Every response carries the four echo blocks: `routing`, `plan`, `hybrid`, `rerank`. | Field | Meaning | | --- | --- | | `rows[].score` | The reranker's probability in `[0, 1]` when `rerank.executed` is `true`. It is absolute: comparable across rows, legs, shards and requests, so you can threshold on it. When the stage did not execute, `score` is the RRF sum and is only an ordering. The two are never mixed in one response. | | `rows[].attributes` | The attributes `include_attributes` asked for. Text the gateway fetched only for the reranker is stripped. | | `routing` | The [query router](/docs/pro/api/query#query-routing)'s decision for this input. `advisory: true`: `/search` runs every leg regardless, and reports the route for the UI and the history. | | `plan` | The planner stage. Not configured in this release: always `{"executed": false, "reason": "unconfigured"}`. | | `hybrid.legs` | One entry per leg that ran, in fusion order: `label`, `kind` (`ann`, `bm25`, `fuzzy`), the `attribute` it ran over, and the `rows` it returned after the shard merge. | | `hybrid.dropped_legs` | Legs the 16-leg budget, or the store, did not run. | | `hybrid.surfaced` | `true` when every text leg returned nothing and the fuzzy legs were re-run ordered by edit distance, as in the [`HybridText` fallback](/docs/pro/api/query#surfacing-fallback). | | `hybrid.threads` | Shard fan-out width. Present on sharded namespaces only. | | `hybrid.fuzziness_clamped` | Present and `true` only when a store forced the effective `fuzziness` to `0`. | | `rerank.executed` | `false` with a `reason` when the stage was disabled (`disabled`), had nothing to score (`empty_pool`), or the provider failed (`provider_error`, `timeout`, `rate_limited`), or the call never got an in-flight slot (`queue_timeout`). | | `rerank.pool` | Candidates scored. | | `rerank.calls` | Provider calls made. | | `rerank.pruned` | Rows dropped by `threshold`. | | `rerank.input_tokens` | Provider-reported input tokens across all calls. | | `performance` | Milliseconds per stage, plus the embed wire's token count. | The response also carries `traceparent` and, when a stable-read cut applied, [`x-layer-stable-as-of`](/docs/pro/api/response-headers). ### Leg budget A request runs at most 16 legs. The budget is spent in this order: 1. The ANN leg. 2. One BM25 leg per full-text attribute, over the whole `query`, in schema order. 3. Fuzzy legs from the [`HybridText` tokenizer policy](/docs/pro/api/query#tokenization), one per token, round-robin across attributes, until the budget is spent. A namespace with many full-text attributes spends its budget on BM25 breadth rather than fuzzy depth. Whatever did not fit is counted in `hybrid.dropped_legs`. ### Explain With `explain: true` each row gains an `explain` object: the feature vector the L1 stage saw and each term's contribution to the L1 score. ```json { "id": "PMC5461234", "score": 0.93, "attributes": { "title": "..." }, "explain": { "features": { "rrf_sum": 0.0481, "age_seconds": 86400 }, "contributions": { "rrf_sum": 0.0481, "age_seconds": 0.0 }, "l1_score": 0.0481, "legs": [ { "label": "ann", "rank": 3, "score": 0.18 }, { "label": "bm25:text", "rank": 1, "score": 14.2 } ] } } ``` The L1 stage in this release is the identity: it keeps RRF order and cuts to `pool`, so every contribution other than `rrf_sum` is `0`. An absent feature is absent, not `0`. | Feature | Present | | --- | --- | | `rrf_sum` | Always. | | `age_seconds` | When the row carries its write stamp. That is turbopuffer-backed namespaces; it is absent on hev search and pgvector. | | `fetch_count_30d` | Reserved. This release keeps no fetch counter, so it is never returned. | ## Collapse `collapse` returns `top_k` groups, such as documents, instead of `top_k` pages: ```json { "query": "kitchen remodel", "top_k": 10, "collapse": { "by": "document" } } ``` The reranker scores pages first, then the gateway groups the reranked pages, so a group ranks by its best page's score. `rows` keeps each group's best page; `groups` lists every reranked page of each group, best first. To have pages to group, the pool grows to `top_k × expansion` (default `expansion` is `10`), up to the 200-page ceiling. That is the same single retrieval, with a deeper pool and more pages to rerank: no query runs per group or per page. `collapse.read` is the pool size and `collapse.exhausted` is `true` when retrieval returned fewer pages than the pool, so there are no more groups to find. `by`, `expansion` and `missing` mean what they do in [collapse on a ranked query](/docs/pro/api/query#collapse). Name the grouping attribute in `include_attributes` to see it on each page. ## Provider key The reranker runs on your TypeSafe account. Set `TYPESAFE_API_KEY` on the gateway. With the stage on and no key, the request fails with `422 rerank_unconfigured`: a missing key is never a silent fused-only response. | Variable | Default | Meaning | | --- | --- | --- | | `TYPESAFE_API_KEY` | unset | Gateway-wide reranker key. | | `LAYER_RERANK_PROVIDER` | `jev` | Reranker provider. | | `LAYER_RERANK_MODEL` | `jev-latest` | Model name sent to the provider. The response echoes the model that answered. | | `LAYER_RERANK_MAX_INFLIGHT` | `6` | Provider calls in flight across the whole gateway. | | `LAYER_RERANK_TIMEOUT_MS` | `3000` | Per-call deadline, retries included. It starts once the call holds an in-flight slot. Time queued for a slot is separate, bounded by the same value, and reported as `queue_timeout`. | ## Degrade If the provider errors, times out, or keeps answering `429`, or a call waits out its queue allowance for an in-flight slot, the response is still `200`: rows in fused order, `score` as the RRF sum, `rerank.executed: false` with a `reason`, and the header `x-layer-warning: rerank_degraded`. Set `rerank.required: true` to get `503 rerank_unavailable` instead. The `503` message names the provider and its HTTP status; the provider's response body is never relayed. Calls are all-or-nothing: if one of several fails, no row keeps a probability. ## Errors | Status | `error` | When | | --- | --- | --- | | `404` | `not_found` | The namespace does not exist. | | `422` | `embed_attribute_missing` | The schema declares no `embed:` attribute. | | `422` | `embed_attribute_invalid` | `embed.attribute` names an attribute without `embed:`, or the schema declares several and none is named. | | `422` | `full_text_attribute_missing` | The schema has no `full_text_search` attribute. | | `422` | `rerank_unconfigured` | The rerank stage is on and no key is set. | | `422` | `UnsupportedByStore` | The store cannot serve an ANN or a BM25 leg. See [Stores](#stores). | | `422` | `validation_error` | `top_k > pool`, `pool` out of range, unknown provider or question, unknown field, a query that yields no tokens. | | `503` | `rerank_unavailable` | The provider failed and `rerank.required` is `true`. | ## Stores `/search` executes on turbopuffer, where reranker text is returned by the store and billed as returned bytes. A store that cannot serve an ANN or a BM25 leg answers `422 UnsupportedByStore`. On a pgvector-backed namespace `/search` runs the BM25 + dense subset: one BM25 leg per full-text attribute and no fuzzy legs. The fuzzy legs are counted in `hybrid.dropped_legs`. ## Data leaves the environment Candidate text is sent to TypeSafe for the rerank, and `query` is sent to the embedding provider when the `embed:` attribute serves natively on turbopuffer rather than locally. `rerank: false` removes the first. Reserved `_hevlayer_*` attributes are never a text leg and never reranker input. ## History and metrics Each request writes one [search history](/docs/pro/api/search-history) entry with `raw_query` taken from the body, so no `x-hevlayer-search-query` header is needed. Metrics: `hevlayer_search_total`, `hevlayer_search_stage_seconds`, `hevlayer_rerank_documents_total`, `hevlayer_rerank_pruned_total`, `hevlayer_rerank_tokens_total`, `hevlayer_rerank_degraded_total`. # Snapshot History Source: https://hevlayer.com/docs/pro/api/snapshots import Edition from "../../../components/docs/Edition.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; Snapshots are materialized facet histograms for a namespace. They carry facet listings in `values[].v` and facet counts in `values[].n`, stored durably in S3 and mirrored into Aerospike for the latest body. Use `POST /snapshots` to materialize a field now. Use history and body routes to read the durable chronology written by the consistency watcher. ## Snapshot policy Configure automatic snapshot writes with the `Index.spec.snapshot` shape. Kubernetes operators put the shape on the namespace's `Index` CR: ```yaml apiVersion: hevlayer.com/v1 kind: Index metadata: name: products spec: backend: namespace: products snapshot: interval: 5m retention: 30d facetFields: - category - brand ``` | Field | Default | Behavior | | --- | --- | --- | | `facetFields` | `[]` | Facet fields to histogram. Empty or unset disables the automatic snapshot writer for the namespace, so history and activity stay empty. | | `interval` | `5m` | Minimum spacing between automatic snapshot writes. The writer fires on each upstream-stable advance; `interval` only floors how often a write lands. The gateway fallback is `LAYER_SNAPSHOT_MIN_INTERVAL_MS`. | | `retention` | `never` | `never` keeps all history. A duration such as `30d` prunes S3 bodies older than the window, while always keeping the most recent body. | Snapshots are event-driven, not scheduled: an idle namespace does not get a new snapshot just because `interval` elapsed. The gateway refreshes Index policy periodically, so edits take effect without a pod restart. API-only namespaces can set the same shape on the gateway: ```bash curl -X PUT "$LAYER_GATEWAY_URL/v2/namespaces/products/snapshot-policy" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "facetFields": ["category", "brand"], "interval": "5m", "retention": "30d" }' ``` The API policy is stored gateway-side and takes effect immediately for the automatic writer. `GET /v2/namespaces/{ns}/snapshot-policy` returns the API-managed policy for review and automation. Manual `POST /snapshots` jobs with `source: origin` and the automatic writer use the same shard fan-out path. Origin work is bounded by `spec.scan.threads`; stored and cache snapshot reads do not fan out. ## Routes | Route | Method | Behavior | | --- | --- | --- | | `POST /v2/namespaces/{ns}/snapshots` | POST | Create an on-demand snapshot job for one field. | | `GET /v2/namespaces/{ns}/snapshot-policy` | GET | Read the API-managed snapshot policy. | | `PUT /v2/namespaces/{ns}/snapshot-policy` | PUT | Set `facetFields`, `interval`, and `retention` using the `Index.spec.snapshot` shape. | | `GET /v2/namespaces/{ns}/snapshot-jobs` | GET | List in-memory snapshot jobs. | | `GET /v2/namespaces/{ns}/snapshot-jobs/{id}` | GET | Read one snapshot job. | | `GET /v2/namespaces/{ns}/history` | GET | Newest-first durable snapshot history. | | `GET /v2/namespaces/{ns}/snapshots/{sha}` | GET | Full snapshot body by full SHA or 7-char prefix. | | `GET /v2/activity/snapshots` | GET | Cross-namespace snapshot-write activity stream. | Need a named cut for a downstream app? Use [checkpoints](/docs/pro/api/checkpoints) to label the newest durable snapshot watermark without running a scan. ## Manual snapshot ```python job = await client.create_snapshot("products", { "field": "category", "source": "auto", "filters": ["brand", "Eq", "Acme"], "page_size": 1000, }) ``` ```go job, err := client.CreateSnapshot(ctx, "products", &hevlayer.CreateSnapshotRequest{ Field: "category", Source: "auto", Filters: []interface{}{"brand", "Eq", "Acme"}, PageSize: 1000, }) ``` ```typescript const job = await client.createSnapshot("products", { field: "category", source: "auto", filters: ["brand", "Eq", "Acme"], page_size: 1000, }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/snapshots" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "field": "category", "source": "auto", "filters": ["brand", "Eq", "Acme"], "page_size": 1000 }' ``` Valid sources are `auto`, `stored`, `cache`, and `origin`. | Source | Reads from | Notes | | --- | --- | --- | | `auto` | Stored snapshot when possible, otherwise cache/origin policy | Default. Stored snapshots only support unfiltered configured fields. | | `stored` | Latest S3 snapshot body, with Aerospike mirror as a cache | Fastest path for configured facet fields. | | `cache` | Aerospike document cache | Supports filters the cache can evaluate. | | `origin` | turbopuffer paginated scan | Authoritative. Persists the computed snapshot body to S3. | The response is `202 Accepted`: ```json { "id": "snapshot-job-uuid", "namespace": "products", "field": "category", "source": "auto", "status": "running", "progress": 0, "documents_scanned": 0, "created_at": "2026-05-26T10:00:00Z" } ``` Poll the job: ```python job = await client.get_snapshot_job("products", job.id) ``` ```go job, err := client.GetSnapshotJob(ctx, "products", jobID) ``` ```typescript const job = await client.getSnapshotJob("products", jobId); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/products/snapshot-jobs/snapshot-job-uuid" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` Completed jobs include `sha` when a body was materialized: ```json { "id": "snapshot-job-uuid", "namespace": "products", "field": "category", "source": "origin", "status": "completed", "documents_scanned": 12844, "sha": "3f9e8b21", "stable_as_of": 1747300000123 } ``` ## History ```python history = await client.list_namespace_history("products", limit=20) ``` ```go history, err := client.ListNamespaceHistory(ctx, "products", &hevlayer.ListNamespaceHistoryParams{Limit: 20}) ``` ```typescript const history = await client.listNamespaceHistory("products", { limit: 20 }); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/products/history?limit=20" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` ```json [ {"watermark_ms": 1747300000123, "sha": "3f9e8b21...", "tags": ["pre-migration"]}, {"watermark_ms": 1747299600045, "sha": "a1c5b09f...", "tags": []} ] ``` | Query param | Default | Purpose | | --- | --- | --- | | `limit` | 50 | Maximum entries returned. Capped at 500. | | `before` | none | Return entries older than this SHA. 7-char prefixes are accepted. | The history endpoint lists S3 keys only; it does not read every snapshot body. `tags` is metadata for operator grouping and restore workflows; it is not part of the snapshot content hash. ## Snapshot body ```python body = await client.get_namespace_snapshot("products", "3f9e8b2") ``` ```go body, err := client.GetNamespaceSnapshot(ctx, "products", "3f9e8b2") ``` ```typescript const body = await client.getNamespaceSnapshot("products", "3f9e8b2"); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/products/snapshots/3f9e8b2" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` ```json { "namespace": "products", "watermark_ms": 1747300000123, "sha": "3f9e8b21", "row_count": 12500, "fields": [ { "name": "category", "values": [ {"v": "books", "n": 1240}, {"v": "electronics", "n": 873} ] } ], "fields_skipped": [ { "name": "tags", "reason": "exceeded_cap", "distinct_observed": 247000, "cap": 10000 } ] } ``` `fields[].values[].v` is the facet listing. `fields[].values[].n` is the facet count. `row_count` is the number of rows scanned into the snapshot; for vector namespaces, [namespace metadata](/docs/pro/api/namespace-metadata) compares it with the upstream namespace row count to report `indexed` and `index_lag_rows`. Fields present in `fields[]` are complete. Fields above the 10,000 distinct-value cap are listed in `fields_skipped[]` instead of being partially materialized. A skipped field is still enumerable on demand with a [values scan](/docs/pro/api/scans#values-mode), which carries a 1,000,000-value cap instead. ## Activity ```python activity = await client.list_snapshot_activity(since=1747200000000, limit=50) ``` ```go activity, err := client.ListSnapshotActivity(ctx, &hevlayer.ListSnapshotActivityParams{Since: 1747200000000, Limit: 50}) ``` ```typescript const activity = await client.listSnapshotActivity({ since: 1747200000000, limit: 50, }); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/activity/snapshots?since=1747200000000&limit=50" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` | Query param | Required | Purpose | | --- | --- | --- | | `since` | yes | Epoch-ms lower bound on `ts_ms`. | | `limit` | no | Cap 500, default 50. | | `namespace` | no | Exact namespace filter. | | `cursor` | no | Pagination cursor from `next_cursor`. | Activity is snapshot lifecycle only. Search history and clickstream events have separate feeds. # Turbopuffer passthrough Source: https://hevlayer.com/docs/pro/api/upstream-features import Edition from "../../../components/docs/Edition.astro"; import PostgresWarning from "../../../components/docs/PostgresWarning.astro"; import ShardingCompare from "../../../components/docs/ShardingCompare.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; These Turbopuffer features pass through Layer unchanged when the store is Turbopuffer, in Community Edition and Pro. Layer forwards the request and returns Turbopuffer's response, errors included, so Turbopuffer's documentation is the reference for each one. Each entry below says how to reach the feature through the gateway and what Layer adds, if anything. Branching, sharding, pinning, and recall. | Feature | Request through Layer | Layer adds | | --- | --- | --- | | [Branching](#branching) | `branch_from_namespace` or `copy_from_namespace` on a write | Source read check, embedding profiles, blobs and lineage follow the branch | | [Sharding](#sharding) | `sharding.num_shards` on the first write | Nothing. Not the same as Layer's shards | | [Pinning](#pinning) | `PATCH /v1/namespaces/{ns}/metadata` | Wider scan fan-out on pinned namespaces | | [Recall](#recall) | `POST /v1/namespaces/{ns}/_debug/recall` | Nothing | We checked each request against a gateway on Turbopuffer on 2026-09-27. ## Branching A branch is an instant copy-on-write clone of a namespace. The source and the branch are independent afterwards, with no merge or diff. ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products-staging" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"branch_from_namespace": "products"}' ``` The value is the source namespace's name, or the object `{"source_namespace": "products"}` that turbopuffer-go sends. Layer accepts both and forwards the body unchanged, so the Layer client can send either compatible request shape. `copy_from_namespace` takes the same forms and makes a full, independent copy instead of a copy-on-write clone. The request rules: - **Branch or copy, nothing else.** A body that combines either key with documents (`upsert_rows`, `upsert_columns`, `patch_rows`, `patch_columns`, `deletes`, `delete_by_filter`, `patch_by_filter`), with `schema`, or with the other key gets `400` before anything reaches the store. Branch first, then write. On a store that cannot branch, the store's `422` comes first. - **The destination must be empty.** Turbopuffer enforces this, and its error comes back unchanged. - **The key must be able to read the source.** A branch or copy needs write scope on the target and read scope on the source. Without read on the source the gateway returns `403` naming the source. No key is minted or widened for a branch: a key reaches the branch when its namespace globs match the branch's name, so name branches into a family the key covers (`wt-*`). - **One store.** Source and target must resolve to the same VectorStore, or the gateway returns `422 BranchAcrossStores` naming both stores. To give a family of branches its own store, declare one Index for the family (see below). After Turbopuffer accepts the branch, Layer carries the state it keeps outside the namespace: | State | On a branch | | --- | --- | | Rows, schema, `_hevlayer_*` attributes | Cloned by Turbopuffer. | | Embedding profiles for gateway-served `embed` attributes | Copied, so the branch embeds query text like its source. | | Blobs | A blob set held in turbopuffer is branched too, so the branch owns its bytes. Blobs held in S3 are read from the source's S3 prefix. Either way, rows keep their `blob://products/…` references, and those resolve on the branch. | | Cache and history | Start empty. Any cache, snapshots or history left under the target's name by a deleted namespace are cleared. | | Write-triggered UDFs and Pipelines | Not run. A branch is not a row write. | | Index config | Follows by name, not from the source. | | Pinning, `read_only` | Turbopuffer's rules: `read_only` is inherited, pinning is not. | If a step after the branch fails, the gateway deletes the new branch and returns the error, so a branch is never half made. Layer records the lineage (`products-staging ← products`) in its object store; without `S3_BUCKET` no lineage is recorded, and blob references that name the source namespace are skipped by [cache warm](/docs/pro/api/warm-cache). S3 cannot branch, so a blob in S3 ([the default](/docs/pro/api/blobs#where-the-bytes-live) on turbopuffer with `S3_BUCKET` set, or too large for the store) is read from the source's S3 prefix. Layer never deletes blobs today, so that read stays valid. **Index config for branches.** An Index configures the namespaces its `spec.backend.namespace` names. A trailing `*` makes it a pattern: one Index with `namespace: wt-*` configures every branch named `wt-…`, including its `storeRef`, facets and blob reference attributes. An exact name wins over a pattern, and two overlapping patterns are rejected. A branch with no matching Index runs on gateway defaults. A copy (`copy_from_namespace`) follows the same rules and copies the same state. A copy with `source_api_key` or `source_region` reads a namespace in another organization or region; Turbopuffer authorizes it with the key in the body, the gateway checks only write on the target, and `blob://` references to the source do not resolve in the copy. Postgres and hev search have no native branch. They return `422 UnsupportedByStore: pgvector: branch_from_namespace` (or `search:`), and the gateway never emulates a branch with a copy. Check before branching with `GET /v2/namespaces/{ns}/capabilities`, whose `branch_from_namespace` and `copy_from_namespace` rows say what the namespace's store supports, or see the store capability matrix. Upstream: [turbopuffer.com/docs/branching](https://turbopuffer.com/docs/branching) ## Sharding Turbopuffer can split one namespace across several shards. Set the shard count on the namespace's first write: ```json { "sharding": {"num_shards": 2}, "upsert_rows": [{"id": 1, "vector": [0.1, 0.2, 0.3]}] } ``` `GET /v1/namespaces/{ns}/metadata` then reports `"sharding": {"num_shards": 2}`. The count is fixed after creation: resending the same value on a later write is accepted, and a different value returns Turbopuffer's `400`. This isn't Layer's sharding, and a namespace written through Layer has both kinds of field in its metadata: Upstream: [turbopuffer.com/docs/sharding](https://turbopuffer.com/docs/sharding) ## Pinning Pinning keeps dedicated warm replicas of a namespace, billed separately by Turbopuffer. Turn it on with a metadata patch: ```bash curl -X PATCH "$LAYER_GATEWAY_URL/v1/namespaces/products/metadata" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"pinning": true}' ``` `{"pinning": null}` turns it off. Turbopuffer can refuse a pin when a region has no capacity, and that `400` comes back unchanged. Readiness appears in `GET /v1/namespaces/{ns}/metadata` under `pinning.status.ready_replicas`. Layer reads pinning state to widen origin [scan fan-out](/docs/pro/api/scans#pinning-and-fan-out) on pinned namespaces. Upstream: [turbopuffer.com/docs/pinning](https://turbopuffer.com/docs/pinning) ## Recall The recall endpoint samples ANN queries against a namespace and compares them with exhaustive search, to measure how accurate the index is. ```bash curl -X POST "$LAYER_GATEWAY_URL/v1/namespaces/products/_debug/recall" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"num": 3, "top_k": 2}' ``` ```json {"avg_recall": 1.0, "avg_exhaustive_count": 2.0, "avg_ann_count": 2.0} ``` Turbopuffer bills the samples as queries. Upstream: [turbopuffer.com/docs/recall](https://turbopuffer.com/docs/recall) # Vector stores Source: https://hevlayer.com/docs/pro/api/vectorstores import Edition from "../../../components/docs/Edition.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; import FeatureGate from "../../../components/docs/FeatureGate.astro"; import Callout from "../../../components/docs/Callout.astro"; A `VectorStore` is the gateway's upstream connection. It names the store kind, endpoint, credential Secret, and the inbound auth policy the gateway applies to client requests. An install may define more than one `VectorStore`; each `Index.spec.backend.storeRef` selects which store serves that upstream namespace. ```yaml apiVersion: hevlayer.com/v1alpha1 kind: VectorStore metadata: name: turbopuffer-default namespace: layer spec: kind: turbopuffer default: true endpoint: url: https://aws-us-east-1.turbopuffer.com region: aws-us-east-1 turbopuffer: orgId: org_123 credential: secretRef: name: layer key: turbopuffer-api-key inboundAuth: mode: deriveFromStore ``` ## Connection Apply the `VectorStore` resource to the gateway's Kubernetes namespace. The operator reads upstream credentials from the referenced Secret in that namespace. | Field | Purpose | | --- | --- | | `kind` | The backend engine: `turbopuffer`. | | `default` | Selects the store for namespaces without an explicit store reference. | | `endpoint.url` | Upstream API base URL. | | `endpoint.region` | Operator-visible region label. | | `turbopuffer.orgId` | Optional organization ID for dashboard links. | | `credential.secretRef` | Kubernetes Secret containing the upstream credential. |
Standalone gateway configuration For the standalone gateway, see the [OSS configuration guide](https://github.com/hev/layer/blob/main/docs/setup.md).
## Routing The gateway builds one upstream client per `VectorStore` in the namespace. Requests whose namespace has an `Index` with `spec.backend.storeRef` use that store; other namespaces use the default store. Two `Index` objects cannot resolve to the same upstream namespace. ## Inbound auth `spec.inboundAuth.mode` controls the bearer token the gateway accepts. | Mode | Behavior | | --- | --- | | `deriveFromStore` | Accept the default store's upstream credential or a verified minted `ApiKey`. | | `keys` | Accept independent key Secrets with `read`, `write`, and `admin` scopes. | | `open` | No inbound authentication; use only for explicitly open environments. | Minted [API keys](/docs/pro/api/keys#using-a-minted-key) retain their entitlements, scopes, and namespace restrictions in both `deriveFromStore` and `keys` modes. The gateway uses the selected store's configured credential for minted callers. Unverified minted tokens are rejected rather than passed upstream. For independent inbound keys: ```yaml spec: inboundAuth: mode: keys keys: - name: shop-rw scopes: [read, write] secretRef: name: layer key: layer-inbound-shop-rw-api-key ``` Kubernetes resolves each Secret in the resource's namespace. Operator-managed workers and KEDA use the same Secret through `LAYER_GATEWAY_API_KEY`. In `keys` and `deriveFromStore` modes the gateway also accepts a minted [`ApiKey`](/docs/pro/api/keys) token whose `vectorstore.` entitlement names this store, enforcing its scopes and namespace globs. ## List VectorStores The gateway exposes read-only routes that include Secret reference names and keys, never Secret contents. All routes require a key with `read` scope. Create and edit connections by applying the resource or through the dashboard's apply forms. ```sh curl -H "Authorization: Bearer $LAYER_API_KEY" \ "$LAYER_BASE_URL/v2/vectorstores" ``` ```json { "vectorstores": [ { "name": "prod-turbopuffer", "kind": "turbopuffer", "default": true, "endpoint": { "url": "https://aws-us-east-1.turbopuffer.com", "region": "aws-us-east-1" }, "turbopuffer": { "orgId": "org_123" }, "credential": { "secretRef": { "name": "layer-turbopuffer", "key": "turbopuffer-api-key" } }, "inboundAuth": { "mode": "deriveFromStore" }, "status": { "reachable": true, "observedGeneration": 7, "conditions": [] }, "turbopufferUrl": "https://turbopuffer.com/organizations/org_123" } ] } ``` `GET /v2/vectorstores/{name}` returns one object in the same shape. `turbopufferUrl` is omitted when `spec.turbopuffer.orgId` is not set. ## Status The operator sets `status.reachable` and a `Ready` condition after validating the Secret references and probing `GET /v1/namespaces` on the store endpoint. # Warehouses Source: https://hevlayer.com/docs/pro/api/warehouses import StoreNote from "../../../components/docs/StoreNote.astro"; import Edition from "../../../components/docs/Edition.astro"; A `Warehouse` declares an upstream source system — the system of record pipelines extract rows from, plus the verified reachability and credential shape needed to reach it. Data in Layer is derived from a warehouse and reconstructible from it. The serving side is the [`VectorStore`](/docs/pro/api/vectorstores); the two sit on opposite sides of the gateway. ## Supported Warehouses | Kind | Status | Source | | --- | --- | --- | | `snowflake` | Shipped | Snowflake databases and tables, key-pair credential | | `huggingface` | Shipped | Hugging Face Hub datasets, public or token-gated | | `rss` | Shipped | Public RSS and Atom feeds | | `youtube` | Shipped | Public channels, optional YouTube Data API key | | `clouddrive` | Shipped | Microsoft 365 through Microsoft Graph: SharePoint and OneDrive | | `rest` | Shipped | Any paginated JSON HTTP API | | `databricks` | Reserved | Schema-reserved; rejected by the operator until implemented | | `iceberg` | Reserved | Schema-reserved; rejected by the operator until implemented | Snowflake warehouses hold a key-pair credential. Hugging Face dataset warehouses can be public and credentialless, or can point at a read-token Secret for gated/private datasets. REST warehouses declare a JSON HTTP API — a base URL, optional auth, and a pagination rule — so any paginated JSON API is a source without a bespoke kind. ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Warehouse metadata: name: prod-snowflake namespace: layer spec: kind: snowflake snowflake: account: acme-xy12345 user: SVC_LAYER role: SVC_LAYER_ROLE warehouse: EXTRACT_WH keyPairSecretRef: name: snowflake-rsa pool: size: 5 timeout: 30s verifyInterval: 1h ``` ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Warehouse metadata: name: huggingface-hub namespace: hev-shop spec: kind: huggingface huggingface: endpoint: https://huggingface.co # tokenSecretRef is optional for public datasets. # tokenSecretRef: # name: hf-read # key: token verifyInterval: 1h ``` ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Warehouse metadata: name: openfda namespace: layer spec: kind: rest rest: baseUrl: https://api.fda.gov userAgent: hevlayer/rest-source (+https://hevlayer.com/contact) # auth is optional — omit for open APIs. # auth: # in: query # query | header # name: api_key # the param/header carrying the credential # secretRef: # name: openfda-key # key: token rateLimit: requestsPerSecond: 4 verify: path: /drug/label.json # a cheap GET the operator probes query: limit: "1" verifyInterval: 24h ``` ## Connection `spec.kind` selects the source system. `snowflake`, `huggingface`, and `rest` select a source; `databricks` and `iceberg` are reserved by the schema but rejected by the operator until implemented. Exactly one kind block must be present and match `spec.kind`. `verifyInterval` (default `1h`) sets the probe cadence for any kind. A warehouse is identity and credential — not a catalog. What to read — a Snowflake database and table, a Hugging Face dataset and split — belongs to the [pipeline source](#pipeline-source); one credential reaches many. ### Snowflake | Field | Purpose | | --- | --- | | `kind` | `snowflake`, `huggingface`, `rss`, `youtube`, `rest`, or `clouddrive`. `databricks` and `iceberg` are reserved by the schema but rejected by the operator until implemented. | | `snowflake.account` | Snowflake account identifier. | | `snowflake.user` | Service user the key pair authenticates. | | `snowflake.role` | Optional role assumed on connect. | | `snowflake.warehouse` | Snowflake compute warehouse extraction queries run on. | | `snowflake.keyPairSecretRef` | Secret in the same namespace holding `private-key.pem` and optional `passphrase`. The credential is never stored in the CRD. | | `snowflake.pool` | Connection pool tuning: `size`, `timeout`. | | `huggingface.endpoint` | Hub endpoint. Defaults to `https://huggingface.co`; override for Enterprise Hub or a mirror. | | `huggingface.tokenSecretRef` | Optional Secret in the same namespace holding `token` for gated/private datasets. Omit for public datasets. | | `verifyInterval` | Probe cadence. Defaults to `1h`. | A warehouse is source identity and credential — not a catalog. Which database, schema, table, dataset repo, config, or split to read belongs to the [pipeline source](#pipeline-source); one credential can reach many tables or repos. ### REST / HTTP JSON API A `rest` warehouse declares a JSON HTTP API. The credential is optional — many public APIs need none, or take a key that only raises a rate limit (omit `auth` for the anonymous case, the same `Verified`-with-no-Secret shape as a public Hugging Face dataset). | Field | Purpose | | --- | --- | | `rest.baseUrl` | Required. API origin. A source's `request.path` resolves against it. | | `rest.userAgent` | User-Agent sent by the stock REST source worker. Defaults to `hevlayer/rest-source (+https://hevlayer.com/contact)`; set a project-specific contact URL for public crawls. | | `rest.auth` | Optional. `in` (`query` or `header`), `name` (the param or header carrying the credential), and either `secretRef` (Secret holding a static `token`) or `login` (renewable token). Omit for open APIs. | | `rest.rateLimit` | Optional. `requestsPerSecond` is a client-side request cap, to stay under the API's fair-access ceiling. Omit to leave pacing to the source worker's defaults. | | `rest.verify` | Required. The reachability probe: a `path` and optional `query` the operator `GET`s to verify the API — and the credential, when `auth` is set. | `rest` holds access only; which endpoint, query, pagination, and field mapping to read belongs to the [pipeline source](#rest) — one warehouse serves many endpoints on the same API. ### Login and renewable bearer tokens For APIs that issue short-lived tokens, use `rest.auth.login` instead of `rest.auth.secretRef`. The operator logs in before the verification GET; the stock REST source logs in at startup and renews before a request when the token is within 60 seconds of expiry. A 401 triggers one renewal and one retry. Refresh failure, or no refresh configuration, falls back to a new login. ```yaml rest: baseUrl: https://api.example.com auth: in: header name: Authorization prefix: "Bearer " login: path: /api/account/login body: email: "{{username}}" password: "{{password}}" secretRef: name: api-login tokenPath: $.accessToken expiresInPath: $.expiresIn refresh: path: /api/account/refresh body: refreshToken: "{{refreshToken}}" verify: path: /api/items ``` Create `api-login` in the Warehouse's namespace with `username` and `password` keys. Login and refresh send POST requests with JSON bodies. Login body string values substitute `{{key}}` from Secret keys, including nested objects and arrays; JSON escaping is preserved. `tokenPath` is a JSONPath selecting one nonempty string. Optional `expiresInPath` selects a positive number of seconds. Without it, renewal happens on a 401. Refresh body templates use the login response's top-level `refreshToken`; a refresh response may rotate that token. Login and refresh paths resolve under `baseUrl`. Authenticated requests do not follow redirects. Authentication errors contain neither response bodies nor credentials; a failed operator login sets the Warehouse to `Failed`. Tokens are held in worker memory, while Secret keys remain projected files. Existing static auth is unchanged: `auth.secretRef` supplies the complete header or query value from its `token` key. `prefix` applies only to login tokens. Set exactly one of `auth.login` and `auth.secretRef`. ### RSS and YouTube An `rss` warehouse declares `rss: {}` with an optional `userAgent` (default `hevlayer/rss-source (+https://hevlayer.com)`). A `youtube` warehouse declares `youtube: {}` and optionally `apiKeySecretRef: {name: youtube-key}` inside that block; the Secret holds `api-key`. Public channel RSS and captions need no key. Feed URLs and channel selections belong to the Pipeline source. These kinds verify their declaration and any required credential locally; workers check individual feeds and channels. The YouTube key, when present, is mounted and passed as `apiKeyPath`, never as credential text. ### Cloud drives / Microsoft Graph A `clouddrive` warehouse declares app-only Microsoft 365 access. The only provider is `msgraph`; drive selection belongs to the [Pipeline source](#cloud-drives). ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Warehouse metadata: name: m365 spec: kind: clouddrive clouddrive: provider: msgraph msgraph: tenantId: 00000000-0000-0000-0000-000000000000 clientId: 11111111-1111-1111-1111-111111111111 credentialSecretRef: name: graph-credential rateLimit: requestsPerSecond: 8 concurrentDownloads: 4 verifyInterval: 1h ``` Use a same-namespace Secret containing `certificate.pem` and `private-key.pem` (the default), or `client-secret`. Upload the public certificate to the app registration and grant admin consent for the Graph application permission `Sites.Read.All`. If either certificate key is present, both are required; the operator never falls back to a client secret after a certificate error. The operator exchanges client credentials for a token, signing an RS256 client assertion for certificate authentication, then calls Graph `GET /v1.0/sites/root`. A successful probe reports `Verified` and the token's application permissions in `status.roles`. `Failed` conditions use `AccessDenied` for rejected credentials or missing consent/access grants; `status.failureReason` distinguishes these cases. `Throttled` includes the upstream `Retry-After`, when provided. Transport errors and other upstream failures use `ProbeFailed`. The whole Secret is mounted at `/var/run/hevlayer/warehouse/`. Worker connection JSON in `HEVLAYER_WAREHOUSE` includes `kind`, `provider`, `tenantId`, `clientId`, optional `rateLimit`, and either `certificatePath` plus `privateKeyPath`, or `clientSecretPath`. It contains no credential material. Rotating the Secret re-verifies the warehouse, including changes between credential types. ## Verification The operator probes the warehouse on apply, whenever the referenced Secret's content changes, and every `verifyInterval`. For `snowflake`, the probe opens a key-pair session, runs `SELECT 1` on the declared compute warehouse, and closes. For `huggingface`, the probe calls the Hub dataset API with the optional token. If pipelines reference the warehouse, the operator resolves each declared `repo@revision` and records the resolved commit SHA in `status.sourceRevisions`. With no consumers yet, it verifies that the Hub dataset API is reachable. For `rest`, the probe issues a single `GET` of `rest.verify.path` (with the optional `query`, and `auth` applied when set). Any `2xx` is `Verified`, confirming both reachability and — when a credential is configured — that it is accepted. | Phase | Meaning | | --- | --- | | `Pending` | Not yet probed. | | `Verified` | Last probe succeeded; `status.verifiedAt` is the probe time. | | `Failed` | Last probe failed; `status.failureReason` says why. | `Failed` is a loud signal, not an outage: in-flight pipeline runs keep their connections, new runs refuse to start, and the condition surfaces in `kubectl get warehouse` and the dashboard. Pipelines start only against a `Verified` warehouse. ## Rotation Swap the referenced Secret's content. The operator re-verifies and `status.verifiedAt` advances; consumers resolve credentials through the warehouse at connection-build time, so new connections pick up the new key with no redeploy. Pointing `keyPairSecretRef` or `tokenSecretRef` at a different Secret name is a spec edit with the same flow. For `huggingface`, swapping the token Secret's content re-verifies the same way. Adding or removing `tokenSecretRef` — moving a warehouse between anonymous and authenticated access — is a spec edit the operator re-verifies. ## Pipeline source A pipeline extracting from a warehouse names it in `spec.sourceRef`. The source block owns the *what* — for Snowflake a database, query, and cursor; for Hugging Face a dataset, split, and field mapping; for a cloud drive the sites, libraries, and folders — and the warehouse owns the *where* and *who*. The operator requires `warehouseRef` to name a `Verified` warehouse of the matching kind in the same namespace, and carries the source block verbatim to the worker as `HEVLAYER_SOURCE_REF`, as for [any other source](/docs/pro/api/pipelines#source). ### Snowflake ```yaml spec: sourceRef: kind: snowflake warehouseRef: prod-snowflake database: ANALYTICS query: >- SELECT ID, TITLE, BODY, REFRESH_ID FROM PUBLIC.NOTES WHERE REFRESH_ID > :cursor cursor: column: REFRESH_ID ``` The operator mounts the warehouse's key-pair Secret into the worker pod at `/var/run/hevlayer/warehouse/` and injects `HEVLAYER_WAREHOUSE` — connection JSON resolved from the warehouse spec (account, user, role, compute warehouse, pool), no credential material. The worker builds its own connection from the two. ### Hugging Face ```yaml spec: target: namespace: squad sourceRef: kind: huggingface warehouseRef: hf-public dataset: rajpurkar/squad # Hub repo id config: plain_text # dataset config; omit for the default split: train # train | validation | test | … revision: ~ # omit → the operator resolves and records the parquet-ref commit mapping: id: id # column → document id text: context # column to index or embed attributes: [title, question] # columns to carry as attributes ``` | `mapping` field | Purpose | | --- | --- | | `text` | Required. The column indexed, and embedded by a following stage. | | `id` | Optional. Column used as the document id. Omitted, the worker synthesizes `{config}/{split}#{offset}` — stable within a revision; name a natural key for anything long-lived. | | `attributes` | Optional. Columns carried as attributes. Omit or `[]` for every remaining scalar column; binary feature columns (image, audio) are skipped. | Set `spec.worker.image` to the stock Hugging Face source image from the mesh-account ECR registry, so no custom code is needed, or to your own image. The operator mounts the warehouse's token Secret (when present) into the worker and injects `HEVLAYER_WAREHOUSE` with the endpoint only, no token. The worker streams rows from the dataset's Parquet conversion at the pinned `revision`; the row offset is the cursor. Omitting `revision` pins to the dataset's current parquet-ref commit, which the operator resolves and records, so a long run never drifts onto a newer version mid-flight and re-enumeration is exact. Reading a newer version is a deliberate `revision` edit. A dataset's `text` column is often a whole document that must be split into chunks before it is embedded. Add a `chunk` block to the source to declare how — it is a pipeline-source feature, not specific to Hugging Face. See [Chunking](/docs/pro/api/pipelines#chunking) on the Pipelines page. ```yaml spec: sourceRef: kind: huggingface warehouseRef: huggingface-hub repo: McAuley-Lab/Amazon-Reviews-2023 config: raw_meta_Electronics split: train revision: main cursor: field: parent_asin ``` When `sourceRef.kind` is `huggingface`, the operator requires `warehouseRef` to name a `Verified` Hugging Face warehouse in the same namespace and requires `repo` on the source block. It injects `HEVLAYER_WAREHOUSE` with the Hub endpoint and, when `tokenSecretRef` is set, mounts the Secret at `/var/run/hevlayer/warehouse/token` and adds `tokenPath` to the connection JSON. `HEVLAYER_SOURCE_REF` remains the verbatim source block and owns the dataset repo, config, split, revision, and cursor. ### REST ```yaml spec: target: namespace: drug-labels sourceRef: kind: rest warehouseRef: openfda request: path: /drug/label.json query: # static query params search: 'openfda.product_type:"HUMAN PRESCRIPTION DRUG"' pagination: kind: offset pageSizeParam: limit # required for offset offsetParam: skip # required for offset pageSize: 1000 # required for offset; must be > 0 response: items: $.results # JSONPath to the record array on each page cursor: field: effective_time # incremental + re-enumeration key (JSONPath per item) mapping: id: openfda.spl_set_id # JSONPath into each item text: ~ # omit when a chunk strategy supplies the section text attributes: [openfda.generic_name, openfda.brand_name, dea_schedule] ``` | Source field | Purpose | | --- | --- | | `request.path` / `request.query` | The endpoint (resolved against `baseUrl`) and any static query params — the *what*. | | `pagination` | How to walk pages. `offset` requires `pageSizeParam`, `offsetParam`, and a positive `pageSize`. `page` requires `pageParam`, `pageSizeParam`, and a positive `pageSize`; `startPage` defaults to 1 (0 is allowed). `searchAfter` requires `cursorParam`, `cursorPath`, and a positive `pageSize`; `pageSizeParam` is optional. `hasMorePath` optionally stops `searchAfter` enumeration when it resolves to `false`. | | `response.items` | JSONPath to the record array on each page; `$` selects a bare top-level array. | | `cursor.field` | JSONPath into each item, used as the incremental refresh key and the re-enumeration cursor. | | `mapping` | JSONPath expressions for `id`, `text`, and `attributes` — the same mapping shape as Hugging Face, over JSON instead of Parquet columns. | Both `offset` and `page` can omit `request.forEach` to enumerate one partition. For example, this source reads a bare JSON array using page numbers: ```yaml sourceRef: kind: rest warehouseRef: orders request: path: /api/orders pagination: kind: page pageParam: PageNumber pageSizeParam: PageSize pageSize: 1000 startPage: 1 # optional; use 0 for zero-based APIs allowEmptySnapshot: false # optional; fail closed by default response: items: "$" cursor: field: updated_at mapping: id: "{{id}}" ``` Page numbers increment by one; offsets increment by `pageSize` from zero. Both stop on a short or empty page. An empty first page fails unless `pagination.allowEmptySnapshot: true` explicitly permits an empty snapshot. Enable it only when an empty response reliably means the source has no rows: a successful empty full snapshot applies `snapshot.absentRow.set` to all previously present rows in that partition. A failed snapshot leaves rows and the manifest intact. Without `forEach`, `snapshot.scope: forEachValue` covers the single implicit partition. For one origin with an editorial set of paths, add `request.forEach`. The default value source is a ConfigMap in the Pipeline's namespace. `select` runs against the JSON stored at `ref.key`; each selected string is bound to `as`, substituted in `request.path` and `request.query`, and remains available while mapping rows. ```yaml spec: sourceRef: kind: rest warehouseRef: greenhouse request: forEach: values: from: configMap ref: { name: roles-companies, key: companies.json } select: "$[?@.ats == 'greenhouse'].token" as: token path: /v1/boards/{{token}}/jobs query: { content: "true" } pagination: kind: offset pageSizeParam: per_page offsetParam: page pageSize: 500 response: { items: $.jobs } cursor: { field: updated_at } snapshot: mode: full scope: forEachValue absentRow: set: { open: false, closed_at: "{{runStartedAt}}" } mapping: id: "{{token}}:{{id}}" text: "{{title}}\n{{content}}" attributes: title: title company: "{{token}}" ats: "{{warehouseRef}}" department: departments[*].name posted_at: first_published open: true ``` To take the values from the API itself instead of a ConfigMap, set `values.from: api`. The worker pages a list endpoint of the same Warehouse (same auth, rate limit and robots rules) and runs `select`, a JSONPath applied to each list item (default `$`), to get one id per item. The endpoint is read again at the start of every sweep, so ids that appear upstream become partitions on the next sweep with no ConfigMap edit. ```yaml request: forEach: values: from: api path: /api/contracts query: { status: active } # optional items: $.data # where the list is in each response select: $.contract.id # id within each item pagination: # optional; defaults to the source's own kind: page pageParam: PageNumber pageSizeParam: PageSize pageSize: 100 totalsHeader: X-Pagination as: contract path: /api/contracts/{{contract}}/jobs ``` For two-level fan-out, nest a `forEach`. The inner `values.path` and `query` may use the outer binding. Every (outer, inner) pair is one partition, with both bindings available to `path`, `query`, `body` and `mapping`. A nested partition is named by a JSON array of its values, such as `["C1","A"]`, in snapshot manifests and sweep reports. ```yaml request: forEach: values: { from: api, path: /api/contracts, items: $.data, select: $.id } as: contract forEach: values: from: api path: /api/contracts/{{contract}}/codes items: $.data select: $.code pagination: { kind: page, pageParam: p, pageSizeParam: s, pageSize: 100, allowEmptySnapshot: true } # a contract may have no codes as: code path: /api/contracts/{{contract}}/codes/{{code}}/jobs ``` Id list endpoints fail closed. If any list request errors, returns a non-2xx status, or reads short of its advertised total, the sweep stops before reading any partition: no row is written or tombstoned and the sweep report records the error. An empty list at any level is also a failure unless that level's `pagination.allowEmptySnapshot` is `true`. Only the outermost level may use `from: configMap`. A partition that stops being listed is no longer swept; its existing rows are left as they were. `mapping.attributes` accepts the existing list form or a name-to-expression object. Object values may be JSONPath expressions, template strings, or non-string JSON literals. `{{token}}` is the active `forEach` value; `{{warehouseRef}}` and `{{runStartedAt}}` are worker-provided values. To index a number returned as a string, use an explicit cast in the attribute object. The cast resolves `path` as a JSONPath or template, then converts a numeric string or JSON number to a finite float. Missing and null values omit the attribute. Invalid strings, booleans, arrays, objects and non-finite numbers fail the partition before any row or snapshot is written; cast errors exclude the source value. Objects without `cast` remain literal attributes. ```yaml mapping: id: "{{id}}" attributes: ki_estimate_total: { path: $.total, cast: float } schema: ki_estimate_total: { type: float, filterable: true } ``` `float` is the supported cast. A cast object requires exactly `path` and `cast`. Numeric MCP filters use `{"ki_estimate_total":{"min":100,"max":200}}`, which becomes inclusive `Gte` and `Lte` predicates in both search and aggregate. Declare the numeric field in the target schema; if the MCP namespace has an explicit filter allowlist, add the field there too. List/detail APIs can use nested API fan-out: enumerate projects, enumerate estimate ids per project, then fetch `/api/estimates/{{estimate}}` as the partition request. Use `response.items: $` for a detail object (or the path to its wrapped object) and a page size greater than one with page/offset pagination to finish after that single object. Both bindings remain available in mappings, so the row can preserve its lead id alongside the estimate id. All calls share the Warehouse auth and rate limit. This shape fetches every listed detail on every sweep; unchanged mapped rows avoid replacement writes. A failed detail request leaves that partition's previous rows and snapshot intact. Removed fan-out partitions retain their rows; this is not a global absence reconciliation. For an ordered incremental projection, omit `request.forEach` and the full-snapshot block. This Travels with Charlie source follows `{data, next_cursor, has_more}` pages and sends the stored cursor back as `since`: ```yaml spec: target: namespace: family-messages sourceRef: kind: rest warehouseRef: travels-with-charlie request: path: /api/layer/messages pagination: kind: searchAfter cursorParam: since cursorPath: $.next_cursor hasMorePath: $.has_more pageSizeParam: limit pageSize: 100 response: items: $.data cursor: field: $.received_at mapping: id: $.id text: $.text attributes: [received_at, sender, subject] ``` The first run starts without `since` and follows echoed cursors until `next_cursor` is absent or null, or `has_more` is false. After successful row writes, the last cursor becomes the stream's S3-backed high-water mark. The next refresh resumes from it, so previously committed rows are not emitted again. A non-advancing cursor while `has_more` remains true fails the stream; the existing 10,000-page guard also remains in force. `snapshot.mode: full` means each selected value is a complete diff partition. After a partition has been read through its final page, rows from its prior S3 manifest that are absent now receive `absentRow.set`; the new manifest commits only after every row write succeeds. A transport error, non-2xx response, or an empty first page (unless `pagination.allowEmptySnapshot: true`) leaves that partition's manifest and rows intact. This failure rule is deliberate: an unavailable or renamed endpoint is not evidence that all of its rows disappeared. #### Totals in a header Some APIs return a bare array and report totals in a response header. KI's `X-Pagination` carries JSON such as `{"TotalCount":2301,"PageSize":500,"TotalPages":5,"HasNext":true}`. Name the header with `pagination.totalsHeader` on an `offset` or `page` source: ```yaml pagination: kind: page pageParam: PageNumber pageSizeParam: PageSize pageSize: 500 totalsHeader: X-Pagination response: items: "$" ``` With the header set, the walk ends on `HasNext: false`, on `TotalPages`, or when `TotalCount` is reached, instead of probing for a short page. Keys are matched case-insensitively and any one of the three is enough. The header also makes the snapshot checkable: if the rows read do not add up to `TotalCount`, the partition fails and nothing is tombstoned. A missing or malformed header fails the partition too. `TotalCount: 0` counts as an empty snapshot without `allowEmptySnapshot`. #### Staying in sync A REST source reads once per pod start by default. Set `sync.interval` to keep it running: ```yaml sourceRef: kind: rest warehouseRef: orders sync: interval: 15m # 60s minimum; units s, m, h, d ``` The worker then re-enumerates on a loop, with the interval measured from the end of the previous sweep, so a slow sweep never overlaps the next. Each sweep is a full enumeration of every partition: - **Adds** are rows with an id not in the previous manifest. - **Edits** are detected by content hash of the mapped row, so an endpoint without a modified-since cursor still notices changes. An unchanged row is not rewritten, which keeps attributes that Functions have added to it. - **Deletes** are rows the API no longer returns. They receive `snapshot.absentRow.set`, as with any full snapshot, and are restored if they come back. - **Failures** leave rows and manifests alone and the loop continues. A login token that expires mid-sweep is renewed once and the request retried. The worker is read-only toward the API: it sends only `GET` requests. It writes only to the target namespace through the gateway. Do not combine `sync` with `spec.schedule`; the worker exits with an error. Use `scaling.mode: fixed` with one replica so a single worker owns the manifests. Each sweep overwrites `pipelines/{pipelineId}/source-snapshots/rest/report.json` in the snapshot bucket, like the [cloud drive report](#reports): | Field | Contents | | --- | --- | | `startedAt`, `finishedAt`, `elapsedMs` | When the last sweep ran and how long it took. | | `status` | `succeeded`, `partial` (some partitions failed), or `failed`. | | `intervalSeconds` | The configured `sync.interval`. | | `rows`, `upserted`, `refreshed`, `tombstoned` | Rows present, written, touched on volatile fields only, and tombstoned by the sweep. | | `partitions` | One entry per partition with the same counts, `status` and `error`. | | `errors` | Every error message from the sweep. | | `lastSuccessAt`, `consecutiveFailures` | The last fully successful sweep, and failed sweeps since. | Set `spec.worker.image` to the stock REST source image from the mesh-account ECR registry, which pages any JSON API by these rules, so there is no per-API worker. The operator injects `HEVLAYER_WAREHOUSE` with the `baseUrl` (and mounts the `auth` Secret when set); `HEVLAYER_SOURCE_REF` is the verbatim source block. A `text` field that is a whole document is split by a [`chunk`](/docs/pro/api/pipelines#chunking) block, as for any source; the `section` strategy with `sectionSource: jsonFields` makes each top-level JSON field its own section. ### Cloud drives A `clouddrive` source reads SharePoint document libraries and OneDrive for Business drives through a [`clouddrive` warehouse](#cloud-drives--microsoft-graph). The stock `clouddrive-source` worker writes one metadata row per file and never downloads file contents. PDFs and photos from the same drives are read by the content workers described [below](#pdfs-from-a-drive). ```yaml spec: target: namespace: m365-files sourceRef: kind: clouddrive warehouseRef: m365 drives: sites: - site: contoso.sharepoint.com:/sites/Projects library: Documents root: /Contracts # omit for the whole library personal: mode: none # none | allowlist | all include: mimeTypes: [application/pdf, "image/*"] maxBytes: 536870912 mapping: id: id version: file.hashes.quickXorHash schedule: cron: "0 * * * *" leaseSeconds: 3300 worker: image: .dkr.ecr.us-east-1.amazonaws.com/clouddrive-source: scaling: replicas: min: 0 max: 1 ``` | Source field | Default | Purpose | | --- | --- | --- | | `warehouseRef` | required | A `Verified` `clouddrive` warehouse in the same namespace. | | `drives.sites[].site` | required | The SharePoint site as a Graph site address, `hostname:/sites/Name`. The worker resolves it with Graph `GET /sites/{site}`. | | `drives.sites[].library` | required | The document library's name. It must match exactly one drive on the site. Declare each site and library pair once. | | `drives.sites[].root` | the whole library | An absolute folder path in the library, such as `/Contracts`. Only files at or under it become rows. `.` and `..` segments are rejected. | | `drives.sites[].roots` | none | More absolute folders of this same library, selected next to `root`; a file is kept when it sits at or under any of them. Use it when the wanted folders are not under one parent, instead of declaring the library twice or widening to the whole library. They never apply to another library, unlike `include.paths`. Matching is case-insensitive. Adding a root re-walks that library once; settled content is not downloaded or parsed again. | | `drives.personal.mode` | `none` | OneDrive for Business drives to read: `none`, `allowlist`, or `all`. | | `drives.personal.users` | `[]` | User principal names to read. Required with `allowlist` and rejected with the other modes. | | `drives.personal.exclude` | `[]` | User principal names to skip. Accepted only with `all`. | | `include.mimeTypes` | every type | MIME types to keep, as `type/subtype` or `type/*`. | | `include.maxBytes` | no limit | Files larger than this are skipped. Must be positive. | | `include.paths` | whole drive root | Folder prefixes, absolute drive paths such as `/Scans`. List several to select several folders of one library. Matching is case-insensitive, as in SharePoint. A file is kept when it sits at or under any of them. Others are counted as `outsidePaths`. | | `exclude.items` | none | Exact items to leave out of the selected set, as `[{driveId, itemId}]` pairs. Reaches single files inside a folder you otherwise keep. Counted as `excluded` only when every other filter selected the file, so `items = rows + skipped` shows what the list removed. A file already published and later excluded loses its rows at the next reconcile; add only items you intend to remove. Keep the ids in the live resource, not in git. | | `exclude.itemsFile` | none | An absolute path to a mounted file of more items to leave out, one ` ` per line (`#` comments and blank lines ignored). Read at the start of every sync, so it is not limited by the resource size and keeps ids out of the resource. An unreadable or malformed file fails that drive's sync. | | `include.items` | none | Exact `[{driveId, itemId}]` files to select, even outside `root`, `roots` and `include.paths`. Size, MIME and `exclude` still apply. | | `include.itemsFile` | none | Mounted file of more exact items, same format as `exclude.itemsFile`. | | `include.itemsOnly` | `false` | Select exactly `include.items` and `include.itemsFile` and nothing else: the folder rules are not applied, so no sibling in a large folder is admitted. Requires a set. The library is still walked; only the listed files become rows. Skipped files are counted as `notInItems`. | | `retention.deselected` | `delete` | What happens to a row already published when selection narrows (a dropped root, `itemsOnly`, an `exclude`). `delete` removes it at the next reconcile. `keep` keeps the row, and the documents built from it, while the file still exists in the drive; they are counted as `retained`, and the row is marked `clouddriveRetained: true` so consumers preserve it without doing new work (the extractor lists it, keeps its published pages and manifest state, and never downloads, parses, OCRs, stages or embeds it; a never-published retained file stays unprocessed). A file deleted from the drive, or without a content hash, is removed either way. New files are always judged by the current rules. | | `mapping.id` | required | Must be `id`. Rows are keyed by the Graph item id. | | `mapping.version` | required | Must be `file.hashes.quickXorHash`. The content version is Graph's quickXorHash. | | `mapping.attributes` | `[]` | Names from the [metadata row](#metadata-rows) list: `name`, `path`, `folder`, `mime`, `size`, `modifiedAt`, `webUrl`, `folderUrl`, `version`, `drive`, `fetch`, `provider`. Other names are rejected. The stock worker writes every metadata attribute whatever the list holds. | The source needs at least one site or a personal mode other than `none`, and unknown fields are rejected. Personal drives are found through Graph `GET /sites/getAllSites`, never by listing users, and personal drives take no `root`. An allowlisted user with no personal site fails the run. The operator validates the block before it creates the worker and reports a problem on the Pipeline's `Ready` condition: | Reason | Cause | | --- | --- | | `InvalidCloudDriveSource` | The source block fails the rules above. | | `InvalidCloudDriveScaling` | `scaling.replicas.max` is not `1`, or `min` is above `1`. Each drive's delta cursor has one writer. | | `InsufficientGraphRoles` | A personal mode other than `none`, without `Sites.Read.All` or `Sites.ReadWrite.All` in the warehouse's `status.roles`. | | `WarehouseNotVerified` | The warehouse is not `Verified`, or its credential Secret changed and has not been verified again. | | `CredentialNotFound` | The warehouse's credential Secret cannot be read. | The operator mounts the warehouse's credential Secret, injects `HEVLAYER_WAREHOUSE` as described under [Cloud drives / Microsoft Graph](#cloud-drives--microsoft-graph), and rolls the worker Deployment with the `Recreate` strategy so two pods never share a cursor. Set `spec.worker.image` to the `clouddrive-source` image in the mesh-account ECR registry. The worker keeps its state in the chart's snapshot bucket and exits at startup when the operator injects none. The worker sweeps every selected drive once when its pod starts, then idles. For periodic sync, set [`spec.schedule`](/docs/pro/api/pipelines#schedule) with `scaling.replicas.min: 0`; the operator starts the pod again at each cron window. The window lasts `leaseSeconds / 60 + 1` minutes, clamped between 5 and 55, so give it enough time for the first full enumeration. #### Metadata rows The worker writes one row per selected file to `spec.target.namespace` through the gateway. Rows have no vector, and folders are not rows. Use a dedicated target namespace for each Pipeline. | Attribute | Contents | | --- | --- | | `id` | The Graph drive item id. | | `name` | The file name. Full-text searchable. | | `path` | The path from the library root, such as `/Contracts/2026/lease.pdf`. Full-text searchable. | | `folder` | The parent folder's path, such as `/Contracts/2026`. Empty at the library root. | | `mime` | Graph's `file.mimeType`. | | `size` | The size in bytes. | | `version` | `quickXor:` followed by Graph's quickXorHash. It changes when the bytes change. | | `modifiedAt` | Graph's `lastModifiedDateTime`. | | `webUrl` | The file's URL, verbatim from Graph's delta feed. For some files this is an Office viewer URL rather than a direct file URL. | | `folderUrl` | The parent folder's URL, verbatim from Graph. | | `drive` | JSON string naming the drive: `{"kind":"site","site":…,"library":…}` or `{"kind":"personal","site":…}`. | | `fetch` | JSON string with `provider` (`msgraph`), `driveId`, and `itemId`. | | `provider` | JSON string with Graph's `image` and `photo` facets, `null` when absent. | | `clouddrivePipeline`, `clouddriveDriveId`, `clouddriveGeneration` | Scope deletes and reconciliation to this Pipeline and drive. | `drive`, `fetch`, and `provider` are strings because some stores reject object attributes. Decode them as JSON when reading. Links always come from Graph; the worker never builds a URL from a path. Items that do not become rows are counted by reason: `folderOrNonFile`, `outsideRoot`, `outsidePaths`, `maxBytes`, `mimeType`, and `missingQuickXorHash` (a file with no provider hash). #### Delta sync and deletes Each drive has a manifest in the snapshot bucket at `pipelines/{pipelineId}/source-snapshots/clouddrive/{driveId}.json`. It holds the Graph delta link, the drive's item tree, and a fingerprint of each row. - **First run.** The worker walks the drive's whole `root/delta` feed. `root` filters rows but does not narrow the walk, so the whole library is enumerated. - **Later runs.** The worker resumes from the saved delta link and reads only changes. It rewrites only rows whose attributes changed, so an unchanged run writes nothing. - **Full walks.** A drive is walked again from the start when Graph answers the delta link with `410 Gone`, when `warehouseRef`, `drives`, `include`, `mapping`, or the target namespace changes, or when the previous run stopped between its writes and its cursor commit. - **Deletes.** A deleted file loses its row. A deleted folder removes the rows under it, except files moved out of it in the same delta feed. A file that stops matching `root` or `include` loses its row. After a full walk, the drive's rows from any older walk are deleted. - **Moves and renames.** A moved or renamed file gets its new `path`, `folder`, `webUrl`, and `folderUrl`. Graph's delta feed leaves out the contents of a renamed folder, so the worker fetches them again from Graph to keep their links current. - **Commit order.** Writes and deletes finish before the delta link is saved. Before its first write, the manifest records that a full walk is needed; a pod that stops mid-write is followed by a full walk that reconciles rows by generation. A drive that answers `423 Locked` is reported as `locked` and does not fail the sweep. Any other drive failure, or a delta chain that does not reach its final link, reports the drive as `failed` and leaves its cursor where it was. The other drives still sync, and the worker exits with an error after writing the report. A site or library that does not resolve fails the whole run. #### Rate limits Graph calls follow the warehouse's `rateLimit`. `requestsPerSecond` (default `8`) spaces every request from the worker, token requests included. `concurrentDownloads` (default `4`) limits content downloads by the content workers. The budget is per worker pod, so Pipelines that share a warehouse each get their own. On `429` or `503`, the worker waits for `Retry-After`, in seconds or as an HTTP date. Without one, it waits 1, 2, 4, 8, and 16 seconds. The wait applies to every drive in the sweep. After five retries the request fails. #### Reports Each sweep writes a report to `pipelines/{pipelineId}/source-snapshots/clouddrive/report.json` in the snapshot bucket, with `throttles` (throttled responses), `elapsedMs`, and one entry per drive: | Field | Contents | | --- | --- | | `driveId`, `driveNumber` | The drive and its position in the sweep. | | `status`, `error` | `succeeded`, `locked`, or `failed`, and the error for a drive that did not succeed. | | `pages`, `deltaItems` | Delta pages read, and delta entries including repeats. | | `items` | Items in the drive's current tree, folders included. | | `rows`, `upserted`, `deleted` | Current rows, and rows written and deleted by this sweep. | | `skipped` | Items that are not rows, by reason. | | `lastFullWalk` | When the drive was last walked in full. | | `bytesDownloaded` | Always `0` for the metadata worker. | For a completed drive, `items` equals `rows` plus the skipped counts. Logs carry counts only, never file names, links, or credentials. In an idle worker pod, `clouddrive-source --once` runs another sweep, `--reset-cursors` clears every drive's delta link so the next sweep walks in full, and `--verify` checks each drive's row count against its manifest and compares eight sampled `webUrl` and `folderUrl` values with Graph. Run these one at a time, never during a sweep. #### PDFs from a drive The [`layer-extract`](/docs/pro/pdf-extraction) extract stage reads a `clouddrive` source directly: it lists the drives through Graph and downloads each PDF, instead of listing an object-store prefix. Page rows, the write stage, failure reasons, and the sweep report work as on [PDF extraction](/docs/pro/pdf-extraction); this section covers what differs. ```yaml spec: pipelineId: contracts target: namespace: contracts-pages sourceRef: kind: clouddrive warehouseRef: m365 dedupe: content # content (default) | item drives: sites: - site: contoso.sharepoint.com:/sites/Projects library: Documents root: /Contracts include: mimeTypes: [application/pdf] mapping: id: id version: file.hashes.quickXorHash parse: timeoutSeconds: 120 rescanSeconds: 3600 worker: image: .dkr.ecr.us-east-1.amazonaws.com/mesh:layer-extract- scaling: mode: fixed replicas: min: 1 max: 1 ``` The drive fields are the ones in the [table above](#cloud-drives). `include.mimeTypes` defaults to `[application/pdf]`. To read scanned images as documents, list the image types in `extract.imageMimeTypes` (see [Scanned images](/docs/pro/pdf-extraction#scanned-images-from-a-drive)); `include.mimeTypes` then accepts `application/pdf` and those types only. Use `include.paths` to say which folders hold scans. To read Word, Excel and PowerPoint files (`.docx`, `.xlsx`, `.pptx`, and the legacy `.doc`, `.xls`, `.ppt`), list their MIME types in `include.mimeTypes`. Any non-PDF document type turns on [conversion to PDF](/docs/pro/pdf-extraction#office-text-and-document-images) inside the extract child, so pages, `#page={n}` links, `locations` and OCR work as they do for PDFs, and sets `dedupe: item`. Image types listed in `extract.imageMimeTypes` are converted the same way alongside them. Any other type is rejected when the worker starts. `parse`, `cacheUri`, and `rescanSeconds` mean what they do for an [`objects` source](/docs/pro/pdf-extraction#source); `uri` and `suffixes` are ignored. Run the write stage (`args: [write]`) beside it with the same `pipelineId`. The extract stage keeps its own drive listing in its cache location and does not need a metadata Pipeline. | `dedupe` | Documents | | --- | --- | | `content` | One document per quickXorHash. Copies of a file anywhere in the selected drives are downloaded, parsed, staged, and embedded once. The document id is the hash as 40 hex characters, so page ids are `{hash}#p{n}`. | | `item` | One document per drive item. The document id is the first 32 hex characters of the sha256 of `clouddrive://{warehouseRef}/{driveId}/{itemId}`. | Content state lives under the cache location in a directory keyed to the gateway, the target namespace, and the `dedupe` mode. Changing any of them starts from empty state. Page rows carry the drive's copies of the document instead of an object key: | Attribute | Contents | | --- | --- | | `locations` | JSON string listing every copy, each with `drive`, `path`, `webUrl`, and `folderUrl`. Not filterable. | | `path`, `name` | The first copy's path from the library root, and its file name. | | `source_modified` | The first copy's Graph modification timestamp; cleared when the timestamp is absent. | | `webUrl`, `folderUrl` | The first copy's file and parent folder URLs, verbatim from Graph. | | `pdf`, `source_uri` | The first copy's `webUrl` with `#page={n}`. | Copies are ordered by drive id, then item id, so the first copy is stable from run to run. - **Unchanged content** is not downloaded, parsed, or embedded again. A second sweep over an unchanged drive reports `bytes_downloaded: 0` and `parsed: 0`. - **Moves, renames, and added or removed copies** patch `locations`, `path`, `name`, `webUrl`, `folderUrl`, `pdf`, `source_uri`, and `source_modified` on the existing page rows. Nothing is downloaded, parsed, or embedded. - **Deleting the last copy** deletes the document's page rows. - **Editing a file** gives it a new quickXorHash, so it becomes a new document. The old document's pages are deleted once no copy has the old hash. After the first sweep, a sweep starts only when every document in the pipeline is `indexed`. While the write or embed stage still has work, the sweep fails and runs again after the worker restarts, so an embed in flight cannot write back an old location. A complete drive listing is authoritative: unlike an `objects` prefix, a listing with no PDFs left deletes their pages. A locked or failed drive fails the sweep before anything is deleted. After each download the worker reads the item again; if its hash or size changed, the document fails with `source_error` and is retried on the next sweep. Two items with the same hash and different sizes fail the sweep before any download. The sweep report adds `unique_contents`, `bytes_downloaded`, `bytes_converted` and `locations_updated`; `documents` counts drive items. Reports and logs carry counts only, without document keys or links. #### Photos from a drive The stock `clouddrive-images` worker embeds photos from a `clouddrive` source. It syncs the selected drives as the metadata worker does, groups image files by quickXorHash, embeds each distinct image with CLIP, and writes one row per content to the target namespace. ```yaml spec: target: namespace: project-photos sourceRef: kind: clouddrive warehouseRef: m365 drives: sites: - site: contoso.sharepoint.com:/sites/Projects library: Documents root: /Photos include: mimeTypes: ["image/*"] maxBytes: 536870912 mapping: id: id version: file.hashes.quickXorHash schedule: cron: "0 * * * *" leaseSeconds: 3300 worker: image: .dkr.ecr.us-east-1.amazonaws.com/clouddrive-images: podSpec: containers: - name: worker resources: requests: {cpu: "2", memory: 4Gi} limits: {cpu: "4", memory: 8Gi} scaling: replicas: min: 0 max: 1 ``` `include.mimeTypes` defaults to `["image/*"]`, and every entry must be an `image/` type. `include.maxBytes` also caps each download, at 512 MiB when unset. `dedupe` may be omitted or `content`; `item` is rejected. The worker exits at startup without a gateway key (`LAYER_GATEWAY_API_KEY`) or a snapshot bucket. Like the metadata worker, it runs once per pod start, so use `spec.schedule` for periodic runs. The model is `openai/clip-vit-base-patch32` at a pinned revision, with the weights in the image, so the worker needs no network access to Hugging Face. CLIP embeds the full decoded image after applying its EXIF orientation. A separate serving copy is re-encoded as JPEG, at most 2048 pixels on its longest side and under 6 MiB, and stored with the [blob API](/docs/pro/api/blobs). Images over 256 megapixels are rejected. Above about 89 megapixels, a JPEG is decoded at reduced resolution, and other formats are rejected. | Attribute | Contents | | --- | --- | | `id` | The sha256 of `version`, as hex. One row per distinct content. | | `embed_image_url` | The normalized 512-dimensional CLIP vector, indexed for cosine distance. | | `image_blob` | The blob reference for the serving JPEG. | | `clip_model` | `openai/clip-vit-base-patch32`. | | `locations` | JSON string listing every copy, each with `drive`, `driveId`, `itemId`, `path`, `webUrl`, and `folderUrl`. Not filterable. | | `name`, `path`, `folder`, `mime`, `size`, `version`, `modifiedAt`, `webUrl`, `folderUrl` | The first copy's [metadata](#metadata-rows). Copies are ordered by drive id, then item id. | | `provider` | JSON string with the first copy's Graph `image` and `photo` facets. Not filterable. | | `photo.`, `image.` | Each scalar field of those facets, such as `photo.takenDateTime`, `photo.cameraModel`, or `image.width`. Numbers are floats, except `image.width` and `image.height`, which are integers. | | `clouddrivePipeline`, `clouddriveGeneration` | Scope reconciliation to this Pipeline and run. | Each run first syncs metadata into a durable stage, which commits before each drive's cursor. The rest of the run needs a complete listing: if any drive is locked or failed, the run stops and existing rows stay. For each content without a cached result, the worker downloads one copy, reads the item again to confirm its hash, runs CLIP, uploads the serving JPEG, and caches the vector and blob reference in the snapshot bucket. It then writes every content row in batches of 100 under a new generation, deletes this Pipeline's rows from older generations, and reads back every expected id and the row count. - **Unchanged content** is not downloaded again. An unchanged rerun downloads nothing and uploads no blobs, but still rewrites each row. - **Moves, renames, and new copies** rewrite the row from the cached result. - **Deleting the last copy** leaves the content out of the new generation, so the cleanup deletes its row. - **A content that fails** (a download, decode, or gateway error) fails the run. Cached results survive, so the next run downloads only what is missing. State lives in the snapshot bucket under `pipelines/{pipelineId}/image-content/{targetHash}/v1/`, where `targetHash` identifies the gateway and target namespace: `metadata.json` is the metadata stage, `artifacts/` holds the cached vectors and blob references, and `report.json` holds the run's aggregates. A new target starts with no cached results. When the metadata stage is missing, the worker clears the drive cursors and walks every drive again. | Report field | Contents | | --- | --- | | `items` | Selected image files. | | `uniqueContents`, `rows` | Distinct contents, and rows written. | | `bytesDownloaded`, `blobsWritten` | Work done by this run. | | `resizedContents` | Contents whose serving JPEG has smaller dimensions than the source image. | | `likelyNearDuplicatePairs` | Pairs of distinct contents whose 64-bit difference hashes differ by at most four bits. The worker counts them and never merges them. | | Variable | Default | Purpose | | --- | --- | --- | | `CLIP_THREADS` | `4` | Torch threads per CLIP process. | | `CLIP_WORKERS` | `1` | CLIP processes, each loading its own model. At most 4, and at most the warehouse's `concurrentDownloads`. Keep workers times threads within the pod's CPU limit. | | `CLIP_DEVICE` | `cpu` | Torch device, for an image that supplies that runtime. | Downloads and processing run up to the warehouse's `concurrentDownloads` at a time. In an idle pod, `clouddrive-images --once` runs another pass, and `--reset-cursors` walks every drive in full before reconciling content. To search photos with text, rank by `embed_image_url` with an inline `Embed` on the same CLIP model. The gateway embeds the query with CLIP's text tower, so it must have its [local CLIP model](/docs/pro/api/embed#images) configured: ```json {"rank_by": ["embed_image_url", "ANN", ["Embed", "kitchen cabinets", {"model": "openai/clip-vit-base-patch32"}]], "top_k": 10} ``` The worker declares `image_url` with a local image-modality CLIP profile (`dims: 512`) and supplies each vector itself, so the gateway doesn't fetch the image again. The gateway must be a release that accepts precomputed vectors on a local image profile. ## Keys An [`ApiKey`](/docs/pro/api/keys) binds to a warehouse with a `warehouse.` entitlement carrying a list of opaque claims strings. Layer stores and echoes the strings; the application routes on them. No client route reaches a source system — clients touch indexes, not warehouses — so the entitlement grants nothing in Layer, and it inerts when the warehouse is deleted. ## Deletion Deleting a warehouse fences everything drawing from it. A finalizer blocks deletion while `status.consumers` is non-zero — pipelines extracting from it or keys entitled to it — annotate with `hevlayer.com/force-delete: "true"` to override. ## List Warehouses The read-only warehouse API requires a Pro gateway connected to Kubernetes. It includes Secret reference names and keys, never Secret contents. All routes require a key with `read` scope. Create and edit connections by applying the resource or through the dashboard's apply forms. ```sh curl -H "Authorization: Bearer $LAYER_API_KEY" \ "$LAYER_BASE_URL/v2/warehouses" ``` ```json { "warehouses": [ { "name": "prod-snowflake", "kind": "snowflake", "snowflake": { "account": "acme-xy12345", "user": "SVC_LAYER", "role": "SVC_LAYER_ROLE", "warehouse": "EXTRACT_WH", "keyPairSecretRef": { "name": "snowflake-rsa" }, "pool": { "size": 5, "timeout": "30s" } }, "verifyInterval": "1h", "status": { "phase": "Verified", "verifiedAt": "2026-06-10T00:00:00Z", "consumers": { "pipelines": 2, "apiKeys": 1 }, "conditions": [] } } ] } ``` `GET /v2/warehouses/{name}` returns one object in the same shape. `phase` is `Pending`, `Verified`, or `Failed`; failed warehouses include `status.failureReason`. `status.consumers` counts pipelines and API keys that still reference the warehouse. ## Status ```yaml status: phase: Verified verifiedAt: "2026-06-10T00:00:00Z" failureReason: null sourceRevisions: McAuley-Lab/Amazon-Reviews-2023@main: 2b6d039ed471f2ba5fd2acb718bf33b0a7e5598e consumers: pipelines: 2 apiKeys: 1 ``` The operator emits Kubernetes Events on phase transitions and counts observed references in `status.consumers`. # Warm cache Source: https://hevlayer.com/docs/pro/api/warm-cache import Edition from "../../../components/docs/Edition.astro"; import Upstream from "../../../components/docs/Upstream.astro"; import Callout from "../../../components/docs/Callout.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; Cache-warm hints are wire-compatible with the upstream `GET /v1/namespaces/{ns}/hint_cache_warm` endpoint and forwarded as-is after validation. The Layer API includes this compatible subset and the gateway operations documented below; backend support is called out at each feature. Layer exposes two warm endpoints. `hint_cache_warm` is the turbopuffer-compatible hint; `warm` is the Layer-only shortcut that creates a gateway warm job. ## Hint-cache warm With no query parameters, the call is a raw passthrough: the gateway forwards it to turbopuffer unchanged and returns the upstream response verbatim. The compatible hint request retains its upstream behavior on this backend. ```bash curl "$LAYER_GATEWAY_URL/v1/namespaces/products/hint_cache_warm" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` Supplying any warm option (`turbopuffer`, `documents`, `snapshots`, `blobs`, `blob_budget_bytes`, `page_size`) switches the call into Layer orchestration. Steps then default on for turbopuffer, documents, and snapshots; blobs default off: | Step | What it does | | --- | --- | | `turbopuffer=true` | Forwards the warm hint upstream. | | `documents=true` | Starts an origin warm job to backfill the document cache. | | `snapshots=true` | Mirrors the latest S3 snapshot body into the cache. | | `blobs=true` | Hydrates declared blob references from the store or S3 into Aerospike within `blob_budget_bytes`. | ```python result = await client.hint_cache_warm( "products", turbopuffer=False, documents=False, snapshots=True, ) ``` ```typescript const result = await client.hintCacheWarm("products", { turbopuffer: false, documents: false, snapshots: true, }); ``` ```bash curl "$LAYER_GATEWAY_URL/v1/namespaces/products/hint_cache_warm?turbopuffer=false&documents=false&snapshots=true" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` The generated Go client omits `false` query parameters, so it cannot turn steps off — disable steps over REST (or the Python client) instead. The orchestrated response reports per-step status: ```json { "namespace": "products", "turbopuffer": { "enabled": true, "status": "completed" }, "documents": { "enabled": true, "status": "started", "job": { "id": "warm-job-uuid", "status": "running" } }, "snapshots": { "enabled": true, "status": "completed", "key": "snapshots/products/...", "watermark_ms": 1715600400000, "sha": "..." } } ``` If `documents` is enabled, the response includes a warm job; poll it through `/warm-jobs/{id}`. ### Blob warming Set `blobs=true` and supply a positive `blob_budget_bytes`. The Index must declare `spec.blobs.referenceAttributes` (for example, `[image_blob]`); see [Blobs](/docs/pro/api/blobs#warm-policy). A missing or zero budget, a budget without `blobs=true`, or an empty reference-attribute declaration is a validation error. ```python result = await client.hint_cache_warm( "products", turbopuffer=False, documents=False, snapshots=False, blobs=True, blob_budget_bytes=104857600, ) ``` ```typescript const result = await client.hintCacheWarm("products", { turbopuffer: false, documents: false, snapshots: false, blobs: true, blobBudgetBytes: 104857600, }); ``` ```bash curl "$LAYER_GATEWAY_URL/v1/namespaces/products/hint_cache_warm?turbopuffer=false&documents=false&snapshots=false&blobs=true&blob_budget_bytes=104857600" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` These examples warm only blobs. Go can enable blob warming with `HintCacheWarmParams{Blobs: true, BlobBudgetBytes: 104857600}`, but its omitted `false` values leave the other three steps enabled. Blob warming finishes within the request. It scans origin rows, deduplicates same-namespace references, and stops before caching an object that would exceed the byte budget. Concurrent reads can fetch beyond that cutoff, so the budget limits cache writes rather than network transfer. Missing objects and invalid references are skipped and counted. The response's `blobs` block reports `attributes`, `budget_bytes`, `documents_scanned`, `refs_seen`, `objects`, `bytes`, `missing`, `invalid_refs`, and `budget_exhausted`. Its status is `completed` even when the budget stops the scan; inspect `budget_exhausted` to distinguish that result. Unlike document warming, blob warming does not return a job to poll. ## Layer warm `POST /v2/namespaces/{ns}/warm` creates an asynchronous job that pages through turbopuffer, backfills Aerospike, and refreshes `cache_warmed_through`. Use it when bootstrapping a namespace whose data was written outside the gateway. ```python job = await client.warm_cache("products", page_size=1000) ``` ```go job, err := client.WarmCache(ctx, "products", &hevlayer.WarmCacheParams{ PageSize: 1000, }) ``` ```typescript const job = await client.warmCache("products", { pageSize: 1000 }); ``` ```bash curl -X POST "$LAYER_GATEWAY_URL/v2/namespaces/products/warm?page_size=1000" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` The response is `202 Accepted` with the warm job: ```json { "id": "warm-job-uuid", "namespace": "products", "status": "running", "progress": 0, "documents_scanned": 0, "created_at": "2026-05-26T10:00:00Z" } ``` Poll it through: ```python job = await client.get_warm_job("products", job.id) ``` ```go job, err := client.GetWarmJob(ctx, "products", jobID) ``` ```typescript const job = await client.getWarmJob("products", jobId); ``` ```bash curl "$LAYER_GATEWAY_URL/v2/namespaces/products/warm-jobs/warm-job-uuid" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" ``` ## Cache-cold behavior Warm jobs, cache scans, cache snapshot jobs, and pipeline chunk reads return 503 `cache_cold` when the document cache is unavailable. Fetch and fetch-many fall through to turbopuffer with `x-layer-cache: miss-on-error` instead. The split is deliberate. Fetch is correctness-first: a cache outage must not turn into a missing document. Warm is throughput-first: warming on a cold cache would be wasted work, so the gateway reports the cold state to the caller rather than silently no-op-ing. A bare `hint_cache_warm` passthrough never touches the gateway cache, so it succeeds even while the cache is cold. The orchestrated form returns 503 `cache_cold` when `documents`, `snapshots`, or `blobs` is requested. For how the cache recovers from an outage and the signals to watch, see the [failure-mode runbook](/docs/pro/failure-modes#read). # Write & Stage Source: https://hevlayer.com/docs/pro/api/write import Edition from "../../../components/docs/Edition.astro"; import StoreMatrixLink from "../../../components/docs/StoreMatrixLink.astro"; import StoreNote from "../../../components/docs/StoreNote.astro"; import Upstream from "../../../components/docs/Upstream.astro"; import FeatureGate from "../../../components/docs/FeatureGate.astro"; import CodeTabs from "../../../components/docs/CodeTabs.astro"; Writes are wire-compatible with the upstream `POST /v2/namespaces/{ns}` endpoint and forwarded as-is after validation. The Layer API includes this compatible subset and the gateway operations documented below; backend support is called out at each feature. On a Postgres-backed namespace, `[]string`, `[]int`, `[]uint`, `[]float` and `[]bool` attributes are stored as native Postgres arrays, in element order, and filterable ones are indexed for [array filters](/docs/pro/api/query#rank-expressions). An undeclared array attribute takes its type from its elements, so declare an attribute whose first write is an empty array. `[]uuid` and `[]datetime` return `422 UnsupportedByStore` with `feature: "schema.type"`. On a Postgres-backed namespace, patches and filtered writes run in the write's single transaction, in turbopuffer's order: `delete_by_filter`, then `patch_by_filter`, then upserts, `patch_rows` / `patch_columns`, and deletes. - A patch writes only the attributes it names; `null` clears one. A patch to an id that does not exist is ignored and creates no row. - A patched attribute not yet in the schema is declared by the `schema` in the same write or inferred from the value, as on an upsert. - Unlike turbopuffer, a client vector attribute can be patched; it is validated like an upserted vector. - An attribute Layer embeds on write, and its vector, cannot be patched: Layer cannot recompute the embedding from a patch, so the write is rejected with a `validation_error`. Upsert the full row instead. - `delete_by_filter` and `patch_by_filter` take the query filter syntax. Past 5,000,000 matching rows for a delete or 50,000 for a patch, the write is a `400` unless `delete_by_filter_allow_partial` / `patch_by_filter_allow_partial` is set, in which case the first rows are written and `rows_remaining` is `true`. - `rows_patched` and `rows_deleted` count the rows written; `rows_affected` is their sum with `rows_upserted`. - A write any part of which is rejected, including a `schema` change beside it, has no effects. For a nested payload that is only returned with a row, declare an explicit `{"type":"json","filterable":false}` attribute. For example: ```json { "schema": {"links": {"type": "json", "filterable": false}}, "upsert_rows": [{ "id": "job-52", "kind": "job", "project_record_id": "project-52", "links": [ {"label": "Job folder", "system": "SharePoint", "url": "https://example.test/jobs/52"}, {"label": "Accounting", "system": "QuickBooks", "url": "https://example.test/accounts/52"} ] }] } ``` Query, search and collapse preserve that JSON value when it is included in the returned attributes, including MCP structured results. JSON attributes cannot be filtered, sorted or ranked; the declaration must set `filterable` to `false`. Types are not inferred from nested objects. This is a Postgres extension; turbopuffer's schema does not accept nested JSON attributes. Keep separately searchable links as rows with `kind: "link"` and query them with both `project_record_id` and `kind` equality filters. On a Postgres-backed namespace, conditions use the query filter syntax and are evaluated against each targeted row's stored value in the write's transaction: - `upsert_condition`: an existing row is replaced only if it passes. A new id is always inserted. A `$ref_new` value reads the row being written, where a missing attribute is `null`. `["id", "Eq", null]` inserts without overwriting, and this condition keeps only newer versions: ```json ["version", "Lt", {"$ref_new": "version"}] ``` - `patch_condition`: applies to `patch_rows` and `patch_columns`. A row is patched only if it passes; `$ref_new` reads the row as patched. - `delete_condition`: a row is deleted only if it passes. `$ref_new` is `null` for every attribute, and a missing id is skipped. - `rows_affected` counts only the rows whose condition passed. - A condition on an undeclared attribute, or a `$ref_new` whose type differs from the compared attribute, is a `400` validation error. The whole write is rejected with no effects. Layer stamps every row-producing write with `_hevlayer_upserted_at` for supported stores. The stamp is what holds the [read watermark](/docs/pro/api/query); the full set of reserved attributes Layer manages on a row lives in the [document model](/docs/pro/document-model). To branch or copy a namespace, see [Branching](/docs/pro/api/upstream-features#branching). ## Upsert and delete `POST /v2/namespaces/{namespace}` accepts a JSON body. The first schema write creates the namespace. With the [common client](/docs/pro/api/introduction#install): ```python await client.write_namespace("preview_docs", { "distance_metric": "cosine_distance", "schema": { "vector": {"type": "[3]f32"}, "body": {"type": "string", "full_text_search": True}, "category": {"type": "string"}, "year": {"type": "int"}, }, "upsert_rows": [{"id": "atlas", "vector": [1, 0, 0], "body": "moon geology", "category": "space", "year": 2026}], }) ``` ```go _, err := client.WriteNamespace(ctx, "preview_docs", hevlayer.TurbopufferWriteRequest{ "distance_metric": "cosine_distance", "schema": map[string]any{ "vector": map[string]any{"type": "[3]f32"}, "body": map[string]any{"type": "string", "full_text_search": true}, "category": map[string]any{"type": "string"}, "year": map[string]any{"type": "int"}, }, "upsert_rows": []map[string]any{{"id": "atlas", "vector": []float64{1, 0, 0}, "body": "moon geology", "category": "space", "year": 2026}}, }) ``` ```typescript await client.writeNamespace("preview_docs", { distance_metric: "cosine_distance", schema: { vector: { type: "[3]f32" }, body: { type: "string", full_text_search: true }, category: { type: "string" }, year: { type: "int" }, }, upsert_rows: [{ id: "atlas", vector: [1, 0, 0], body: "moon geology", category: "space", year: 2026 }], }); ``` ```bash curl --fail-with-body "$LAYER_GATEWAY_URL/v2/namespaces/preview_docs" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"distance_metric":"cosine_distance","schema":{"vector":{"type":"[3]f32"},"body":{"type":"string","full_text_search":true},"category":{"type":"string"},"year":{"type":"int"}},"upsert_rows":[{"id":"atlas","vector":[1,0,0],"body":"moon geology","category":"space","year":2026}]}' ``` Delete by string ID using the same route: ```python await client.write_namespace("preview_docs", {"deletes": ["atlas"]}) ``` ```typescript await client.writeNamespace("preview_docs", { deletes: ["atlas"] }); ``` ```bash curl --fail-with-body "$LAYER_GATEWAY_URL/v2/namespaces/preview_docs" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H 'Content-Type: application/json' -d '{"deletes":["atlas"]}' ``` ## Patches, conditions and column writes On other stores, write-shape support follows the backend notes above. Use `patch_rows` to modify named fields without replacing a complete document only when the selected store supports patches. ## Status Layer validates the body before forwarding and can fail independently of the backing store, so the write path carries a few statuses a plain proxy wouldn't: - **200 OK** — applied to the selected store. Gateway bookkeeping depends on the backend. - **422 Unprocessable Entity** — Layer rejected the body before forwarding: no recognized native write operation, a reserved `_hevlayer_*` attribute name, a removed custom-write key, or a schema type the configured store cannot represent. The body is a Layer error (`{ "error": "validation_error", … }` or `{ "error": "UnsupportedByStore", … }`), not a store one. - **Upstream passthrough** — any non-2xx the backing store returns is relayed verbatim. - **502 Bad Gateway** — Layer could not reach the backing store (`{ "error": "upstream_error", … }`); the write did not apply. The passthrough case includes a failed conditional write (`upsert_condition`, `patch_condition`, `delete_condition`) — turbopuffer's error body comes back untouched. ## Stage Stage caches a document before it's upserted into your vector store. That O(1) read/write is especially useful for queuing chunks in a [two-stage pipeline](/docs/pro/api/pipelines), where a CPU worker stages chunks and a GPU worker reads them back to write vectors. Staged documents are ephemeral until they're upserted, though — a Layer document cache outage loses anything still staged. ```python await client.put_pipeline_document_chunks("product-images", "asin-B08N5WRWNW", { "chunks": [ {"id": "asin-B08N5WRWNW-0", "text": "Wireless noise-cancelling headphones"}, {"id": "asin-B08N5WRWNW-1", "text": "40-hour battery life", "metadata": {"page": 2}}, ], }) ``` ```go client.PutPipelineDocumentChunks(ctx, "product-images", "asin-B08N5WRWNW", &hevlayer.PutChunksRequest{ Chunks: []hevlayer.Chunk{ {ID: "asin-B08N5WRWNW-0", Text: "Wireless noise-cancelling headphones"}, {ID: "asin-B08N5WRWNW-1", Text: "40-hour battery life", Metadata: map[string]interface{}{"page": 2}}, }, }) ``` ```typescript await client.putPipelineDocumentChunks("product-images", "asin-B08N5WRWNW", { chunks: [ { id: "asin-B08N5WRWNW-0", text: "Wireless noise-cancelling headphones" }, { id: "asin-B08N5WRWNW-1", text: "40-hour battery life", metadata: { page: 2 } }, ], }); ``` ```bash curl -X PUT "$LAYER_GATEWAY_URL/v2/pipelines/product-images/documents/asin-B08N5WRWNW" \ -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "chunks": [ {"id": "asin-B08N5WRWNW-0", "text": "Wireless noise-cancelling headphones"}, {"id": "asin-B08N5WRWNW-1", "text": "40-hour battery life", "metadata": {"page": 2}} ] }' ``` Staging stores chunks in the Aerospike document cache and marks the document `pending`. Re-staging the same document ID replaces the chunks and resets state to `pending`. The full pipeline API is documented under [Pipelines](/docs/pro/api/pipelines). # Retain Warehouse originals Source: https://hevlayer.com/docs/pro/guides/warehouse-originals Use `clouddrive-originals` to retain the PDFs selected by a `clouddrive` Warehouse source, including files that have never been indexed. It shares the connector's selection rules. It does no OCR, embedding, indexing or Function queue work. In the `clouddrive-source` image, set the container command to `/usr/local/bin/clouddrive-originals`; the default command remains the metadata worker. Both binaries provide `--help` without network or credential access. ## Inventory first Mount the same credentials and item-list files as the source worker. Supply its `HEVLAYER_SOURCE_REF` and projected `HEVLAYER_WAREHOUSE` connection JSON; the latter is not the full Kubernetes resource. Sources with keep retention also need the verified history binding below. Then choose the destination: ```sh export HEVLAYER_ORIGINAL_BUCKET=private-layer-bucket export HEVLAYER_ORIGINAL_NAMESPACE=documents export HEVLAYER_ORIGINAL_STATE_PREFIX=original-retention/documents clouddrive-originals --inventory-only ``` `HEVLAYER_ORIGINAL_REGION` defaults to `us-east-1`. `HEVLAYER_ORIGINAL_ENDPOINT` selects an S3-compatible endpoint. The destination must be the private bucket the gateway uses for that namespace's blobs. Use one writer per state prefix, and keep that prefix outside `pipelines/`, `blobs/` and `original-catalog/`. Inventory reads metadata, without downloading source files. It follows every configured drive's complete page chain and checks that roots resolve, including empty folders. Explicit include/exclude identities must resolve too. Root and multi-root selection, paths, mounted item lists, MIME/size rules and exclusions are shared with the metadata connector. A missing provider content hash does not exclude an otherwise selected PDF. The report's `inventoryComplete` must be true before treating its selected count as the denominator. Declared and resolved roots, include identities and exclude identities are counted separately. `selectedBytes` sums known source sizes; `unknownSizes` records omissions. A null counter means it was not recorded by an older checkpoint. Missing roots, identities or ancestry leave explicit unresolved evidence and a nonzero exit. Metadata entries can repeat across pages; selected identities are unique by drive/item. ## Preserve prior source membership A source with `retention.deselected: keep` includes still-current prior members, not only files that match today's literal folder paths. Supply a pinned Layer source snapshot; this is `drive-listing.json`, not an extraction-success manifest or a search-index export: ```sh export HEVLAYER_ORIGINAL_HISTORY_KEY=pipelines/example/extract/cache/drive-listing.json export HEVLAYER_ORIGINAL_HISTORY_VERSION_ID=immutable-s3-version export HEVLAYER_ORIGINAL_HISTORY_SHA256=expected-full-object-sha256 export HEVLAYER_ORIGINAL_HISTORY_BYTES=expected-byte-count export HEVLAYER_ORIGINAL_HISTORY_PIPELINE_ID=example export HEVLAYER_ORIGINAL_HISTORY_TARGET=s3://private-layer-bucket/pipelines/example/extract/cache/ clouddrive-originals --inventory-only --reselect-inventory ``` The importer verifies the complete object and each source row's committed hash, generation and provider identity. Drive manifests must match the current Warehouse, mapping, MIME rules, configured roots and exact cache target. Item lists and retention are not part of that historical config hash: current explicit exclusions always win. Keep retention carries proven prior PDFs across later folder, item-list, MIME or size-filter changes; it does not turn a current non-PDF into a PDF. Prior membership alone proves neither current PDF bytes nor a successful copy. Current metadata, ancestry and versions determine what can be copied; missing members or those no longer selected as PDFs by current metadata remain explicit unresolved cases. Pinned history reads first use an ordinary S3 GetObject request. The response VersionId must equal `HEVLAYER_ORIGINAL_HISTORY_VERSION_ID` before any bytes are read. This route needs no GetObjectVersion permission when the pin is still current. If the current version differs, the worker tries an explicit-version GET and checks its returned VersionId too. A missing VersionId or denied version read fails closed. Full SHA-256 and size checks still apply. An existing authorized operator can preserve the requested version at a dedicated key, verify its full checksum and size, and supply a new binding for that preserved object; never substitute the latest object for the pin. A saved generation retains its binding, so preserve its evidence and reconcile any binding change before resuming. Reselection creates a new generation from completed immutable metadata pages. It preserves the old generation and acquired byte proofs and performs no new Graph delta walk. Subsequent generations also carry the worker's own source membership, including new files selected on an earlier run. Missing members remain unresolved across repeats. A missing literal root can be repaired only by a unique folder identity proven in the verified old tree and present in the current tree. Similar folder names or a broader parent are insufficient. Independent valid files can still copy while scope gaps remain unresolved. Changed file versions require an explicit new current inventory with `--refresh-inventory`; no stale version is silently accepted. ## Inspect source metadata Use an existing completed inventory to diagnose a selected file without copying it or changing retention evidence: ```sh umask 077 clouddrive-originals --inspect-item DRIVE ITEM > private-item-inspection.json ``` Keep the same source configuration, projected Warehouse, namespace, bucket and state prefix. This mode requires the existing binding and fully committed selected inventory, with no unresolved inventory scope errors. A copy run may still be incomplete or have changed/missing source outcomes. Inspection reads binding, state and bounded selected shards, rejects an unselected identity before Graph access, then makes one logical authenticated item-metadata lookup. Existing Graph authentication, pacing and bounded HTTP retries apply; the JSON counts actual attempts and received metadata/auth bytes. There is no content request, source discovery, metadata traversal, source-history/hint import or sink write. The JSON is private evidence: it contains the inventory generation, selected and current source metadata, observation time, ETag, version, QuickXor, size, provider-declared MIME, citation, changed fields, HTTP error class/status and request/byte counters. Authenticated download URLs, provider error bodies and credentials are excluded. Citation query strings and fragments are removed from this diagnostic output. Logs go to stderr; stdout contains the diagnostic JSON. `sourceStatus` is `unchanged`, `changed`, `missing` or `error`. Missing 404/410 and changed metadata are successful diagnostic observations, so the command exits zero after recording them. Provider errors emit safe diagnostic JSON and exit nonzero. `retentionVerified` is always false: this mode never verifies bytes, resolves a retention gap or updates counts. It does not change binding, phase, cursors, outcomes, acquired proofs, original objects, catalog or queues, and never starts a new inventory even when the old copy phase is complete. `--inspect-item` cannot be combined with inventory, refresh, reselection or copy flags. History and hint import environment variables are not used. The selected snapshot remains unchanged; any later source refresh or retention repair is a separate explicit operation. ## Typed source originals Selection follows the configured PDF scope, including files whose provider calls them PDFs. Retention does not change that scope or alter source bytes. If a selected original is a valid PNG despite its PDF metadata, the worker retains the exact PNG with actual MIME `image/png`, provider-declared MIME and explicit `sourceFormatMismatch` provenance. It verifies the PNG container, chunk CRCs, zlib stream and scanline framing without rendering pixels. Indexed-color PNGs remain unsupported because palette index validation needs pixel reconstruction. Unsupported or malformed formats, HTML responses, missing files and changed sources remain unresolved. PNG validation uses bounded buffers and a 1 GiB inflated scanline limit; exceeding that bound is unsupported, never successful retention. The same before/after source checks, provider QuickXor check when present, SHA-256, size and full S3 readback apply to typed originals, including checksum reuse. A verified PNG resolves its original-copy gap but never counts as a verified PDF. Reports expose `candidateTotal`, `verifiedOriginals`, `verifiedPdfs`, `verifiedNonPdfOriginalsWithFormatMismatch`, `pending`, `missing`, `changed`, `corrupt`, `unsupported` and `failed`. Successful original types are disjoint, and pending plus verified originals plus unsuccessful candidates conserves the candidate total. `unresolved` is the sum of pending and unsuccessful candidates plus `scopeUnresolved`, the separate inventory scope errors. The legacy `verified` aggregate now equals `verifiedOriginals`, not `verifiedPdfs`; `invalidPdf` retains its legacy outcome name for unsupported selected bytes. Old reports without typed fields leave those counts unknown. For one known selected identity, use `clouddrive-originals --only-item DRIVE ITEM` on the existing state prefix. No new inventory is needed while its generation is resumable. Historical invalid-PDF quarantine receipts remain in place; the new verified outcome records the format mismatch separately. No conversion, OCR, indexing or source metadata update takes place. MCP returns the whole PNG as an embedded resource with its actual MIME and source link. A `pages` request for a non-PDF explicitly fails. Namespace grants, pins and source/version bindings are unchanged. Existing v1 PDF catalogs remain readable; typed catalog fields are optional additions. The MCP arguments and HTTP API are unchanged, so this capability needs no generated SDK changes. ## Copy, resume and repeat With the same environment, run: ```sh clouddrive-originals ``` For a bounded copy check after inventory, use `clouddrive-originals --max-files 2`. It processes at most two selected identities in that invocation. Remaining files stay pending and the command exits nonzero until the full inventory reconciles. Remove the flag to process the entire selection. The worker inventories first if needed, then copies its selected PDFs. Each file must have a stable source ETag and size. It streams to scratch disk while hashing, checks the source again, writes the object, and fully reads it back to verify SHA-256, size and supported actual format. Originals over the older Function's 10 MiB cap are copied too. Objects use `blobs//` and the existing `blob:///` reference format. A failed run resumes from its committed pages and per-file evidence. A run that finished starts a fresh inventory on the next invocation. Unchanged source-version receipts can avoid another Graph download, but every reused S3 object is read and hashed. Size and S3 ETag alone never prove reuse. Files that changed, disappeared, failed verification or contain unsupported bytes remain unresolved. A run succeeds only when its complete inventory reconciles with verified objects and catalog references, with unresolved zero. Use `--refresh-inventory` after repairing source scope or when a source change invalidates the saved inventory. Old generations remain under the prefix. Changing Warehouse, resolved selection or destination requires a new prefix. To inspect one inventoried file without copying other members, run `clouddrive-originals --only-item DRIVE ITEM`. Other files stay pending, so this bounded diagnostic run exits nonzero. Invalid PDF bytes retain their observed SHA-256, byte count and before/after metadata. A private `quarantine/` receipt classifies HTML-like, JSON-like or missing-PDF-header responses and points to at most 64 KiB of raw bytes, with its own checksum and truncation flag. These bytes are never published as an original or silently repaired. ## State and resource use Metadata pages are immutable shards, written before their compact cursor checkpoint. Selected shards contain at most 500 files. A disposable SQLite projection resolves ancestry; the accumulated tree is never rewritten per page or file. Outcomes are small per-file objects, and aggregate progress is saved in batches. Private reports and invocation meters remain under the state prefix, separate from extraction state. They contain source identities and citations; keep them private. `HEVLAYER_ORIGINAL_CONCURRENCY` defaults to 2 and accepts 1–4 files. Each has its own scratch directory; all share the Warehouse Graph rate limiter. Scratch disk must hold current and historical metadata projections, the pinned history file, optional hint manifest and up to that many originals at once. Downloads do not buffer a whole file in memory. S3 multipart uploads use 8 MiB parts, increasing only to stay within 10,000 parts; the worker's object safety bound is 5 TiB. Known upload IDs are checkpointed before any parts and reconciled on restart. A crash between initiation and saving that ID can leave an empty upload for bucket lifecycle cleanup. Other writers' uploads and completed objects are not deleted. Recovery requires AbortMultipartUpload for the exact owned key and upload ID; GetObject and PutObject alone do not grant it. If the worker cannot abort, it fails and preserves the marker. The error reports the S3 operation, safe service code/status, owned key, upload ID and marker location without SDK request URLs or response bodies. An existing authorized operator can read that private marker, abort only its recorded upload, and resume the same generation. NoSuchUpload is safe to reconcile after an operator abort; the worker then removes the marker. Do not delete markers to bypass denied cleanup. If initiation succeeded but checkpointing failed, the error carries the upload identity and intended marker location for operator recovery. Check this recovery capability before a large copy test; permission expansion is not required. Reports separate copied, checksum-verified reuse, source changes, missing files, invalid PDF signatures, failures and unrepaired corruption. Root aliases and content aliases are separate counts. Graph/auth/content requests include retries, and content bytes count received chunks. Successful metadata and auth response payload bytes are counted separately. S3 counters report request attempts, received object bytes and acknowledged write payloads, not wire billing. `reusedHints` and `reusedReceipts` distinguish verified reuse through extraction hints from the worker's own acquired proofs; outcomes also identify checksum reuse after a source download. Meters identify their invocation and checkpoint boundary; a hard crash can leave later attempts unrecorded. Such intervals are not evidence of zero work. ## Reuse an extraction receipt An existing hev layer extraction manifest can supply optional source-version-to-SHA hints: ```sh export HEVLAYER_ORIGINAL_HINT_MANIFEST_KEY=pipelines/example/extract/cache/manifest.json export HEVLAYER_ORIGINAL_HINT_PIPELINE_ID=example clouddrive-originals ``` The manifest is streamed to scratch and imported into SQLite. Its version and Pipeline identity must match. A hint requires the same namespace's retained blob reference and exact drive/item/ETag/provider version. The source version uses the connector's `quickXor:` spelling. Conflicting hints are ignored. Parser status is not proof. A matching blob still receives full SHA-256/size/PDF-signature validation; a mismatch falls back to source copying. ## Fetch indexed and unindexed originals Enable `originals` on the gateway's [MCP namespace](/docs/pro/api/mcp#original-files). Per-file outcomes contain a stable `original:` catalog ID. Pass that ID to `fetch_original_`, or to generic `fetch_original` with the namespace. It uses the same `id` and optional `pages` arguments as indexed originals. The returned metadata includes source drive/item/version/ETag, SHA-256 and the provider citation. SharePoint links are preserved. Existing indexed IDs also work when their rows lack `original_blob`: the gateway lazily matches the authorized row's exact content hash or source identity/version to a catalog receipt. It does not scan or patch rows. Unknown legacy identity remains an error. Namespace read authorization applies before catalog access. For direct catalog IDs, every pinned filter must be provable from catalog attributes; a missing value is denied. Indexed IDs retain their row authorization and pinned filters. Their fallback adds only the verified blob binding, preserving the row's own citation and source fields. Catalog IDs cannot widen a tool's scope. Retention does not raise MCP whole-file, page-render or response-size limits; oversized responses still return an error with the source citation. ## Probe access without copying `clouddrive-source --preflight` reads metadata from each discovered drive and streams one selected PDF into SHA-256, checking byte count and before/after source identity. It constructs no sink or state store and makes no S3, index or queue writes. This establishes access, not a complete inventory or retention. `HEVLAYER_PREFLIGHT_MAX_PAGES` defaults to 100 per drive (1–10,000), and `HEVLAYER_PREFLIGHT_MAX_BYTES` to 512 MiB. Larger candidates are counted while the probe searches for a sample within its cap; they are not skip-success. No sample within the budgets, an unreadable drive or unstable bytes fails. Do not combine `--preflight` with `--once`, `--verify` or `--reset-cursors`. Set `HEVLAYER_PREFLIGHT_RECEIPT` to a new absolute file path for an atomic, owner-only private sample receipt. It binds resolved selection, Warehouse, drive/item/path/citation, before/after metadata and observed SHA/bytes. Identifiers stay out of routine progress logs. Credentials and signed download URLs are never written. Raw sourceRef parsing strips only existing worker-owned `parse`, `extract`, `cacheUri`, `rescanSeconds`, `suffixes`, `uri`, `merge` and `dedupe` settings (`dedupe` must be `content` or `item`); other unknown fields fail validation. ## Retained snapshot when the source is unavailable After deploying a gateway that reads snapshot catalogs, quiesce the sole copy worker before enabling `--allow-historical-reuse`. This opt-in policy preserves an exact selected inventory revision whose authenticated metadata lookup returns 404 or 410. It does not select more files or substitute an older original for known changed content. Start with one selected identity: ```sh clouddrive-originals --allow-historical-reuse --only-item DRIVE ITEM ``` This mode requires an existing completed inventory binding and cannot be combined with inspection, refresh, reselection or inventory-only flags. A bound acquisition receipt, or verified source history plus a pinned extraction manifest, supplies a byte-reference lead. For the manifest path, keep the source-history variables from [prior membership](#preserve-prior-source-membership) and supply: ```sh export HEVLAYER_ORIGINAL_HINT_MANIFEST_KEY=pipelines/example/manifest.json export HEVLAYER_ORIGINAL_HINT_PIPELINE_ID=example export HEVLAYER_ORIGINAL_HINT_VERSION_ID=exact-provider-version-id export HEVLAYER_ORIGINAL_HINT_SHA256=full-manifest-sha256 export HEVLAYER_ORIGINAL_HINT_BYTES=exact-manifest-byte-count ``` Private per-file outcomes record `objectVersionId` only after accepted full S3 readback. Legacy outcomes and unversioned live objects have an unknown version binding; historical proof still requires a nonempty S3 VersionId. Empty bodies and entirely zero-filled selected originals are established corruption, not unknown formats. They remain unresolved and cannot use unclassified or historical retention; MCP refuses empty originals too. Both evidence objects must match their pinned VersionId, full SHA and size. The receipt binds provider, tenant and client identity as well as selection, namespace and destination; a Warehouse name alone is insufficient. Layer must fully read the retained S3 object and verify its VersionId, SHA-256, size, actual format and inventory provider checksum. It checks source metadata again before publication; both observations must be 404 or 410. Missing checksums, ambiguous evidence, known newer content and corrupt or absent objects remain unresolved. No source body is read and no raw original is uploaded on this path. The returned immutable `original:` reference identifies that inventory snapshot. MCP reports `retentionBasis=inventorySnapshot`, `sourceAvailability=unavailable`, the observed status and time, and `deletionStatus=unknown`. Its SourceLink is the inventory citation, not a claim that the source still opens. Internal object and evidence locations remain private. Existing namespace grants and pins apply. Snapshot IDs use a canonical versioned tuple and exclude timestamps and attempt IDs. Verification receipts, prior outcome archives and snapshots use conditional create, with conflict validation. A retry after any publication boundary can verify and finish the same snapshot without replacing a newer source alias. Each validation writes a private receipt with its own invocation and both status/time observations. A new proof lead or retained object version can create a new receipt for the same frozen snapshot. The first snapshot and its first receipt pointer stay unchanged; the current outcome names the new receipt. The earlier missing outcome stays archived. Verified historical originals count as originals and as their PDF, mismatched PNG or opted-in unclassified partition; source unavailability remains a separate anomaly. Reports add `verifiedHistoricalOriginals` as a subset of `verifiedOriginals`, and disjoint `sourceAvailable`, `sourceUnavailable` and `sourceAvailabilityUnknown` counters. The availability counters conserve the candidate total: authenticated 200 is available, 404/410 is unavailable, and unvisited candidates or a failed metadata recheck are unknown. Earlier reports without these fields remain unknown. `historicalProofPolicy` records the enabled proof-policy revision. Refreshing unchanged keep-retention selection must carry previously authenticated missing members, preserving their exact requested revision or an explicit unknown-version unresolved placeholder. It must not shrink the denominator or clear root and scope errors. Changed content requires fresh inventory and a copy of that newly requested version. Historical writing requires a compatible reader; old readers reject snapshot schema 2. Rollback disables writing and preserves the compatible reader and immutable evidence. Schema 1 aliases remain unchanged. ### Isolated snapshot acceptance Use an isolated MinIO instance through an HTTP loopback endpoint. An existing authorized executor owns its lifecycle and supplies synthetic credentials. The harness creates an owned `scratch-original-snapshot-*` bucket, enables versioning, and creates a separate unversioned bucket to check refusal of missing VersionIds. It never disables versioning on an existing bucket or uses ambient AWS credentials. ```sh export LAYER_SNAPSHOT_TEST_ENDPOINT=http://127.0.0.1:9000 export LAYER_SNAPSHOT_TEST_BUCKET=scratch-original-snapshot-test export LAYER_SNAPSHOT_TEST_ACCESS_KEY_ID=fixture-access export LAYER_SNAPSHOT_TEST_SECRET_ACCESS_KEY=fixture-secret cargo test -p clouddrive-source minio_real_ -- --ignored --nocapture --test-threads=1 ``` Use the fixture's supplied test credentials. The client requires a loopback URL, an owned bucket prefix and both explicit credential variables before any request. For an executor's custom scratch prefix, set `LAYER_SNAPSHOT_TEST_BUCKET_PREFIX` to that exact owned prefix; it must start with `scratch-`. Tests retain only fixed synthetic PDF/PNG bytes and metadata, with a 16 MiB successful-PUT budget. Source-body calls and raw uploads during historical verification are prohibited by the test adapter. Request timeouts and per-case progress bound an interrupted run. The fixture audit lists at most 1,000 owned keys and verifies producer receipts and their exact retained object versions; `LAYER_SNAPSHOT_TEST_REQUIRE_COMPLETE=1` also requires the complete matrix. Preserve interrupted evidence and audit effects before a retry. Leave the fixture available for independent inspection, then let its owner remove it. The ordinary source tests cover unavailable observations, changed-content refusal, checksums, format and binding failures, refresh conservation, and interruption after each durable publication boundary. Gateway MCP integration tests separately cover schema 2 PDF/PNG, PDF pages, schema 1 compatibility, tuple tampering and namespace and pin denials. Passing synthetic tests does not establish live historical retention: validate the exact source candidate, its two authenticated unavailable observations, pinned proof, counters, restart and authenticated MCP response after the reader rollout. Keep earlier missing outcomes and acceptance receipts. ## Preserve unclassified source bytes `--allow-unclassified-originals` opts into exact-byte retention when the selected source format cannot be established. It does not broaden selection or transform content. It requires the existing complete inventory binding, keeps its generation and rejects refresh, reselection and inventory-only combinations. After the reader rollout, run `clouddrive-originals --allow-unclassified-originals --only-item DRIVE ITEM` with the same projected configuration and state prefix. The source must still match its inventoried ETag, version and size; a provider checksum is required. Layer checks the whole SHA-256 and checksum, reads the complete S3 object back, and rechecks source metadata before publication. The same policy can accompany `--allow-historical-reuse` with its exact-version evidence and two unavailable observations. These originals use `application/octet-stream`, preserve declared MIME, and report `formatValidation=unverified`. Actual format and source-format mismatch are unknown, not false. MCP serves whole bytes with the original citation and refuses PDF pages. Namespace grants and pins still apply. Roll out the reader before enabling this writer policy; older readers reject the new catalog shape. `verifiedUnclassifiedOriginals` is a third disjoint partition: `verifiedOriginals = verifiedPdfs + verifiedNonPdfOriginalsWithFormatMismatch + verifiedUnclassifiedOriginals`. `unverifiedFormatOriginals` reports the format anomaly separately; raw retention completion says nothing about container validity. Legacy reports without these fields remain unknown. Known malformed or truncated PNGs, HTML/JSON error responses, checksum or size mismatches, changed sources, and missing or unbound proof remain unresolved. A format outside validation limits is unassessed, not a validated image. Existing unsupported outcomes and quarantine evidence are archived before replacement. If later analysis establishes corruption, preserve an append-only reassessment and restore the unresolved outcome; never rewrite the original proof. The writer records established PNG corruption as a conditional, append-only assessment bound to the namespace and whole-object SHA. Source reuse, historical reuse and MCP refuse assessed bytes; rerunning restores an unresolved outcome while preserving the predecessor and quarantine. Assessment records are private operator evidence, not MCP object locations. # Backfill a Function over existing rows Source: https://hevlayer.com/docs/pro/kubernetes/backfill-a-function A [Function](/docs/pro/functions) normally treats each row on its own: discovery finds it, a claim reads it, completion reads it again, and a write trigger can read it a third time. turbopuffer bills every query at the full size of the namespace being queried, so on a large namespace those per-document reads cost far more than the work. A backfill declares the same Function with `backfill: true`. Layer then reads the inputs in bulk, 9,999 rows per query, hands them to your worker in batches, and patches the results back with no further reads from the store. Use it to add an attribute to rows that already exist: classify, tag, derive a file reference, extract a field. Rows written after the backfill are not covered; see [What a backfill does not do](#what-a-backfill-does-not-do). ## Before you start `backfill` is a newer Function field. Two cluster components must know it, or the field is dropped and the Function silently runs on the per-document path that a backfill exists to avoid: - **The `Function` CRD.** The Kubernetes API server prunes fields its stored schema does not declare. Apply the CRDs from a chart that includes `spec.backfill` (`infra/helm/layer/crds/hevlayer.com_crds.yaml`, layer-pro `3dcdeee6` or later). - **The layer-operator.** The operator re-serializes the Function spec before registering it with the gateway, so an older operator drops the field even when the CRD keeps it. Run an operator image built from layer-pro `3dcdeee6` or later. If you start backfills with `layer run`, use a `layer` CLI from the same release or later for the same reason. Check both before the first run. The CRD must list the field: ```sh kubectl get crd functions.hevlayer.com -o json \ | jq '.spec.versions[] | select(.name=="v1alpha1") | .schema.openAPIV3Schema.properties.spec.properties.backfill' ``` A `null` result means the CRD is too old. Then confirm the operator image is the one you expect: ```sh kubectl -n layer get deploy layer-operator \ -o jsonpath='{.spec.template.spec.containers[?(@.name=="operator")].image}' kubectl -n layer get pods -l app.kubernetes.io/component=operator \ -o jsonpath='{range .items[*]}{.status.containerStatuses[0].imageID}{"\n"}{end}' ``` Compare the running image digest with the digest published for an operator revision that includes `backfill`. ## Declare it Start from an ordinary Function. Add `backfill: true` and keep `discovery` as the only trigger. Declare it paused the first time, so nothing runs until you have checked that the field survived. ```yaml apiVersion: hevlayer.com/v1alpha1 kind: Function metadata: name: tag-products namespace: layer spec: paused: true targetNamespaces: - products inputs: - id - title version: v1 backfill: true triggers: - discovery filter: - category - Eq - outdoor worker: image: .dkr.ecr.us-east-1.amazonaws.com/hev-tag-products:latest dispatch: pull batchSize: 200 timeoutSeconds: 120 schedule: discoveryIntervalSeconds: 60 maxInFlightBatches: 8 ``` Your worker is the same pull worker you would write for any Function: it claims items, computes, and completes them with attributes. The claim already contains the `inputs` you declared, so the worker needs no lookup of its own. With `groupByParent: true` a claim carries every page row of a document, taken from the rows the backfill scanned. Backfill claims carry `input_snapshot: true`. Keep the worker's own provider reads off the per-document path. The bulk scan is cheap because it reads many rows per query. Every other read your worker makes through Layer is billed at the full size of the namespace it reads: a row read or a blob read from the namespace's blob set, which holds every stored file. One such read per document undoes the backfill's savings. Use the claim's input instead of re-reading the row. Write blobs with `PUT`, which is an idempotent upsert keyed by hash and billed by bytes written, rather than checking first whether they exist. The stock `layer-extract originals` worker works this way. Send `x-hevlayer-function: ` on the worker's gateway calls (the operator sets `HEVLAYER_UDF_ID` on worker pods). The gateway then labels that traffic's provider billing `caller_kind="function_worker"` with the Function id, apart from anything else using the same key. The stock workers send it. Writes can cost reads too. A backfill's completion writes its outputs without a provider read. A worker that writes other rows itself through `POST /v2/namespaces/{ns}` (for example, the label on every page of a document) can make that write read. A write reads the written rows before and after for each Function that observes it: a write-triggered or discovery Function, not a backfill, that targets the namespace. Each such read is billed at the namespace's size. A patch-only write (`patch_columns` or `patch_rows`) skips two kinds of Function, so it reads nothing for them: - A Function the patch cannot concern: none of the patched attributes is one of its `inputs` or appears in its `filter`. - A paused Function with a `discovery` trigger. It claims nothing while paused, so the write records a durable intent instead, and the Function's discovery re-reads those rows in its batched scan when it resumes. So patch only the attributes your Function produces. Rewriting an unchanged attribute that another Function reads (for example, `page`) makes every such write read for that Function. Every write also looks up the namespace's shard marker; with the Function read cache enabled, that lookup is served from cache. These write-path reads are charged to the write-triggered Functions that need them, never to a backfill: a backfill neither observes nor is queued by writes, and its `queriesPerItem` never blocks another writer. Two metrics attribute provider reads to Functions: | Metric | Labels | Counts | | --- | --- | --- | | `hevlayer_function_provider_queries_total` | `function`, `stage` | Physical provider queries made on behalf of the Function's items | | `hevlayer_function_billable_bytes_queried_total` | `function`, `stage` | Their billed queried bytes | `stage` is `lookup`, `claim`, `completion`, `discovery`, `write_trigger` or `other`. A read that serves several Functions counts once for each, so don't sum these metrics across Functions. For a backfill Function, `lookup`, `claim` and `completion` should stay at 0. The backfill's own scan pages are reported in the status `backfill` block, not here. ### Look up a document's other pages A worker that classifies a document from its first page often needs the other pages too. The [parent lookup](/docs/pro/functions#parent-and-sibling-lookup-for-workers) (`POST /v2/udfs/{id}/items/lookup`) returns them. In a backfill it makes no provider read: it answers from rows the scan already holds. For each scan page, the backfill also reads the complete sibling group of every parent document on that page (`_hevlayer_parent_id` `In` the page's parents, 9,999 rows per query). It holds those rows next to the claimed inputs. A filter that selects only page 1 still gets every page of the document. For a `groupByParent` Function the scan skips this step: its claims already carry the scanned pages, and the lookup returns those. A namespace whose schema has no `_hevlayer_parent_id` (rows written without pages) is scanned without it: the scan asks only for the Function's inputs, and `group_queries` stays `0`. ```yaml spec: backfill: true triggers: [discovery] filter: [page, Eq, 1] inputs: [page, name, text, page_count, _hevlayer_parent_id] ``` ```python lookup = await client.lookup_udf_siblings( function_id, { "worker_id": worker_id, "namespace": item["namespace"], "id": item["id"], "input_revision": item["input_revision"], "include_attributes": ["page", "text"], }, ).model_dump() pages = lookup["documents"] # every page of the document, ordered by id ``` The response has the same shape as on the per-document path, with three differences: - **Inputs only.** The held rows carry the Function's `inputs`. Every name in `include_attributes` must be one of them, or the lookup returns `422`. `page_two_id` needs `page` among the inputs. - **Scan-time values.** Like claims, the lookup returns the values the scan read, not later writes. - **Rows scanned by an older gateway.** Rows scanned before the gateway held sibling groups have none. Their lookup returns `409` instead of reading the store. Change any field of the definition to scan again. Rows that already carry the version marker are not selected again, so the new scan covers only the remaining work. ## Confirm the field round-trips Apply the paused Function and read it back twice: once from Kubernetes (the CRD kept the field) and once from the gateway (the operator passed it on). ```sh kubectl apply -f tag-products.yaml kubectl -n layer get function tag-products -o jsonpath='{.spec.backfill}' # true curl -s -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \ "$HEVLAYER_BASE_URL/v2/udfs/tag-products" | jq '.udf.spec.backfill, .udf.paused' # true # true ``` Wait for the operator to reconcile before the second read; the Function's `Ready` condition shows when it has. If either read is not `true`, stop: leave the Function paused (or delete it) and update the CRD or the operator first. A Function registered without `backfill` scans and reads per document. ## Run it Unpause the Function to start the scan: ```sh kubectl -n layer patch function tag-products --type merge -p '{"spec":{"paused":false}}' ``` The gateway continues the scan on its own every `schedule.discoveryIntervalSeconds`. Call `POST /v2/udfs/{id}/discover` to advance it immediately. Once a cluster has passed these checks, `layer run -f tag-products.yaml` (with `paused` removed from the manifest) does the same in one command: it applies the Function, starts the scan and waits until the scan has finished and the queue is empty. ## Follow progress `GET /v2/udfs/{id}/status` (or `getUdfStatus` in the SDKs) reports a `backfill` block once the first page is scanned. `layer udf get tag-products --watch` shows the queue counts alongside it: ```json { "backfill": { "version": "v1", "scan_complete": false, "pages": 12, "rows_scanned": 119988, "queries": 12, "group_queries": 120, "queried_bytes": 92400000000, "returned_bytes": 31000000, "rows_done": 97310, "billed_queried_bytes_per_row": 770077.0 } } ``` | Field | Meaning | | --- | --- | | `pages`, `queries` | Scan pages read, and the row queries made for them, each billed at the namespace's size. A page read again after an interruption counts again. | | `metadata_requests` | Namespace metadata requests made alongside the pages (the id-type lookup for a cursor page, cached for 60 s). They are not counted in `queries`. | | `group_queries` | Row queries that read the sibling groups of each page's parents, so the parent lookup needs no read. Each is billed at the namespace's size. Each 1,000 parents take one query per 9,999 of their rows. A full scan page makes 10 when documents average under 10 rows, and 20 under 20. Zero for `groupByParent` and for rows without a parent. | | `rows_scanned` | Rows the scans returned, counted when the page's rows are queued. | | `rows_done` | Scanned rows that are no longer pending, processing or failed. | | `queried_bytes` | Billed logical bytes queried, from the provider's own response billing. Includes the sibling-group queries. | | `scan_complete` | Every target namespace has been traversed once. | A page's billed read is recorded as soon as the provider answers, and its rows are counted in the same commit that queues them. A page interrupted in between, for example by a pause, shows in `pages`, `queries` and `queried_bytes` but adds nothing to `rows_scanned`. Resuming reads that page again, so it is billed twice and its rows are counted once. While no worker runs, `rows_done` stays `0`. The cost of the reads is `(queries + group_queries) × namespace size`. One page covers 9,999 rows, so a namespace of 387,826 rows needs 39 pages. A full scan page adds 10 group queries when documents average under 10 rows, and 20 when they average under 20. Compare that with a query or more per document, and per lookup, on the per-document path. After a run, check that `queries` equals `pages`, and that `queried_bytes / rows_scanned` is far below the size of the namespace. `scan_complete` is true only when every current target has a completed checkpoint for the current Function definition. A definition change, including changing the worker image, makes it false before discovery resumes. Historical scan totals and held queue rows remain available; they do not prove that the current definition has finished scanning. A change to the source store can also reopen a checkpoint when discovery resumes. ## Resume and re-run - **Resume.** Scan progress is stored in Layer's Postgres after each page is queued. A gateway restart, a lost lease or a paused-then-resumed Function continues from the last page. Workers can stop and start; unfinished claims are recovered when their lease expires. - **Pause.** Pausing stops the scan at a page boundary, without an error and without counting a discovery failure. Pages already queued stay queued and are not read again. A page that was still being read when the pause landed is dropped, so unpausing reads that one page again: `pages` and `queries` each gain 1 and its billed bytes count twice. ```sh kubectl -n layer patch function tag-products --type merge -p '{"spec":{"paused":true}}' # later: continue from the last queued page kubectl -n layer patch function tag-products --type merge -p '{"spec":{"paused":false}}' ``` - **Safe to repeat.** Each completion stamps a version marker on the row, and the scan only selects rows without the current marker. A row finished twice gets the same patch. Re-applying the manifest does not scan again once the scan is complete. - **Run again.** Change `spec.version` (or any other part of the definition) to scan from the start. Use this after changing the worker's output or to catch rows that were written during the first run. - **Failures.** Failed items stay in the queue with their error; fix the worker and run `reset-failed`. The scan itself fails and retries per page. ## Switching an existing Function to backfill A Function that ran on the per-document path can still have queue rows from that run: pending, or failed. A backfill leaves them as they are. It claims only rows whose inputs its own scans hold, so it never claims, fails, retries or rewrites an older row: - An older pending row that the scan selects again is processed as ordinary backfill work. - An older pending row outside every scan stays pending. - An older failed row stays failed, even when a scan selects its document again. Retry it separately if you choose to. The `backfill` block in status counts the two kinds apart: | Field | Meaning | | --- | --- | | `held_pending`, `held_processing`, `held_failed` | Queue rows whose inputs a scan holds. `held_failed` includes older failures a scan selected again. | | `legacy_pending`, `legacy_processing`, `legacy_failed` | Older queue rows without held inputs. | Each status response reads the whole `backfill` block and the top-level `pending_count`, `processing_count` and `failed_count` from one snapshot. These hold in every response, even in the middle of a scan: - `held_X + legacy_X` equals the matching `X_count`. - `rows_done` equals `rows_scanned` less `held_pending`, `held_processing` and `held_failed`. Compare values from the same response, not from two reads. If you check these in a script and a check fails, save the whole response before you act on it. It holds only counts, and it shows which values disagreed. A backfill is finished when `scan_complete` is `true` and `held_pending` and `held_processing` are both `0`. `pending_count` and `failed_count` keep the older rows. `rows_done` counts held rows only. ## What a backfill does not do - **Rows written later.** Gateway writes never queue a backfill Function, and the backfill makes no read for them. Run a backfill again when you need to cover new rows, or use a regular Function with a write trigger for ongoing work. - **Vector output.** A backfill writes attributes only. Embedding and other vector output reads the whole row to rewrite it; use a regular Function. - **A consistent snapshot.** Scans use eventual reads. A row changed while the backfill runs may be processed with the older inputs. The next run corrects it. - **Large inputs.** The scanned inputs, and the sibling groups held for the lookup, wait in Layer's Postgres until the queue drains, then are deleted. Declare only the attributes you need. A backfill needs no cache or finality configuration. It does not use the certified Function read cache, `queriesPerItem` or the layer-only namespace setting. Its paged scans are labeled with the Function in billing metrics like any other Function read. ## Example: attach original files to existing Story rows [Backfill original-file references](/docs/pro/functions#backfill-original-file-references) describes `layer-extract originals`, the stock worker that fills `original_blob` on rows indexed before originals were kept. Add `backfill: true` and `triggers: [discovery]` to its Function and the backfill reads the page rows in 10,000-row scans instead of once per document. # Operator Overview Source: https://hevlayer.com/docs/pro/kubernetes/operator `layer-operator` manages declarative state for your hev layer deployment. It serves a few crucial functions — monitoring for changes to your indexes and managing scaling. It does this through a set of abstractions known as [custom resource definitions (CRDs)](/docs/pro/concepts#glossary). The gateway handles the read and write path; the operator handles everything that wants to be expressed as desired state in the cluster: which vector store the gateway fronts, which indexes exist, how worker pools scale, and which stateless functions run against which indexes. ## CRDs The operator reconciles five resource kinds, each documented on its own page: - [`VectorStore`](/docs/pro/api/vectorstores) — the upstream store endpoint, credential reference, and gateway inbound auth policy. - [`Index`](/docs/pro/indexes) — one resource per turbopuffer namespace the gateway should manage. - [`InfraRules`](/docs/pro/scaling) — cluster-wide compute pools, document cache rules, and shared scaling policy. - [`Pipeline`](/docs/pro/api/pipelines) — staged work that changes row count. - [`Function`](/docs/pro/functions) — stateless user-defined functions that read and write attributes on an index. ## Relationship to the gateway The gateway and the operator are decoupled. The operator reconciles declarative state; the gateway serves the read and write path. Neither sits in the other's hot path, so the gateway keeps serving even if the operator is restarted or lagging. The link between them is one-directional and read-only. For some features the gateway reads CRD status, such as which indexes exist and which worker pools are ready, to inform what it serves. It never writes to the CRDs; declarative state is authored by you and reconciled by the operator, and the gateway is only ever a reader of it. ## Scheduling and node pools The operator applies the compute pool chosen by each Pipeline and Function. A pool can set container resources, `nodeSelector`, and `tolerations`, so operators can pin CPU, storage-heavy CPU, and GPU work to the right node capacity. The operator supplies built-in `cpu`, `cpu-large`, and `gpu` pools when no `InfraRules/default` exists. The stock pools select the chart-rendered Karpenter worker pools: `layer.hev.dev/node-role=worker-cpu` for CPU and `layer.hev.dev/node-role=worker-gpu` for GPU. The GPU pool also requests `nvidia.com/gpu: "1"` and carries the standard NVIDIA toleration. Custom placement is configured once on `InfraRules/default`, not per workload — see [InfraRules](/docs/pro/scaling) for the compute-pool fields and how Pipelines and Functions choose a pool.