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:
- Declare it with
syncable "name" { ... } - Create it with
sync_create("name", data)from any handler - 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.