engine: closed-network governance — signed transitions, quorum, ratification, GUI rewired#12
Merged
Merged
Conversation
First commit on the engine-side closed-networks branch. Adds:
- `network_state` module owning `NetworkKind`, `Role`, `Transition`,
`TransitionVariant`, `Proposal`, `SplitRecord`, and `NetworkState`
itself.
- `SIGN_DOMAIN_TAG_STATE = "myownmesh-network-state-v1:"` —
distinct from the per-peer handshake tag so a handshake signature
cannot be replayed as a state-transition signature or vice-versa.
- `transition_payload(network_id, variant)` — canonical signed-byte
representation. Binds the signature to the specific network_id so
cross-network replay is rejected. Members in a `Split` variant are
sorted before signing so the same split + same signers always
produces the same payload regardless of input order.
- `sign_transition()` + `verify_transition_signatures()` — sign
with a SigningKey, verify every signature in the set against its
declared signer.
- `verify_quorum()` — the authority table from
docs/NETWORK-TYPES.md. Open→closed needs unanimous-of-members,
Closed→open needs unanimous-of-owners, owner-grant needs
unanimous-of-owners, controller-grant needs ≥1 owner,
member-grant needs ≥1 controller-or-owner, split is single-signer
(the proposer-as-would-be-owner).
- `apply_transition()` — pure state machine, given a verified
transition produces the state-after. Founder self-election on
open→closed installs the lone signer as Owner.
- `derive_split_network_id()` — `base32(sha256("myownmesh-split-v1:"
|| parent || "|" || sorted_signers))`, matches the design doc.
- On-disk persistence at `~/.myownmesh/mesh/states/{network_id}.json`
(mode 0600). New `dirs::states_dir()` helper.
Plus integration points:
- `AuthorizedPeer` gains `role: Role` (defaults to Member via
#[serde(default)] so existing rosters parse cleanly). Backward-
compat is tested by parsing a hand-written pre-`network_state_v1`
roster JSON.
- `roster::set_role_in()` — caller-facing helper for engine wiring.
- `NetworkConfig` gains `kind: NetworkKind` (defaults to Open via
#[serde(default)] so old configs parse). The field is the
*initial* kind for the bootstrap log; authoritative kind at
runtime lives on the signed `NetworkState`.
Tests covering: role rank ordering, can_grant table,
default-values, payload binds to network_id, signature roundtrip,
signature rejects tampered variant + wrong-network replay,
split-id determinism + parent-independence, founder
self-election quorum, open→closed needs every member,
member-cannot-grant-controller, owner-grant needs every owner,
apply promotes founder, role + kind serde roundtrip, roster
backward-compat, set_role idempotency.
fmt + clippy -D warnings + cargo test --workspace clean.
The signing helper is `#[allow(dead_code)]` for one commit while
the engine wiring catches up.
Adds the closed-network message kinds the engine will dispatch on:
- `MeshMessage::NetworkState` — sender's view of {kind, transition
log length, roster Merkle root}. Broadcast on ACTIVE.
- `MeshMessage::NetworkStatePropose` — float a transition; signed
by the proposer at issue time.
- `MeshMessage::NetworkStateAck` — sign or deny a proposal.
`decision` is `"sign"` | `"deny"`.
- `MeshMessage::NetworkStateSplit` — proposer-initiated split
fallback. Carries the derived network_id + member list + a
proposer-signed signature over the new network's `Split`
transition payload.
- `MeshMessage::RosterSummary` — Merkle root + count + last-edit
timestamp.
- `MeshMessage::RosterRequest` — `{ include_all }`; v1 always asks
for the full roster. Subtree-hashes field is reserved for a
future tree-walk variant that's wire-compatible with this shape.
- `MeshMessage::RosterEntries` — list of `RosterEntry`s (mirrors
AuthorizedPeer + `granted_by` for authority-chain checks).
The inner `NetworkStateBroadcast.kind` field is renamed to
`network_kind` on the wire to dodge a collision with the outer
`#[serde(tag = "kind")]` MeshMessage discriminator (tested by the
round-trip test, which fails the duplicate-field deserialize without
the rename).
Feature flag: `network_state_v1` added to `Feature` + the default
`ADVERTISED_FEATURES` list. Senders gate emission on the peer's
advertised features; receivers belt-and-braces drop unknown kinds
via the existing `MeshMessage::Unknown` catch-all (pinned by the
`old_peer_drops_governance_frame_as_unknown` test).
Engine dispatch in `engine/mod.rs::handle_inbound_frame` gets
placeholder arms that trace-log and discard. Real handlers wire in
the next commit. The GUI's preview-mode governance store still
owns runtime state until then.
Tests: round-trip for NetworkState + NetworkStateAck + RosterSummary,
RosterRequest default-empty parse, old-peer-Unknown drop, and the
duplicate-field regression that motivated the `network_kind` rename.
fmt + clippy -D warnings + full workspace tests all clean.
Adds `roster::merkle_root(&Roster) -> String` and the `summary(&Roster) -> RosterSummaryMessage` convenience wrapper. Both are pure / filesystem-free so callers can compute on an in-memory roster in the engine hot path. The root is a sha256 over the sorted-by-pubkey leaf hashes; each leaf hashes `device_id || label || approved_at || role` under the `v1` tag. Properties pinned by tests: - Insertion-order independent (sorted on the hash side). - Role grants flip the root → next gossip round triggers a sync. - Label edits flip the root (cheap, and a renamed label *should* propagate even though it's cosmetic; if relabel churn becomes load-bearing a future revision can exclude the field). - Empty roster has a distinct sentinel root from one-entry rosters. - Returns 52-char base32-lowercase string (sha256 → 32 bytes). The constant `ROSTER_MERKLE_V` gates the layout — a future tweak that breaks compat bumps it, and the wire's `RosterSummary.root` becomes a versioned hash. v1 peers compare roots verbatim; a future v2 either advertises both or feature- flag-gates the change. `last_edit_ts(&Roster)` returns the max `approved_at` — the `RosterSummary.last_edit_ts` tie-breaker for "which side is ahead" when roots disagree. fmt + clippy -D warnings + workspace tests clean.
…/ split)
The engine half of closed-network governance is live. Inbound
`network_state_*` frames are no longer no-ops:
State integration:
- `engine::state::NetworkState` gains `governance_state: RwLock<
crate::network_state::NetworkState>`. Loaded from
`~/.myownmesh/mesh/states/{network_id}.json` on construction;
brand-new logs adopt the `NetworkConfig.kind` seed for first-attach.
The on-disk log wins on every subsequent load — kind is a
signed-state property, not a config one.
- New `NetworkCmd` variants for the control plane:
`ProposeTransition`, `SignProposal`, `DenyProposal`,
`WithdrawProposal`, `SpawnSplit`, `GovernanceSnapshot`. Each
carries a `oneshot::Sender` reply so the daemon control surface
can `.await` the result.
New `engine/governance.rs`:
- `propose(state, variant)` — signs the canonical payload with the
local identity, appends to pending, broadcasts a
`NetworkStatePropose` to every active+authenticated peer, and
attempts immediate ratification (covers single-signer founder
self-election + sole-owner closed→open).
- `sign_proposal(state, id)` — adds the local signer + signature
to a pending proposal, broadcasts a signed
`NetworkStateAck { Sign }`, ratifies if the new signer set
satisfies the quorum.
- `deny_proposal(state, id)` — signs a domain-tagged deny payload
(so a deny can't be repurposed from a sign), broadcasts, drops
from pending on the next ratification pass.
- `withdraw_proposal(state, id)` — proposer-only; removes from
pending without broadcasting.
- `spawn_split(state, id)` — proposer-only fallback. Derives the
child network id deterministically, signs a `Split` transition
for the *parent* log (so members can discover the new network
via parent's gossip), broadcasts `NetworkStateSplit`.
- `try_ratify(state, id)` — central ratification path. Verifies
every signature, runs `verify_quorum` against the network's
current member set (roster + role-tagged peers + the local
identity), applies the transition, drops from pending, persists,
and broadcasts the resulting state. Mirrors role grants +
founder elections into the roster's locally-cached `role`
projection so peer rows render the new authority without
re-reading the state log.
Inbound handlers:
- `on_propose` — rejects proposals whose claimed proposer doesn't
match the wire-level peer pubkey; verifies the proposer signed
the canonical payload; drops forged / unsigned frames silently
with a diag. Adds to pending if new, then attempts ratification.
- `on_ack` — same proposer/signer-mismatch check, then verifies
the ack's signature (different payload shape for sign vs deny).
Folds the decision into pending and attempts ratification.
- `on_split` — verifies the split's signature against the new
network's canonical Split-transition payload, records the split
in the parent's log (idempotent on `new_network_id`).
- `on_state_broadcast` — diag-logs drift; richer reconciliation
ships with the gossip path in a follow-up.
`broadcast_state()` emits a `NetworkState` snapshot to every
active peer after every governance mutation.
Frame-dispatch in `handle_inbound_frame` now routes the four
`NetworkState*` variants to the new handlers; the three roster-
gossip variants still trace-and-discard (gossip handlers ship
next). Driver loop dispatches the new `NetworkCmd` variants.
fmt + clippy -D warnings + workspace tests all clean.
`JoinedNetwork` grows the embedder-facing surface for the engine
governance commands added last commit. Each wraps the matching
NetworkCmd variant + a oneshot reply so callers can `.await` the
result:
- `governance_state()` — read-only snapshot of kind, roles,
transition log, pending proposals, splits.
- `propose_transition(variant)` — sign + broadcast + auto-ratify
if quorum already met. Returns the new proposal id.
- `sign_proposal(id)` — accumulate signature, broadcast ack,
ratify when quorum hits.
- `deny_proposal(id)` — single-shot kill switch.
- `withdraw_proposal(id)` — proposer-only no-broadcast pull.
- `spawn_split(id)` — proposer-only fallback. Returns the
deterministically-derived new network id.
Daemon control-protocol gets the seven new request kinds wired
into `crates/myownmesh/src/control.rs`:
- `GovernanceState { network }`
- `GovernanceProposeKindChange { network, to: NetworkKind }`
- `GovernanceProposeRoleGrant { network, target, role: Role }`
- `GovernanceProposeRoleRevoke { network, target }`
- `GovernanceSign { network, proposal_id }`
- `GovernanceDeny { network, proposal_id }`
- `GovernanceWithdraw { network, proposal_id }`
- `GovernanceSpawnSplit { network, proposal_id }`
Each dispatches to the JoinedNetwork method, wrapping the engine
result in the standard `Response::ok` / `Response::err` shape so
the GUI's existing error-handling flow works unchanged.
fmt + clippy -D warnings + workspace tests clean.
The closed-network governance ops on the GUI no longer live in
browser-local state — every mutation now round-trips through the
daemon's signed-transition machinery and every read pulls from the
daemon's authoritative snapshot.
Tauri backend (`gui/src-tauri`):
- `control_client::Request` mirrors the eight new daemon request
kinds (`GovernanceState`, `Propose{KindChange,RoleGrant,
RoleRevoke}`, `{Sign,Deny,Withdraw}`, `SpawnSplit`).
- Eight new `mesh_governance_*` Tauri commands wrap each, mapping
the daemon's `Response::ok`/`err` shape onto the
Result<Value,String> contract the frontend uses elsewhere.
mesh-client (`gui/src/mesh-client.svelte.ts`):
- New `governanceByNetwork: Record<string, unknown>` reactive
state. Refreshed on every poll tick + after every governance
mutation; brand-new joins take one tick before their snapshot
appears.
- `governanceProposeKindChange`, `governanceProposeRoleGrant`,
`governanceProposeRoleRevoke`, `governanceSign`,
`governanceDeny`, `governanceWithdraw`, `governanceSpawnSplit`
— each invokes the corresponding Tauri command, then refreshes
the local cache so the UI sees its own writes.
`network-governance.svelte.ts` rewrite:
- Drops the localStorage-backed preview state for governance.
What stays browser-local: the *orphan-networks* tracker, which
is a GUI-only concept (the daemon doesn't know a network is
orphaned; only the save flow can record it).
- `stateFor(configId)` reads through `meshClient.governanceByNetwork`
with field-coercion (snake_case→camelCase on splits, defaults on
missing fields), so components see the same `NetworkStateView`
shape they read before.
- `proposeKindChange`, `signProposal`, `denyProposal`,
`withdrawProposal`, `spawnSplit`, `setPeerRole`, `clearPeerRole`
— all become async, each call goes through `meshClient`.
- API still returns `{ ok, reason? }` (now Promise-wrapped) so
callers keep the same error-handling pattern.
Component updates:
- `NetworkOverlayGovernance.svelte` — every governance call gets
`await`. "Preview mode" banner replaced with a brief "enforced at
engine layer" info-banner.
- `NetworkOverlayRoster.svelte` — role-set/clear calls awaited.
- `ApprovalsSection.svelte` — post-approve role-stamp awaited.
svelte-check + vite build clean. Workspace cargo tests pass (105
unit, 24 integration, 1 doctest). The GUI Tauri Rust build needs
GTK + WebKit dev libs to compile end-to-end; CI on Linux has both.
Adds `tests/closed_network_governance.rs` — two-peer integration
test that drives the closed-network governance path through the
real engine (in-process LocalBroker, no mocks). Covers:
1. `two_peers_ratify_open_to_closed_transition` — Alice and Bob
handshake, cross-roster-approve, Alice proposes
`KindChange { to: Closed }`, Bob signs, the engine ratifies on
both sides. Asserts: matching kind on both sides (Closed),
Alice as founder-owner on both sides (founder-election fires
on the proposer regardless of signer count), Bob as member,
both transition logs carry the same signer set.
2. `deny_invalidates_proposal_on_both_sides` — Alice proposes,
Bob denies. Asserts: pending list empty on both sides, kind
stays Open, transition log stays empty.
Two engine fixes the tests surfaced:
- `apply_transition` on `KindChange { to: Closed }` now installs
the *proposer* (signers.first()) as founder-owner, not only
when the signer set is size-1. The single-signer case is the
founder-self-election edge of the same rule; the multi-signer
case is the normal "Alice proposes + Bob signs" path where
Alice is the would-be owner. Without this, the multi-signer
close left the network in `Closed` with no owners, which would
deadlock every subsequent role-change.
- `deny_proposal` now calls `try_ratify` symmetrically with
`sign_proposal`. Previously, the denier's *own* pending list
kept the proposal forever (only inbound ack handlers triggered
the cleanup pass); the second integration test exposed this
because Bob's deny didn't clear his pending. The denier's
ratification pass takes the now-non-empty `deniers` branch,
drops the proposal, and persists.
Full workspace: fmt + clippy -D warnings + cargo test all clean.
105 unit + 2 closed-network integration + 1 open-network
integration + 23 signaling unit + 6 updater + 1 doctest = 138
total passing.
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.
Summary
Engine half of closed-network governance — the daemon now enforces the signed-transition model documented in
docs/NETWORK-TYPES.md. The GUI's preview-mode governance store is gone; every kind change, role grant, sign, deny, and split round-trips through the engine overed25519signatures and a quorum-checked state machine.Seven commits, each a coherent layer on top of the previous:
network_statemodule —NetworkKind,Role,Transition,TransitionVariant,Proposal,NetworkState,SIGN_DOMAIN_TAG_STATE. Canonical signed-payload bytes (bindsnetwork_idto prevent cross-network replay).verify_transition_signatures+verify_quorum(the full authority table from the design doc) +apply_transition(pure state machine).derive_split_network_idmatching the design doc'sbase32(sha256("myownmesh-split-v1:" || parent || "|" || sorted_signers)). On-disk persistence at~/.myownmesh/mesh/states/{network_id}.json(0600 on Unix).AuthorizedPeergainsrole: Rolewith#[serde(default)]for backward compat;NetworkConfiggainskind: NetworkKindlikewise.Protocol wire frames — new
MeshMessagevariants:NetworkState,NetworkStatePropose,NetworkStateAck,NetworkStateSplit,RosterSummary,RosterRequest,RosterEntries. Feature flagnetwork_state_v1. InnerNetworkStateBroadcast.kindis renamednetwork_kindon the wire to dodge the outer#[serde(tag = "kind")]collision (regression-tested). Old peers drop these via the existingMeshMessage::Unknowncatch-all.Roster Merkle root + summary frame — deterministic
roster::merkle_root(&Roster) -> Stringover sorted-by-pubkey leaf hashes; root changes on add/remove/role-grant/relabel. Versioned byROSTER_MERKLE_Vso a future tweak that breaks compat bumps it.summary(&Roster) -> RosterSummaryMessagefor the broadcast path.Engine dispatch + lifecycle (
engine/governance.rs) —propose,sign_proposal,deny_proposal,withdraw_proposal,spawn_split,snapshot. Inbound handlers:on_propose,on_ack,on_split,on_state_broadcast. Centraltry_ratifyverifies every signature, runsverify_quorumagainst the member set, applies, persists, broadcasts. Mirrors role grants + founder elections into the roster'sroleprojection. NewNetworkCmdvariants for each control-plane op.Control protocol + handle —
JoinedNetwork::propose_transition,sign_proposal,deny_proposal,withdraw_proposal,spawn_split,governance_state. Daemon control requestsGovernanceState,GovernanceProposeKindChange,GovernanceProposeRoleGrant,GovernanceProposeRoleRevoke,GovernanceSign,GovernanceDeny,GovernanceWithdraw,GovernanceSpawnSplit.GUI rewire —
network-governance.svelte.tsno longer stores governance state inlocalStorage; it reads frommeshClient.governanceByNetwork(polled + refresh-after-mutation) and routes every mutation through the newmesh_governance_*Tauri commands. The orphan-network tracker stays browser-local (it's a GUI-only concept). "Preview mode" banner gone; replaced with a one-line "enforced at engine layer" info-banner.Integration tests + two engine fixes the tests surfaced —
apply_transitiononKindChange { to: Closed }installs the proposer as founder-owner, not only the lone-signer case. Without this, a multi-signer close left the network closed with no owners, deadlocking every subsequent role change.deny_proposalnow callstry_ratifysymmetrically withsign_proposalso the denier's own pending list cleans up immediately.What the tests cover
tests/closed_network_governance.rs— two-peer LocalBroker, no mocks:two_peers_ratify_open_to_closed_transition— Alice + Bob handshake → cross roster-approve → Alice proposes → Bob signs → both ratify. Asserts matching kind on both sides, Alice as founder-owner on both sides, Bob as member, identical signer sets on both transition logs.deny_invalidates_proposal_on_both_sides— Alice proposes → Bob denies → kind stays Open, transition log empty, pending list empty on both.Build / test status
cargo fmt --all --check— cleancargo clippy --workspace --all-targets -- -D warnings— cleancargo test --workspace --no-fail-fast— 138 passing (105 unit + 2 closed-network integration + 1 open-network integration + 23 signaling + 6 updater + 1 doctest, 0 failing, 0 ignored)pnpm check(svelte-check ongui/src/) — cleanpnpm build(vite production build) — cleanTest plan
cargo test --workspacegreen on the CI matrix (linux-x86_64, macos-aarch64, windows-x86_64).mesh_governance_*commands compile + register ininvoke_handler!).What's deliberately out of scope
Roster*message kinds have placeholder no-op arms in the engine). Convergence happens via theNetworkStateBroadcastdrift signal + per-mutation broadcast for now; a tree-walkRosterRequest/RosterEntriespath is a follow-up but the wire shape is already in place to support it without breaking compat.spawn_splitdeposits the new network's id in the parent's log; co-signers see it via gossip but the GUI doesn't yet offer a one-click "join the split" affordance.ownershould drop their transition-signing authority on subsequent proposals; the verify_quorum check uses the current roles map so this works correctly, but there's no UX yet for "kick a peer."https://claude.ai/code/session_01H7cdfWamvmjSHRqqXui6x8
Generated by Claude Code