Federated BBS: How Phosphor Makes Multi-Node Sync Declarative

July 31, 2026

Every BBS in the 80s and 90s was an island. Your callers dialed your node. Your boards lived on your machine. If you wanted to share content with another sysop, you mailed a floppy.

Phosphor changes that. Federation is now declarative — you write 10 lines of Phosphor instead of 200 lines of custom Java protocol code.

The Problem

Today, adding a new syncable resource to a BBS means writing custom Java serialization, a custom message type, a custom deserializer on the receiving end, and custom conflict resolution. This is why most BBS systems only sync boards and chat messages — everything else is too much work.

Phosphor introduces a generic envelope format that carries any data type, with built-in conflict resolution, incremental sync, and compression. You declare what gets synced. The runtime handles the rest.

Declaring Syncable Resources

Two modes: event (append-only, immutable) and entity (updatable by natural key).

// Event — append-only. Identity = origin:sequence from the envelope.
// No separate ID. Can't update, can't be overwritten.
syncable "trade_signal" {
    mode = "event"
    fields = {
        symbol: String,
        action: String,
        shares: Int,
        price: Float,
        user: String,
        timestamp: Time
    }
    conflict_resolution = "last_write_wins"
    retention = 7d
    write = ["vip"]
}

// Entity — updatable, identified by a natural key
syncable "user_profile" {
    mode = "entity"
    key = ["user_id"]     // which field(s) form the unique identity

    fields = {
        user_id: Int,
        display_name: String,
        bio: String,
        avatar: String,
        location: String
    }
    conflict_resolution = "merge"
    retention = forever
    write = ["users"]
}

// Composite key — multiple fields form the identity
syncable "door_game_score" {
    mode = "entity"
    key = ["game", "user"]   // unique per game+user pair

    fields = {
        game: String,
        user: String,
        score: Int,
        node: String,
        played_at: Time
    }
    conflict_resolution = "highest_value"   // highest score wins
    retention = forever
    write = ["users"]
}

The difference between events and entities is fundamental:

Property Event (mode = "event") Entity (mode = "entity")
Identity origin:sequence (from envelope) Natural key (declared fields)
Updates Not allowed — always new entries Upsert by key
Deletion Tombstone (marked deleted, not removed) Delete by key
Deduplication Automatic via sequence number By key — same key = same entity
Use case Trade signals, chat messages, achievements User profiles, game scores, settings

Creating and Updating Entries

From any event handler, you call sync_create, sync_update, or sync_delete:

// Event — always creates a new entry
on trade_executed {
    sync_create("trade_signal", {
        symbol: event.symbol,
        action: event.action,
        shares: event.shares,
        price: event.price,
        user: user.username,
        timestamp: now()
    })
    // Runtime wraps in envelope: {origin: "bronxville", sequence: 4824, ...}
    // Pushes to peers. Peers check: have I seen bronxville:4824? No → apply.
}

// Entity — upsert by key
on profile_update {
    sync_create("user_profile", {
        user_id: user.id,
        display_name: "Joe",
        bio: "Updated bio",
        avatar: "joe.ans",
        location: "Bronxville"
    })
    // Runtime checks: does user_profile with key user_id=1 exist locally?
    // No → create. Yes → update (with conflict resolution).
}

// Entity — update specific fields
sync_update("user_profile", {
    user_id: user.id,
    bio: "New bio only"
})

// Entity — delete by key (sends tombstone to peers)
sync_delete("user_profile", key: {user_id: 1})

Receiving Synced Data

On the receiving side, handlers fire when synced data arrives:

// Event — fires on append
on sync_receive type "trade_signal" {
    say "#trading: Federated trade: ${payload.action} ${payload.shares} ${payload.symbol} @ $${payload.price} (from ${envelope.origin})"

    // Store locally — no dedup check needed, runtime already checked sequence
    sql_exec("INSERT INTO federated_trades (origin, sequence, symbol, action, shares, price, timestamp)
              VALUES (?, ?, ?, ?, ?, ?, ?)",
              envelope.origin, envelope.sequence, payload.symbol, payload.action,
              payload.shares, payload.price, payload.timestamp)

    fire("federated_trade", payload)
}

// Entity — fires on delete (tombstone received from peer)
on sync_delete type "user_profile" {
    sql_exec("DELETE FROM federated_profiles WHERE user_id = ?", payload.user_id)
}

Conflict Resolution Strategies

When two nodes update the same entity, the runtime resolves the conflict based on the declared strategy:

Strategy Description Use case
last_write_wins Most recent timestamp wins Chat topics, board posts
first_write_wins Earliest timestamp wins Achievements, immutable events
highest_value Highest numeric field wins High scores, rankings
lowest_value Lowest numeric field wins Response time records
merge Merge non-conflicting fields, last-write-wins on conflicts User profiles
custom: function_name Custom Phosphor function Complex merge logic

Custom conflict resolvers let you express domain-specific logic in Phosphor itself:

function resolve_profile_conflict(local, remote) {
    return {
        user_id: local.user_id,
        display_name: if remote.timestamp > local.timestamp then remote.display_name else local.display_name,
        bio: local.bio,                              // local wins for bio
        avatar: remote.avatar,                        // remote wins for avatar
        location: remote.location ?? local.location   // remote if set, else local
    }
}

syncable "user_profile" {
    conflict_resolution = "custom: resolve_profile_conflict"
}

Federation Flags

Boards and channels are marked federated or local-only:

board "General" {
    read = ["users"]
    write = ["users"]
    federated = true          // sync posts to all connected jnet peers
}

board "Family Office" {
    read = ["vip"]
    write = ["vip"]
    federated = false          // local only — never replicated
}

chat "#lobby" {
    topic = "The main lobby"
    federated = true           // shared across all nodes
}

Node identity is declared in the bbs block:

bbs "The Exchange" {
    node_name = "bronxville"
    federate_with = ["ohio.boremaj.net:2323", "florida.boremaj.net:2323"]
}

The Architecture: Four Layers

The sync system is layered. Handlers only touch the top layer — everything below is the runtime’s job.

Layer 1: Handler — the Phosphor code the sysop writes. Declare the type, call sync_create, handle on sync_receive. That’s it. The handler does not serialize data, generate IDs, track sequence numbers, detect duplicates, compress, batch, send over the network, or resolve conflicts.

Layer 2: Sync Runtime — Java inside PhosphorRuntime. Assigns identity (events get origin:sequence, entities resolve by natural key). Wraps payloads in envelopes. Runs conflict resolution. Deduplicates (events by origin:sequence, entities by key upsert). Orders by sequence, buffers out-of-order entries, rejects stale. Batches and compresses (gzip over 1KB, up to 100 per batch). Sends tombstones. Purges expired entries. Backfills on reconnect.

Layer 3: Transport — the existing jnet mesh. TCP connections, message framing, reconnection with backoff, peer authentication. The transport layer knows nothing about Phosphor types, envelopes, or conflict resolution. It just moves bytes.

Layer 4: Local Storage — Postgres. The runtime manages bbs.phosphor_sync_state (seen sequence numbers) and bbs.phosphor_sync_envelopes (envelope log for backfill). The handler manages its own tables for application data.

The Envelope Format

Every federated message is wrapped in a standard envelope:

{
    "protocol": "phosphor-sync/1.0",
    "type": "board_post",
    "origin": "bronxville",
    "timestamp": "2026-07-30T14:32:01Z",
    "sequence": 4823,
    "resource": "General",
    "action": "create",
    "payload": { ... },
    "signature": "..."
}

One format. Any data type. The runtime handles serialization, dedup, ordering, and compression automatically.

Backfill on Reconnect

When a peer reconnects after being offline, the runtime requests all missed entries:

on peer_connected {
    say "#sysop: Syncing ${sync.pending_count} entries from ${peer.node_name}"
    // Runtime sends: "Give me all syncable entries since sequence 4823"
    // Peer responds with all entries from sequence 4824 onward
}

on sync_complete {
    say "#sysop: Sync complete — ${sync.received} entries received, ${sync.conflicts_resolved} conflicts resolved"
}

Why This Matters

Before Phosphor, adding a new federated resource meant writing 200 lines of Java protocol code — custom serialization, custom message types, custom deserializers, custom conflict resolution. This is why only boards and chat messages sync in most BBS systems. Everything else is too expensive.

With Phosphor, adding a new syncable type takes three steps:

  1. Declare it with syncable "name" { ... }
  2. Create it with sync_create("name", data) from any handler
  3. Handle it with on sync_receive type "name" { ... } on the receiving side

No custom Java. No custom message types. The generic envelope handles everything.

You go from “write 200 lines of Java protocol code” to “write 10 lines of Phosphor.” That’s what makes adding new federated resources cheap — and that’s what makes a federated BBS network practical for the first time.