Skip to content
Last updated

Sealed stores

A sealed store keeps a person's records in sync across their devices without Aitronos being able to read them. The client encrypts every record before it leaves the device. The server stores the ciphertext, orders the changes, arbitrates conflicting writes between devices, keeps a short history, and removes what has expired. It never holds a key that opens a record, and it never interprets what is inside one.

Every path starts with https://api.aitronos.com/v1/sealed-stores/{store}.

Stores and record kinds

One store is available today:

StoreKindLive records per person
aitronos-browsersession500
aitronos-browserfavourites1

Any other store name answers 404 SEALED_STORE_NOT_FOUND, and any other kind answers 422 SEALED_KIND_INVALID. Only live records count towards a cap; deleted records do not. Get a sealed store's limits returns the caps, every other limit on this page, and how many live records of each kind the caller holds, so a client can check a save before it starts.

Who can call

Sealed stores are reached only with a person's own sign-in session: Authorization: Bearer <access token>, where the token comes from that person signing in. API keys of any kind and service accounts are refused with 403 SEALED_STORE_PERSONAL_SESSION_REQUIRED, because nothing but the person may act on their encrypted data. A missing or invalid token is 401. As on every signed-in route, the person must belong to an organization, or the call is 403 NO_ORGANIZATION_MEMBERSHIP; the store itself belongs to the person, not to an organization, so it is the same store whichever organization is active.

Each person's store is their own. A record id that belongs to somebody else answers 404 SEALED_RECORD_NOT_FOUND, exactly like an id that was never created.

Records: envelope and payload

Every record is two parts.

The envelope is what the server needs to do its job, and it is readable:

FieldSet byMeaning
idclientThe record's id, minted randomly by the client: 16 to 128 characters of A-Z a-z 0-9 _ -. Never derive it from the content.
kindclientsession or favourites. Fixed at create.
schema_versionclientThe client's own schema and sealing version for this value, 1 to 2,147,483,647. Stored and returned as sent.
device_idclientThe device of the last accepted write: the device id the device signed in with, 16 to 255 characters of A-Z a-z 0-9 . _ : @ -.
revisionserver1 at create, plus 1 on every accepted write, delete and undelete included.
created_atserverWhen the record was created.
last_touched_atserverSet on every accepted write. Sort by it, ties by id.
deleted, deleted_atserverWhether the record is a tombstone, and since when.
payload_bytesserverThe decoded size of the payload; null for a tombstone.

Values the server owns are ignored if a client sends them.

The sealed content is opaque to the server:

  • payload — the encrypted bytes, standard padded base64. At least 28 bytes decoded (a 12-byte nonce plus a 16-byte tag) and at most 512 KiB (524,288 bytes) decoded.
  • wrapped_dek — the record's data key, wrapped to the account's identity public key with an ephemeral X25519 key agreement and an AEAD:
{"v": 1, "aead": "aes-256-gcm", "nonce": "<base64 12 bytes>", "ct": "<base64 48 bytes>", "epk": "<base64 32 bytes>"}

v is accepted as 1 or "1" and always returned as the integer 1. aead is aes-256-gcm or chacha20-poly1305. ct is the wrapped key plus its tag. A wrapped key of the wrong shape is 422 SEALED_WRAPPED_KEY_INVALID; an unknown field inside it is 422 VALIDATION_ERROR.

The payload ceiling counts the payload alone. wrapped_dek is a small fixed-size key wrap and is not counted. A payload over the ceiling is 413 SEALED_PAYLOAD_TOO_LARGE; one that is not standard padded base64, or is shorter than 28 bytes, is 422 SEALED_PAYLOAD_INVALID. These are the only checks the server makes on sealed content: that it decodes and that its size is in bounds.

A deleted record (a tombstone) is envelope-only: deleted is true, and wrapped_dek, payload and payload_bytes are null.

All byte values are standard padded base64. All times are ISO 8601 UTC with a Z, to the microsecond.

Idempotency-Key

Every POST, PUT and DELETE requires an Idempotency-Key header: a random id the client mints once per change and sends again on every retry of that change. It is 16 to 128 printable ASCII characters with no spaces; a UUID works. A missing key is 422 SEALED_IDEMPOTENCY_KEY_REQUIRED and a malformed one 422 SEALED_IDEMPOTENCY_KEY_INVALID.

  • A replay answers the first answer. The first accepted answer to a key is kept for 30 days, up to the newest 50,000 keys per store. Sending the same key with the same call again answers the exact same status and bytes, with the header Idempotent-Replayed: true, and changes nothing, even if the record has changed since. A first answer carries Idempotent-Replayed: false.
  • A refused call is not remembered. A 409, 413 or 422 stores nothing, so after merging, the client may retry with the same key.
  • One key, one call. The same key sent for a different call (another endpoint, another record, or another body) is 422 SEALED_IDEMPOTENCY_KEY_REUSED.
  • Keys are scoped to the person and the store.

Every write is also idempotent by its own rules, so a retry whose remembered answer has aged out never makes a duplicate: a create with the same id answers the record that id names (200), an update or delete naming an old revision answers 409 with the current record, and a superseded append with the same idempotency_key answers the stored entry (200).

Writes to one store are serial

The server takes one lock per person and store for every write, so revisions, the change sequence and the caps can never race each other. Writes sent at the same time therefore wait for each other. A write that waits more than 2 seconds for that lock is 503 SERVICE_TEMPORARILY_UNAVAILABLE with retry_after, and nothing was written: send it again with the same Idempotency-Key and the same body. Sending one store's writes one at a time, or a few at once, keeps the wait short.

Writes answer the envelope

An accepted create, update, delete or undelete answers the record's envelope (object: "sealed_record_envelope"), with its new revision and last_touched_at and without a copy of the sealed content. The writer already holds what it sent. To read the content, get the record or read the change feed, for example after an undelete.

Conflicts

A write names the revision it was based on. When the record has moved on, or is in the wrong state for the write, the answer is a 409 that carries the full record as the server holds it now in error.details.current: the envelope and the payload, or the tombstone. The client merges and writes again without another read.

CodeWhen
SEALED_RECORD_REVISION_CONFLICTThe revision sent is not the record's current revision.
SEALED_RECORD_DELETEDThe record is a tombstone. Undelete it before writing. This wins over a stale revision.
SEALED_RECORD_NOT_DELETEDUndelete of a record that is live.
SEALED_RECORD_ID_CONFLICTCreate with an id that already names a record of another kind.
SEALED_RECORD_SINGLETON_EXISTSCreate (or undelete) of a favourites record when the person already holds the live one. current is that record: adopt it.

Reading changes

List changes is the sync read.

  • Full read. Without since, it returns every record in the store, live and tombstoned, in change order.
  • Delta read. With since, it returns every record created, updated, deleted or undeleted after that cursor, each once, in its current state. A deleted record arrives as its tombstone.

Follow next_cursor while has_more is true, then keep the last next_cursor for the next sync. Cursors are opaque: store them and send them back, never parse or build one.

A cursor issued more than 30 days ago, or one from before a tombstone that has since been removed, is refused with 410 SEALED_CURSOR_EXPIRED. It means the delta can no longer be complete: do a full read and reconcile the device's records against it. A cursor this store did not issue is 422 SEALED_CURSOR_INVALID; treat it the same way.

Deleting, recovery and history

  • Tombstones. A delete turns the record into a tombstone. It appears in the feed and in List deleted records, and it can be undeleted for 30 days, after which it is removed for good. A store keeps at most its newest 500 tombstones: a delete past that removes the oldest tombstone at once, before its 30 days are up.
  • Revision history. Every create, update and undelete keeps a copy of the content for 30 days, at most the newest 100 per record and 1,000 across the store (past that, the store's oldest revisions go first, whichever record they belong to). A delete keeps no content. See List a record's revisions.
  • Superseded values. A value a client-side merge discarded, such as the name that lost a rename on another device, can be kept on the record so the person can recover it. Kept 30 days, at most 200 per record and 2,000 per store, each at most 64 KiB (65,536 bytes) decoded. A full log refuses new entries rather than dropping old ones.
  • Account key blob. Each store holds one account-scoped opaque blob, at most 2 KiB (2,048 bytes) decoded, that every one of the person's signed-in devices can read. The first create wins and it is never replaced.

Limits

LimitValue
Live session records per person500
Live favourites records per person1
Sealed payload per record512 KiB (524,288 bytes) decoded, counting payload alone, not wrapped_dek
Smallest sealed value28 bytes decoded
Superseded entry payload64 KiB (65,536 bytes) decoded
Superseded entries200 per record and 2,000 per store, within 30 days
Account key blob2 KiB (2,048 bytes) decoded
Revision history100 per record and 1,000 per store, within 30 days
Tombstones500 per store, each for 30 days
Remembered Idempotency-Key answersThe newest 50,000 per store, each for 30 days
Retention30 days, for tombstones, history, superseded entries, Idempotency-Key answers and feed cursors
Feed pagelimit 1 to 500 (default 100); a page also stops early once it carries 8 MiB of payload, never below one record
Deleted and superseded list pageslimit 1 to 500 (default 100)

Errors

Every error uses the standard error format:

{
  "success": false,
  "error": {
    "code": "SEALED_RECORD_NOT_FOUND",
    "message": "That item isn't synced.",
    "system_message": "no record with this id in this store",
    "type": "client_error",
    "status": 404,
    "details": {},
    "trace_id": "9d63d4ed-24bb-4579-81cf-a78ff1e78c0c",
    "timestamp": "2026-10-10T09:12:40.318552Z"
  }
}

message is written for the person using the app; system_message is for the developer.

StatusCodeMeaning
401AUTHENTICATION_REQUIREDNo access token, or one that is not valid.
403SEALED_STORE_PERSONAL_SESSION_REQUIREDThe call used an API key or a service account. Only a person's own sign-in session reaches a sealed store.
403NO_ORGANIZATION_MEMBERSHIPThe signed-in person belongs to no organization.
404SEALED_STORE_NOT_FOUNDNo store with that name (details.store).
404SEALED_RECORD_NOT_FOUNDNo record with that id in the caller's store, or its tombstone has passed the 30-day retention.
404SEALED_REVISION_NOT_FOUNDThat revision is a delete (it has no content) or is no longer in the kept history (details.revision).
404SEALED_ACCOUNT_KEY_NOT_FOUNDNo device has created the store's account key blob yet.
409SEALED_RECORD_REVISION_CONFLICTThe revision sent is stale. details.current is the record now.
409SEALED_RECORD_DELETEDThe record is a tombstone. details.current is the tombstone.
409SEALED_RECORD_NOT_DELETEDUndelete of a live record. details.current is the record.
409SEALED_RECORD_ID_CONFLICTThe id already names a record of another kind. details.current is that record.
409SEALED_RECORD_SINGLETON_EXISTSThe one live favourites record already exists. details.current is that record.
409SEALED_OPERATION_IN_PROGRESSAn earlier call with the same Idempotency-Key has not finished. Retry in a moment.
410SEALED_CURSOR_EXPIREDThe feed cursor is too old to be complete (details.retention_days). Do a full read and reconcile.
413SEALED_PAYLOAD_TOO_LARGEA sealed value is over its ceiling (details.field, details.max_bytes, details.payload_bytes).
422SEALED_PAYLOAD_INVALIDA sealed value is not standard padded base64, or decodes to fewer than 28 bytes (details.field).
422SEALED_WRAPPED_KEY_INVALIDwrapped_dek is not a wrapped key of the documented shape (details.field).
422SEALED_KIND_INVALIDThe store does not hold that kind (details.kind, details.allowed).
422SEALED_RECORD_LIMIT_REACHEDThe person already holds the most live records of that kind (details.kind, details.limit). Delete one to add another.
422SEALED_SUPERSEDED_LIMIT_REACHEDThe record or the store already holds the most superseded entries (details.limit; details.scope is record or store).
422SEALED_CURSOR_INVALIDThe cursor was not issued by this store. Do a full read.
422SEALED_IDEMPOTENCY_KEY_REQUIREDA write without an Idempotency-Key header.
422SEALED_IDEMPOTENCY_KEY_INVALIDThe Idempotency-Key is not 16 to 128 printable ASCII characters without spaces.
422SEALED_IDEMPOTENCY_KEY_REUSEDThe Idempotency-Key was already used for a different call. Mint a new one for a new change.
422VALIDATION_ERRORA parameter or body field is missing, of the wrong type or out of range (for example an id that does not match its pattern, a revision above 9,223,372,036,854,775,807, or an unknown field in wrapped_dek).
503SERVICE_TEMPORARILY_UNAVAILABLEThe write waited too long for the store's lock (see Writes to one store are serial). Nothing was written; retry with the same Idempotency-Key after retry_after seconds.

Endpoints

MethodPathPage
GET/v1/sealed-stores/{store}Get a sealed store's limits
POST/v1/sealed-stores/{store}/recordsCreate a sealed record
GET/v1/sealed-stores/{store}/records/{record_id}Get a sealed record
PUT/v1/sealed-stores/{store}/records/{record_id}Update a sealed record
DELETE/v1/sealed-stores/{store}/records/{record_id}Delete a sealed record
POST/v1/sealed-stores/{store}/records/{record_id}/undeleteUndelete a sealed record
GET/v1/sealed-stores/{store}/changesList sealed store changes
GET/v1/sealed-stores/{store}/deletedList deleted sealed records
GET/v1/sealed-stores/{store}/records/{record_id}/revisionsList a sealed record's revisions
GET/v1/sealed-stores/{store}/records/{record_id}/revisions/{revision}Get a sealed record revision
POST/v1/sealed-stores/{store}/records/{record_id}/supersededAppend a superseded value
GET/v1/sealed-stores/{store}/records/{record_id}/supersededList superseded values
POST/v1/sealed-stores/{store}/account-keyCreate the account key blob
GET/v1/sealed-stores/{store}/account-keyGet the account key blob