Optional modules & importer contract

draft v0.1Chat and boards get full field specs because losing message history hurts most. The relationships module has a provisional shape — input wanted before it firms up. The other optional modules (polls, reminders, habits, proxy, sharing, safety) are sketched at the card level for v0.2.

on this page
  1. Optional chat module
    1. Conversation
    2. ChatMessage
    3. Attachment
    4. Reaction
  2. Optional boards module
    1. BoardPost
  3. Optional relationships module provisional
    1. RelationshipType
    2. MemberRelationship
  4. Other modules
  5. Importer contract
  6. ImportResult
  7. Validator plans

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.

FieldTypeRequiredNotes
idUUIDyes
system_idUUIDyes
kindstringyesSee enum below.
titlestring | nullno
categorystring | nullnoOptional grouping. Prism: conversations.category.
descriptionstring | nullno
emojistring | nullno
participant_member_idsUUID[]noEmpty for system-wide channels.
creator_member_idUUID | nullno
archivedbooleannoDefaults to false.
mutedbooleanno
direct_messagebooleannoPrism: conversations.isDirectMessage.
created_atISO8601 | nullno
sort_ordernumber | nullno
source_refsSourceRef[]no
extensionsRecord<string, unknown>no
Recommended kind values "internal_chat" | "forum" | "thread" | "direct_message" | "proxy_log" | "unknown"

ChatMessage

A single message. Supports channel posts, system messages, replies, and edits.

FieldTypeRequiredNotes
idUUIDyes
conversation_idUUIDyesReferences chat.conversations[].id.
author_member_idUUID | nullnoNull for system messages.
kind"text" | "image" | "file" | "system" | "reply" | "reaction" | "unknown"noDefaults to "text".
bodystringyesMarkdown if the source app uses it.
created_atISO8601yes
edited_atISO8601 | nullno
reply_to_message_idUUID | nullnoFor threaded replies.
attachment_asset_idsUUID[]noReferences assets[]. See Attachment for richer per-attachment metadata.
system_messagebooleanno
pinnedbooleanno
source_refsSourceRef[]no
extensionsRecord<string, unknown>no

Attachment

Optional richer metadata for a message attachment. Lightweight references can use chat.messages[].attachment_asset_ids directly.

FieldTypeRequiredNotes
idUUIDyes
message_idUUIDyes
asset_idUUIDyesReferences assets[].
role"primary" | "thumbnail" | "preview" | "inline" | nullno
captionstring | nullno
sort_ordernumber | nullno
source_refsSourceRef[]no

Reaction

A reaction left by a member on a message.

FieldTypeRequiredNotes
idUUIDyes
message_idUUIDyes
member_idUUID | nullnoNull for anonymous reactions.
valuestringyesUnicode emoji, :shortcode:, or app-specific reaction ID.
created_atISO8601 | nullno
source_refsSourceRef[]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.

FieldTypeRequiredNotes
idUUIDyes
system_idUUIDyes
target_member_idUUID | nullnoNull = system-wide timeline (Prism public board with no target).
author_member_idUUID | nullnoNull = legacy/imported post with no surviving author (Prism preserves this for SP imports).
titlestring | nullnoPrism, Simply Plural. Plural Star and Ampersand have no title.
bodystringyesMarkdown if the source app uses it. Required and non-empty.
audience"public" | "private" | "unknown"noDefaults to "public". Prism's public/private distinction. Apps without an audience concept use "public".
pinnedbooleannoDefaults to false. Plural Star, Simply Plural.
created_atISO8601yesWhen the post entered the source system.
written_atISO8601 | nullnoUser-facing authored time. Differs from created_at on imports — Prism preserves SP's writtenAt here.
edited_atISO8601 | nullno
deleted_atISO8601 | nullnoSoft-delete tombstone. Prism uses isDeleted; importers should set deleted_at to a timestamp when known, or to a sentinel time when not.
source_refsSourceRef[]no
extensionsRecord<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").

FieldTypeRequiredNotes
idUUIDyes
system_idUUIDyes
namestringyesCanonical name, e.g. "partner", "parent_of", "metamour". Free-form — no enum.
inverse_namestring | nullnoFor asymmetric types: the opposite-direction name. "parent_of""child_of". Null for symmetric types.
symmetricbooleannoDefaults to false. true means one stored edge represents the relationship in both directions.
descriptionstring | nullno
colorHexColor | nullno
sort_ordernumber | nullno
source_refsSourceRef[]no
extensionsRecord<string, unknown>no

MemberRelationship

An edge between two members of a given RelationshipType.

FieldTypeRequiredNotes
idUUIDyes
system_idUUIDyes
from_member_idUUIDyesFor asymmetric types, the subject (e.g. the parent in "parent_of"). For symmetric types, just storage order.
to_member_idUUIDyesFor asymmetric types, the object.
type_idUUIDyesReferences relationships.types[].id.
notestring | nullnoFree-text annotation on this specific edge.
started_atISO8601 | nullnoReal-world start of the relationship, distinct from when the record was written.
ended_atISO8601 | nullnoNull = ongoing.
created_atISO8601 | nullnoWhen the record was written.
deleted_atISO8601 | nullnoSoft-delete tombstone — preserves CRDT-sync semantics for apps that delete-by-flag.
source_refsSourceRef[]no
extensionsRecord<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.

FieldTypeRequiredNotes
started_atISO8601yes
finished_atISO8601yes
source{ openplural_version: string, producer_app: string, producer_app_version: string | null }yesEchoed from the imported envelope's producer.
counts{ imported: number, skipped: number, degraded: number, preserved_only: number, failed: number }yesAggregate across all modules.
per_moduleRecord<string, ModuleCounts>noOptional per-module breakdown using the same shape as counts.
warningsWarning[]noSee Warning.
extensionsRecord<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.

Back to core records or fronting. Maintainers: see the adoption guide.