Status.
- Shipped in
@opendatalabs/personal-server-ts1.16.0, on both the Node server and the in-browser PS Lite runtime, and in@opendatalabs/vana-sdk3.23.0. - Writes are free. The escrow settles two operation types today,
grantanddata_access; there is no write or registration fee on either network. - The hosted consent screen issues read grants only. A write grant is still arranged with the Vana team rather than self-served.
- Records are append-only. A write adds a version; it does not edit one.
- The user’s Personal Server signs the on-chain registration as the owner. The app never holds the owner key and never touches the encryption key for the scope.
- The write grant carries a
write:prefix and confers no read.write:notes.entrieslets an app write that scope and nothing else. Reading it back needs a separate bare entry in the same grant. See Scope grammar. - Every record carries who wrote it. The app signs the exact bytes it stored, and the Personal Server keeps that signature with the record, so authorship is verifiable from the record alone.
The flow
You prove control of your key once per session, then write with a bearer token plus a per-write signature. The session is authorization; the signature is attribution.Prerequisites
Step 1 — Open a write session
aud, method, uri, bodyHash, iat, exp — and it must carry a grantId claim naming the write grant. It is signed EIP-191 by your builder key. A bearer credential can never open a write session: the handshake exists to prove control of the key.
scope lists the grant’s write patterns with the write: prefix stripped — exactly what POST /v1/data/:scope will accept under this token.
What the handshake checks: the builder is registered, the grant exists and is not revoked, the grant carries at least one write: entry, the grantee is the handshake signer, and the grantor is this server’s owner. Per-scope enforcement is not settled here — it runs against the live grant on every write, so a grant revoked mid-session fails the next write, not the next handshake.
The handshake proof is single-use: a still-valid proof cannot mint a second token (WRITE_SESSION_PROOF_REPLAY). Sign a fresh one per handshake.
Step 2 — Write the record
X-Vana-Write-Signature proof attributes it: it is signed by the same builder key over aud, method, uri, bodyHash, grantId — so a stored record cannot be lifted onto another scope, or relabelled with a different grant, and still verify.
Three rules the proof imposes:
- The body must be compact JSON — what
JSON.stringifyemits, no insignificant whitespace. The app signs the bytes that will be stored, so a pretty-printed body is refused withWRITE_BODY_NOT_CANONICALrather than stored with an attribution nobody can check. - The signed
uricovers the query string, canonicalised with parameters sorted by name and then value. Parameter order does not matter; the parameters themselves must match. - Repeat requests need a
nonceclaim. Two identical writes signed in the same second are byte-identical, and the replay guard refuses the second (WRITE_ATTRIBUTION_REPLAY). Add anonce— any unique string up to 128 characters, a uuid is the intended shape — and each call is distinct. The nonce is then itself single-use.
201:
status: "syncing" means the record is stored locally and the on-chain registration is in flight. See Provenance & verifiability for what lands on chain.
Writing bytes instead of JSON
AnyContent-Type other than JSON stores the payload as an unstructured record:
$binary record — media type, filename, your metadata, size, content hash, content — not the raw bytes, so switching the same bytes between a JSON and a binary write fails verification. The Node server caps an ingest body at 50 MB.
What the Personal Server stamps on the record
The server adds two bookkeeping keys inside the record’sdata:
A request body that brings its own
$writtenBy or $lineage is rejected with 400 INVALID_BODY. Both are stripped before the stored bytes are re-hashed, so a record with lineage verifies exactly like one without.
A third reserved key, $binary, is not bookkeeping: it is the stored form of an unstructured write, holding the media type, filename, your metadata, size, content hash and content. It is served on a read like any other content.
$writtenBy and $lineage are not served on a read under a grant. A data point id is keccak256(owner, scope), so a grantee holding one could hash known scope names against the owner’s address and learn which sources that person has connected. The owner reads them from their own storage; a grantee gets the derivation through the lineage view instead, with uncovered nodes redacted.Using the SDK
401. sessionCoversScope(session, scope) answers whether a scope is writable under the session you hold before you send anything.
Errors
Limits to design around
- Append-only. A write registers a new version; there is no edit in place. Model corrections as a new version, not a mutation.
- Sessions do not survive a restart. Re-handshake on
401. - A write grant is not a read grant. If your app needs to read what it wrote, the same grant must also carry a bare entry for that scope.
- The Personal Server must be reachable. For a browser-hosted PS Lite that means the user has Vana open. A write cannot be queued for an offline server.
Related
- Scope grammar — how
write:entries are encoded and why they never confer read - Derivative data — writing a record that carries lineage, and having the Personal Server compute one for you
- Grants & permissions — obtaining the grant this API runs under
- Personal Servers — where the write lands
- Payments & fees — what the escrow settles today