feat: grove as gossip#34
Draft
passcod wants to merge 12 commits into
Draft
Conversation
… protocol-scoped trust Pulls the transport primitives currently living in oi/server.rs and oi/auth.rs into a new crates/core/src/transport/ module, in preparation for a second ALPN (bes.grove/1) sharing the same endpoint. - transport/auth.rs holds TrustedKeys, the ring TLS verifier helpers, and a new ProtocolTrustRegistry keyed by ALPN. The verifier admits any key trusted by any registered protocol at handshake time (rustls's API can't see the negotiated ALPN there); the post-handshake gate then enforces per-protocol authorisation against the negotiated ALPN. - transport/endpoint.rs holds build_tls_config, the accept loop, extract_client_fp, read_json_line, and a small ALPN handler registry. Each protocol registers a connection handler against an ALPN; on accept, the negotiated ALPN selects the handler. - oi/auth.rs slims to bootstrap-file and DB CRUD; oi/server.rs's run() now registers the OI handler with transport and delegates endpoint setup. - DEFAULT_PORT moves to transport/endpoint (it's the shared listen port). DEFAULT_MAX_STREAMS stays in oi/server (per-protocol concurrency cap). Pure refactor — OI behaviour unchanged.
22-field OiState is mostly node-wide runtime state, not transport- or OI-session-specific. The originally-planned TransportState/OiState/Daemon restructure would touch every OI handler signature for limited cleanup value. Grove lands GroveState as a sibling struct in commit 5 instead; that gives the same handler-scoping property without the churn. Also notes commit 0a as completed.
- New docs/spec/transport.md (t[...] namespace) holds what is common to every application protocol on the shared QUIC endpoint: handshake, ALPN negotiation, server identity, raw-public-key client auth (now protocol-scoped), listen addresses, fingerprint probing. - docs/spec/interface.md drops those items (now linked from transport.md) and gains a smaller i[trust.bootstrap] item describing the OI-specific bootstrap-file rule that was previously embedded in transport.client-auth. - New docs/spec/grove.md is a skeleton of g[...] requirements covering identity, signed-payload versioning, membership, trust, wire protocol, onboarding, parameters, mappings, events, operator surface, and a Known limitations section. Code annotations land in later commits; for now grove items are uncovered, which tracey reports without error. - .config/tracey/config.styx registers transport/main and grove/main. - Existing transport-related code annotations move from i[transport.*] to t[*]: transport/endpoint.rs, protocol/lib.rs, protocol/client.rs, daemon/main.rs. - import_bootstrap_file gains an i[impl trust.bootstrap] annotation.
Adds five tables under v53.sql, no consumers yet — they land alongside the protocol implementation in later commits. - grove_membership (single-row, id CHECK = 1): the grove this node belongs to, the role, the pinned leader fingerprint, and the verbatim signed payload + signature for the currently-applied sequence. - grove_peers: per-fingerprint hint cache for the dial loop and the 'grove peers' surface (label, addresses, last-seen, last-connected). - grove_params: denormalised projection of the latest payload's grove parameters, for query convenience. The signed payload remains the canonical source. - grove_param_mappings: local-only bindings between local app params and grove parameter names. Never replicated. - grove_versions: history of all applied signed payloads keyed by seq, with INSERT OR IGNORE making duplicate apply idempotent. Tests verify the tables exist after migration and that the single-row constraint on grove_membership is enforced. The version-cap assertions in existing migration tests bump 52 → 53.
A pure-types module: no transport, DB, or filesystem dependency, so both the daemon and seedling-ctl can sign and verify payloads independently. - Payload struct mirrors the spec's payload field list (grove_id, seq, created_at, leader_fp, members, params, secrets). canonicalise() sorts members by fingerprint and params by name; canonical_json() produces JCS bytes (RFC 8785); signing_bytes() prepends the bes.grove/sig/v1 domain separator. - Payload::sign canonicalises before signing, so callers do not need to. - SignedPayload pairs payload + Ed25519 signature; signature is a hex string on the JSON wire. SignedPayload::verify checks the signature against a leader VerifyingKey. - Message tagged union covers hello/version/peers/abort. Hello includes a payload_hash so peers reporting the same seq can detect leader-side forks. - Tests: round-trip sign/verify, verify-fails-under-wrong-key, verify-fails-after-mutation, signing-bytes-starts-with-domain-separator, canonicalise sorts, signed bytes invariant under input ordering, Message JSON round-trip, payload_hash is order-stable. Adds serde_jcs 0.2.0 and hex 0.4.3 (with serde feature) to crates/protocol/Cargo.toml.
Previous adjustment dropped the OiState restructure entirely. That was overcautious: the user's actual ask was to split state cleanly, but without forcing every existing handler signature to change. Updated approach: introduce TransportState as the canonical owner of node-wide transport state (spki_fingerprint, ProtocolTrustRegistry, AlpnHandlers). Build OiState from it by Arc-cloning the relevant fields, so handlers stay on state.trusted_keys / state.spki_fingerprint unchanged. GroveState in commit 5 is built the same way and registers its trust set + ALPN handler against the shared registry. Daemon main becomes the orchestrator: build TransportState, build OiState/GroveState from it, register, then run the transport endpoint once. Schedules this as commit 0c (numbered separately to avoid implying a deferred split).
Adds a new TransportState struct as the canonical owner of the node-wide
transport-level state: key path, SPKI fingerprint (Arc<OnceLock>), trust
registry, ALPN handler dispatch, and the per-connection log-label
resolver. Built once at daemon startup; per-protocol state structs (now
OiState; soon GroveState) are built on top of it.
OiState gains an Arc<TransportState> field plus an Arc<OnceLock<String>>
for spki_fingerprint that shares the same OnceLock as TransportState.
Existing OI handlers continue to access state.trusted_keys and
state.spki_fingerprint with no change — the Arcs make a set in one view
visible in the other.
oi::run is renamed to oi::register and becomes synchronous: it loads the
authorised-keys set, registers OI's trust set + ALPN handler + label
resolver against TransportState, and returns. The QUIC endpoint is now
started by daemon main calling transport::endpoint::run separately, so
future protocols (grove) can register before the endpoint comes up.
EndpointConfig narrows to { state: Arc<TransportState>, addrs }; key
path, trust registry, handlers, and label lookup all come from the
shared state. transport::endpoint::run sets the SPKI fingerprint on
TransportState; OiState sees it through the shared Arc.
Lays down the per-node grove state and the typed DB layer that the publish operation (next commit) and the wire handler (commit 5) build on. No transport handler yet; no publish operation yet. - GroveState: built from TransportState (held as an Arc), owns the grove trust set, the publish mutex, and the in-memory cache of the latest applied SignedPayload. ::load constructs from DB and seeds the trust set from the persisted membership list, so a daemon restart preserves grove authorisation without waiting for a peer. - grove::db: typed read/write helpers over the v53 tables. Includes Membership row decode, write_membership (upsert into the single-row grove_membership table), insert_version (idempotent on seq via INSERT OR IGNORE), and replace_params (denormalised projection of the latest payload's grove parameters). Tests: round-trip membership write/load, single-row replacement, insert_version idempotence, replace_params overwrite, GroveState load returning none on a fresh DB and seeding the trust set from a persisted membership.
Adds the canonical write-side primitives that grove init / invite / revoke / param-set will all flow through. Both entry points serialise on the publish mutex established in commit 4a, so seq is monotonic and the on-disk membership row stays consistent with the latest signed payload. - GroveState::init: generates a fresh grove_id, builds a seq=1 self-only payload, signs with the leader's transport key, persists. Rejects with AlreadyMember if the node already belongs to a grove. - GroveState::publish: under the mutex, re-loads membership from DB to avoid acting on a stale cache, gates on role == leader, verifies the passed-in leader key matches the pinned leader_fingerprint, then applies a caller-supplied mutate closure to a PendingPayload (only members + params are mutable; seq, created_at, grove_id, leader_fp, secrets are managed). Bumps seq, canonicalises, size-checks, signs. - finalise_publish: shared tail. Pre-publish size check rejects PayloadTooLarge before bumping seq or computing the signature (per the versioning.size-cap spec item). Persists membership + audit row + denormalised params projection in one transaction. Refreshes the in-memory cache and the grove trust set. - Constants: PAYLOAD_SIZE_CAP_BYTES = 256 KiB, headroom = 16 KiB, effective publish cap = 240 KiB. Adds Payload::canonical_size to seedling-protocol so core can size-check without depending on serde_jcs directly. Tests cover seq=1 invariant on init, seq bump on subsequent publish, trust-set update, NotMember/AlreadyMember/NotLeader/LeaderKeyMismatch errors, oversize rejection (and that seq does not advance on rejection), signature verifies against the leader key, params projection picks up the new value.
Wires the leader publish operation from commit 4b through the OI surface. Adds eight dispatch entries under /grove/*: init, invite, revoke, params/set, params/unset, status, members, params. - OiState gains an OnceLock<Arc<GroveState>> 'grove' field. Daemon main loads GroveState from TransportState + DbHandle after OiState is built and attaches it via .set(). Handlers reach it through state.grove and return ServerBusy if it has not been attached. - New oi/handler/grove.rs holds the handlers. Mutating handlers load the leader signing key on each call (via transport.key_path); not caching the key keeps long-lived signing material out of GroveState. - PublishError variants map to OiError codes: AlreadyMember and duplicate-invite -> AlreadyInstalled; NotMember and unknown-fp -> NotFound; NotLeader, oversize, secret-rejected, leader-self-revoke -> RequirementsInvalid; Sign / Db / KeyMismatch -> Internal. - invite checks for duplicate fingerprints before publishing; revoke refuses to remove the leader's own fp; param_set updates an existing entry in place rather than appending a duplicate. No CLI yet (commit 8) and no event emission yet (commit 8). The handlers return enough structured info that the response is useful on its own.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
We've been playing around with a "Grove" concept, linking several seedlings together. This is an early approach using a gossip protocol, where seedlings talk directly to one another. We're currently exploring a different approach where seedlings talk to a higher component called Canopy.