> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vana.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Derivative data

> Records computed from other records — lineage, consent-time questions, and answers an app can read without ever seeing the sources.

<Info>
  **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`.
</Info>

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](/protocol-reference/scope-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

| Path                        | Who computes                                       | What your grant carries                                                | Your app sees the sources?  |
| --------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------- |
| **Consent-time question**   | The user's Personal Server, registered by the user | A bare read entry on the derived scope, and nothing else               | **No**                      |
| **App-registered question** | The user's Personal Server, registered by your app | Bare read on every source, bare read and `write:` on the derived scope | Yes — the grant covers them |
| **Write it yourself**       | Your own backend                                   | Bare read on every source, `write:` on the derived scope               | Yes                         |

The first is the default. Reach for the second or third only when your product genuinely needs the raw records.

## Consent-time questions

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.

```typescript theme={null}
import { createDirectDataController } from "@opendatalabs/vana-sdk/server";

const vana = createDirectDataController({
  env: "production",              // "dev" for Moksha
  network: "mainnet",             // "moksha" for the test environment
  appPrivateKey: process.env.APP_KEY,
  app: { id: "your-app-id", name: "Your App", homepageUrl: "https://yourapp.example" },
  source: "linkedin",
  scopes: ["coach.weekly"],       // the derived scope, as a BARE read entry
});

const created = await vana.createAccessRequest({
  returnUrl: "https://yourapp.example/?returned=1",
  questions: [{
    derivedScope: "coach.weekly",
    sourceScopes: ["linkedin.profile", "linkedin.experience"],
    question: "What is my latest position, and what does my experience add up to?",
    recompute: "snapshot",
  }],
});
// created.requestId, created.approvalUrl
```

Rules the service enforces — the SDK mirrors them client-side, and violating any is a `400`:

| Field          | Rule                                                                                                                                                                        |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `questions`    | 1 to 4 entries when present; no two share a `derivedScope`                                                                                                                  |
| `derivedScope` | A concrete scope, no wildcard, no operation prefix. It **must also appear verbatim in `scopes`** as a bare read entry, or your app could never read the answer it asked for |
| `sourceScopes` | 1 to 16 concrete scopes, no duplicates, none equal to the derived scope. They do **not** go into `scopes` — sources are never granted                                       |
| naming         | The first dot-segment of `derivedScope` must differ from that of every source scope                                                                                         |
| `question`     | 1 to 4000 characters after trimming                                                                                                                                         |
| `recompute`    | `"snapshot"` (compute once at registration) or `"on-change"` (mark stale when a source changes, recompute on the next read). Omitted means the server default, `on-change`  |

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](/protocol-reference/grants-permissions#requesting-a-grant-the-connect-flow).

<Warning>**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.</Warning>

## App-registered questions

When your app needs to register the question itself, it does so over a [write session](/protocol-reference/write-api#step-1--open-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:

| Entry                          | What it is for                                              |
| ------------------------------ | ----------------------------------------------------------- |
| Bare read on each source       | Registering the question, and every recompute               |
| Bare read on the derived scope | Reading the answer back                                     |
| `write:<derivedScope>`         | Registering, and the question routes' builder authorization |

```json theme={null}
{
  "scopes": [
    "oura.sleep",
    "chatgpt.conversations",
    "coach.weekly",
    "write:coach.weekly"
  ]
}
```

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

| Method   | Path                                      | Who                                                            | Result                          |
| -------- | ----------------------------------------- | -------------------------------------------------------------- | ------------------------------- |
| `POST`   | `/v1/derivatives/questions`               | App with `write:<derivedScope>`, or the owner                  | `201` registration view         |
| `GET`    | `/v1/derivatives/questions`               | Owner; an app must pass `?derivedScope=` and sees only its own | `{ questions: [...] }`          |
| `GET`    | `/v1/derivatives/questions/:id`           | Owner or the registering app                                   | Registration view               |
| `POST`   | `/v1/derivatives/questions/:id/recompute` | Owner or the registering app                                   | `202`, recompute now            |
| `DELETE` | `/v1/derivatives/questions/:id`           | Owner or the registering app                                   | `{ questionId, deleted: true }` |

Registration body:

```json theme={null}
{
  "derivedScope": "coach.weekly",
  "sourceScopes": ["chatgpt.conversations", "oura.sleep"],
  "question": "How did my sleep relate to my mood this week?",
  "model": "z-ai/glm-5.3-flash",
  "recompute": "on-change"
}
```

`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.

<Note>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 `GET`s signed in the same second are byte-identical and the second is refused as a replay. Both are handled for you by the SDK.</Note>

### 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.

```json theme={null}
{
  "answerShape": {
    "fields": [
      { "name": "score", "type": "integer", "min": 1, "max": 5 },
      { "name": "mood", "type": "enum", "values": ["up", "down", "flat"] },
      { "name": "reason", "type": "string", "maxLength": 200, "required": false }
    ]
  }
}
```

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.

<Note>`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.</Note>

## Writing a derivative you computed yourself

An interpretation that runs outside Vana writes its result back through the [Write API](/protocol-reference/write-api) with a `lineage` field — the source data point ids, at most 256, all belonging to the same owner:

```typescript theme={null}
import { writeData } from "@opendatalabs/vana-sdk";

await writeData({
  session,
  scope: "coach.weekly",
  data: { summary: "Sleep improved after March." },
  lineage: [{ ownerAddress, scope: "oura.sleep" }],   // or raw data point ids
});
```

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:

```http theme={null}
GET {personalServerUrl}/v1/derivatives/status?derivedScope=coach.weekly
Authorization: Web3Signed <base64url(payload)>.<signature>
```

```json theme={null}
{
  "derivedScope": "coach.weekly",
  "status": "failed",
  "lastComputedAt": "2026-09-04T09:12:44.000Z",
  "derivedVersion": 3,
  "derivedCollectedAt": "2026-09-04T09:12:44Z",
  "errorCode": "inference_unavailable",
  "retryAfterSeconds": 300
}
```

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.

```typescript theme={null}
import { waitForDerivativeStatus } from "@opendatalabs/vana-sdk";

const status = await waitForDerivativeStatus({
  personalServerUrl,
  derivedScope: "coach.weekly",
  grantId,
  signer: appAccount,
});
// returns as soon as the scope is ready or has failed for good; branch on errorCode
```

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.

```
pending --compute ok--> ready
pending --compute err-> failed
ready | failed --source changed / owner recompute--> stale
stale --compute ok--> ready
stale --compute err-> failed
```

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

```json theme={null}
{
  "version": "1.0",
  "scope": "coach.weekly",
  "collectedAt": "2026-09-04T09:12:44.000Z",
  "data": {
    "questionId": "5f0c...",
    "question": "How did my sleep relate to my mood this week?",
    "answer": "Your sleep improved through the week ...",
    "answerData": { "score": 4, "mood": "up" },
    "evidence": "...",
    "model": "z-ai/glm-5.3-flash",
    "computedAt": "2026-09-04T09:12:44.000Z",
    "sources": [{ "scope": "oura.sleep", "version": 4, "collectedAt": "..." }],
    "lineage": ["0x5b1a...9c"]
  }
}
```

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](/protocol-reference/write-api#what-the-personal-server-stamps-on-the-record). The derivation comes to you through the lineage view instead.

<Note>**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](/protocol-reference/payments-fees).</Note>

## Reading lineage

Two views of the same graph, both authorized like a read and both free:

```http theme={null}
GET {personalServerUrl}/v1/data/coach.weekly/lineage[/{version}]
GET {gateway}/v1/data/{dataPointId}/lineage[/{version}]
```

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.

```json theme={null}
{
  "data": {
    "dataPointId": "0xd3f1...aa",
    "scope": "coach.weekly",
    "version": "3",
    "deletedAt": null,
    "sources": [
      { "dataPointId": "0x5b1a...9c", "scope": "oura.sleep", "version": "12", "deletedAt": null },
      { "redacted": true }
    ],
    "derivatives": []
  },
  "proof": { "gatewaySignature": "0x...", "status": "confirmed", "chainBlockHeight": 8829146 }
}
```

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](/applications/confidential-compute) for the pooled-data counterpart, which is a different mechanism.

## Errors

| Status | `errorCode`                         | Meaning                                                                                                                                                                     |
| ------ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `DERIVATIVE_QUESTION_INVALID`       | Body shape, scope grammar, limits, or `answerShape` grammar                                                                                                                 |
| 400    | `LINEAGE_INVALID`                   | Not an array of distinct 32-byte hex ids, over 256 entries, or a source is the record's own id                                                                              |
| 400    | `LINEAGE_SCOPE_UNDER_SOURCE_PREFIX` | The derived scope shares its first dot-segment with a source                                                                                                                |
| 400    | `DERIVATIVE_DERIVED_SCOPE_REQUIRED` | An app listed questions without `?derivedScope=`                                                                                                                            |
| 403    | `DERIVATIVE_SOURCE_NOT_GRANTED`     | A source scope is not read-granted to the app; `details.scopes` names them                                                                                                  |
| 403    | `SCOPE_MISMATCH`                    | The grant does not cover the requested read — most often a missing bare entry for the derived scope, since `write:` grants no read. This is a grant error, not a timing one |
| 404    | `DERIVATIVE_QUESTION_NOT_FOUND`     | Unknown id, or another app's question                                                                                                                                       |
| 409    | `DERIVATIVE_CYCLE`                  | The registration would make the derived scope its own source                                                                                                                |
| 410    | `DATA_DELETED`                      | The user deleted the scope. Stop reading it and drop your cached copy                                                                                                       |
| 413    | `CONTENT_TOO_LARGE`                 | Registration body over 16 KB                                                                                                                                                |
| 422    | `LINEAGE_SOURCE_UNKNOWN`            | A source is not a data point of this owner; `details.unknown` lists them                                                                                                    |
| 501    | `LINEAGE_CASCADE_UNAVAILABLE`       | `?cascade=lineage` on delete; specified, not implemented                                                                                                                    |
| 503    | `DERIVATIVE_COMPUTE_UNAVAILABLE`    | That Personal Server has no compute layer wired                                                                                                                             |

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.

## Related

* [Write API](/protocol-reference/write-api) — the session and the signed write a derivative is stored through
* [Scope grammar](/protocol-reference/scope-grammar) — why a `write:` entry never satisfies a read
* [Grants & permissions](/protocol-reference/grants-permissions) — obtaining the grant, and the access-request flow questions ride in
* [Provenance & verifiability](/protocol-reference/provenance) — what the chain commits to
* [Confidential compute](/applications/confidential-compute) — jobs over pooled data from many users, as opposed to one user's own server
* [Payments & fees](/protocol-reference/payments-fees) — what a read of an answer settles
