Skip to content

engine: closed-network governance — signed transitions, quorum, ratification, GUI rewired#12

Merged
mrjeeves merged 7 commits into
mainfrom
claude/network-state-engine
May 25, 2026
Merged

engine: closed-network governance — signed transitions, quorum, ratification, GUI rewired#12
mrjeeves merged 7 commits into
mainfrom
claude/network-state-engine

Conversation

@mrjeeves
Copy link
Copy Markdown
Owner

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 over ed25519 signatures and a quorum-checked state machine.

Seven commits, each a coherent layer on top of the previous:

  1. network_state moduleNetworkKind, Role, Transition, TransitionVariant, Proposal, NetworkState, SIGN_DOMAIN_TAG_STATE. Canonical signed-payload bytes (binds network_id to 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_id matching the design doc's base32(sha256("myownmesh-split-v1:" || parent || "|" || sorted_signers)). On-disk persistence at ~/.myownmesh/mesh/states/{network_id}.json (0600 on Unix). AuthorizedPeer gains role: Role with #[serde(default)] for backward compat; NetworkConfig gains kind: NetworkKind likewise.

  2. Protocol wire frames — new MeshMessage variants: NetworkState, NetworkStatePropose, NetworkStateAck, NetworkStateSplit, RosterSummary, RosterRequest, RosterEntries. Feature flag network_state_v1. Inner NetworkStateBroadcast.kind is renamed network_kind on the wire to dodge the outer #[serde(tag = "kind")] collision (regression-tested). Old peers drop these via the existing MeshMessage::Unknown catch-all.

  3. Roster Merkle root + summary frame — deterministic roster::merkle_root(&Roster) -> String over sorted-by-pubkey leaf hashes; root changes on add/remove/role-grant/relabel. Versioned by ROSTER_MERKLE_V so a future tweak that breaks compat bumps it. summary(&Roster) -> RosterSummaryMessage for the broadcast path.

  4. 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. Central try_ratify verifies every signature, runs verify_quorum against the member set, applies, persists, broadcasts. Mirrors role grants + founder elections into the roster's role projection. New NetworkCmd variants for each control-plane op.

  5. Control protocol + handleJoinedNetwork::propose_transition, sign_proposal, deny_proposal, withdraw_proposal, spawn_split, governance_state. Daemon control requests GovernanceState, GovernanceProposeKindChange, GovernanceProposeRoleGrant, GovernanceProposeRoleRevoke, GovernanceSign, GovernanceDeny, GovernanceWithdraw, GovernanceSpawnSplit.

  6. GUI rewirenetwork-governance.svelte.ts no longer stores governance state in localStorage; it reads from meshClient.governanceByNetwork (polled + refresh-after-mutation) and routes every mutation through the new mesh_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.

  7. Integration tests + two engine fixes the tests surfaced

    • apply_transition on KindChange { 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_proposal now calls try_ratify symmetrically with sign_proposal so 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 — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test --workspace --no-fail-fast138 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 on gui/src/) — clean
  • pnpm build (vite production build) — clean
  • GUI Tauri Rust workspace needs GTK/WebKit dev libs to compile end-to-end; this sandbox doesn't have them, so the GUI Tauri layer is unverified locally. CI on Linux has both — please confirm green there before merging.

Test plan

  • cargo test --workspace green on the CI matrix (linux-x86_64, macos-aarch64, windows-x86_64).
  • GUI Tauri workspace builds on Linux CI (the eight new mesh_governance_* commands compile + register in invoke_handler!).
  • Manual two-device smoke: open network, both devices online, propose close from one, sign from the other, observe kind flip to Closed + founder-owner role on both GUIs.
  • Manual deny smoke: propose close, deny from peer, observe pending clear + kind stays Open.

What's deliberately out of scope

  • Roster gossip dispatch (the three Roster* message kinds have placeholder no-op arms in the engine). Convergence happens via the NetworkStateBroadcast drift signal + per-mutation broadcast for now; a tree-walk RosterRequest / RosterEntries path is a follow-up but the wire shape is already in place to support it without breaking compat.
  • Split-spawned-network auto-join. The proposer's spawn_split deposits 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.
  • Removed-member roster cleanup. Removing an owner should 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

claude added 7 commits May 25, 2026 15:50
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.
@mrjeeves mrjeeves merged commit 700c4f2 into main May 25, 2026
6 checks passed
@mrjeeves mrjeeves deleted the claude/network-state-engine branch May 25, 2026 16:39
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.

2 participants