# Parrhesia Relay Sync

## 1. Purpose

This document defines the Parrhesia proposal for **relay-to-relay event synchronization**.

It is intentionally transport-focused:

- manage remote relay peers,
- catch up on matching events,
- keep a live stream open,
- expose health and basic stats.

It does **not** define application data semantics.

Parrhesia syncs Nostr events. Callers decide which events matter and how to apply them.

---

## 2. Boundary

### Parrhesia is responsible for

- storing and validating events,
- querying and streaming events,
- running outbound sync workers against remote relays,
- tracking peer configuration, worker health, and sync counters,
- exposing peer management through `Parrhesia.API.Sync`.

### Parrhesia is not responsible for

- resource mapping,
- trusted node allowlists for an app profile,
- mutation payload validation beyond normal event validation,
- conflict resolution,
- replay winner selection,
- database upsert/delete semantics.

For Tribes, those remain in `TRIBES-NOSTRSYNC` and `AshNostrSync`.

---

## 3. Security Foundation

### Default posture

The baseline posture for sync traffic is:

- no access to sync events by default,
- no implicit trust from ordinary relay usage,
- no reliance on plaintext confidentiality from public relays.

For the first implementation, Parrhesia should protect sync data primarily with:

- authenticated server identities,
- ACL-gated read and write access,
- TLS with certificate pinning for outbound peers.

### Server identities

Parrhesia owns low-level server identities used for relay signing and relay-to-relay
authentication. The default role is `:relay`, so ordinary relay use does not need to
specify a role.

These identities are separate from:

- TLS endpoint identity,
- application event author pubkeys.

Recommended model:

- Parrhesia has one public relay identity (`:relay`),
- deployments may also configure one per-node sync identity (`:node`),
- sync peers authenticate as node/server-auth pubkeys,
- ACL grants are bound to those authenticated server-auth pubkeys,
- application-level writer trust remains outside Parrhesia.

Example:

```elixir
config :parrhesia, :identities,
  relay: [private_key: shared_relay_private_key],
  node: [private_key: per_node_private_key]
```

Identity lifecycle:

1. use configured/imported key if provided,
2. otherwise use persisted local identity,
3. otherwise generate once during initial startup and persist it.

Sync workers sign NIP-42 AUTH events with the `:node` role. Deployments that enable
outbound sync should configure a node identity explicitly.

Private key export should not be supported.

### ACLs

Sync traffic should use a real ACL layer, not moderation allowlists.

Current implementation note:

- Parrhesia already has storage-backed moderation state such as `allowed_pubkeys` and `blocked_ips`,
- that is not the sync ACL model,
- sync protection must be enforced in the active websocket/query/count/negentropy/write path, not inferred from management tables alone.

Initial ACL model:

- principal: authenticated pubkey,
- capabilities: `sync_read`, `sync_write`,
- match: event/filter shape such as `kinds: [5000]` and namespace tags.

This is enough for now. We do **not** need a separate user ACL model and server ACL model yet.

A sync peer is simply an authenticated principal with sync capabilities.

### TLS pinning

Each outbound sync peer must include pinned TLS material.

Recommended pin type:

- SPKI SHA-256 pins

Multiple pins should be allowed to support certificate rotation.

---

## 4. Sync Model

Each configured sync server represents one outbound worker managed by Parrhesia.

Implementation note:

- Khatru-style relay designs benefit from explicit runtime stages,
- Parrhesia sync should therefore plug into clear internal phases for connection admission, auth, query/count, subscription, negentropy, publish, and fanout,
- this should stay a runtime refactor, not become extra sync semantics.

Minimum behavior:

1. connect to the remote relay,
2. run an initial catch-up query for the configured filters,
3. ingest received events into the local relay through the normal API path,
4. switch to a live subscription for the same filters,
5. reconnect with backoff when disconnected.

The worker treats filters as opaque Nostr filters. It does not interpret app payloads.

### Sync modes

Parrhesia supports two catch-up modes:

- `:req_stream` — catch-up via `REQ` + overlap window, then live `REQ` subscription. When the remote relay advertises the Parrhesia `parrhesia_sync_page` capability in its NIP-11 `limitation` block (see "Wire frames" below), this mode auto-upgrades on cold start to paged keyset backfill via `SYNC-PAGE` before issuing the live `REQ`. Warm reconnects (cursor already present) skip backfill and rely on the overlap window, since `:overlap_window_seconds` already covers transient drift.
- `:negentropy_first` — attempt NIP-77 negentropy catch-up first, then fetch missing event ids via `REQ`, then switch to live `REQ` subscription. Falls back to `:req_stream` behavior when negentropy is unavailable or fails.

This keeps deployment flexibility while allowing bandwidth-efficient catch-up on trusted links.

#### Wire frames

`SYNC-PAGE` is a Parrhesia-specific extension of the Nostr wire protocol used to page through history with keyset cursors. It is **not** a NIP. Relays that implement it advertise the capability via NIP-11 `limitation.parrhesia_sync_page: true`. Parrhesia workers dispatch on that flag and fall back to standard `REQ`+overlap against any peer that does not advertise it, so interoperability with vanilla Nostr relays is preserved. Operators can suppress this NIP-11 advertisement with `:features.parrhesia_sync_page` / `PARRHESIA_FEATURES_PARRHESIA_SYNC_PAGE` when a listener should present as a plain Nostr relay.

Request frame:

```json
["SYNC-PAGE", "<subscription_id>", {"kinds": [1]}, {"limit": 500, "order": "asc"}]
```

Options:

- `limit` — required positive integer; capped by the relay's `:limits.max_filter_limit`.
- `order` — optional, either `"asc"` (default) or `"desc"`.
- `after` — optional keyset lower bound encoded as `[created_at, event_id_hex]`; returned events must be strictly greater than this key.
- `until` — optional keyset upper bound encoded as `[created_at, event_id_hex]`; returned events must be less than or equal to this key.
- `batch` — optional boolean. When `true`, events are returned in one batched `EVENTS` frame.

Parrhesia filters also support multi-character tag filter keys such as `"#dataset"` and
`"#ref"` in addition to standard Nostr single-letter tag filters. This is a Parrhesia
extension intended for application-scoped clone/fetch flows where events carry tags like
`["dataset", dataset_id]` or `["ref", ref_name]`.

Reply frames:

- Unbatched: zero or more `["EVENT", "<subscription_id>", <event>]` frames followed by `["EOSE", "<subscription_id>"]`.
- Batched: `["EVENTS", "<subscription_id>", [<event>, ...]]` followed by `["EOSE", "<subscription_id>"]`.
- Errors: `["CLOSED", "<subscription_id>", "<message>"]`; auth-related failures may be preceded by a standard NIP-42 `["AUTH", "<challenge>"]` frame.

### Topology and convergence semantics

Parrhesia sync is intentionally a relay sync foundation, not an application convergence engine.

Operationally:

- use sync peers to define topology (mesh, hub/spoke, or staged rollout),
- keep per-peer filters narrow and explicit,
- treat sync health as transport/control-plane health, not proof of app-level convergence.

Delivery expectations:

- persisted events: practical eventual convergence via reconnect + catch-up,
- ephemeral events: best-effort only,
- no global total ordering guarantee across nodes.

### NIP-77

Parrhesia now has a real reusable relay-side NIP-77 engine:

- proper `NEG-OPEN` / `NEG-MSG` / `NEG-CLOSE` / `NEG-ERR` framing,
- a reusable negentropy codec and reconciliation engine,
- bounded local `(created_at, id)` snapshot enumeration for matching filters,
- connection/session integration with policy checks and resource limits.

That means NIP-77 can be used for bandwidth-efficient catch-up between trusted nodes.

The sync worker now exposes this as configuration (`mode: :req_stream | :negentropy_first`) so deployments can choose the operational tradeoff per peer.

---

## 5. API Surface

Primary control plane:

- `Parrhesia.API.Identity.get/1`
- `Parrhesia.API.Identity.ensure/1`
- `Parrhesia.API.Identity.import/2`
- `Parrhesia.API.Identity.rotate/1`
- `Parrhesia.API.ACL.grant/2`
- `Parrhesia.API.ACL.revoke/2`
- `Parrhesia.API.ACL.list/1`
- `Parrhesia.API.Sync.put_server/2`
- `Parrhesia.API.Sync.remove_server/2`
- `Parrhesia.API.Sync.get_server/2`
- `Parrhesia.API.Sync.list_servers/1`
- `Parrhesia.API.Sync.start_server/2`
- `Parrhesia.API.Sync.stop_server/2`
- `Parrhesia.API.Sync.sync_now/2`
- `Parrhesia.API.Sync.server_stats/2`
- `Parrhesia.API.Sync.sync_stats/1`
- `Parrhesia.API.Sync.sync_health/1`

Bulk clone/fetch helpers:

- `Parrhesia.API.Sync.page_events/2`
- `Parrhesia.API.Sync.stream_events/2`
- `Parrhesia.API.Sync.copy_events/3`
- `Parrhesia.API.Sync.import_events/2`

These APIs are in-process. HTTP management may expose control-plane operations through
`Parrhesia.API.Admin` or direct routing to `Parrhesia.API.Sync`. Bulk helpers should normally
stay behind application policy code that authenticates the peer, builds allowed filters, and
passes an appropriate `Parrhesia.API.RequestContext`.

---

## 6. Server Specification

`put_server/2` is an upsert.

Suggested server shape:

```elixir
%{
  id: "tribes-primary",
  url: "wss://relay-a.example/relay",
  enabled?: true,
  auth_pubkey: "<remote-server-auth-pubkey>",
  relay_pubkey: "<remote-relay-nip11-pubkey>",
  mode: :negentropy_first,
  filters: [
    %{
      "kinds" => [5000],
      "#r" => ["tribes.accounts.user", "tribes.chat.tribe"]
    }
  ],
  overlap_window_seconds: 300,
  relay_info_mode: :diagnostic,
  auth: %{
    type: :nip42,
    mode: :on_challenge
  },
  tls: %{
    mode: :required,
    hostname: "relay-a.example",
    ca_certfile: "/etc/tribes/sync-ca.pem",
    client_certfile: "/etc/tribes/node.crt",
    client_keyfile: "/etc/tribes/node.key",
    pins: [
      %{type: :spki_sha256, value: "<pin-a>"}
    ]
  },
  metadata: %{}
}
```

Required fields:

- `id`
- `url`
- `auth_pubkey`
- `filters`
- `tls`

Recommended fields:

- `enabled?`
- `mode`
- `overlap_window_seconds`
- `relay_pubkey`
- `relay_info_mode`
- `auth`
- `metadata`

Rules:

- `id` must be stable and unique locally.
- `url` is the remote relay websocket URL.
- `auth_pubkey` is the expected remote server-auth pubkey.
- `relay_pubkey` is the expected remote NIP-11 relay pubkey. It defaults to `auth_pubkey`
  for older single-identity deployments.
- `filters` must be valid NIP-01 filters.
- filters are owned by the caller; Parrhesia only validates filter shape.
- `mode` supports `:req_stream` and `:negentropy_first`; it defaults to `:req_stream`.
- `relay_info_mode` supports `:required`, `:diagnostic`, and `:disabled`; it defaults to `:required`.
- `auth.mode` supports `:on_challenge` and `:disabled`; it defaults to `:on_challenge`.
- `tls.mode` defaults to `:required`.
- `tls.pins` are optional and may be combined with dedicated CA trust and client certs.

---

## 7. Runtime State

Each server should have both configuration and runtime status.

Suggested runtime fields:

```elixir
%{
  server_id: "tribes-primary",
  state: :running,
  connected?: true,
  last_connected_at: ~U[2026-03-16 10:00:00Z],
  last_disconnected_at: nil,
  last_sync_started_at: ~U[2026-03-16 10:00:00Z],
  last_sync_completed_at: ~U[2026-03-16 10:00:02Z],
  last_event_received_at: ~U[2026-03-16 10:12:45Z],
  last_eose_at: ~U[2026-03-16 10:00:02Z],
  reconnect_attempts: 0,
  last_error: nil
}
```

Parrhesia should keep this state generic. It is about relay sync health, not app state convergence.

---

## 8. Stats and Health

### Per-server stats

`server_stats/2` should return basic counters such as:

- `events_received`
- `events_accepted`
- `events_duplicate`
- `events_rejected`
- `query_runs`
- `subscription_restarts`
- `reconnects`
- `last_remote_eose_at`
- `last_error`

### Aggregate sync stats

`sync_stats/1` should summarize:

- total configured servers,
- enabled servers,
- running servers,
- connected servers,
- aggregate event counters,
- aggregate reconnect count.

### Health

`sync_health/1` should be operator-oriented, for example:

```elixir
%{
  "status" => "degraded",
  "servers_total" => 3,
  "servers_connected" => 2,
  "servers_failing" => [
    %{"id" => "tribes-secondary", "reason" => "connection_refused"}
  ]
}
```

This is intentionally simple. It should answer “is sync working?” without pretending to prove application convergence.

---

## 9. Event Ingest Path

Events received from a remote sync worker should enter Parrhesia through the same ingest path as any other accepted event.

That means:

1. validate the event,
2. run normal write policy,
3. persist or reject,
4. fan out locally,
5. rely on duplicate-event behavior for idempotency.

This avoids a second ingest path with divergent behavior.

Before normal event acceptance, the sync worker should enforce:

1. pinned TLS validation for the remote endpoint,
2. remote node/server-auth identity match,
3. local ACL grant permitting the peer to perform sync reads and/or writes.

The sync worker may attach request-context metadata such as:

```elixir
%Parrhesia.API.RequestContext{
  caller: :sync,
  peer_id: "tribes-primary",
  metadata: %{sync_server_id: "tribes-primary"}
}
```

Recommended additional context when available:

- `remote_ip`
- `subscription_id`

This context is for telemetry, policy, and audit only. It must not become app sync semantics.

---

## 10. Persistence

Parrhesia persists enough sync control-plane state to survive restart:

- local relay and node server identity references,
- configured ACL rules for sync principals,
- configured sync servers (`sync_servers` table),
- per-server sync runtime snapshot (`sync_server_runtime` table), including cursor/watermark and basic health counters.

This persistence is controlled by `:sync.persist_state?` (`PARRHESIA_SYNC_PERSIST_STATE`) and is enabled by default.

Parrhesia does not persist application replay heads or winner state. That remains in the embedding application.

---

## 11. Relationship to Runtime Features

### Cross-node sync data plane

Parrhesia provides cross-node relay sync primitives through `Parrhesia.API.Sync` workers.

Local in-node fanout remains process-local (`Parrhesia.Fanout.Dispatcher` + subscription index).
Cross-node event convergence is handled by authenticated relay-to-relay sync.

### Management stats

Current admin `stats` is relay-global and minimal.

Sync adds a new dimension:

- peer config,
- worker state,
- per-peer counters,
- sync health summary.

That should be exposed without coupling it to app-specific sync semantics.

---

## 12. Tribes Usage

For Tribes, `AshNostrSync` should be able to:

1. rely on Parrhesia’s local node/server-auth identity for sync,
2. register one or more remote relays with `Parrhesia.API.Sync.put_server/2`,
3. grant sync ACLs for trusted server-auth pubkeys,
4. provide narrow Nostr filters for `kind: 5000`,
5. observe sync health and counters,
6. consume events via the normal local Parrhesia ingest/query/stream surface.

Tribes should not need Parrhesia to know:

- what a resource namespace means,
- which node pubkeys are trusted for Tribes,
- how to resolve conflicts,
- how to apply an upsert or delete.

That is the key boundary.
