Documentation
    Preparing search index...

    Specification

    Peerkit is a peer-to-peer (P2P) data synchronization library. Its purpose is to enable developers to add decentralized data sharing to their applications, where groups of users have full control over their data and their participation.

    Peerkit is not a runtime or an all-or-nothing framework. It is a library that can be integrated into existing applications. An app may use Peerkit to synchronize only a subset of its data, while the rest of the app uses conventional storage, APIs, or other backends. Peerkit only manages the data that is specified through its storage hooks. This makes adoption incremental: developers can add P2P capabilities to one feature of an existing app without rewriting the rest.

    People lack agency over their digital tools. This manifests when:

    • Infrastructure fails (an excavator breaks a fibre line, a device goes offline)
    • A company discontinues a product or removes a feature
    • A platform monetizes a group's relational and knowledge wealth against their interests
    • A state actor collaborates with an application provider to compromise a group's safety

    Existing P2P frameworks have not solved this problem at scale, or provide mainly low-level features to this end.

    Peerkit enables groups to credibly say "this is ours" about their digital tools. "Credibly" means a group realistically has agency: they can use, operate, and modify their tools with only a basic level of tech savvy.

    For developers, choosing P2P should not be a painful choice. A developer new to Peerkit should be able to follow documentation and build a functioning app within hours.

    • A developer new to Peerkit can build a functioning app within hours (e.g. at a hackathon).
    • Clear, detailed technical documentation uses consistent terminology, enabling developers to communicate effectively with maintainers.
    • The framework has no fundamental limitations that would prevent maintainers from recommending it for serious production use cases.
    • Solid API documentation and compliance tests make it possible for developers to replace modules with correctly implemented alternatives of their own.
    • Apps written with the framework can run on all desktop devices running Linux, macOS or Windows, mobile devices running Android or iOS and in web browsers.
    • Everything required to develop an application end-to-end is demonstrated as part of launching v1 and documented for developers.
    • Apps built with the framework can be scaled to simultaneous usage by 100s of thousands of users, given a reasonable developer-provided distribution and storage algorithm.

    Low-level libraries like libp2p provide networking primitives: transport, encryption, peer discovery, and connection management. iroh goes further — beyond its core connectivity layer (QUIC-based transport, key-based addressing, NAT traversal with relay fallback), it also offers composable protocols including gossip (iroh-gossip for pub-sub overlay networks), content-addressed blob transfer (iroh-blobs), an eventually-consistent key-value store (iroh-docs), and a Willow protocol implementation (iroh-willow, in development). Despite this broader feature set, both remain building blocks rather than application frameworks. A developer using libp2p or iroh directly must still solve:

    • How to structure, store, and query application data in a way that fits their domain
    • How to validate data and maintain consistency across peers
    • How to manage access control, membership, and privacy
    • How to handle scalability as the network grows

    Peerkit sits above these libraries and provides an opinionated-by-default but modular framework that addresses all of the above.

    p2panda provides a similar layer of abstraction (structured data, gossip, access control) but is written in Rust, has limited documentation, unclear maturity, and its storage layer (SQLite) doesn't build to browser WASM. Peerkit differentiates by:

    • Being a TypeScript framework, accessible to a much larger developer ecosystem
    • Targeting browser, mobile, and desktop from day one
    • Providing comprehensive documentation and tooling
    • Focusing on scalability as a first-class concern
    1. Accessibility: TypeScript framework with great documentation. Beginner developers can build apps quickly and easily.
    2. Universal platform support: Runs on Linux, macOS, Windows, Android, iOS, and browsers (at minimum in lite/leecher mode).
    3. Scalability as a design principle: No "get all" methods on unbounded collections, no "notify everyone" networking concepts. The framework is designed from the ground up to scale to 10,000+ users.
    4. Security and privacy built in: Open networks require explicitly disabling security controls, not the other way around. Agents control who can connect, read, write, and see their data.
    5. Modularity without fragmentation: A modular architecture with dependency injection allows swapping implementations of any component, while providing quality defaults that work out of the box.
    6. Data flexibility: Not append-only at the protocol level. Supports destructive edits for scalability and privacy. Agnostic to data format or uses human-readable, easily exportable formats.
    7. Compliance-tested module boundaries: Published specifications and compliance test suites allow third parties to build compatible implementations.
    • Fun to use and easy to understand conceptually
    • Usable by beginner devs, powerful for advanced devs
    • Safe defaults: when using the highest-level APIs, it is hard to write an app that breaks basic usability. Lower-level APIs offer more power with fewer guardrails.
    • Runs everywhere: browser (at least lite mode), desktop (Linux, macOS, Windows), and mobile (Android, iOS)
    • Local-first: data sovereignty, supports easily portable data formats
    • Not append-only at the protocol level
    • Flexible: about discovery, connectivity, and data model, with quality defaults
    • Secure by default: encryption of data, security built in with reasonably safe defaults
    • Well-documented: extensive documentation for app devs and system developers
    • Supports an ecosystem: extensible with libraries/plugins, comes with specifications for interoperability
    • Scalable: effortless scalability to 10,000+ users
    • Group sovereignty: groups can say "this is ours" -- they have the power to keep the lights on, modify their tools, and manage membership
    • Platforms: runs fully on Linux, macOS, Windows, Android, and iOS. Runs on Chromium at least in leecher mode over a relayed connection.
    • Language: users MAY write apps in JavaScript/TypeScript. Framework itself is TypeScript.
    • Minimal out-of-band data: ideally only the address of an entry point and entrance secrets for the app space are required to use an app.
    • Modularity: must be agnostic to or allow swapping of data distribution strategy, data/state model, storage implementation, and transport medium.
    • Privacy and security: assume adversaries are present. Privacy and security built in at the network layer.
    • Resource-conscious: should run on old smartphones with metered data. No unreasonable burdens on devices.
    • Local-first and meshnet-friendly: peer discovery and operation must work on local networks without internet.
    • Data format: either agnostic to data format, or uses a format that is human-readable and easy to export.
    • Open networks must require disabling security controls (secure by default).
    • Comprehensive tooling: tooling to inspect data and state for system devs, app devs, and end users. Distributed test setup.
    • Existing technology: use existing technology wherever sensible, not reinventing transport or peer discovery.
    • API expressiveness: expressive enough to build features missing from core; simple enough to allow groups to vibe-code ad-hoc apps.

    Peerkit is a set of packages composed by an orchestrator package. Three layers are implemented today:

    • Layer 0 — platform-specific transport (@peerkit/transport-libp2p facade resolving to the -nodejs / -react-native builds): data transmission over the wire, specific to each target platform.
    • Layer 1 — peerkit transport (@peerkit/transport-libp2p-core): the platform-independent transport contract — establishing connections, enforcing network access control, and routing messages, all behind an opaque NodeId.
    • Layer 2 — peerkit core (@peerkit/peerkit): persistent agent identity, the mapping between agent IDs and transport node IDs, the agent store, signed agent-info exchange, and a host for pluggable modules.

    Data synchronization capabilities are provided by modules registered for a node. The full-replication push and pull modules for authored data shipped today are one such example.

    Higher-level concerns (structured data, indexing, state changes / CRDTs) build on these primitives but are speculative at this stage. They are sketched below as future direction.

    Design principles:

    • The transport layer makes the most opinionated, restrictive choices about the networking library. Cross-cutting components above it remain modular and pluggable.
    • When making a restrictive choice: can it be pushed up to a higher component?
    • The transport calcifies sooner; higher-level components may be more experimental.
    • We will get the architecture wrong. It must be straightforward to make dramatic changes by swapping out a single component.
    • Network access management for group membership (see Layer 1: network access control)
    • With network access security in place and a single set of data per app, additional data access security isn't required.
    • Encryption of data at rest and in transit

    An agent is a participant with a cryptographic identity: a public/private keypair. The agent's public key is its AgentId — a stable, verifiable identifier that does not depend on any central registry.

    The framework generates the agent's keypair on first run and persists it through storage. On subsequent runs it reloads the same keypair. The app does not provide or manage key material. Signing happens and the private key is never exposed.

    The local agent's public identity is accessible after initialization through the orchestrator.

    AgentInfo is the shareable, serializable descriptor exchanged between peers. Its schema is defined in the API package. From the transport's perspective AgentInfo is opaque bytes. It carries the AgentId, the addresses where the agent can be contacted, an expiry timestamp, and a signature. The expiry timestamp demarcates a point in time after which the agent info should be discarded.

    AgentId encoding: AgentId is the hex encoding of the raw 32-byte Ed25519 public key. This makes AgentId self-describing: any peer can reconstruct the public key from the string alone, with no key registry.

    AgentInfo signature: every AgentInfo record carries a signature field — the Ed25519 signature over the canonical encoding of {agentId, addresses, expiresAt}. Verification is deterministic: decode the hex agentId to obtain the public key bytes and verify directly. No injected verifier or key lookup is required.

    Signing: signed payloads (e.g. state changes in higher-level components) are signed by their author using the agent's private key. Peers verify the signature using the AgentId.

    • Newly received data should be considered "pending" and must be "accepted"
    • The exact validation mechanisms are not imposed by the framework
    • Apps implement custom validation functions
    • Validation cannot access the network; required data must be available locally

    Storage is pluggable so developers can choose a backend that fits their app's constraints and deployment environment. Different components persist different things, so the framework defines several storage interfaces rather than one: IAgentStore for agent records, IAgentKeyStore for the local private key, and IAuthoredDataSyncStore as an example for synchronized data blobs.

    Stores are injected as dependencies. Peerkit ships in-memory implementations as the working default (MemoryAgentStore, MemoryBlobStore), a persisted backend is planned. The in-memory blob store is one implementation of the data-store interface, and an app can supply its own. The exact interface for each store is documented at the package level.

    Peerkit's network architecture distinguishes regular nodes — the participants — from infrastructure that supports them. Infrastructure serves two distinct roles:

    • Relay: provides NAT traversal so regular nodes behind restrictive routers can be reached. Most consumer devices cannot accept incoming connections directly. Relays bridge this gap. A second role of relays is relaying data traffic as a fallback when direct connections between peers fail to be established. The relay is transport-only infrastructure: its address is passed as configuration at construction and it is never exposed to the layer above as a NodeId. It is ignorant about agent identity and messages and just stores and passes through bytes. A relay can be given a persisted RelayCertificate, so its dial address stays stable across restarts, and can announce a public IP when it is bound behind NAT. It can also expose a liveness ping that is deliberately not gated by network access, so an external monitor can health-check the relay without holding access bytes.
    • Bootstrap node: an addressable node that distributes the current set of connected agents to new joiners. A node freshly connecting to the network contacts the bootstrap peer, exchanges agent infos with it, and through those infos learns about other peers. The bootstrap peer is a regular node, the layer above sends agent infos to it by NodeId just like any other peer. When a node sends fresh agent info, the bootstrap node forwards it to the other peers it is connected to, so peers that joined after the original publish still learn about it.

    These two roles are logically independent. They can be hosted on the same machine at the same public address (as in the MVP, where a single infrastructure node serves both), or split across separate nodes.

    Infrastructure is openly runnable: there is no privileged operator, no central authority. App developers, communities, or motivated users run infrastructure nodes; networks support multiple. Infrastructure has no privileged access to application data — it is gated by the network access control and routed end-to-end between regular nodes.

    Resource management for relaying: A node acting as relay carries the bandwidth cost of forwarding traffic. The framework must:

    • Allow infrastructure operators to set bandwidth and connection limits
    • Support multiple relays so load can be spread
    • Automatically prefer direct connections once established (the relay drops out of the path)

    Layer 0 is the layer of data transmission over the wire, specific to the target platform. Desktop, mobile and browser platforms have different requirements and permissions that allow or deny apps access to low-level networking features of the operating system. Also since Peerkit is written in TypeScript and executed in a JavaScript runtime, the various platforms impose more or less restrictions on the framework.

    This has to be considered for each platform individually. Transports meeting the requirements of the target platform live on layer 0.

    Layer 1 is responsible for establishing connections between peers, enforcing network access control, and routing application-level messages. The reference implementation is @peerkit/transport-libp2p-core, built on libp2p.

    The transport's public API uses a single opaque NodeId type (a string) across every method, callback, and event. Library-specific identifiers (libp2p PeerId, libp2p Connection, multiformats Multiaddr) never cross the public boundary. The transport translates internally, so the networking library can be swapped without affecting any caller.

    Mapping between peerkit AgentId (public key) and the transport's NodeId is the responsibility of the layer above the transport.

    The only externally-supplied address in the transport's public API is the relay address used at bootstrap (passed at construction). All other addressing — peers' addresses, relay paths, identification of unconnected peers — is an implementation detail of the transport, handled through the networking library's own peer-discovery and peer-store mechanisms.

    The transport package supports being run as part of the infrastructure or as a regular node. The two modes share access gating; they differ in their networking-library configuration: infrastructure nodes run a relay service, regular nodes use a relay client. Infrastructure-mode transports do not handle application messaging — peers attempting to send application data to infrastructure see a clean protocol-unsupported error. Relay mode adds the optional liveness ping and certificate persistence described under Infrastructure. Node mode runs a renewal timer that re-signs and re-sends the node's agent info before its TTL expires.

    The mode is fixed at construction because the underlying networking library cannot reconfigure relay services at runtime.

    The transport has three built-in protocols:

    • Network access handshake: the first message on every new connection. Carries the connecting agent's NetworkAccessBytes. Policy enforcement lives in the transport package.
    • Agent-info messages: opaque routing for agent-info exchange. Wire format being owned by core peerkit, the transport routes payload bytes between connected peers and peers and relays.
    • Application messages: opaque routing for application data via send. A call resolves once the transport has accepted the message and can take the next one, which applies backpressure to callers that await it; there is no delivery acknowledgement. Wire format owned by application code. Available on regular nodes only.

    Beyond these three, the transport opens arbitrary named protocol streams. A caller registers a handler with registerStreamHandler(protocol, …) and opens an outgoing stream with createStream(nodeId, protocol); each side gets an IStream for bidirectional bytes. The data synchronization modules ride on these custom streams rather than the application-message path. Incoming custom streams are subject to the same access check as everything else.

    Concrete protocol identifiers and wire formats are documented in the transport package, not here.

    Networks are closed by default. Every incoming connection must present NetworkAccessBytes in the access handshake before any other messages can be exchanged. The orchestrator registers a network access handler that returns accept or reject for given bytes.

    To make a network open, the app explicitly registers a handler that accepts all bytes. This is opt-out, not the default.

    The transport tracks two sets of agents per session:

    • Granted: agents whose access bytes have been verified. The AgentId↔internal-id mapping is established here, enabling subsequent message routing.
    • Denied: agents whose access bytes were rejected. This set is sticky for the lifetime of the transport instance. A denied agent that reconnects (possibly from a different connection) is rejected immediately without re-running the access check. This is both a performance optimization and a security measure — repeated guessing of network access bytes for the same agent must not be possible.

    A connection that fails the access check is fully closed.

    Most consumer devices sit behind a router that blocks incoming connections. The networking library attempts direct connections where possible, and falls back to relay-assisted connectivity otherwise. When both peers are behind restrictive routers, a relay-assisted connection stays active as a fallback (estimated 5–15% of attempts). Infrastructure relays are required for relay-assisted connectivity; direct connections work without dedicated infrastructure when reachability allows.

    At construction, a regular node receives one or more relay addresses (transport-internal config) and the NodeId of one or more bootstrap peers. Once connected to the relay, the node exchanges agent infos with the bootstrap peer and learns about existing peers. After the initial bootstrap, agent discovery proceeds bi-directionally with all connected peers.

    Relay-mediated peer discovery and connection flow:

    1. A node connects to the relay and completes the access handshake. The transport notifies the layer above once the node has a relay-reachable address, an address through which other peers can contact it via the relay.
    2. The node compiles and signs its own AgentInfo, which includes its relay-reachable address, and stores it in the agent store.
    3. Then the node sends all its AgentInfos to the relay.
    4. The relay stores received agent infos. When a new node connects and completes the access handshake, the relay sends it the stored agent infos so the joining node learns about existing peers.
    5. The joining node has the relay-reachable addresses and node IDs of existing peers and can connect to them through the relay.
    6. The transport attempts to upgrade relay-assisted connections to direct connections where possible. On success the relay drops out of the data path; on failure the relay-assisted connection remains as fallback.

    Local network discovery (mDNS) is a future enhancement that allows zero-configuration discovery on the same LAN.

    A browser is a peer that uses different transports (post-MVP). Browsers cannot open listening sockets, so they can only initiate connections. Once connected, they participate as full regular nodes on those connections.

    A network of only browser nodes cannot bootstrap. At least one infrastructure node must be reachable for initial connection.

    Platform Capabilities
    Desktop (Linux, macOS, Windows) Full participation; can run as infrastructure (relay + bootstrap) or as a regular node.
    Mobile (Android, iOS) Regular node; background restrictions limit suitability for infrastructure.
    Browser (post-MVP) Lite regular node: cannot be bootstrapped to, always initiates connections. Can relay between peers it has connected to.

    All connections are encrypted.

    The transport's contract to the rest of Peerkit is fully described by:

    • The two construction modes (infrastructure, regular).
    • A network access handler hook.
    • Connect by NodeId.
    • Send/receive opaque bytes by NodeId for agent-info and application messages.
    • Relay willingness configuration.

    A second implementation (iroh, future) would replace the library-specific internals while exposing the same surface. Packages above the transport remain unchanged when the networking library changes.

    The transport is injected as a dependency. The orchestrator interacts with it through the ITransport interface, and the transport package exposes two construction paths — one for infrastructure nodes, one for regular nodes — both producing instances that satisfy ITransport. On infrastructure nodes, application messaging (send) is not serviced; peers attempting to use it see a clean protocol-unsupported error.

    The vocabulary the boundary deals in:

    • NodeId — an opaque string identifying a peer at the transport level. Mapping to a peerkit AgentId is the caller's job.
    • NodeAddress and RelayDialAddress — opaque dialable addresses, parsed by each transport implementation.
    • NetworkAccessBytes — the byte sequence that proves access to a network has been granted.
    • RelayCertificate — a caller-supplied TLS certificate for a relay's listener; persisting it keeps the relay's dial address stable across restarts.
    • IStream — a bidirectional byte stream for arbitrary data, with message / remoteClose / close events.

    ITransport exposes: getNodeId; connect (to a list of dialable addresses); sendAgents and send (opaque agent-info and application bytes to a connected, access-granted peer); isConnected, getConnectedPeers, and isDirectConnection (connection state, where a non-direct connection is relayed); createStream and registerStreamHandler (custom protocol streams); disconnect; getListenAddresses (the transport's real dial addresses after startup, including any identity suffix); and shutDown. Construction takes the access handler plus the lifecycle callbacks (peer connected/disconnected, connected-to-relay, addresses-changed, agents-received, and the message handler on regular nodes).

    The exact signatures, callback shapes, and TSDoc live in @peerkit/api and are published via typedoc; they are the authoritative reference and are not duplicated here. This boundary is what enables replacing js-libp2p with iroh or another networking library without affecting higher layers.

    Peerkit core (@peerkit/peerkit) sits directly on the transport. It owns the agent's persistent identity, which survives transport restarts, maps that identity to transport node IDs, exchanges signed agent-info between peers, and hosts the modules that do the actual data work. It does not store or distribute application data itself.

    Responsibilities:

    • Persistent agent identity (AgentKeyPair) and its key lifecycle, loaded from or created in an injected IAgentKeyStore.
    • The mapping between an AgentId and the transport's NodeId, in both directions.
    • The agent store (IAgentStore): the set of known, non-expired peers and their dial addresses.
    • Building, signing, verifying, serializing, and renewing AgentInfo records.
    • Evaluating incoming network access bytes to allow or deny connections.
    • Node and relay construction via PeerkitNodeBuilder and PeerkitRelayBuilder.
    • Hosting pluggable modules through the node's module interface.

    Operations between peers use the agent ID, not the transport's node ID. The mapping between the two lives in core. When a node learns of a peer from agent info, it has that peer's agent ID and dial addresses, so it can open an outbound connection and immediately associate the connection with the agent.

    Inbound connections are different: at the transport level the remote is only a node ID, and the transport has no notion of agents. So the agent ID travels with the network access bytes in the access handshake. The transport treats those bytes as opaque; core reads the agent ID out of them and records the node-ID-to-agent-ID mapping. This is why agent identity is a core concern, not a transport one.

    On top of this mapping, core lets consumers address peers by agent ID — sending a message, opening a stream, or checking connectivity — and translates to the underlying node ID.

    Core exposes a small interface to modules so they can be packaged and distributed without depending on the @peerkit/peerkit package directly. A module (INodeModule) is attached to a node with register; the node calls init to wire it up, then start, and stop on shutdown. Through the node interface (IPeerkitNode) a module reads its own ownAgentId, lists connected agents, opens custom protocol streams (createStream), handles incoming ones (registerStreamHandler), and sends application messages — all keyed by agent ID rather than transport node ID. The exact signatures live in @peerkit/api and are published via typedoc.

    Distributing application data is the job of modules, not of core. The full-replication push and pull modules shipped today are one example of how to do it — appropriate for small networks where every peer can hold everything, but not the right choice for every app. Other distribution policies (DHT, direct, topic-based) are possible against the same hook, and an app can attach its own modules instead.

    These modules operate on authored data: each stored blob (StoredBlob) carries its content hash, the blob bytes, its author, and an authoredAt timestamp that is monotonic per author, so an author's blobs have a well-defined order. The store (IAuthoredDataSyncStore) owns the content-hash algorithm and that per-author clock. Local authoring goes through store (which hashes, stamps, persists, and notifies onAuthored subscribers); blobs received from peers go through accept, which the store may decline — for example when the distribution policy says this node should not hold the blob, or the blob is too large. The store also answers the queries the sync protocols need: by hash and author, the author's most recent blob, and the recent (authoredAt >= since) and historical (authoredAt < before) segments.

    The distribution policy (IDataDistributionPolicy) decides which blobs a given peer should hold. Its one method, willStore(peerId, blobHash), receives the content hash — enough for hash-based routing such as a DHT — rather than the blob itself. FullReplicationPolicy is the default: willStore always returns true, so every peer keeps everything. Signatures for the store and the policy live in @peerkit/api.

    Two modules use this together:

    • Push (/peerkit/authored-data-push/v1): fire-and-forget. The module subscribes to onAuthored and streams newly authored blobs to connected peers, who run them through accept. No acknowledgement.
    • Pull (/peerkit/authored-data-pull/v1): periodic anti-entropy that repairs whatever push missed. Each exchange is segmented by an epoch boundary. The requester asks for two things: a recent delta (blobs with authoredAt >= epochStart, since the last one it has seen) and a historical segment (blobs before the epoch). The historical segment is reconciled by XOR-ing the hashes of the blobs each side holds: if the two XOR summaries match, the history is in sync and nothing is sent; if they differ, the responder sends its historical blobs. This keeps the common case — unchanged history — down to a single hash comparison instead of a full listing.

    Connection management is still pluggable in principle, but the shipped default simply maintains connections to all peers, which is adequate at the scale full replication targets.

    Adds semantic meaning to blobs through schemas.

    Capabilities:

    • The first bytes of a blob reference a schema blob containing the blob's data structure definition (JSON Schema or similar)
    • Referenced schema blobs must be fetched to decode the data blob.
    • Blobs must decode according to their schema
    • Hook called when new data arrives matching a given schema

    API:

    interface IStructuredData {
    // Publish a schema definition so peers can decode blobs that reference it
    publishSchema(schema: Schema): Promise<Hash>;

    // Publish a blob whose structure conforms to the given schema
    publishBlob(schemaId: Hash, data: Uint8Array): Promise<Hash>;

    // New data coming in, matching a given schema
    onNewData(schemaId: Hash, data: Uint8Array);
    }

    Adds queryability through deterministic metadata and distributed indexes. Layer 3 provides the "detail view" (fetch a specific blob by hash). Layer 4 provides the "list view" (browse and search blobs by their properties).

    When a blob is created, metadata is deterministically extracted according to its schema's METADATA_FIELDS definition and published as separate blobs via publish(). METADATA_FIELDS is a list of field names declared in the schema that should be indexed — each named field gets its own metadata blob per value. The distribution strategy routes metadata blobs like any other blob. This means index lookups use the same get() mechanism as any other data retrieval — no special query protocol is needed.

    Open question: the schema format is not yet specified. Either the schema definition format is hardcoded (e.g. a JSON Schema dialect with a metadata_fields key), or an additional hook is needed (e.g. get_metadata_fields(schema) => string[]) to let schemas express this dynamically. This has implications for Layer 3 as well.

    Metadata must be a separate blob because it needs to be routed independently of its source blob. In a DHT, hash("tag:meeting-notes") routes to different peers than the content blob itself — that's what makes index lookups efficient without scanning. If metadata were embedded in the source blob, it would be co-located with the content and distributed lookup would be impossible.

    Example: A document blob has schema fields author, tags, and createdAt. The schema declares tags and author as metadata fields. When the blob is published:

    1. The blob itself is published via publish(blob)
    2. A metadata entry { blobId, tag: "meeting-notes" } is published via publish(metadataBlob)
    3. A metadata entry { blobId, author: "agentXyz" } is published via publish(metadataBlob)

    To find all documents tagged "meeting-notes", a peer queries for metadata entries matching that tag and receives a list of blob IDs.

    • Deterministic: Metadata is derived from the blob itself using rules defined in the schema. Any peer can independently verify that a metadata entry is correct by re-deriving it from the source blob. Forged index entries can be detected and rejected.
    • Distributed: Metadata entries are blobs distributed via Layer 2 like any other — they are replicated and gossiped using the active distribution strategy. No peer needs a global index.
    • Composable: Queries on multiple fields can be resolved by intersecting results from multiple lookups.
    • Prunable: When a blob is pruned, its associated metadata entries can be pruned too.
    interface IIndexedData {
    // Get all metadata entries for a blob
    getMetadata(blobId: Hash): Promise<Metadata[]>;

    // Find blobs matching field criteria (translates to Layer 2 lookups)
    query(metadataQuery: MetadataQuery): Promise<Hash[]>;

    // Verify that a metadata entry was correctly derived from the given blob
    verifyMetadata(blob: Blob, metadata: Metadata): boolean;
    }

    Layers 2-4 deal with immutable blobs: you publish a blob, it gets distributed, it can be queried. But real applications need mutable state — a document that gets edited, a counter that increments, a list with items added and removed. In a P2P system without a central authority, multiple peers can mutate the same logical entity concurrently. Layer 5 defines how those concurrent mutations are expressed, linked, and resolved.

    Open question: A CRDT definition needs to be declared somewhere — grouping a set of state change schemas that belong to the same CRDT, declaring the merge function, and optionally defining CRDT-level operations (e.g. deduplicating redundant state changes). The format and location of this definition is not yet specified.

    State changes are blobs. They use Layer 3 schemas and Layer 4 indexing. No fundamentally new storage or networking mechanism is introduced — Layer 5 adds conventions and semantics on top of what exists below.

    Each state change blob contains:

    • Schema ID: identifies the type of state change (e.g. CreateEntry, UpdateEntry, DeleteEntry)
    • Target: the blob ID of the entity being acted on (absent for creates)
    • Causal links: blob IDs of the state changes this one depends on (the state changes the author had seen when making this change)
    • Payload: the new data, if needed
    • Author: the AgentId (public key) of the agent who made the change
    • Signature: the author's signature over the blob content, verifiable by any peer using the author's public key

    Causal links form a DAG (directed acyclic graph) of state changes per entity. This DAG captures the order in which changes were made and allows detection of concurrent changes (branches in the DAG).

    Peerkit ships one built-in CRDT: a CRUD object. The following are the state changes it allows:

    • CreateEntry: establishes a new logical entity. The blob ID of this state change becomes the entity's identity.
    • UpdateEntry: targets a CreateEntry (by blob ID) and carries causal links to the previous state changes the author had seen. Payload contains the new state.
    • DeleteEntry: targets a CreateEntry. Signals that the entity should be considered removed. Triggers pruning of the original data and prior updates (see destructive edits below).

    Multiple peers may issue UpdateEntry or DeleteEntry for the same entity concurrently, creating branches in the causal DAG. Resolution is handled by merge functions.

    When a peer receives state changes that form concurrent branches (no causal link between them), a merge is required. The CRDT schema defines how merges work.

    Default merge strategy (last-writer-wins): Compare timestamps; if equal, compare blob hashes deterministically. Simple, sufficient for many apps, and requires no custom logic.

    Custom merge functions: For richer semantics, the schema can specify a merge function. This is an open design question — options include:

    • A named, well-known merge strategy (e.g. "lww", "set-union") that the framework ships implementations for
    • Application-provided merge logic registered at startup.

    The framework ships built-in merge strategies for common patterns. Custom strategies are possible but not required for typical CRUD apps.

    Open question:

    The merge logic could also be published as part of the CRDT specification. The CRDT spec would include the actual code and the intrepreter/vm/assembler it must be run with, i.e.

    {
    "merge": {
    "interpreter": "node_22.1",
    "exec": "function(a: AdditionStateChange, b: AdditionStateChange) -> AdditionState { return State { value: a.value + b.value }; }"
    }
    }

    One of the benefits is it makes everything needed to use the CRDT fully in-band as long as the node has the interpreter available. There might need to be some tooling to make it easy for devs to write the code in a .js file and then have it imported into their CRDT type as a string.

    Pruning in a P2P system without a central coordinator is inherently difficult, but possibly solvable with trade-offs.

    Gossip treats a missing blob as something to repair. A pruned blob also looks missing. Without a mechanism to distinguish "not yet received" from "intentionally removed," gossip will undo prunes by re-fetching the data. Permanent tombstones solve this but grow monotonically, partially defeating the purpose of deletion.

    Peerkit uses epochs — coordinated time windows that bound tombstone retention and define the sync protocol:

    1. Peers store tombstone records (compact: hash + deletion timestamp) for the duration of one epoch.
    2. After an epoch boundary, tombstones from previous epochs are dropped.
    3. Peers that were online during the epoch use incremental gossip as normal — tombstones and prune state prevent data resurrection.
    4. Peers that were offline for longer than one epoch cannot use incremental gossip. They must treat themselves as new peers joining and perform a full state sync against a snapshot.

    Snapshot-based anti-entropy: Peers periodically produce compact state snapshots. A returning or joining peer syncs against the snapshot. If a blob isn't in the snapshot, it doesn't exist — no tombstone needed. The snapshot is the authoritative representation of current state.

    This bounds tombstone storage to one epoch's worth of deletions (proportional to deletion rate, not deletion history) and shifts the cost of long absence from the network to the returning peer (a one-time full resync) rather than burdening all peers with permanent tombstones.

    When an entity is deleted:

    1. A DeleteEntry state change is published
    2. Peers that integrate the delete may prune the original CreateEntry blob, all UpdateEntry blobs, and the associated Layer 4 metadata entries
    3. The DeleteEntry is retained as a compact tombstone record for the current epoch
    4. After the epoch boundary, the tombstone is dropped — snapshot-based sync handles any peers that missed it

    Data is fully removed from the network over time — not just tombstoned. This is critical for storage scalability.

    Update chain pruning is an open problem. Two structural issues make it difficult:

    1. Chain integrity: If updates form a causal chain A → B → C, pruning an intermediate element (e.g. B) orphans all successors (C). The only structurally safe prune point is the tail — everything before the latest known update. But this is only well-defined once the merge has converged across all peers.

    2. Consistency under degradation: Falling back to a different merge strategy when causal links are missing would cause peers with different data availability to reach different merge decisions — a consistency violation.

    Open question: Should updates be a set rather than a chain? Set-based updates (no causal links between updates of the same entity) would make pruning straightforward: once the merge result is stable across peers, all inputs can be discarded. This trades causal ordering between updates for simpler pruning semantics.

    • How are epoch boundaries coordinated in a decentralized network? Options include wall-clock intervals (simple but clock-skew-sensitive), consensus-based epoch numbers, or leader-based epoch advancement.
    • What is a reasonable default epoch duration? Too short increases resync frequency for intermittently-connected peers; too long delays tombstone reclamation.
    • How large are state snapshots in practice, and how expensive is full resync for a peer rejoining after a long absence?
    • Should updates be modelled as a set (no causal links between updates of the same entity) rather than a chain? This would simplify pruning but lose causal ordering between updates.

    For real-time collaborative editing (e.g. the knowledge base showcase app), the framework supports CRDT library integration. Instead of coarse-grained UpdateEntry state changes, an app can use fine-grained CRDT operations (e.g. Yjs operations for text editing).

    These operations are still blobs, published and synced through Layers 2-4. The CRDT library handles merge semantics — the framework just transports the operations. This keeps Peerkit agnostic to the specific CRDT implementation while enabling rich collaborative features.

    interface IStateChanges {
    // Create a new entity; returns its ID (the hash of the CreateEntry blob)
    createEntry(schemaId: Hash, data: unknown): Promise<Hash>;

    // Update an entity; automatically includes causal links to known prior state changes
    updateEntry(entityId: Hash, data: unknown): Promise<void>;

    // Delete an entity and trigger pruning of its associated blobs
    deleteEntry(entityId: Hash): Promise<void>;

    // Get the current resolved state of an entity, applying merge if concurrent branches exist
    getEntry(entityId: Hash): Promise<unknown | null>;

    // Query entities via Layer 4 metadata
    queryEntries(metadataQuery: MetadataQuery): Promise<Hash[]>;

    // Get the full causal history (DAG of state changes) for an entity
    getHistory(entityId: Hash): Promise<StateChange[]>;
    }

    Not yet specified. Potential areas:

    • Data validation framework (received data is "pending" until "accepted")
    • Deletable / deduplicatable / warrantable CRDT state changes
    • Features implementable directly as CRDT types vs. requiring lower-level hooks
    • Rethinking validation dependency and induction

    Upgrading apps in a P2P network is fundamentally harder than in client-server: there is no central point to deploy updates to. Peers update independently, at different times, and the network will have peers running different versions simultaneously — potentially for a long time. Peerkit must make this easy and smooth.

    Schema versioning: Schemas (Layer 3) carry a version number. When a schema evolves (new fields, changed structure), the new version is published as a new schema blob. Old schema blobs remain in the network. Peers on the new version must handle data created under old schemas.

    Backward-compatible by default: Schema changes should be additive where possible — new fields with defaults, new optional metadata. Blobs created under an older schema version remain valid and readable. A peer running a newer app version can read old data without migration. A peer running an older app version that receives data with an unknown schema version can choose to store it opaquely (for gossiping to others) without processing it.

    Validation across versions: Validation rules may change between versions. Each blob carries its schema version, and validation is applied according to the rules of that version. A v2 peer validates v1 blobs using v1 rules and v2 blobs using v2 rules. This avoids the problem of old peers rejecting new data or new peers rejecting old data.

    Protocol versioning (Layer 0-1): The transport and networking protocols are versioned. Peers exchange version information during the connection handshake. The framework defines compatibility rules: which protocol versions can interoperate, and how to negotiate down to a common version.

    No forced upgrades: In a P2P system where groups own their tools, upgrades cannot be forced. Peers must coexist across versions gracefully. The framework never requires all peers to be on the same version for the network to function. Degraded functionality (e.g. a new feature not available to old peers) is acceptable; broken connectivity is not.

    Target: millions of nodes over time. Scalability is addressed at three levels:

    Connections: Each peer maintains a bounded number of connections (O(log N) or a configured maximum). No operation requires contacting all peers.

    Data distribution: Peerkit defines a pluggable distribution strategy interface but does not prescribe a routing topology. The distribution strategy is injected by the developer. Peerkit ships full replication as the built-in default. See Layer 2 for the interface and example strategies (DHT, direct replication, topic-based).

    Destructive edits: This is critical for long-term storage scalability. Peerkit is not append-only at the protocol level. Data that has been superseded, deleted, or pruned can be fully removed over time. Without destructive edits, storage grows monotonically regardless of sharding, and networks eventually become unusable on constrained devices. Higher layers define the semantics of when pruning is appropriate. Tombstone storage is bounded through epoch-based compaction combined with snapshot-based anti-entropy for returning peers. See Layer 5's "Destructive edits and pruning" section for the full design.

    Resource budgets (future): Each peer advertises its capacity (storage, bandwidth, connection count). The framework respects these limits and distributes load accordingly. Constrained devices (mobile, old hardware) take on less responsibility without being excluded from the network.

    API discipline: No "get all" methods on potentially large collections. No networking concepts requiring notification of or connection to all peers. Every query and operation must be bounded.

    • Language: TypeScript (large ecosystem, accessible to app developers, lower resource cost than Rust development)
    • Networking: js-libp2p as primary, iroh as fallback
      • libp2p: more mature, direct browser connections, large community, proven at scale (Ethereum, IPFS)
      • iroh: technically excellent, better hole-punching via relay fallback, but lacks JS wrapper and browser direct connections
    • Storage: SurrealDB as default (common database for browser and desktop, JS SDK available), but pluggable
    • Architecture style: core API covers peer discovery, transport, and agents; storage logic (data model, distribution strategy) is not built into the core API but selected per application
    • A suite of compliance tests constrains the behavior of implementations
    • The project is modular and relies on dependency injection
    • Module APIs are defined by the framework for common problems: peer discovery, authentication, peer connections
    • Data schemas for core modules are versioned
    • After first release, tests constrain updates to be compatible with previous versions
    • The system is not constrained by a specific data store or network protocol
    • The observable API is defined by published specifications

    Not every app needs every layer. The layers are additive — each builds on the one below but is independently useful. The MVP ships Layer 0 + 1 + 2 to get a working P2P library as quickly as possible.

    What's included:

    • js-libp2p transport with manual bootstrap address entry, encrypted connections, and relay/bootstrap infrastructure
    • Network access bytes handshake (closed networks by default)
    • Peerkit core: persistent agent identity, agent-ID-to-node-ID mapping, the agent store, and signed agent-info exchange with TTL renewal
    • A module host on the node, so data behavior is attached rather than baked in
    • Authored-data push and pull modules with the full-replication policy — every peer stores everything, kept in sync by push on author and periodic anti-entropy pull
    • An in-memory data store for authored blobs (the persisted store comes later)
    • Peer messaging (application messages) and arbitrary custom protocol streams
    • A developer CLI for running nodes and relays

    What's deliberately excluded from MVP:

    • Custom data distribution — full replication is the built-in default (every peer stores everything). The distribution interface is exposed for developers to provide their own strategy.
    • Layer 3 (schemas) — app structures its own blobs
    • Layer 4 (indexing) — app builds its own local indexes
    • Layer 5 (state changes, conflict resolution) — app handles its own state change semantics
    • Pruning / destructive edits — requires epoch-based compaction and snapshot sync (see Layer 5). MVP storage is append-only; full replication on small networks makes this acceptable.
    • Browser support — desktop only initially
    • Mobile support — desktop only initially

    The MVP gives developers: encrypted P2P connections, closed networks, and a shared blob store with full replication via gossip. That's enough for:

    • Simple messaging / chat
    • Shared document storage (without conflict resolution — last-write-wins at the app level)
    • Configuration or state sync between devices
    • Any app where the data set is small enough for every peer to hold

    The app developer handles data structure, indexing, and conflict resolution in their own code. The developer experience improves as Layers 3-5 are added, but the MVP is functional.

    • How should merge function logic be embedded in schemas? Options include interpreted language source code or compiled WASM, but both constrain app languages and runtime platforms. Could specify both a merge function and the name of its runtime engine.
    • What gossip strategy should the MVP use? K2's gossip approach (by arc and timestamp) may be more efficient than simple flooding, but porting K2's implementation to TypeScript is a significant effort.
    • How to handle multi-data/multi-app composition (multiple dataspaces with bridge calling/signals)?
    • How to handle atomic operations across multiple blobs in a distributed system? (A higher-order CRDT could be added that enables publishing multiple state changes of lower-order CRDTs as a single state change.)