This contract is a tentative design, distinct from Seed's currently registered peer service. It uses the tentative authority policy and SQLite index. The companion peer-rpc.proto is a standalone syntax-checkable artifact; its complete definitions appear below.
Transport and encoding
Recommend retaining gRPC over authenticated libp2p streams for peer control. Use protobuf for RPC messages and binary CID/principal encodings, and retain canonical DAG-CBOR for signed content blobs. Blob transport carries original uncompressed bytes; compression at rest is local. Negotiate a new contract version/protocol ID; old peers continue using the old service rather than interpreting new messages as old ones. This does not choose a production protocol-version number.
The existing Syncing service already exposes ReconcileBlobs and AnnounceBlobs. The P2P service authenticates accounts and lists public blobs. New scope snapshots, control bootstrap, offer receipts and watches below are proposed additions. Daemon document APIs are not peer RPCs.
Authentication and private bootstrap
Hello operates on the established libp2p connection. It returns a one-use challenge and the serving principal's signature. Both server and client signatures cover a canonical DAG-CBOR map containing domain hm26-peer-auth/1, side (server or client), contract version, handshake ID, both transport peer IDs, both nonces, the signing principal, and challenge expiry. Peer IDs come from the transport, not user-supplied strings. Suggested challenge lifetime is one minute; the resulting authentication token lasts at most one hour and is bound to that connection and principal. A disconnect requires reauthentication. These are transport lifetimes, not capability expiry or authority freshness guarantees.
A private connection proves both identities before either side sends fingerprints or private content. A signed invitation/source bundle supplies the resource CID, expected root principal, root policy/initial-node evidence, the recipient's grant, and the intended hosting principal's reader grant and admin proofs. An owner/root endpoint can prove implicit admin instead. Verify the hosting principal against that bundle locally before sending private seed evidence. A URL or peer address alone is routing, not custody authorization. Unknown sources get public requests only until their private custody evidence is established.
Authenticate proves exactly one principal; its token never grants resource rights. A node with several identities opens separate views for each identity. Anonymous calls use an empty auth token and can receive public material only. Do not union all local accounts' rights behind an unauthenticated HTTP or peer request.
Control access before content
GetControl takes the exact resource, expected root and signed seed evidence. Evidence is checked, not trusted merely because supplied. The full roster response is admin-only. A current reader receives its own access evidence and the writer/admin evidence needed for the permitted node statements it receives. An anonymous public reader receives only evidence for already-disclosed public statements, not a private membership roster. Requested node CIDs must themselves be permitted/disclosed; the server also includes evidence for the currently selected permitted statement by default. This control-metadata disclosure policy is tentative. The narrower personal response is available to an authenticated past grant delegate. The latter contains only its named grants, exact revocations and required permanent admin/root dependencies. It does not disclose unrelated membership, content, path metadata or counts.
This resolves the revoked-reader discovery catch-22. Root/bootstrap evidence must still match the caller's independently expected root. A current writer's signed statements can be validated using the full resource control view. Where evidence is missing, an already-entitled caller receives MISSING_EVIDENCE; an unentitled caller receives uniform NOT_AVAILABLE, also used for a nonexistent private resource. Do not disclose whether a guessed CID exists.
Pages are bounded, opaque and bound to caller, resource, mode and a consistent filtered control view. Validate current access on every page; falling from current resource access to personal-only access requires a reset, not continued roster paging. observation_token records agreement with that source's view. It is neither a global authority epoch nor proof of no unseen revocation.
Scope snapshots and allowed sets
OpenScope selects exact, children or recursive placement, plus facets. It creates a bounded frozen data set, not frozen permission. Its watch cursor captures the same committed index boundary, closing the initial-snapshot/watch race.
For each candidate blob B:
allowed(B, caller, scope) = verified and scope-candidate(B) and
(previously-public(B) or
exists active, origin-verified association B→resource R
where caller may currently read R)Facets select caller-permitted control facts, current state closure, optional history, metadata and required content. Ordinary hyperlinks do not add targets. A private shared file may have several resource associations; one authorized active association suffices. Association establishment needs authorized publication and origin checks described in the workflows, not just a referenced CID.
Build the filtered set before counting, listing, hashing, exposing metadata or sending watch events. The server checks its own custody rights too. The client filters its locally offered set by the remote serving principal's rights before constructing any fingerprints.
A view's policy stamp covers every included resource's authority/policy revision and visible membership, not just the scope root. A grant to a previously excluded child or a move into the scope requires recomputing visible membership and invalidating affected views. Do not reset an outsider's view for unrelated hidden traffic when its allowed membership/control view is unchanged.
Before each response page, obtain a coherent live authorization fence. If the view's policy stamp is obsolete, emit STALE_VIEW without additional IDs, fingerprints or private metadata. Fetching also rechecks live read/custody rights for each block. A revocation committed before response authorization prevents the response; data already authorized and queued for transmission before that boundary cannot be recalled. No long-running SQLite reader transaction should hold a stale permission snapshot while a writer commits revocation.
Default recommendation: five-minute view leases, eight concurrent views per peer, 64 KiB reconciliation pages and 256 KiB block frames. Limits are tuning defaults, not wire-format invariants. Exhaustion returns RETRY_LATER; it does not relax authorization.
Reconciliation and fetching
Reuse RBSR, initially with CID-only ordering: every algorithm item has time zero; full binary CID orders items. This avoids handler-dependent timestamp classification. Range upper bounds are exclusive; upper_unbounded denotes positive infinity and excludes upper_cid. List values must be sorted, unique and inside that range. Initial lower bound is negative infinity; subsequent lower bounds are the preceding upper bound. Malformed or non-monotone ranges are rejected.
Tentatively retain Seed's fingerprint construction: add SHA256(binary CID) as little-endian integers modulo 2^256, append the little-endian 64-bit item count, SHA-256 the result and take its first 16 bytes. Specify endianness independently of the implementation's machine order. This is a difference detector, not an authorization or authenticated-completeness proof. Golden vectors are required before interoperable implementation.
ReconcileBlobs binds every round and page to a view. A request ID identifies that round; a continuation supplies its page token and same request ID, without new ranges. The client accumulates or durably stages the entire round before advancing the interactive algorithm. Empty result closes reconciliation, not block download or global synchronization. A stale view restarts from a new allowed set; already verified blobs are deduplicated by CID.
FetchBlobs uses either a scope view or a publisher-created export token, never both. The publisher daemon, acting as the serving endpoint, mints the export token after committing its outbox. It binds the authenticated recipient peer and principal, exact resource and offered CID set, and a short expiry. The publishing app cannot mint it. It restricts an already-authorized recipient to that bounded manifest and cannot override live read rights. Each frame identifies CID and contiguous byte offset. Suggested frame size is 256 KiB. first_blob_offset applies only to the first CID in request order; all subsequent CIDs start at offset zero. Resumption can reuse a durable local prefix, but only a full reconstructed CID hash passes verification. A terminal per-blob outcome distinguishes temporary missing evidence for entitled callers from unavailable material. Raw receipt of bytes is not index admission.
Bitswap remains a possible optimized content path using the same per-peer live access predicate and offer context. The explicit streaming RPC is the reference behavior for contextual errors, cancellation and receipts; an implementation need not replace working Bitswap wholesale.
Offers and durable acknowledgements
OfferBlobs is a manifest plus initial authority evidence, not an instruction to accept arbitrary data. The receiver checks configured interest/hosting policy, trusted root, source signer authority and its own reader/custody rights. A relay may offer another writer's signed node; relay authentication is not authorship.
The receiver persists (peer, request ID, body digest, CID) offer state before returning its wanted list. The digest covers the canonical resource, proposed statement, expected root, CID set and seed evidence; exclude replaceable auth/export tokens. Reusing an ID with a different digest is invalid. Same ID/body across retries returns the same durable work and current receipt stages. An existing byte blob may still require origin/publication evidence before being associated with a new resource.
The receiver pulls wanted bytes from the publisher via FetchBlobs(export_token), hashes them, then indexes in bounded transactions. GetOfferStatus resumes receipt paging after disconnection. Stages mean:
Stage | Meaning | Durable? |
|---|---|---|
RECEIVED | Request parsed or bytes in a transient buffer | No promise |
WANTED | Work and idempotency context recorded; bytes needed | Yes |
STORED | Full hash verified and bytes committed | Yes |
WAITING | Stored but awaiting named evidence/dependencies | Yes |
INDEXED | Required facts and eligibility evaluation committed; state may still be unsupported | Yes |
REJECTED | Invalid data or a refused admission decision with an explicit outcome | Yes |
Receipt changes and their index-event boundary commit together. A final receipt is read after commit, never emitted as a promise from an uncommitted transaction. INDEXED records local processing at that time; it is not a permanent read grant, a globally canonical head or proof every replica has the update. A later grant/revoke can reevaluate an indexed candidate. Unsupported apply code is reported separately from successful structural indexing.
Watches, gaps and errors
WatchScope(after_cursor) streams filtered committed state/control changes. Cursors are opaque server-side handles or encrypted tokens; they must not expose a global event sequence or unfiltered traffic counts. Bind them to peer, principal, scope, facets and index generation. They refer to a durable local index sequence, not global or causal authority order.
A node first snapshots, then watches from that snapshot cursor. Buffered/replayed events are at-least-once; a repeated cursor is deduplicated after durable local enqueue. Never advance the client cursor merely on transient receipt. Compacting the event log, rebuilding the index or exceeding retained history produces RESET_REQUIRED; control refresh and RBSR repair the gap. A heartbeat carries no private head, count or unrelated activity signal.
On revoked access, emit a personalized ACCESS_CHANGED for that already-known resource without subsequent private metadata, then allow only the personal control path. New connections require fresh authentication. Disconnection alone is not revocation; mark knowledge stale and reconnect with bounded backoff. Authorization changes wake waiting tasks rather than relying on timed sleeps for correctness.
The full candidate protobuf is below. It deliberately leaves production service registration and executable apply-function encoding for implementation work.
syntax = "proto3";
package hm26.design.v1;
// Tentative design artifact. Not registered in Seed's live peer service.
service ResourceSync {
rpc Hello(HelloRequest) returns (HelloResponse);
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse);
rpc GetControl(ControlRequest) returns (ControlResponse);
rpc OpenScope(OpenScopeRequest) returns (OpenScopeResponse);
rpc ReconcileBlobs(ReconcileRequest) returns (ReconcileResponse);
rpc FetchBlobs(FetchRequest) returns (stream BlockFrame);
rpc OfferBlobs(OfferRequest) returns (OfferResponse);
rpc GetOfferStatus(OfferStatusRequest) returns (OfferResponse);
rpc WatchScope(WatchRequest) returns (stream ScopeEvent);
}
enum Outcome {
OUTCOME_UNSPECIFIED = 0;
OK = 1;
NOT_AVAILABLE = 2;
MISSING_EVIDENCE = 3;
STALE_VIEW = 4;
RETRY_LATER = 5;
INVALID_BLOB = 6;
UNSUPPORTED_FORMAT = 7;
INVALID_REQUEST = 8;
}
message Auth { bytes token = 1; }
message Block { bytes cid = 1; bytes data = 2; }
message HelloRequest { bytes client_nonce = 1; uint32 contract_version = 2; }
message HelloResponse {
bytes handshake_id = 1; bytes server_nonce = 2;
bytes server_principal = 3; bytes server_signature = 4;
int64 challenge_expires_ms = 5; uint32 max_frame_bytes = 6;
}
message AuthenticateRequest { bytes handshake_id = 1; bytes principal = 2; bytes signature = 3; }
message AuthenticateResponse { Auth auth = 1; int64 auth_expires_ms = 2; }
message ControlRequest {
Auth auth = 1; bytes resource_cid = 2; bytes expected_root = 3;
repeated Block seed_evidence = 4; repeated bytes personal_grant_cids = 5;
bytes page_token = 6; uint32 page_size = 7; repeated bytes requested_node_cids = 8;
}
message ControlResponse {
Outcome outcome = 1; repeated Block evidence = 2;
bytes observation_token = 3; bytes next_page_token = 4;
bool personal_only = 5; bool public_evidence_only = 6;
}
message Scope {
bytes resource_cid = 1;
enum Depth { EXACT = 0; CHILDREN = 1; RECURSIVE = 2; }
Depth depth = 2;
// Bit mask: control=1, current state=2, history=4, metadata=8, content=16.
uint32 facets = 3;
}
message OpenScopeRequest { Auth auth = 1; Scope scope = 2; }
message OpenScopeResponse {
Outcome outcome = 1; bytes view_token = 2; bytes watch_cursor = 3;
int64 view_expires_ms = 4; bytes selected_node_cid = 5;
bytes computed_state_cid = 6; string materialization_status = 7;
bool public_history_only = 8;
}
message Range {
enum Mode { SKIP = 0; FINGERPRINT = 1; LIST = 2; }
Mode mode = 1; bytes upper_cid = 2; bool upper_unbounded = 3;
repeated bytes cids = 4; bytes fingerprint = 5;
}
message ReconcileRequest {
Auth auth = 1; bytes view_token = 2; bytes request_id = 3;
repeated Range ranges = 4; bytes page_token = 5;
}
message ReconcileResponse { Outcome outcome = 1; repeated Range ranges = 2; bytes next_page_token = 3; }
message FetchRequest {
Auth auth = 1; bytes view_token = 2; bytes export_token = 3;
repeated bytes cids = 4; uint64 first_blob_offset = 5;
}
message BlockFrame {
bytes cid = 1; uint64 offset = 2; bytes data = 3;
bool complete = 4; Outcome outcome = 5;
}
message OfferRequest {
Auth auth = 1; bytes request_id = 2; bytes resource_cid = 3;
bytes expected_root = 4; bytes proposed_node_cid = 5;
repeated bytes blob_cids = 6; repeated Block seed_evidence = 7;
bytes export_token = 8;
}
enum ReceiptStage {
RECEIPT_UNSPECIFIED = 0;
RECEIVED = 1;
WANTED = 2;
STORED = 3;
WAITING = 4;
INDEXED = 5;
REJECTED = 6;
}
message BlobReceipt {
bytes cid = 1; ReceiptStage stage = 2; Outcome outcome = 3;
repeated bytes missing_cids = 4; string materialization_status = 5;
}
message OfferResponse {
Outcome outcome = 1; bytes request_id = 2;
repeated BlobReceipt receipts = 3; bytes next_page_token = 4;
}
message OfferStatusRequest { Auth auth = 1; bytes request_id = 2; bytes page_token = 3; }
message WatchRequest { Auth auth = 1; Scope scope = 2; bytes after_cursor = 3; }
message ScopeEvent {
enum Kind { HEARTBEAT = 0; STATE_CHANGED = 1; CONTROL_CHANGED = 2; ACCESS_CHANGED = 3; RESET_REQUIRED = 4; }
Kind kind = 1; bytes cursor = 2; bytes resource_cid = 3;
bytes selected_node_cid = 4; Outcome outcome = 5;
}Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime