Optional chat module
One shape that fits internal chat, forums, threaded discussion, and proxy logs. Member-addressed posts (boards, noteboards) live in the separate boards module because the unit is a post with a target member, not a message in a thread. Apps without chat keep the whole object as archive data and report it in warnings.
Conversation
A container for messages. kind distinguishes channel chat from forum-style and thread-style surfaces.
| Field | Type | Required | Notes |
| id | UUID | yes | |
| system_id | UUID | yes | |
| kind | string | yes | See enum below. |
| title | string | null | no | |
| category | string | null | no | Optional grouping. Prism: conversations.category. |
| description | string | null | no | |
| emoji | string | null | no | |
| participant_member_ids | UUID[] | no | Empty for system-wide channels. |
| creator_member_id | UUID | null | no | |
| archived | boolean | no | Defaults to false. |
| muted | boolean | no | |
| direct_message | boolean | no | Prism: conversations.isDirectMessage. |
| created_at | ISO8601 | null | no | |
| sort_order | number | null | no | |
| source_refs | SourceRef[] | no | |
| extensions | Record<string, unknown> | no | |
ChatMessage
A single message. Supports channel posts, system messages, replies, and edits.
| Field | Type | Required | Notes |
| id | UUID | yes | |
| conversation_id | UUID | yes | References chat.conversations[].id. |
| author_member_id | UUID | null | no | Null for system messages. |
| kind | "text" | "image" | "file" | "system" | "reply" | "reaction" | "unknown" | no | Defaults to "text". |
| body | string | yes | Markdown if the source app uses it. |
| created_at | ISO8601 | yes | |
| edited_at | ISO8601 | null | no | |
| reply_to_message_id | UUID | null | no | For threaded replies. |
| attachment_asset_ids | UUID[] | no | References assets[]. See Attachment for richer per-attachment metadata. |
| system_message | boolean | no | |
| pinned | boolean | no | |
| source_refs | SourceRef[] | no | |
| extensions | Record<string, unknown> | no | |
Attachment
Optional richer metadata for a message attachment. Lightweight references can use chat.messages[].attachment_asset_ids directly.
| Field | Type | Required | Notes |
| id | UUID | yes | |
| message_id | UUID | yes | |
| asset_id | UUID | yes | References assets[]. |
| role | "primary" | "thumbnail" | "preview" | "inline" | null | no | |
| caption | string | null | no | |
| sort_order | number | null | no | |
| source_refs | SourceRef[] | no | |
Reaction
A reaction left by a member on a message.
| Field | Type | Required | Notes |
| id | UUID | yes | |
| message_id | UUID | yes | |
| member_id | UUID | null | no | Null for anonymous reactions. |
| value | string | yes | Unicode emoji, :shortcode:, or app-specific reaction ID. |
| created_at | ISO8601 | null | no | |
| source_refs | SourceRef[] | no | |
{
"chat": {
"conversations": [
{
"id": "conv_01HV4Z...",
"system_id": "sys_01HV4Z...",
"kind": "internal_chat",
"title": "General",
"participant_member_ids": [],
"archived": false,
"source_refs": [{ "app": "prism", "collection": "conversations", "id": "c_..." }],
"extensions": {}
}
],
"messages": [
{
"id": "msg_01HV4Z...",
"conversation_id": "conv_01HV4Z...",
"author_member_id": "mem_01HV4Z...",
"kind": "text",
"body": "Hi.",
"created_at": "2026-04-29T18:00:00Z",
"edited_at": null,
"reply_to_message_id": null,
"attachment_asset_ids": [],
"source_refs": [],
"extensions": {}
}
],
"attachments": [],
"reactions": []
}
}
An app without chat should still preserve the entire chat object as import archive data and emit a warning with code "module_archived_only". Users need to know their messages weren't dropped.
Optional boards module
Member-addressed posts: profile walls, noteboards, board messages. The unit is a post with a target member (or system-wide timeline), not a message in a thread. No replies, threading, or per-message reactions in the v0.1 shape — apps that have those should record them in extensions until v0.2.
BoardPost
A post addressed to a member's board, or to the system-wide timeline when target_member_id is null.
| Field | Type | Required | Notes |
| id | UUID | yes | |
| system_id | UUID | yes | |
| target_member_id | UUID | null | no | Null = system-wide timeline (Prism public board with no target). |
| author_member_id | UUID | null | no | Null = legacy/imported post with no surviving author (Prism preserves this for SP imports). |
| title | string | null | no | Prism, Simply Plural. Plural Star and Ampersand have no title. |
| body | string | yes | Markdown if the source app uses it. Required and non-empty. |
| audience | "public" | "private" | "unknown" | no | Defaults to "public". Prism's public/private distinction. Apps without an audience concept use "public". |
| pinned | boolean | no | Defaults to false. Plural Star, Simply Plural. |
| created_at | ISO8601 | yes | When the post entered the source system. |
| written_at | ISO8601 | null | no | User-facing authored time. Differs from created_at on imports — Prism preserves SP's writtenAt here. |
| edited_at | ISO8601 | null | no | |
| deleted_at | ISO8601 | null | no | Soft-delete tombstone. Prism uses isDeleted; importers should set deleted_at to a timestamp when known, or to a sentinel time when not. |
| source_refs | SourceRef[] | no | |
| extensions | Record<string, unknown> | no | |
{
"boards": {
"posts": [
{
"id": "bp_01HV4Z...",
"system_id": "sys_01HV4Z...",
"target_member_id": "mem_01HV4Z...",
"author_member_id": "mem_01HV4Z...",
"title": null,
"body": "Welcome back. Hope today is gentle.",
"audience": "public",
"pinned": false,
"created_at": "2026-04-29T18:00:00Z",
"written_at": "2026-04-29T18:00:00Z",
"edited_at": null,
"deleted_at": null,
"source_refs": [{ "app": "prism", "collection": "member_board_posts", "id": "..." }],
"extensions": {}
}
]
}
}
Boards is a separate module from chat so that apps that have one but not the other don't pull in unused infrastructure. An app without boards should preserve the entire boards object as archive data and emit warning code "module_archived_only".
Optional relationships module provisional
Member-to-member edges for apps with relationship maps — partner, parent_of, metamour, etc. Two records: a type vocabulary, and the edges themselves.
Heads up: this module is more provisional than the rest of v0.1. Only one inspected app (Lighthouse) currently exports any relationship data, and it's an opaque text field. PluralSpace's developer page previews relationship endpoints, but the API itself isn't released yet. The shape below is forward-looking and likely to shift once more apps implement it. Treat it as a sketch we'd like input on, not a settled design.
RelationshipType
A reusable type for member-to-member edges. Symmetric ("partner") or asymmetric ("parent_of" with inverse "child_of").
| Field | Type | Required | Notes |
| id | UUID | yes | |
| system_id | UUID | yes | |
| name | string | yes | Canonical name, e.g. "partner", "parent_of", "metamour". Free-form — no enum. |
| inverse_name | string | null | no | For asymmetric types: the opposite-direction name. "parent_of" → "child_of". Null for symmetric types. |
| symmetric | boolean | no | Defaults to false. true means one stored edge represents the relationship in both directions. |
| description | string | null | no | |
| color | HexColor | null | no | |
| sort_order | number | null | no | |
| source_refs | SourceRef[] | no | |
| extensions | Record<string, unknown> | no | |
MemberRelationship
An edge between two members of a given RelationshipType.
| Field | Type | Required | Notes |
| id | UUID | yes | |
| system_id | UUID | yes | |
| from_member_id | UUID | yes | For asymmetric types, the subject (e.g. the parent in "parent_of"). For symmetric types, just storage order. |
| to_member_id | UUID | yes | For asymmetric types, the object. |
| type_id | UUID | yes | References relationships.types[].id. |
| note | string | null | no | Free-text annotation on this specific edge. |
| started_at | ISO8601 | null | no | Real-world start of the relationship, distinct from when the record was written. |
| ended_at | ISO8601 | null | no | Null = ongoing. |
| created_at | ISO8601 | null | no | When the record was written. |
| deleted_at | ISO8601 | null | no | Soft-delete tombstone — preserves CRDT-sync semantics for apps that delete-by-flag. |
| source_refs | SourceRef[] | no | |
| extensions | Record<string, unknown> | no | |
{
"relationships": {
"types": [
{
"id": "rtype_01HV...",
"system_id": "sys_01HV...",
"name": "parent_of",
"inverse_name": "child_of",
"symmetric": false,
"description": null,
"color": null,
"sort_order": 0,
"source_refs": [],
"extensions": {}
}
],
"edges": [
{
"id": "rel_01HV...",
"system_id": "sys_01HV...",
"from_member_id": "mem_01HV...",
"to_member_id": "mem_01HV...",
"type_id": "rtype_01HV...",
"note": null,
"started_at": null,
"ended_at": null,
"created_at": "2026-04-29T18:00:00Z",
"deleted_at": null,
"source_refs": [],
"extensions": {}
}
]
}
}
Apps without relationships should preserve the whole relationships object as archive data and emit a warning with code "module_archived_only". Lighthouse's denormalized text field can be parsed heuristically into multiple edges — that's lossy by definition; flag the parse with warning code "relationships_parsed_from_text".
Other optional modules
Sketched at module level.
polls
Polls, options, votes, vote reasons, close state, expiry. Maps Prism polls and Ampersand board polls.
reminders
One-off and recurring reminders, intervals, assigned members, notification preferences.
habits
Habit definitions, recurrence rules, completions, streaks, assigned members.
proxy
Discord proxy tags, autoproxy settings, account links. Distinct from core ProxyTag on members.
sharing
Friends, peer keys, granted scopes, verification state, per-record visibility.
safety
Safety plans, retention overrides, lock states, destructive-action safeguards, app security flags.
Importer contract
The file shape is half the story. The other half is what an importer reports back: what moved, what changed, what was kept as archive only.
Expected importer behavior
- Validate the envelope and resolve every cross-record reference before mutating app data.
- Emit per-module counts: imported, skipped, degraded, preserved-only, failed.
- Keep raw unsupported module data when an archive area exists.
- Preserve
source_refs on imported records when the target app has a place for them.
- Use conservative privacy defaults when the target app can't express the source privacy model.
- Reject the file with an error if
openplural_version is unknown — don't silently ignore unsupported versions.
ImportResult
The shape importers should return after a run. Useful as both a UI summary and a logged audit record.
| Field | Type | Required | Notes |
| started_at | ISO8601 | yes | |
| finished_at | ISO8601 | yes | |
| source | { openplural_version: string, producer_app: string, producer_app_version: string | null } | yes | Echoed from the imported envelope's producer. |
| counts | { imported: number, skipped: number, degraded: number, preserved_only: number, failed: number } | yes | Aggregate across all modules. |
| per_module | Record<string, ModuleCounts> | no | Optional per-module breakdown using the same shape as counts. |
| warnings | Warning[] | no | See Warning. |
| extensions | Record<string, unknown> | no | |
{
"import_result": {
"started_at": "2026-04-29T18:05:00Z",
"finished_at": "2026-04-29T18:05:14Z",
"source": {
"openplural_version": "0.1",
"producer_app": "Sheaf",
"producer_app_version": "1.4.2"
},
"counts": {
"imported": 284,
"skipped": 3,
"degraded": 12,
"preserved_only": 158,
"failed": 0
},
"per_module": {
"members": { "imported": 24, "skipped": 0, "degraded": 0, "preserved_only": 0, "failed": 0 },
"chat": { "imported": 0, "skipped": 0, "degraded": 0, "preserved_only": 158, "failed": 0 }
},
"warnings": [
{
"level": "warning",
"code": "module_archived_only",
"record_type": "chat.messages",
"message": "Target app does not display chat. Messages retained as archive data.",
"count": 158
}
]
}
}
Validator plans
What we're planning to build once v0.1 fields are settled.
- JSON Schema for
openplural_version: "0.1" covering every record on this site.
- Reference fixtures: a hand-written export per researched app under
fixtures/<app>/.
- Validator CLI: validates a file against the schema and prints an ImportResult-shaped report.
- Reference converters: Prism, Sheaf, Simply Plural, PluralKit, Plural Star — each as a small standalone script that reads a real export and emits an OpenPlural file.
- Conformance test: round-trip exporters → importers → exporters and diff the result. Lossy fields must be flagged in
warnings.