SyncBackend Sync/Async Reconciliation — Synchronous Trait, Backend-Owned Runtime for Network Transports
Accepted
Context
ADR-027 defines `SyncBackend` as a SYNCHRONOUS trait (`announce`, `fetch_op`, `announced_ops`, `subscribe_peer` return `Result` directly). The default `FilesystemSync` is naturally synchronous, and bl-018's `Substrate::ingest` drives it from synchronous code. Implementing the first real transport, NatsSync over JetStream (bl-031), surfaced an impedance mismatch: `platform-nats`/JetStream is async (tokio), so an async client cannot satisfy a synchronous trait method without bridging, and calling `block_on` from within a tokio worker thread panics. The trait shape must be settled before NatsSync can be written.
A second, related point: the trait's verbs (`announce(op_id)` + `fetch_op(op_id)` + `announced_ops`) are filesystem-shaped (announce an id, fetch a body by id). A pull-consumer transport like JetStream more naturally publishes and pulls whole op messages. The contract must stay expressible by both without forcing one model onto the other.
Decision
ACCEPTED. `SyncBackend` STAYS synchronous — the synchronous trait is the contract, and synchrony is preserved at the call site (`Substrate::ingest` and the embedded/filesystem path stay runtime-free, honouring protocol-not-runtime and offline-first).
Async network transports bridge INTERNALLY: a backend such as NatsSync owns its own current-thread tokio runtime and `block_on`s its async client inside each trait method. The contract is that synchronous `SyncBackend` methods are invoked from a BLOCKING context — never from a tokio worker — so the daemon drives a sync round via `spawn_blocking` (or a dedicated sync thread). The backend's runtime is an implementation detail invisible to the trait and to `ontoref-core`.
The verb mapping for pull-consumer transports is fixed as: `announce` publishes the op id (and, for transports without a separate body store, the canonical op bytes) to the project subject; `fetch_op` retrieves the op bytes by id; `announced_ops` drains/pulls the durable consumer to list ids seen. A backend MAY publish body-with-announcement in one message and serve `fetch_op` from a local index of pulled messages — the trait does not mandate a separate body channel.
Backend selection stays trigger-deferred and feature-gated: NatsSync is compiled only under the daemon `nats` feature and is an OPTIONAL accelerator that degrades to absent; FilesystemSync remains the zero-dependency default.
Constraints
- Hard The `SyncBackend` trait MUST remain synchronous; async transports MUST bridge internally (backend-owned runtime) and MUST NOT require ontoref-core or FilesystemSync to depend on an async runtime. Sync rounds invoking a network backend MUST run on a blocking context, never a tokio worker.
- Hard A network SyncBackend (e.g. NatsSync) MUST be feature-gated and degrade to absent when its service is unavailable; the zero-dependency FilesystemSync MUST remain the default. No tier may be forced to require a broker.
Alternatives considered
- Make SyncBackend async (async fn in trait / async-trait) — rejected: Forces an async runtime onto ontoref-core and every substrate consumer, including offline/embedded uses. Contradicts protocol-not-runtime and ADR-029 offline-first for the sake of one network backend.
- Add a parallel async trait and a bridge layer — rejected: Doubles the contract surface and the maintenance burden before any second async backend exists. The backend-owned-runtime bridge achieves the same with no new public trait.
- Spawn the async client on the daemon's shared runtime and block_on there — rejected: block_on inside a tokio worker thread panics. The backend must own a separate runtime (or run on spawn_blocking) — a shared-runtime block_on is unsound.