Skip to content

Latest commit

 

History

History
574 lines (468 loc) · 21.6 KB

File metadata and controls

574 lines (468 loc) · 21.6 KB

Proxion Pod Data Model

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.

The philosophy, made concrete

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.

Namespace

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:).

Container layout

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.

Interoperability with the Solid chat ecosystem

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:content in place (a SPARQL-Update DELETE/INSERT/WHERE on the day file), so the latest text shows in any reader of sioc:content. This is used in preference to a dct:isReplacedBy replacement chain, whose rendering is reader-dependent; the px: layer keeps full edit history.
  • A delete appends a schema:dateDeleted tombstone (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.

The Long Chat container layout

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) and wf:message (http://www.w3.org/2005/01/wf/flow#). The SolidOS databrowser enumerates a channel's messages with wf:message, so a chat carrying only meeting:message is 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.

Resource types

px:Message

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

px:Profile

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"
}

px:MemberList

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"
}

px:ReactionSet

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"
}

px:ReadState

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"
}

px:Contact

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"
}

px:SavedMessage

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"
}

px:Settings

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"
}

px:MuteList

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"
}

px:BlockList

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"
}

px:GifFavorite

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"
}

px:ScheduledMessage

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"
}

px:Webhook

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"
}

px:PendingInvite

proxion/invites/{id}.jsonld. An invitation you have received but not yet accepted. px:invite is the invite object.

px:Index

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.

Binary resources

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

Access control

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:Control on proxion/.
  • 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.

What is deliberately NOT on the pod

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:Message JSON-LD under proxion/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 the proxion/ 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.

Reading Proxion data from another Solid app

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.

Known rough edges

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) .json mirror and a index.json directly 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.

Stability

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.