first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
# @suaveplan/network-plugin-governance - Election-engine plugin for the mesh: candidate scoring, reputation, voting, host transitions
## Summary
Election engine for the Suaveplan mesh: candidate scoring, reputation tracking, voting, and host transitions.
Concrete `NetworkPlugin` that consumes `peer:latency` and `peer:disconnected` events from the bus, runs elections, and publishes the winner so `network-plugin-topology` can promote the node to Tier 0.
<!-- begin:auto-docs -->
## Documentation tree
- [election](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [election-engine](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [governance-plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reputation](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reputation-tracker](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [scoring](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [candidate-scorer](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transition](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transition-manager](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [voting](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [voting-manager](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum,…
<!-- end:auto-docs --> - [election-engine](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [governance-plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation-tracker](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [scoring](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [candidate-scorer](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition-manager](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting-manager](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum,…
- [election-engine](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [governance-plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation-tracker](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [scoring](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [candidate-scorer](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition-manager](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting-manager](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum,…
- [election-engine](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [governance-plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation-tracker](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [scoring](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [candidate-scorer](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition-manager](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting-manager](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum,…
- [election-engine](./src/election/election-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [governance-plugin](./src/plugin/governance-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [reputation](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [reputation-tracker](./src/reputation/reputation-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [scoring](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [candidate-scorer](./src/scoring/candidate-scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [transition](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transition-manager](./src/transition/transition-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [voting](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [voting-manager](./src/voting/voting-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum,…

View File

@@ -0,0 +1,70 @@
# ElectionEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Orchestrates a single election round across three modes (`metric`, `direct`, `hybrid`), publishes the result on the bus, and bumps a monotonic `term` counter so downstream subscribers can detect re-elections. Combines `CandidateScorer` (metric axis) with `VotingManager` (vote axis) into one consistent API.
## Features
- Three election modes: `metric` (score only), `direct` (vote tally only), `hybrid` (score × `(1 + voteShare)`).
- Quorum check on `direct` and `hybrid` modes; throws `NO_QUORUM` when not met.
- Empty-candidate guard via `NO_CANDIDATES`.
- Monotonic `term` counter; one increment per successful round.
- Injectable `scorer` and `voting` for testing and customisation.
- Publishes `election:result` on the bus with `{ mode, winnerId, score, term }`.
## Usage: Basic Example
```ts
import { ElectionEngine } from "@suaveplan/network-plugin-governance";
const engine = new ElectionEngine({ bus });
const result = engine.run("metric", candidates, knownPeers);
```
## Usage: Advanced Example
```ts
import {
CandidateScorer,
ElectionEngine,
VotingManager,
} from "@suaveplan/network-plugin-governance";
const scorer = new CandidateScorer({
latency: 0.5,
uptime: 0.3,
bandwidth: 0.1,
reputation: 0.1,
});
const voting = new VotingManager({ quorumFraction: 0.66 });
const engine = new ElectionEngine({ bus, scorer, voting });
voting.recordVote({ voterId: "alice", candidateId: "host-x" });
voting.recordVote({ voterId: "bob", candidateId: "host-x" });
voting.recordVote({ voterId: "carol", candidateId: "host-y" });
try {
const result = engine.run("hybrid", candidates, 4);
// mode === "hybrid", term increments by 1
} catch (err) {
// NO_QUORUM if too few voters
}
```
## API Reference
- `type ElectionMode = "metric" | "direct" | "hybrid"`.
- `interface ElectionResult { mode, winnerId, score, term }`.
- `interface ElectionEngineConfig { bus, scorer?, voting? }`.
- `new ElectionEngine(config)`.
- `run(mode, candidates, knownPeers): ElectionResult` — throws `NO_CANDIDATES`/`NO_QUORUM`.
- `getVoting(): VotingManager` — exposed so consumers can `recordVote` directly.
- `getTerm(): number`.
## Implementation Notes
`hybrid` mode multiplies each candidate's normalised score by `1 + voteShare` (where `voteShare ∈ [0, 1]`). This means a candidate with 100% of the votes can at most double its score, preventing pure popularity from overriding poor metrics. `direct` mode uses the raw vote tally and synthesises a `score = 1` if the winning candidate is not in the metric set (e.g. an underdog write-in). `metric` mode skips the voting subsystem entirely and is the cheapest path for low-coordination scenarios. The `term` counter is bumped only on success so failed runs don't perturb the counter monotonicity that downstream FSMs rely on.

View File

@@ -0,0 +1,59 @@
# NETWORK_GOV_CODES
## Purpose
Frozen registry of error codes for the governance plugin. Every failure mode in the election pipeline — missing quorum, empty candidate set, duplicate votes, failed host transitions — surfaces a typed `NetworkGovError` with one of these codes so callers can pattern-match without parsing message strings. The registry is registered under the `NET_GOV` namespace (via `defineCodes`), keeping it distinct from the mesh core's `NET` namespace.
## Features
- Five frozen codes: `NO_QUORUM`, `NO_CANDIDATES`, `DUPLICATE_VOTE`, `TRANSITION_FAILED`.
- `NetworkGovError` extends `SuaveplanError<NetworkGovErrorCode>` for cross-package compatibility.
- `name: "NetworkGovError"` on every instance for log filtering.
- `cause` and `context` propagation via `SuaveplanErrorOptions`.
## Usage: Basic Example
```ts
import {
NETWORK_GOV_CODES,
NetworkGovError,
} from "@suaveplan/network-plugin-governance";
throw new NetworkGovError(NETWORK_GOV_CODES.NO_QUORUM, "election.no-quorum", {
context: { received: 3, required: 5 },
});
```
## Usage: Advanced Example
```ts
import {
NETWORK_GOV_CODES,
NetworkGovError,
} from "@suaveplan/network-plugin-governance";
try {
runElection();
} catch (err) {
if (
err instanceof NetworkGovError &&
err.code === NETWORK_GOV_CODES.NO_CANDIDATES
) {
// fall back to single-node mode
return;
}
throw err;
}
```
## API Reference
- `NETWORK_GOV_CODES` — frozen `{ NO_QUORUM, NO_CANDIDATES, DUPLICATE_VOTE, TRANSITION_FAILED }` registry.
- `type NetworkGovErrorCode` — union of registered code values.
- `class NetworkGovError extends SuaveplanError<NetworkGovErrorCode>` — typed error class.
## Implementation Notes
The registry is intentionally small. Each code maps to a single, well-understood failure mode in the election pipeline; we resist the temptation to add codes for "could-have-failed" situations that are normal outcomes (e.g. `LOSING_CANDIDATE`). When new failure modes emerge, the rule is: add a code only if a caller would reasonably want to handle it differently than the existing codes. Otherwise reuse an existing code with richer `context`.
The package barrel side-effect-imports `./error-codes.js` so any consumer that drills into a subpath export still registers the codes with the global `defineCodes` registry. Consumers that need stable error-code values for serialization or wire formats can rely on the `NETWORK_GOV_CODES.<KEY>` constants being string literals like `"NET_GOV.NO_QUORUM"` — no runtime mutation, no opaque tagged values.

View File

@@ -0,0 +1,71 @@
# GovernancePlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
NetworkPlugin that wires the governance subsystems (`CandidateScorer`, `ReputationTracker`, `VotingManager`, `ElectionEngine`, `TransitionManager`) into a `NetworkNode` and bridges mesh events into reputation updates. The plugin is the single integration surface — callers only need to register one plugin to get the full election pipeline.
## Features
- Constructs and exposes the four sub-managers; injectable for testing.
- Subscribes to `peer:latency` (counts as `success`) and `peer:disconnected` (counts as `failure`) so reputation reflects observable mesh behaviour.
- `election.run(...)` and `transition.begin(...)` drive the active rounds.
- Clean `stop()` unsubscribes bus handlers; `dispose()` clears all state.
## Usage: Basic Example
```ts
import { GovernancePlugin } from "@suaveplan/network-plugin-governance";
const plugin = new GovernancePlugin();
node.use(plugin);
await node.start();
const result = plugin.election!.run("metric", candidates, knownPeers);
```
## Usage: Advanced Example
```ts
import {
CandidateScorer,
GovernancePlugin,
VotingManager,
} from "@suaveplan/network-plugin-governance";
const plugin = new GovernancePlugin({
scorer: new CandidateScorer({
latency: 0.5,
uptime: 0.2,
bandwidth: 0.2,
reputation: 0.1,
}),
voting: new VotingManager({ quorumFraction: 0.66 }),
});
node.use(plugin);
await node.start();
plugin.voting.recordVote({ voterId: "alice", candidateId: "host-x" });
plugin.voting.recordVote({ voterId: "bob", candidateId: "host-x" });
const result = plugin.election!.run("hybrid", candidates, 4);
plugin.transition!.begin(result.term);
plugin.transition!.transfer();
plugin.transition!.confirm();
plugin.transition!.complete(result.winnerId);
```
## API Reference
- `interface GovernancePluginConfig { scorer?, voting?, reputation? }`.
- `new GovernancePlugin(config?)` — exposes `.scorer`, `.voting`, `.reputation`.
- `install(node)` — wires sub-managers and subscribes to bus events.
- `election: ElectionEngine | null` and `transition: TransitionManager | null` — populated after `install`.
- `start()` / `stop()` / `dispose()` — standard lifecycle.
## Implementation Notes
`election` and `transition` are nullable until `install()` runs because both depend on the node's bus, and we deliberately avoid eager construction with a stub bus. Callers that need to interact with the plugin pre-install must use the always-present `scorer`/`voting`/`reputation` references; the election engine and transition manager are intentionally gated behind a successful `install`. The `peer:latency` → `success` mapping is a defensible approximation: low-latency neighbours are healthy by definition. A more nuanced mapping (e.g. only count as success when latency stays under a threshold) is left to consumers via a custom `ReputationTracker`.

View File

@@ -0,0 +1,58 @@
# ReputationTracker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Maintains a per-peer reputation score in `[0, 1]` (configurable bounds) that fades over time and responds to three event kinds: `success`, `failure`, and `violation`. Used by `CandidateScorer` as one of the four election axes. Reputation is intentionally pessimistic — a single violation can drop a peer's score significantly so untrusted nodes are unlikely to win Tier-0 elections.
## Features
- Configurable `decay` (multiplicative, default `0.95`), `initial` value (default `0.5`), and per-event deltas.
- Hard bounds via `minReputation`/`maxReputation` (defaults `[0, 1]`).
- Decay is applied **before** the event delta so old behaviour fades and new behaviour dominates.
- `reset(peerId)` to forget one peer; `clear()` to wipe everything.
- `get(peerId)` returns `initial` for unknown peers so first-time peers don't get a score of zero.
## Usage: Basic Example
```ts
import { ReputationTracker } from "@suaveplan/network-plugin-governance";
const tracker = new ReputationTracker();
tracker.record("alice", "success");
tracker.record("alice", "success");
const score = tracker.get("alice");
```
## Usage: Advanced Example
```ts
import { ReputationTracker } from "@suaveplan/network-plugin-governance";
// Slow decay, harsh violation penalty.
const tracker = new ReputationTracker({
decay: 0.99,
initial: 0.7,
successDelta: 0.02,
failureDelta: 0.05,
violationDelta: 0.6,
});
tracker.record("alice", "violation");
tracker.record("alice", "success"); // doesn't recover quickly
```
## API Reference
- `type ReputationEventKind = "success" | "failure" | "violation"`.
- `interface ReputationTrackerConfig` — `decay`, `initial`, `successDelta`, `failureDelta`, `violationDelta`, `minReputation`, `maxReputation`.
- `record(peerId, kind): number` — apply an event and return the new score.
- `get(peerId): number` — read current score (or `initial` if unknown).
- `reset(peerId)` / `clear()` — forget one peer / all peers.
- `getInitial(): number` — exposed for downstream visualisations.
## Implementation Notes
Decay is applied multiplicatively so the score asymptotically approaches zero in the absence of events. After each event, `score = clamp(score × decay + delta)`. The clamp prevents under/overshooting the configured bounds when `delta` is large relative to the score range. We deliberately lump `failure` and `violation` into separate event kinds rather than a single `negative` kind so the policy can punish protocol violations harder than transient network failures, which are usually no-fault.

View File

@@ -0,0 +1,65 @@
# CandidateScorer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Computes a deterministic composite score for each peer in an election candidate set, weighting four axes (latency, uptime, bandwidth, reputation) so independent observers across the mesh agree on the winner. Tiebreaks deterministically via FNV-1a on `peerId` so two nodes that see the same metrics always pick the same Tier-0 host.
## Features
- Min-max normalisation per axis so the composite stays unitless and bounded.
- Inverted latency: lower-RTT peers score higher on the latency axis.
- Configurable `ScoringWeights`; defaults are 40/30/20/10 (latency/uptime/bandwidth/reputation).
- Deterministic tiebreaking via FNV-1a on `peerId` (lowest hash wins).
- Empty candidate set returns `[]`/`undefined` cleanly.
## Usage: Basic Example
```ts
import { CandidateScorer } from "@suaveplan/network-plugin-governance";
const scorer = new CandidateScorer();
const scored = scorer.score([
{ peerId: "alice", latencyMs: 50, uptimeMs: 1000, bandwidthBps: 1000, reputation: 0.9 },
{ peerId: "bob", latencyMs: 200, uptimeMs: 500, bandwidthBps: 500, reputation: 0.5 },
]);
const winner = scorer.selectWinner(scored);
```
## Usage: Advanced Example
```ts
import {
CandidateScorer,
type ScoringWeights,
} from "@suaveplan/network-plugin-governance";
// Heavily weight reputation for a slow, trust-prone link.
const weights: ScoringWeights = {
latency: 0.2,
uptime: 0.2,
bandwidth: 0.1,
reputation: 0.5,
};
const scorer = new CandidateScorer(weights);
const winner = scorer.selectWinner(scorer.score(metrics));
if (winner === undefined) {
// empty candidate set — fall back to single-node mode
}
```
## API Reference
- `interface CandidateMetrics { peerId, latencyMs, uptimeMs, bandwidthBps, reputation }`.
- `interface ScoringWeights { latency, uptime, bandwidth, reputation }` — must sum to a positive number.
- `interface CandidateScore { peerId, score }`.
- `new CandidateScorer(weights?)` — default weights 40/30/20/10.
- `score(candidates): CandidateScore[]` — composite score per candidate.
- `selectWinner(scored): CandidateScore | undefined` — highest score with deterministic tiebreak.
## Implementation Notes
When all candidates have an identical metric value on an axis, `(hi - lo)` would be zero. The scorer handles this by returning `1` from the normaliser (every candidate is equally "best" on that axis), avoiding both NaN propagation and an artificial zero-weight degeneration. Latency is clamped to `≥ 1 ms` before inversion to dodge division by zero on the (rare) localhost case. Tiebreaking via `fnv1a(peerId)` is preferred to lexicographic sort because it scrambles peer-id locality — alphabetical neighbours don't deterministically beat each other every election.

View File

@@ -0,0 +1,73 @@
# TransitionManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Linear FSM that walks a host handover through `preparing → transferring → confirming → completed`. On any failure the FSM jumps to `failed`, emits `transition:failed`, and resets to `idle` so the next election can run cleanly. Decoupling this from the election engine keeps each round's state machine narrow and observable.
## Features
- Strict FSM: every step asserts the current state and throws `TRANSITION_FAILED` on out-of-order calls.
- Bus events: `transition:state-change` on every step, `transition:completed` on success, `transition:failed` on rollback.
- Auto-reset to `idle` after both `completed` and `failed` so the next term starts clean.
- `getState()` exposes the live FSM state for observability.
## Usage: Basic Example
```ts
import { TransitionManager } from "@suaveplan/network-plugin-governance";
const transition = new TransitionManager({ bus });
transition.begin(1);
transition.transfer();
transition.confirm();
transition.complete("host-x");
```
## Usage: Advanced Example
```ts
import {
NETWORK_GOV_CODES,
NetworkGovError,
TransitionManager,
} from "@suaveplan/network-plugin-governance";
const transition = new TransitionManager({ bus });
transition.begin(1);
transition.transfer();
try {
// simulate the new host failing to ack
transition.fail("ack-timeout");
} catch (err) {
if (
err instanceof NetworkGovError &&
err.code === NETWORK_GOV_CODES.TRANSITION_FAILED
) {
// wrong state — already idle
}
}
// FSM is back at idle; next election can begin
transition.begin(2);
```
## API Reference
- `type TransitionState = "idle" | "preparing" | "transferring" | "confirming" | "completed" | "failed"`.
- `interface TransitionBus { emit("transition:state-change" | "transition:failed" | "transition:completed", payload) }`.
- `new TransitionManager({ bus })`.
- `getState(): TransitionState`.
- `begin(term)` — `idle → preparing`.
- `transfer()` — `preparing → transferring`.
- `confirm()` — `transferring → confirming`.
- `complete(winnerId)` — `confirming → completed → idle`.
- `fail(reason)` — any non-terminal state → `failed → idle`.
## Implementation Notes
The FSM is intentionally strict — it throws on out-of-order calls rather than ignoring them, because a transition that skips a step has almost certainly hit a bug rather than a recoverable runtime condition. After both `complete` and `fail`, the manager assigns `state = idle` directly (without firing another `state-change` event) so subscribers see clean `…→ completed` or `…→ failed` chains. The `term` is captured at `begin` and threaded into every event so subscribers can correlate transitions across overlapping elections.

View File

@@ -0,0 +1,74 @@
# VotingManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Collects per-peer votes for one election round, dedupes by voter id, and surfaces an aggregate tally when a configurable fraction of the known peer set has voted. Drives the `direct` and `hybrid` modes of `ElectionEngine`. Tiebreaks deterministically by lexicographic `candidateId` so independent observers reach the same winner without further coordination.
## Features
- `recordVote` rejects duplicate voter ids with `DUPLICATE_VOTE`.
- `hasQuorum(knownPeers)` checks whether enough peers have voted.
- `tally()` returns counts, total votes, and a deterministic winner.
- Configurable `quorumFraction` (default `0.5` — strict majority).
- `clear()` resets state between rounds.
## Usage: Basic Example
```ts
import { VotingManager } from "@suaveplan/network-plugin-governance";
const voting = new VotingManager({ quorumFraction: 0.5 });
voting.recordVote({ voterId: "alice", candidateId: "host-x" });
voting.recordVote({ voterId: "bob", candidateId: "host-x" });
if (voting.hasQuorum(3)) {
const { winner, counts } = voting.tally();
}
```
## Usage: Advanced Example
```ts
import {
NETWORK_GOV_CODES,
NetworkGovError,
VotingManager,
} from "@suaveplan/network-plugin-governance";
const voting = new VotingManager({ quorumFraction: 0.66 });
try {
voting.recordVote({ voterId: "alice", candidateId: "host-x" });
voting.recordVote({ voterId: "alice", candidateId: "host-y" }); // throws
} catch (err) {
if (
err instanceof NetworkGovError &&
err.code === NETWORK_GOV_CODES.DUPLICATE_VOTE
) {
// log and ignore — alice's first vote stands
}
}
const tally = voting.tally();
if (tally.winner === undefined) {
// no votes were cast yet
}
voting.clear();
```
## API Reference
- `interface Vote { voterId, candidateId }`.
- `interface VotingManagerConfig { quorumFraction? }` — default `0.5`.
- `interface VoteTally { counts, totalVotes, winner }`.
- `recordVote(vote)` — throws `DUPLICATE_VOTE` on repeated voter ids.
- `hasQuorum(knownPeers): boolean` — `false` when `knownPeers ≤ 0`.
- `tally(): VoteTally` — deterministic tiebreak on lexicographic `candidateId`.
- `clear()` / `size()`.
## Implementation Notes
`tally()` is O(votes) and recomputes every call rather than caching, since the cost is small and a stale cache would cause subtle bugs across rounds. The `winner` field of `VoteTally` is `undefined` only when no votes have been recorded — even a single vote is sufficient for a tally winner. Callers MUST check `hasQuorum` separately before promoting that winner; the `tally()` API intentionally does not bake quorum into its return shape so consumers can observe partial-tally telemetry mid-round.