Skip to main content
Status.
  • Shipped in @opendatalabs/personal-server-ts 1.16.0, on both the Node server and the in-browser PS Lite runtime, and in @opendatalabs/vana-sdk 3.23.0.
  • Available on Moksha and on mainnet.
  • The mobile Vana app does not register consent-time questions. A request carrying questions cannot complete on a phone; ask for source scopes directly on that path.
  • ?cascade=lineage on delete is specified but not implemented — it answers 501.
A derivative is an ordinary Personal Server record that carries a lineage pointer to the records it was computed from. Nothing else about it is special: the same write path, the same encryption, the same registration, the same grant grammar. This is what lets an app receive an answer without receiving the records behind it. The app declares a question; the user’s own Personal Server computes it over its own copy of the records; the app reads only the answer, under a grant that covers the answer’s scope and nothing else. The lineage records which data points the answer was computed from.

The two invariants

A grant on a derivative confers nothing on its sources, and the reverse. The read policy matches a requested scope against the grant’s entries verbatim, so a grant on coach.weekly never satisfies a read of oura.sleep. The read policy refuses an ungranted scope before any data is looked up, so an app holding only the derivative’s grant has no path to the sources at all. A derived scope must not share its first dot-segment with any of its sources. A grant on chatgpt.* reads every scope under chatgpt., so a derivative of chatgpt.conversations named chatgpt.summary would let a source-namespace grant read the derivative, and a chatgpt.* grant taken for the derivative read the sources. That is the only way the wildcard grammar can leak across a lineage edge, so it is refused at write time with 400 LINEAGE_SCOPE_UNDER_SOURCE_PREFIX. Put derivatives in your own namespace: coach.weekly, not oura.weekly.

Three ways to produce one

The first is the default. Reach for the second or third only when your product genuinely needs the raw records. The question travels inside the access request. Vana’s consent screen renders it verbatim, names the sources in plain words, and states that the app will not see them. When the user approves, their own Vana tab registers the question on their Personal Server as the owner — your app holds no write credential and never talks to the Personal Server about the question at all — and a grant is minted covering only the derived scope.
Rules the service enforces — the SDK mirrors them client-side, and violating any is a 400: Then poll the request status, and once it is approved, read the derived scope from the returned personalServerUrl under the returned grantId — the ordinary signed read described in Grants & permissions.
The stale-answer rule. A derived scope is an ordinary scope and it keeps its last record. If the same user approves your app twice with two different questions, a read taken while the second answer computes succeeds and returns the first answer. Accept a record only when its question field equals the question this approval carried, compared on trimmed text. Anything else is “not ready yet”, exactly like a 404 — and never acknowledge the request on a mismatched record, because the acknowledgement ends the delivering state that keeps the computing Personal Server alive.

App-registered questions

When your app needs to register the question itself, it does so over a write session — no new credential. The routes live under /v1/derivatives, authorized by the write-session bearer plus the X-Vana-Write-Signature proof, exactly like a write to the derived scope.

The grant this needs

Three kinds of entry, in one grant:
Leaving out the bare coach.weekly entry is the common mistake: the question registers and computes, and then the read of the answer fails with SCOPE_MISMATCH, because write:coach.weekly grants no read. Patterns work in both positions. Consent for an app-registered question is read on every source plus write on the derived scope, from that one grant. The prompt is not the security boundary; the grant is — a question can ask for the sources verbatim, so an app that may read the derived scope can read anything the prompt saw. A source scope the grant does not cover with a bare read entry refuses the registration with 403 DERIVATIVE_SOURCE_NOT_GRANTED, and the same check re-runs against the live grant before every compute, so a grant narrowed later fails the question closed without an inference call.

The routes

Registration body:
question is 1 to 8000 characters on this path, sourceScopes is 1 to 16, the registration body is capped at 16 KB, and a registration that would make the derived scope a transitive source of itself is refused with 409 DERIVATIVE_CYCLE. Sources do not have to hold data yet: the question computes once they do.
Two proof rules bite on these routes. The signed uri covers the query string (the list route authorizes you against ?derivedScope=), and repeated polls need a nonce claim in the signed payload — two identical GETs signed in the same second are byte-identical and the second is refused as a replay. Both are handled for you by the SDK.

Declaring the answer’s shape

Without a declared shape the answer is free text, and nothing about the reply is enforced beyond the token cap. A free-text answer can therefore contain source content verbatim, because the question is free to ask for it. A registration may instead declare the fields its answer is made of, up front. The server then instructs the model to answer in that shape and validates the reply against it before writing anything, so a field declared as an integer between 1 and 5 can only ever be one.
The grammar is a flat list of named fields — string, number, integer, boolean, enum — with no nesting, and the declaration rejects any key not listed for the field’s type, so a shape cannot smuggle a second prompt past the question’s character cap. Limits: 1 to 16 fields, a field name of 1 to 64 characters matching [A-Za-z][A-Za-z0-9_]*, maxLength 1 to 4000 on a string, 1 to 32 distinct enum values. required defaults to true. On the way back, a field the model returns that was not declared is dropped and never stored. A reply that violates a declared field gets exactly one corrective turn; a second miss fails the compute rather than writing an answer the app cannot trust. The declaration is also what makes a recompute the same promise as the first compute: a later version matches the shape the approved one did.
registerQuestion in @opendatalabs/vana-sdk 3.23.0 sends derivedScope, sourceScopes, question and model only. Post the registration body yourself when you need answerShape or recompute on this path. A consent-time question accepts recompute through the SDK; answerShape has no consent-time equivalent today.

Writing a derivative you computed yourself

An interpretation that runs outside Vana writes its result back through the Write API with a lineage field — the source data point ids, at most 256, all belonging to the same owner:
A data point id is keccak256(abi.encode(address owner, string scope)) — stable across every version of that scope. Lineage is version-less by design: it says “computed from the owner’s oura.sleep data point”, and is recorded per version of the derived record, immutable once registered. [] is an explicit root statement; an absent lineage makes no statement at all. Because the stamped lineage lives inside the record’s data, the on-chain dataHash commits to it. Changing the lineage changes the hash.

Watching a question

A 404 on the derived scope means three different things — the compute is running, it failed and will be retried, or it failed for good — and polling the data route cannot tell them apart. The status route can, and it is authorized like a read (a live grant covering the derived scope, or the owner), so the consent-time app that holds only a bare read entry can call it:
Nothing is served and nothing is charged, so a priced grant raises no 402 here. status is pending, ready, stale or failed. errorCode is a closed vocabulary — inference_unavailable, source_missing, grant_invalid, internal — so nothing about the user’s data leaks through it, and it is null unless the status is failed. Drive your loop with retryAfterSeconds, not your own interval. It is the server’s own cadence, and it separates a failure still being worked on from one that is over: failed with retryAfterSeconds: null is terminal — stop polling and tell the user.
A failed status is returned, not thrown. An older Personal Server answers 404 for the route itself — treat that as “no signal” and fall back to plain polling.

Compute is just-in-time

A source refresh marks the answer stale; it does not recompute it. The recompute happens the next time someone actually asks for the answer — a read of the derived scope, a status poll, or an explicit recompute. Otherwise the inference bill would scale with how often the user’s sources refresh rather than with how often anyone reads the result.
Three rules make demand-driven compute safe: authorization runs first, so a refused request never spends an inference call; one compute runs per question at a time, so N concurrent readers cause one compute; and reads are never blocked on inference — a read serves the stored version while the recompute runs behind it. A question registered with recompute: "snapshot" never takes the source-change edge at all.

The answer record

The envelope’s own version is the envelope format, never the data point version. answer is always a string; answerData is present only for a question that declared an answerShape, and is the authoritative value when it is. inference carries the provider receipt when one was returned. Who registered the question is not part of the answer record — it is on the registration view, as registeredBy. What you will not find in a record read under a grant are the Personal Server’s own stamps, $writtenBy and $lineage — they are bookkeeping and are redacted from any grant read. The derivation comes to you through the lineage view instead.
On mainnet, a freshly computed answer is not immediately payable. The 402 challenge carries an access record the Personal Server can only sign once the answer’s registration has landed at the gateway, usually under a minute. A 402 loop right after a compute is expected — poll through it at the same cadence you use for “not ready”. A 402 that never clears is an empty escrow, not a slow compute: check your balance. See Payments & fees.

Reading lineage

Two views of the same graph, both authorized like a read and both free:
The version is a path segment, not a query parameter, so it is inside the signed uri; the grant view is the signed grantId claim. A signature for /lineage/2 is refused on /lineage/3, and a captured one cannot be replayed for another grant view.
A node your grant does not cover comes back as exactly { "redacted": true } — no id, no scope, no version, because a data point id is keccak256(owner, scope) and a grantee who knows the owner could dictionary-recover the scope from it. Order and count are preserved, so a redacted node is still identified by its position, and the gateway signs the view it served: un-redacting or dropping a node breaks the proof. A source that no longer resolves comes back with version: "0". Preserve redacted nodes and their positions when you render a lineage graph. The graph still states which data points the answer came from, in which order and at which versions, while the source scopes stay unreadable to you. Lineage is served from the gateway, so a derivative becomes walkable once its registration has synced. getLineage in @opendatalabs/vana-sdk reads either view.

Where the compute runs

The Personal Server assembles the prompt locally and sends it to a confidential inference provider. For each source scope it reads the newest local version, trims a record’s arrays to the newest 50 items, drops the bookkeeping keys ($lineage, $writtenBy, $binary), and sends one section per source alongside the question. A binary record contributes a marker only — its bytes are never sent. The default model is z-ai/glm-5.3-flash. By default the prompt and the answer are end-to-end encrypted to the Phala confidential-inference gateway with the E2EE v2 protocol, under a key fetched from an attested keyset and verified structurally before use. Vana’s inference relay sits in between and forwards ciphertext: it cannot read the user’s data, the question, or the answer. What it does see is the model name, the number and size of the ciphertexts, timing, and the response receipt headers. The raw source data never leaves the Personal Server except through that call. A failure stores an error class only — never a prompt, never an answer. See Confidential compute for the pooled-data counterpart, which is a different mechanism.

Errors

Two disciplines cover most of this table. Treat 404 NOT_FOUND and a question mismatch as the same “not ready” state behind one bounded poll loop — but not SCOPE_MISMATCH, which is a grant that will never start working. And treat an unreachable Personal Server as “the user’s Vana tab is closed” rather than as a failure.