This document is the authoritative description of how Proxion stores your data on a Solid pod. It exists so that any Solid app, not just Proxion, can read and reuse your data. That is the whole point of building on Solid: your messages are open, typed resources in a datastore you control, not rows in someone else's database.
Proxion draws a deliberate line between two things that are easy to conflate:
- Encryption in transit (end-to-end). Direct messages are end-to-end encrypted on the wire between you and your contact, so no relay or gateway that forwards them can read them. This is a property of delivery.
- Storage at rest (open). What lands on your own pod is written as plain, typed JSON-LD, using a documented vocabulary. It is not an encrypted blob. Any app you authorize can read it. This is a property of storage.
Because these are separate, Proxion can be private against the infrastructure in the middle while still keeping your pod an open, interoperable store. This document describes the storage side.
All Proxion-specific terms use one namespace:
https://proxion.dev/vocab/v1#
conventionally bound to the prefix px:. Every JSON-LD document below carries
"@context": { "px": "https://proxion.dev/vocab/v1#" }. Until a machine-readable
ontology is served at that URL, this document is the definition of the terms.
Access control uses the standard Web Access Control vocabulary,
http://www.w3.org/ns/auth/acl# (prefix acl:).
Everything lives under a single container, {pod}/proxion/:
{pod}/proxion/
├── profile/
│ ├── display_name.jsonld px:Profile
│ └── avatar.png (binary image)
├── rooms/
│ ├── index.jsonld px:Index (room ids)
│ └── {roomId}/
│ ├── members.jsonld px:MemberList
│ ├── messages/
│ │ └── {messageId}.jsonld px:Message
│ ├── reactions/
│ │ └── {messageId}.jsonld px:ReactionSet
│ └── files/
│ ├── {messageId}.webm (binary voice note)
│ └── {messageId}/{name} (binary attachment)
├── dm/ (only when DM pod archive is enabled; opt-in)
│ ├── index.jsonld px:Index (thread ids)
│ └── {threadId}/
│ └── messages/
│ ├── index.jsonld px:Index (message ids)
│ └── {messageId}.jsonld px:Message
├── saved/ (only when bookmarks+settings sync is enabled; opt-in)
│ ├── index.jsonld px:Index (saved message ids)
│ └── {messageId}.jsonld px:SavedMessage
├── settings.jsonld px:Settings (only when sync enabled; opt-in)
├── mutes.jsonld px:MuteList (only when sync enabled; opt-in)
├── blocks.jsonld px:BlockList (only when sync enabled; opt-in)
├── gifs/ (only when sync enabled; opt-in)
│ ├── index.jsonld px:Index (gif ids)
│ ├── {id} (binary image, real content type)
│ └── {id}.jsonld px:GifFavorite (metadata + image ref)
├── contacts/
│ ├── index.jsonld px:Index (cert ids)
│ └── {certId}.jsonld px:Contact
├── invites/
│ ├── index.jsonld px:Index (invitation ids)
│ └── {id}.jsonld px:PendingInvite
├── readstate/
│ └── {threadId}.jsonld px:ReadState
├── scheduled/
│ └── {id}.jsonld px:ScheduledMessage
└── webhooks/
└── {id}.jsonld px:Webhook
Identifiers (roomId, threadId, messageId, certId) match
^[\w-]{1,128}$. Timestamps are ISO 8601 strings.
Other Solid chat apps (the SolidOS databrowser's Long
Chat, POD-CHAT) share one vocabulary, so they can read each other's chats. Room
messages written by Proxion carry those same standard terms in addition to
the px: ones, in the same JSON-LD graph, so another Solid app can read a
Proxion room without understanding anything Proxion-specific:
| Standard term | Namespace | Carries |
|---|---|---|
sioc:content |
http://rdfs.org/sioc/ns# |
the message text |
foaf:maker |
http://xmlns.com/foaf/0.1/ |
the author, as an IRI (WebID) |
dct:created |
http://purl.org/dc/terms/ |
the send time, as xsd:dateTime |
Nothing is lost by this: apps that do not understand px: ignore it, and Proxion
keeps using px: for the things the shared vocabulary has no term for
(reactions, forwarding, content-type nuance, cached reply snippets).
Rooms interoperate; direct messages deliberately do not. This split is the
honest consequence of end-to-end encryption. You cannot have bytes that a
third-party app can read and that no third party can read. Rooms are shared by
design, so their history is plaintext and open. DMs are end-to-end encrypted, so
they stay px:-only and are not third-party readable, on purpose.
A pod-less identity is a did:key, which is a valid IRI but is not
dereferenceable, so other apps will show the identifier rather than a name. That
is the expected limit of interop for users who have not connected a pod.
Edits and deletes are reflected in the Long Chat too, both verified against the real SolidOS databrowser:
- An edit rewrites the message's
sioc:contentin place (a SPARQL-UpdateDELETE/INSERT/WHEREon the day file), so the latest text shows in any reader ofsioc:content. This is used in preference to adct:isReplacedByreplacement chain, whose rendering is reader-dependent; thepx:layer keeps full edit history. - A delete appends a
schema:dateDeletedtombstone (http://schema.org/). The message node stays so the append-only day file remains valid; the databrowser hides a tombstoned message, and Proxion's reader blanks its content.
Not yet mapped: replies and threads (sioc:has_reply, sioc:Thread). Long Chat
models replies on the parent message while Proxion models them on the child, so
that mapping is being prototyped against a real SolidOS thread rather than
guessed. Those relations remain px:-only until verified.
Message signatures. Each room message Proxion writes also carries a cryptographic signature, following the Solid chat SHACL shape:
| Standard term | Namespace | Carries |
|---|---|---|
sec:proofValue |
https://w3id.org/security# |
an Ed25519 signature over the message |
The signature covers exactly the message's core fields: its IRI (@id), the
dct:created time, the sioc:content text, and the foaf:maker author, framed
as length-prefixed bytes so no field can be shifted into another. Because the
shape allows one literal, the value packs the signer's did:key and the base64
signature as "<did:key>|<base64-signature>", so a reader has both the key and
the signature from the single term.
On read, Proxion verifies the signature and then confirms the signer is one the
author published at their own pod (the same trust anchor a direct message
uses; see proxion/identity/signer.json), which is what lets a reader trust that
foaf:maker is really the author and not a value a third party wrote into a
shared container. A message shows as verified only when both checks pass.
A message with no signature, an invalid one, or a signer the author has not
published shows as unverified, but is still read and displayed, never
dropped. This is required for interoperability: SolidOS and POD-CHAT write
unsigned messages, and an edit that rewrites sioc:content in place changes the
bytes the original signature covered, so an edited message also reads as
unverified.
Carrying the right terms is not enough for another app to open a Proxion room;
the layout has to match too. So each room is additionally written in the standard
Long Chat shape, next to the px: archive:
proxion/rooms/{roomId}/index.ttl <#this> a meeting:LongChat
proxion/rooms/{roomId}/YYYY/MM/DD/chat.ttl that day's messages
A day file links each message to the channel and then describes it:
<../../../index.ttl#this> meeting:message :m-abc123 .
<../../../index.ttl#this> wf:message :m-abc123 .
:m-abc123
dct:created "2026-07-22T14:03:11.000Z"^^xsd:dateTime;
sioc:content "Morning, everyone";
foaf:maker <https://alice.pod.example/profile/card#me>;
sec:proofValue "did:key:z6Mk...|Base64Signature==".Details worth stating because they are easy to get wrong:
- Both linking predicates are emitted:
meeting:message(the written spec and POD-CHAT) andwf:message(http://www.w3.org/2005/01/wf/flow#). The SolidOS databrowser enumerates a channel's messages withwf:message, so a chat carrying onlymeeting:messageis invisible in the reference app. This was found by driving the real databrowser, not by reading the spec. - The channel title uses Dublin Core Elements (
dc:), while message timestamps use Dublin Core Terms (dct:). Different namespaces. - Day partitioning uses the message's UTC date, as the spec requires.
- Messages are appended with a SPARQL-Update
PATCH, not read-modify-write, so two devices writing the same day do not clobber each other.
Reading goes the other way: Proxion requests these resources as JSON-LD via content negotiation, so it can display a chat written by SolidOS or POD-CHAT without shipping an RDF parser to the browser.
The px: per-message JSON-LD documents described below are still written and
remain the canonical Proxion copy; the Long Chat layout is the interoperable
view of the same conversation.
A single message in a room or DM thread. Path:
proxion/rooms/{roomId}/messages/{messageId}.jsonld or
proxion/dm/{threadId}/messages/{messageId}.jsonld. Room messages are archived
automatically when a pod is connected; DM messages only when you turn on the
opt-in DM pod archive (see "What is deliberately NOT on the pod" below).
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:Message",
"@id": "https://alice.pod.example/proxion/rooms/general/messages/m-abc123.jsonld",
"px:messageId": "m-abc123",
"px:threadId": "general",
"px:content": "Morning, everyone",
"px:contentType": "text",
"px:fromWebid": "https://alice.pod.example/profile/card#me",
"px:fromName": "Alice",
"px:timestamp": "2026-07-20T14:03:11.000Z",
"px:replyToId": null,
"px:replyToSnippet": null,
"px:forwarded": false,
"px:forwardedFromName": null
}| Term | Type | Meaning |
|---|---|---|
px:messageId |
string | Stable id, unique within the thread |
px:threadId |
string | Room id or DM thread id this belongs to |
px:content |
string | The message body, in plain text |
px:contentType |
string | text, audio, etc. |
px:fromWebid |
string | Sender's WebID or did:key |
px:fromName |
string | Sender's display name at send time |
px:timestamp |
string | ISO 8601 send time |
px:replyToId |
string / null | Id of the message this replies to |
px:replyToSnippet |
string / null | Cached preview of the replied-to message |
px:forwarded |
boolean | Whether this message was forwarded |
px:forwardedFromName |
string / null | Original author name, if forwarded |
proxion/profile/display_name.jsonld. The avatar, if set, is a plain PNG at
proxion/profile/avatar.png.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:Profile",
"px:displayName": "Alice",
"px:updatedAt": "2026-07-20T14:00:00.000Z"
}proxion/rooms/{roomId}/members.jsonld. px:members is an array of member
descriptors (WebID or did:key plus display name).
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:MemberList",
"px:roomId": "general",
"px:members": [
{ "webid": "https://alice.pod.example/profile/card#me", "name": "Alice" }
],
"px:updatedAt": "2026-07-20T14:00:00.000Z"
}proxion/rooms/{roomId}/reactions/{messageId}.jsonld. px:reactions maps an
emoji (or :custom_name:) to the list of reactors.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:ReactionSet",
"px:messageId": "m-abc123",
"px:reactions": { "👍": ["https://alice.pod.example/profile/card#me"] },
"px:updatedAt": "2026-07-20T14:05:00.000Z"
}proxion/readstate/{threadId}.jsonld. The last message you have read in a thread,
for cross-device read sync.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:ReadState",
"px:threadId": "general",
"px:lastReadMessageId": "m-abc123",
"px:updatedAt": "2026-07-20T14:06:00.000Z"
}proxion/contacts/{certId}.jsonld. Wraps the relationship certificate that
authorizes a contact. px:certificate is the certificate object.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:Contact",
"@id": "https://alice.pod.example/proxion/contacts/cert-xyz.jsonld",
"px:certId": "cert-xyz",
"px:certificate": { "...": "certificate fields" },
"px:updatedAt": "2026-07-20T14:00:00.000Z"
}proxion/saved/{messageId}.jsonld, enumerated by proxion/saved/index.jsonld.
A private bookmark (a snapshot of a saved message, not a live copy). Written
only when the opt-in bookmarks + settings sync is enabled; owner-only, because a
bookmark can quote a DM.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:SavedMessage",
"px:messageId": "m-abc123",
"px:threadId": "general",
"px:threadType": "local_room",
"px:threadLabel": "general",
"px:fromName": "Alice",
"px:content": "worth remembering",
"px:hasFile": false,
"px:fileKind": "",
"px:timestamp": "2026-07-20T14:03:11.000Z",
"px:savedAt": 1721480400000,
"px:updatedAt": "2026-07-20T14:10:00.000Z"
}proxion/settings.jsonld. A single document holding your synced, account-level
app preferences under px:prefs (a plain string map). Written only when sync is
enabled. Device-specific preferences (local DM-history cache, the sync toggles
themselves, gateway URL) are deliberately excluded.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:Settings",
"px:prefs": {
"proxion_receipts_enabled": "1",
"proxion_link_previews_enabled": "0",
"proxion_locale": "de"
},
"px:updatedAt": "2026-07-20T14:10:00.000Z"
}proxion/mutes.jsonld. The set of conversations you have muted, as a plain
list of thread ids, so muting follows you across devices. Written only when sync
is enabled; owner-only.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:MuteList",
"px:threads": ["general", "dm-thread1"],
"px:updatedAt": "2026-07-20T14:10:00.000Z"
}proxion/blocks.jsonld. The WebIDs you have blocked, as a plain list, so a
block follows you across devices. Written only when sync is enabled; owner-only.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:BlockList",
"px:webids": ["did:key:zEvil"],
"px:updatedAt": "2026-07-20T14:10:00.000Z"
}proxion/gifs/{id}.jsonld, enumerated by proxion/gifs/index.jsonld. A saved
GIF-tray favorite. The image itself is stored as a real binary resource at
proxion/gifs/{id} (with its true content type, so other apps see an image, not
base64 in JSON); this doc is metadata referencing it. {id} is the SHA-256 of
the image (content-addressed, deduplicated). Written only when sync is enabled;
owner-only.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:GifFavorite",
"px:gifId": "9f86d0818...",
"px:filename": "reaction.gif",
"px:mime": "image/gif",
"px:image": "https://alice.pod.example/proxion/gifs/9f86d0818...",
"px:addedAt": 1721480400000,
"px:updatedAt": "2026-07-20T14:10:00.000Z"
}proxion/scheduled/{id}.jsonld. A message queued for future delivery. Only a
preview is stored, never the full pending body.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:ScheduledMessage",
"px:id": "sched-1",
"px:threadId": "general",
"px:sendAt": "2026-07-21T09:00:00.000Z",
"px:contentPreview": "Reminder: standup",
"px:createdAt": "2026-07-20T14:00:00.000Z"
}proxion/webhooks/{id}.jsonld. An integration endpoint. The secret is stored only
as a SHA-256 hash (px:tokenHash), never in the clear.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:Webhook",
"px:id": "wh-1",
"px:direction": "incoming",
"px:botName": "CI Bot",
"px:url": null,
"px:tokenHash": "9f86d0818...",
"px:createdAt": "2026-07-20T14:00:00.000Z"
}proxion/invites/{id}.jsonld. An invitation you have received but not yet
accepted. px:invite is the invite object.
Several containers keep a companion index.jsonld listing the ids of the
resources beside it, so a reader can enumerate without a container LIST.
{
"@context": { "px": "https://proxion.dev/vocab/v1#" },
"@type": "px:Index",
"px:ids": ["general", "team-standup"],
"px:updatedAt": "2026-07-20T14:00:00.000Z"
}Indexes exist for rooms (proxion/rooms/index.jsonld), DM threads
(proxion/dm/index.jsonld), contacts, and invites. Room message lists are
enumerable both by the container itself and by a companion index.
Attachments and voice notes are stored as ordinary files with their real content types, so any app or file browser can open them directly:
- Voice notes:
proxion/rooms/{roomId}/files/{messageId}.webm(audio/webm) - File attachments:
proxion/rooms/{roomId}/files/{messageId}/{filename} - Avatar:
proxion/profile/avatar.png
Proxion writes standard Web Access Control ACLs, so sharing is enforced by the pod server, not by Proxion:
- The pod owner gets
acl:Read, acl:Write, acl:Controlonproxion/. - For a shared room container, each member WebID is granted
acl:Read.
An example room ACL:
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
<#owner>
a acl:Authorization;
acl:agent <https://alice.pod.example/profile/card#me>;
acl:accessTo <.../rooms/general/>;
acl:default <.../rooms/general/>;
acl:mode acl:Read, acl:Write, acl:Control.
<#members>
a acl:Authorization;
acl:agent <https://bob.pod.example/profile/card#me>;
acl:accessTo <.../rooms/general/>;
acl:default <.../rooms/general/>;
acl:mode acl:Read.Being honest about the boundary matters more than a tidy story:
- End-to-end encrypted DM content, unless you opt in. 1:1 DMs are E2E
encrypted in transit, so by default their plaintext lives only in local device
storage, not on your pod. There is an opt-in DM pod archive (Settings, off
by default): when you turn it on, your decrypted DM history is written to your
own pod as
px:MessageJSON-LD underproxion/dm/, exactly like room history, so it syncs across your devices and any Solid app you authorize can read it. This is safe because it is your pod holding your messages, which you can already read; the E2E-in-transit property is unchanged (relays and gateways still never see plaintext). The archive is owner-only: DM resources inherit theproxion/container's owner-only ACL, and Proxion never grants member read on them (your contact keeps their own copy on their own pod). It stays off unless you choose it, because some people do not want conversations on their pod at all. - Private keys. Your Ed25519 identity key and message keys never leave the device except through the explicit, passphrase-protected recovery kit.
Everything above is fetchable with a normal authenticated Solid request. A rough sketch of listing a room's messages from any Solid client:
// `session` is an authenticated Solid session (e.g. @inrupt/solid-client-authn).
const base = "https://alice.pod.example/proxion/rooms/general/messages/";
const index = await (await session.fetch(base + "index.jsonld")).json();
for (const id of index["px:ids"] ?? index.ids ?? []) {
const msg = await (await session.fetch(`${base}${id}.jsonld`)).json();
console.log(msg["px:fromName"], msg["px:content"], msg["px:timestamp"]);
}No Proxion code, no gateway, and no Proxion account are involved: it is your data, in open formats, in your pod.
In the spirit of an honest spec rather than a marketing one:
- Legacy plain-JSON mirror. Older room message writes also produce a plain
(non-JSON-LD)
.jsonmirror and aindex.jsondirectly under{pod}/rooms/{roomId}/. The canonical, documented form is the JSON-LD tree under{pod}/proxion/described here; the plain mirror is retained for backward compatibility and may be consolidated. - Vocabulary dereferenceability. The
https://proxion.dev/vocab/v1#terms are defined by this document; a machine-readable ontology at that URL is planned.
The v1 in the namespace is a promise: within it, terms are added but not
removed or repurposed. A breaking change bumps to v2 with a documented
migration.