Skip to content

feat: grove as gossip#34

Draft
passcod wants to merge 12 commits into
mainfrom
grove-as-gossip
Draft

feat: grove as gossip#34
passcod wants to merge 12 commits into
mainfrom
grove-as-gossip

Conversation

@passcod

@passcod passcod commented Jun 16, 2026

Copy link
Copy Markdown
Member

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.

passcod added 11 commits June 18, 2026 03:14
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant