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-audit-mesh - Tamper-evident, cryptographically signed, append-only audit log chain for the Suaveplan mesh: SignedLog, LogReplicator (R-copy with tier-aware target selection), LogRetriever (range queries + pagination), IntegrityChecker, ConflictResolver, LogCompaction
## Summary
Tamper-evident, cryptographically signed, append-only audit log chain for the Suaveplan mesh. `SignedLog` produces hash-chained `LogEntry` records keyed by monotonic sequence with caller-injected `SignFn` (Ed25519 typically) so each entry's signature covers the deterministic SHA-256 of the prior entry plus the current payload; `LogReplicator` ships entries to `R` sibling hosts (default 2) with tier-aware target selection and synchronous-for-CRITICAL / async-for-NORMAL semantics; `LogRetriever` answers paginated `getLogs(meshId, fromSeq, toSeq)` range queries; `IntegrityChecker` walks the chain, detecting gaps / forks / tampered entries and emitting `audit:corruption-detected`; `ConflictResolver` reconciles divergent chains via longest-chain-wins with deterministic lex tiebreak; `LogCompaction` snapshots state into signed checkpoints to reduce reconciliation volume after server reconnect. Wrapped in a single `AuditMeshPlugin` satisfying the `NetworkPlugin` lifecycle.
<!-- begin:auto-docs -->
## Documentation tree
- [compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
<!-- end:auto-docs --> - [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ - [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_ - [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_ - [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…

View File

@@ -0,0 +1,75 @@
# LogCompaction
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Periodically compacts a long signed audit chain into a single signed `LogCheckpoint`, dramatically reducing the volume of entries the server has to replay after a long outage. A checkpoint is a snapshot of mesh/session state at a particular sequence number, signed by the same host (and the same key) that authored the surrounding chain — so consumers can verify the snapshot in isolation without walking back to the suaveplan entry.
The compaction module is deliberately stateless. It does not hold the chain or the snapshots; it just produces signed checkpoints from the inputs you give it and provides a `truncate` helper to discard already-checkpointed entries. This keeps the storage policy decoupled from the cryptographic policy.
## Features
- Builds a `LogCheckpoint` over `(seq, timestamp, meshId, hostId, stateHash, entryHashAtSeq)` and signs the canonical bytes.
- Two anchors per checkpoint — `entryHashAtSeq` ties the checkpoint to a specific entry in the chain (so the server can verify it against its retrieval), and `stateHash` ties it to the application-level snapshot.
- `verify(checkpoint, verifyFn)` round-trips the canonical bytes and asks the injected verifier whether the signature is valid.
- `truncate(entries, checkpoint)` returns the entries with `seq > checkpoint.seq` so callers can prune their hot log to just the post-checkpoint suffix.
- Empty entry list and missing-anchor errors raise `NET_AUDIT.CHECKPOINT_INVALID` with explanatory `error.context`.
## Usage: Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { LogCompaction } from "@suaveplan/network-plugin-audit-mesh";
const compactor = new LogCompaction({
meshId: "mesh-a",
hostId: "host-1",
sign: (bytes) => ed25519.sign(bytes, hostPrivateKey),
});
const checkpoint = await compactor.createCheckpoint({
atSeq: 1024,
entries: log.entries(),
stateSnapshot: { peers: snapshot.peers, sessions: snapshot.sessions },
});
const remaining = compactor.truncate(log.entries(), checkpoint);
console.log(remaining.length); // post-1024 only
```
## Usage: Advanced Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { LogCompaction } from "@suaveplan/network-plugin-audit-mesh";
const compactor = new LogCompaction({
meshId: "mesh-a",
hostId: "host-1",
clock: () => 1_700_000_000_000,
sign: async (bytes) => await hardwareSigner.sign(bytes),
});
const checkpoint = await compactor.createCheckpoint({ atSeq, entries, stateSnapshot });
const valid = await compactor.verify(
checkpoint,
(bytes, sig, hostId) => ed25519.verify(sig, bytes, hostKeys.get(hostId)!)
);
if (!valid) throw new Error("checkpoint signature failed");
```
## API Reference
- `LogCompaction(config)` — `config: { meshId; hostId; sign: SignFn; clock?: () => number }`.
- `createCheckpoint(input)` — `input: { atSeq; entries; stateSnapshot }`. Returns `Promise<LogCheckpoint>`. Raises `NET_AUDIT.CHECKPOINT_INVALID` on empty entries or missing anchor.
- `verify(checkpoint, verify)` — `verify: CheckpointVerifyFn`. Returns `Promise<boolean>`.
- `truncate(entries, checkpoint)` — returns the entries whose `seq` is strictly greater than `checkpoint.seq`.
- `checkpointSigningBytes(value)` — exported helper that returns the canonical bytes a verifier should hash and signature-check. Useful when the caller already has a checkpoint and just wants to validate it without instantiating `LogCompaction`.
## Implementation Notes
- The canonical bytes for a checkpoint are the same sorted-key JSON encoding used for entries; we keep two helpers (`entryHashBytes` for entries, `checkpointSigningBytes` for checkpoints) so that one cannot be accidentally substituted for the other.
- `stateHash` is computed over the caller-supplied `stateSnapshot` value via the same canonicalize routine, so two snapshots with semantically equal contents but different key insertion order produce identical hashes.
- `truncate` returns a fresh array so the caller can keep using the original entries for archival.

View File

@@ -0,0 +1,63 @@
# ConflictResolver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Picks the canonical chain when the server pulls audit logs from multiple Tier 0 hosts and finds disagreement. The rule is two-step and deterministic: (1) longest chain wins; (2) on equal length, the chain whose `lastHash` sorts lexicographically smallest wins. The deterministic tiebreak matters because the reconciler is itself replicated — two reconciler instances seeing the same divergent set must reach the same conclusion or the mesh keeps thrashing on every server reconnection.
The resolver is pure: input goes in, an outcome comes out, no side effects, no time, no randomness. That makes it the safest possible thing to call inside a property test or replay scenario.
## Features
- Longest-chain-wins primary rule.
- Lexicographic-tiebreak secondary rule on `lastHash`, with a final stable sort key on `hostId` so two chains that somehow share both length and `lastHash` still produce a deterministic answer.
- Returns the full winning chain, the winning host, the discriminating `reason` (`"longest-chain"` vs `"lex-tiebreak"`), and the list of `losers` so callers can rollback those hosts via the mesh.
- Empty input throws `NET_AUDIT.CONFLICT_UNRESOLVED` rather than returning a sentinel — there is no meaningful "no chains" winner.
## Usage: Basic Example
```ts
import { ConflictResolver } from "@suaveplan/network-plugin-audit-mesh";
const resolver = new ConflictResolver();
const result = resolver.resolve([
{ hostId: "host-1", entries: chain1, lastHash: "abc..." },
{ hostId: "host-2", entries: chain2, lastHash: "def..." },
]);
console.log(result.winningHostId, result.reason, result.losers);
```
## Usage: Advanced Example
```ts
import {
ConflictResolver,
type ConflictResolution,
} from "@suaveplan/network-plugin-audit-mesh";
function reconcile(
pulled: ReadonlyMap<string, LogEntry[]>
): ConflictResolution {
const chains = Array.from(pulled.entries()).map(([hostId, entries]) => ({
hostId,
entries,
lastHash: entries.length === 0
? ""
: entryDigestHex(entries[entries.length - 1]!),
}));
return new ConflictResolver().resolve(chains);
}
```
## API Reference
- `ConflictResolver` — stateless class; the no-arg constructor is intentional so all callers get the same rule set.
- `resolve(chains)` — `chains: readonly DivergentChain[]`. Returns `ConflictResolution`. Throws `NET_AUDIT.CONFLICT_UNRESOLVED` if `chains.length === 0`.
## Implementation Notes
- The "longest" reduce uses `>` rather than `>=` so the first chain wins on equal length; the `tied` filter then catches the equal-length case explicitly. This avoids a subtle bug where a strict-greater reduce would leak the iteration order into the result.
- `hostId` is the final sort key because two chains with identical content (and therefore identical `lastHash`) can in theory exist when the same content was independently produced by two hosts; this is rare but not impossible, and we want the resolver to be total.
- The losers list is produced by filtering the input by `winningHostId`, not by negating the tiebreak result, so a five-way conflict produces exactly four losers regardless of how deep the tiebreak chain went.

View File

@@ -0,0 +1,53 @@
# error-codes
## Purpose
Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem can surface — a missing sequence number in the chain, a tampered entry, a checkpoint that fails signature verification, a server reconciliation that cannot decide between divergent chains — is mapped onto one of these stable codes. Codes are emitted by `defineCodes("NET_AUDIT", [...] as const)` so the registry is locked at module load time and consumers downstream (logger redaction maps, i18n catalogs, the OpenSpec conformance checker) can rely on the set never silently growing.
The registry exists in its own module so the `index.ts` barrel can do `import "./error-codes.js"` first to register the codes via side effect before any subsystem starts referencing them.
## Features
- Eight codes covering chain integrity (`LOG_GAP`, `CHAIN_TAMPERED`, `SIGNATURE_INVALID`), sequencing (`INVALID_SEQ`), replication failure (`REPLICATION_FAILED`), checkpoint failures (`CHECKPOINT_INVALID`), conflict resolution failures (`CONFLICT_UNRESOLVED`), and range-query input validation (`RANGE_INVALID`).
- Sibling `NetworkAuditError` extends `SuaveplanError<NetworkAuditErrorCode>` so consumers can write `instanceof NetworkAuditError` checks and access `error.code` with full type narrowing.
- The registry is frozen by `defineCodes`; attempting to mutate it throws at runtime, and TypeScript's `as const` narrowing prevents adding new keys without a code change.
## Usage: Basic Example
```ts
import {
NETWORK_AUDIT_CODES,
NetworkAuditError,
} from "@suaveplan/network-plugin-audit-mesh";
throw new NetworkAuditError(
NETWORK_AUDIT_CODES.LOG_GAP,
"audit.log_gap",
{ context: { meshId: "mesh-a", missingSeq: 7 } }
);
```
## Usage: Advanced Example
```ts
import {
NetworkAuditError,
type NetworkAuditErrorCode,
} from "@suaveplan/network-plugin-audit-mesh";
function classify(err: unknown): NetworkAuditErrorCode | null {
return err instanceof NetworkAuditError ? err.code : null;
}
```
## API Reference
- `NETWORK_AUDIT_CODES` — frozen object with eight keys: `LOG_GAP`, `CHAIN_TAMPERED`, `SIGNATURE_INVALID`, `REPLICATION_FAILED`, `CHECKPOINT_INVALID`, `CONFLICT_UNRESOLVED`, `INVALID_SEQ`, `RANGE_INVALID`. Values are `"NET_AUDIT.<KEY>"` strings.
- `NetworkAuditErrorCode` — string-literal union of the registry values.
- `NetworkAuditError` — error class extending `SuaveplanError<NetworkAuditErrorCode>`. Constructor: `(code, messageKey, options?)`.
## Implementation Notes
- The list grows only by adding to the array literal in `error-codes.ts`; there is no programmatic `register()` API by design — the `as const` plus `defineCodes` arrangement is the contract.
- Every code in the registry must be referenced from `src/` somewhere — typically as the argument to `new NetworkAuditError(...)` (thrown classes), but `LOG_GAP` / `CHAIN_TAMPERED` / `SIGNATURE_INVALID` are **report-only** codes carried on `CorruptionReport.code` rather than thrown. The `CORRUPTION_KIND_TO_CODE` map in `integrity-checker.ts` is the single source of truth wiring those three codes onto the integrity-report stream. The conformance gate enforces that the codes are referenced at least once so dead codes cannot accumulate.
- Code messages use dotted i18n keys (e.g. `audit.log_gap`) so the logger's i18n adapter can resolve them at format time without coupling this package to a specific locale catalog.

View File

@@ -0,0 +1,74 @@
# IntegrityChecker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Walks a sequence of `LogEntry` records and decides whether the chain is intact: every `seq` is present (no gaps), every `prevHash` matches the SHA-256 of the previous entry's canonical bytes (no tamper, no fork), and — when an injected `verifySignature` is supplied — every `hostSignature` is a valid Ed25519 signature over those same bytes (no tamper of fields under signature). Any failure produces a `CorruptionReport` and emits `audit:corruption-detected` on the optional bus, so observability adapters can dashboard corruption rates.
The checker is the only module in audit-mesh that owns the canonical chain-walk algorithm. Both the integrity self-check on the originating host and the server-side cross-host validation step in `ConflictResolver` go through this module — keeping the rules in one place keeps the system honest.
## Features
- Detects **gaps** — when `entry.seq !== expectedSeq` (skipped sequences in a delivered chunk). Reports with `code: NETWORK_AUDIT_CODES.LOG_GAP`.
- Detects **forks** — when an entry's `prevHash` is empty but the chain has continued (a re-suaveplan attempt) or differs from what the chain so far implies. Reports with `code: NETWORK_AUDIT_CODES.CHAIN_TAMPERED`.
- Detects **tampered prev-hash** — when an entry's `prevHash` doesn't match the actual SHA-256 of the previous entry. Reports with `code: NETWORK_AUDIT_CODES.CHAIN_TAMPERED`.
- Detects **tampered signatures** — when the injected verifier returns false, with the exact `seq` and `hostId` in the report. Reports with `code: NETWORK_AUDIT_CODES.SIGNATURE_INVALID`.
- Each `CorruptionReport` carries both a granular `kind` (string discriminant for debugging) and a stable `code` (registry-backed identifier from `NETWORK_AUDIT_CODES`) so downstream consumers (logger, alerting, server reconciler) can match on the registry code without coupling to the human-readable string.
- Optional `verifySignature` (skip if signing keys aren't available — e.g. integrity check on a single-host log) keeps the checker reusable in test contexts.
- Optional `bus` emits `audit:corruption-detected` per finding for fan-out to alerting/observability.
## Usage: Basic Example
```ts
import {
SignedLog,
IntegrityChecker,
} from "@suaveplan/network-plugin-audit-mesh";
const checker = new IntegrityChecker();
const report = await checker.check(log.entries());
if (!report.ok) console.warn(report.issues);
```
## Usage: Advanced Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { IntegrityChecker } from "@suaveplan/network-plugin-audit-mesh";
const hostKeys = new Map<string, Uint8Array>([
["host-1", host1Public],
["host-2", host2Public],
]);
const checker = new IntegrityChecker({
verifySignature: (bytes, signature, hostId) => {
const pub = hostKeys.get(hostId);
if (pub === undefined) return false;
return ed25519.verify(signature, bytes, pub);
},
bus: { emit: (topic, payload) => observability.publish(topic, payload) },
});
const report = await checker.check(retriever.getLogs(query).entries);
metrics.record("audit.integrity.issues", report.issues.length);
```
## API Reference
- `IntegrityChecker(config?)` — `config: { verifySignature?: SigVerifyFn; bus?: IntegrityBus }`.
- `check(entries)` — returns `Promise<IntegrityReport>`. Walks entries in iteration order; the first entry's expected `prevHash` is `""`.
- `CorruptionKind` — `"gap" | "fork" | "tampered-prev-hash" | "tampered-signature"`.
- `CorruptionReport` — `{ kind; code: NetworkAuditErrorCode; seq; hostId; detail? }`.
- `CORRUPTION_KIND_TO_CODE` — frozen `Record<CorruptionKind, NetworkAuditErrorCode>` mapping each kind to its registry code; the integrity checker uses this so the kind→code mapping has a single source of truth.
- `IntegrityReport` — `{ ok; inspected; issues }`.
- `SigVerifyFn` — `(bytes, signature, hostId) => boolean | Promise<boolean>`.
- `IntegrityBus` — `emit("audit:corruption-detected", report)` shape.
## Implementation Notes
- The checker continues past a discovered issue rather than throwing on first error — a single retrieval pass surfaces every problem at once, so the operator does not need to re-run after each fix.
- "Fork at first entry" (a re-suaveplan when the chain already has history) is a special case of `prevHash` mismatch and is reported as `kind: "fork"` to distinguish it from a mid-chain tamper. All other prevHash mismatches are `kind: "tampered-prev-hash"`.
- Signature verification is async-aware; even though `ed25519.verify` is synchronous, the verifier signature accepts `Promise<boolean>` so a hardware-backed verifier can plug in without forcing the caller to wrap.

View File

@@ -0,0 +1,86 @@
# SignedLog
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Append-only, hash-chained, cryptographically signed audit log per `(meshId, hostId)` pair. Every entry's `prevHash` is the hex-encoded SHA-256 of the previous entry's canonical bytes, and every entry's `hostSignature` covers those same canonical bytes — so flipping any field (including `seq`, `timestamp`, or `prevHash`) breaks both the chain and the signature.
The class deliberately decouples the signing primitive: callers inject a `SignFn` (typically `(bytes) => ed25519.sign(bytes, hostPrivateKey)`) so the log itself depends only on `@suaveplan/crypto-hash`. This keeps audit-mesh independent of any specific signature engine and makes property-based tests trivial — substitute a deterministic test signer and the chain becomes byte-stable.
## Features
- Monotonic sequence (`seq` starts at 0 and increments per append, never resets except via a fresh `SignedLog` instance).
- Hash-chained — first entry has `prevHash = ""`; subsequent entries have `prevHash = hex(sha256(canonicalBytes(prev)))`.
- Canonical serialization uses sorted-key JSON over the entry-without-signature; non-finite numbers serialize as `null` to keep bytes deterministic.
- Injectable `SignFn` may return either a `Uint8Array` synchronously or a `Promise<Uint8Array>` — `append` awaits the result, so async signers (e.g. hardware-backed Ed25519) work transparently.
- Injectable clock (defaults to `Date.now`) keeps tests deterministic.
- `getBySeq` raises `NET_AUDIT.INVALID_SEQ` on out-of-range lookups.
## Usage: Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { SignedLog } from "@suaveplan/network-plugin-audit-mesh";
const privateKey = ed25519.randomPrivateKey();
const log = new SignedLog({
meshId: "mesh-a",
hostId: "host-1",
sign: (bytes) => ed25519.sign(bytes, privateKey),
});
const entry = await log.append({
eventType: "host-elected",
sessionId: null,
severity: "critical",
payload: { electedHost: "host-1" },
});
console.log(entry.seq, entry.prevHash, entry.hostSignature.length);
```
## Usage: Advanced Example
```ts
import {
SignedLog,
entryDigestHex,
} from "@suaveplan/network-plugin-audit-mesh";
const log = new SignedLog({
meshId: "mesh-a",
hostId: "host-1",
clock: () => 1_700_000_000_000,
sign: async (bytes) => await hardwareSigner.sign(bytes),
});
await log.append({
eventType: "session-created",
sessionId: "sess-1",
severity: "critical",
payload: { peers: ["peer-a", "peer-b"] },
});
const digest = entryDigestHex(log.entries()[0]!);
console.log(`tip digest = ${digest}; chain size = ${log.size()}`);
```
## API Reference
- `SignedLog(config)` — constructs an empty log. `config: { meshId; hostId; sign: SignFn; clock?: () => number }`.
- `append(input)` — appends one entry. `input: { eventType; sessionId; severity; payload }`. Returns the persisted `LogEntry` after signing.
- `entries()` — returns a defensive shallow copy of all entries in append order.
- `size()` — number of entries appended.
- `tipHash()` — hex SHA-256 of the most recent entry, or `""` for an empty log.
- `getBySeq(seq)` — returns the entry at sequence `seq` or throws `NET_AUDIT.INVALID_SEQ`.
- `entryHashBytes(entry)` — exported helper that returns the canonical bytes a verifier should hash and signature-check.
- `entryDigestHex(entry)` — exported helper that returns the hex SHA-256 of those bytes (handy for debug/inspection).
- `bytesToHex(bytes)` — exported lowercase-hex utility used by both this module and `IntegrityChecker`.
## Implementation Notes
- `prevHash` is a hex string rather than `Uint8Array` so chain entries are inspectable in JSON dumps and equality comparisons fall back on string compare without a length-prefix dance.
- `canonicalize` walks the entry-without-signature in sorted-key order; the sort is stable across all V8/JSC versions so two correct implementations produce byte-identical output.
- The `Omit<LogEntry, "hostSignature">` shape is the implicit "thing under signature" — any future field added to `LogEntry` is included automatically because canonicalization is structural.

View File

@@ -0,0 +1,88 @@
# AuditMeshPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single `NetworkPlugin` facade that wires `SignedLog`, `LogReplicator`, `LogRetriever`, `IntegrityChecker`, `ConflictResolver`, and `LogCompaction` into a cohesive subsystem and registers them with a `NetworkNode` via the standard plugin lifecycle (`install` / `start` / `stop` / `dispose`). Consumers register exactly one of these per host; everything else is reachable via the public properties on the plugin instance.
The plugin keeps its own state minimal — installation just remembers the node, and the start/stop pair is a flag — because the subsystems it composes are independently useful and testable. The plugin is the wire-up; the modules are the work.
## Features
- One-shot construction wires every subsystem from a single config object.
- Public read-only access to `log`, `replicator`, `retriever`, `integrity`, `conflicts`, and `compaction` so consumers can drop directly into any of them.
- Lifecycle hooks are idempotent and side-effect-light; `start` only flips the started flag (replication is per-append, not background).
- `dispose` clears the node reference so the plugin can be re-installed later if the host's `NetworkNode` is rebuilt.
## Usage: Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { NetworkNode } from "@suaveplan/network";
import { AuditMeshPlugin } from "@suaveplan/network-plugin-audit-mesh";
const privateKey = ed25519.randomPrivateKey();
const node = new NetworkNode({ id: "host-1" });
const audit = new AuditMeshPlugin({
meshId: "mesh-a",
hostId: "host-1",
sign: (bytes) => ed25519.sign(bytes, privateKey),
replication: {
replicationFactor: 2,
localTier: 0,
localMeshId: "mesh-a",
localHostId: "host-1",
},
send: async (target, entry) => network.send(target.hostId, entry),
});
await node.use(audit).start();
await audit.log.append({
eventType: "host-elected",
sessionId: null,
severity: "critical",
payload: { electedHost: "host-1" },
});
```
## Usage: Advanced Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { AuditMeshPlugin } from "@suaveplan/network-plugin-audit-mesh";
const audit = new AuditMeshPlugin({
meshId: "mesh-a",
hostId: "host-1",
sign: hardwareSigner.sign,
replication: { replicationFactor: 3, localTier: 1, localMeshId: "mesh-a", localHostId: "subhost-c" },
send: transportManager.relay,
candidates: peerRegistry.snapshot(),
verifySignature: (bytes, sig, hostId) =>
ed25519.verify(sig, bytes, hostKeys.get(hostId)!),
bus: { emit: (topic, payload) => observability.publish(topic, payload) },
defaultPageSize: 250,
maxPageSize: 5000,
});
const entry = await audit.log.append({ ... });
await audit.replicator.replicate(entry);
const report = await audit.integrity.check(audit.log.entries());
```
## API Reference
- `AuditMeshPlugin(config)` — `config: AuditMeshPluginConfig`. See type definition for the full surface.
- `name` — `"audit-mesh"` (constant).
- Subsystem properties: `log`, `replicator`, `retriever`, `integrity`, `conflicts`, `compaction`.
- `install(node)` / `start()` / `stop()` / `dispose()` — `NetworkPlugin` lifecycle.
- `host()` — returns the installed `NetworkNode` or `null`.
- `isStarted()` — boolean lifecycle inspector.
## Implementation Notes
- The plugin uses spread-with-conditional-key tricks (`...(config.x === undefined ? {} : { x: config.x })`) to honour `exactOptionalPropertyTypes` — passing `undefined` to a sub-component that expects an _absent_ key would type-error at construction time, so we elide the key entirely instead.
- The `bus` property is reused as both `ReplicatorBus` and `IntegrityBus` (their `emit` shapes are compatible in practice); if a consumer ever needs to route the two streams separately they can construct the subsystems individually rather than going through the plugin facade.
- Replication is intentionally not awaited inside `log.append`; the caller decides whether to `await replicator.replicate(entry)` immediately (sync semantics) or fire-and-forget (async semantics is also achieved by passing a `severity: "normal"` entry).

View File

@@ -0,0 +1,80 @@
# LogReplicator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Ships every signed log entry to `R` sibling hosts so a single host failure cannot erase audit history. The replicator is tier-aware: a Tier 0 host preferentially replicates to other Tier 0s (cross-mesh durability) and falls back to a Tier 1 child within its own mesh (local redundancy); a Tier 1 host replicates to its Tier 0 parent and a sibling Tier 1. The mode is determined by the entry's severity — critical events (host election, session creation) ship synchronously, normal events (peer churn, state updates) ship asynchronously.
The replicator is intentionally side-effect-light: routing decisions and retry policy are pure; the only outward I/O is the injected `SendToReplicaFn`. This makes it composable with any transport (`@suaveplan/network-plugin-transport-manager`, a real WebRTC mesh, or a memory-only test harness).
## Features
- R-copy replication; default `replicationFactor` is 2 (so each entry lives on 3 hosts: originator + 2 replicas).
- Tier-aware target scoring (Tier 0 → other Tier 0s preferred; Tier 1 → Tier 0 parent preferred); deterministic alphabetical tiebreak on score collisions so replication is reproducible across runs.
- Severity-mode mapping: `"critical"` → synchronous (all R awaited; throws `NET_AUDIT.REPLICATION_FAILED` if every replica fails), `"normal"` → fire-and-forget with bus failure events.
- Per-target failures emit `audit:replication-failed` on the optional bus rather than throwing — keeps the originating host's append loop fast.
- `registerCandidate` / `unregisterCandidate` so topology changes (peer joins / disconnects) flow into the replicator without rebuilding it.
- Self-exclusion: a candidate matching `localHostId` is silently dropped at registration time and at selection time.
## Usage: Basic Example
```ts
import { LogReplicator } from "@suaveplan/network-plugin-audit-mesh";
const replicator = new LogReplicator({
config: {
replicationFactor: 2,
localTier: 0,
localMeshId: "mesh-a",
localHostId: "host-1",
},
candidates: [
{ hostId: "host-2", tier: 0, meshId: "mesh-b" },
{ hostId: "host-3", tier: 1, meshId: "mesh-a" },
],
send: async (target, entry) => transport.send(target.hostId, entry),
});
await replicator.replicate(entry);
```
## Usage: Advanced Example
```ts
import { LogReplicator } from "@suaveplan/network-plugin-audit-mesh";
const replicator = new LogReplicator({
config: { replicationFactor: 3, localTier: 1, localMeshId: "mesh-a", localHostId: "subhost-c" },
bus: { emit: (topic, payload) => observability.publish(topic, payload) },
send: async (target, entry) => {
try {
await network.send({ to: target.hostId, payload: entry });
return true;
} catch {
return false;
}
},
});
replicator.registerCandidate({ hostId: "host-1", tier: 0, meshId: "mesh-a" });
const outcome = await replicator.replicate(criticalEntry);
console.log(outcome.mode, outcome.succeeded.length, outcome.failed.length);
```
## API Reference
- `LogReplicator(input)` — `input: { config: ReplicationConfig; send: SendToReplicaFn; bus?: ReplicatorBus; candidates?: readonly ReplicationTarget[] }`. Throws `NET_AUDIT.REPLICATION_FAILED` if `replicationFactor < 1`.
- `replicate(entry)` — chooses targets, dispatches, returns `ReplicationOutcome`. Synchronous mode throws `NET_AUDIT.REPLICATION_FAILED` if every replica fails. Asynchronous mode returns an outcome with empty `succeeded`/`failed` (failures arrive on the bus).
- `selectTargets()` — pure target-ranking helper; returns up to `replicationFactor` candidates ordered by tier-aware score then by `hostId`.
- `registerCandidate(target)` / `unregisterCandidate(hostId)` / `listCandidates()` — candidate-set management.
- `ReplicationOutcome` — `{ mode; attempted; succeeded; failed }`.
- `ReplicatorBus` — minimal `emit(topic, payload)` shape so the package does not couple to `@suaveplan/events`.
## Implementation Notes
- The scoring function gives `100` to the most-preferred class and `50` to the fallback class; any other candidate scores `0` and is dropped only if there are enough higher-scored candidates. This keeps the mesh "best-effort R" rather than "exact R or fail" — durability degrades gracefully.
- Sync mode awaits all replicas via `Promise.all` so the originator can record both the success count and the failure list in a single round-trip.
- Async mode dispatches via `void this.sendOne(...).then(...)`; thrown errors inside the send function are caught and surfaced as failures rather than uncaught promise rejections.
- A bus is optional. If one is wired, the replicator emits `audit:replication-failed` for every per-target failure (even in sync mode where the throw also fires), so the observability adapter can count failures without needing access to the throw site.

View File

@@ -0,0 +1,75 @@
# LogRetriever
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Answers range queries against a `SignedLog`, the natural query shape for the server-bridge reconciliation flow: "give me every entry in mesh X with sequence between A and B". Pagination is the surface that keeps reconciliation tractable when a host has accumulated tens of thousands of entries during a long server outage — the server iterates page-by-page rather than buffering the entire chain.
The retriever is deliberately a thin read facade. It has no signature verification logic (that lives in `IntegrityChecker`) and no authorization logic (that lives upstream in `network-plugin-auth`). Its only job is to take a well-formed range request and return well-paginated results.
## Features
- `getLogs(query)` returns one page of entries plus pagination metadata (`page`, `pageSize`, `totalPages`).
- `iterate(query)` is a generator that yields every entry across all pages, so callers can stream a long chain without juggling pagination state.
- Per-call `pageSize` is clamped to `maxPageSize` (default 1000) so a malformed request cannot exhaust memory.
- Mesh-mismatch (`requested.meshId !== log.meshId`) raises `NET_AUDIT.RANGE_INVALID` rather than silently returning an empty result, so misrouted queries fail loudly.
- Negative `fromSeq`, `toSeq < fromSeq`, negative page index, and zero-or-negative `pageSize` all raise `NET_AUDIT.RANGE_INVALID` with explanatory `error.context`.
## Usage: Basic Example
```ts
import {
SignedLog,
LogRetriever,
} from "@suaveplan/network-plugin-audit-mesh";
const log = new SignedLog({
meshId: "mesh-a",
hostId: "host-1",
sign: signFn,
});
const retriever = new LogRetriever({ log });
const result = retriever.getLogs({
meshId: "mesh-a",
fromSeq: 0,
toSeq: 99,
});
console.log(result.page, result.totalPages, result.entries.length);
```
## Usage: Advanced Example
```ts
import { LogRetriever } from "@suaveplan/network-plugin-audit-mesh";
const retriever = new LogRetriever({
log,
defaultPageSize: 250,
maxPageSize: 5000,
});
for (const entry of retriever.iterate({
meshId: "mesh-a",
fromSeq: 1000,
toSeq: 9999,
pageSize: 500,
})) {
await server.ingest(entry);
}
```
## API Reference
- `LogRetriever(config)` — `config: { log: SignedLog; defaultPageSize?: number; maxPageSize?: number }`. Defaults: 100 / 1000.
- `getLogs(query)` — `query: { meshId; fromSeq; toSeq; pageSize?; page? }`. Returns `RangeResult`.
- `iterate(query)` — `Generator<LogEntry>`; auto-advances pages until the range is exhausted.
- `RangeResult` — `{ meshId; fromSeq; toSeq; page; pageSize; totalPages; entries }`.
## Implementation Notes
- The mesh-mismatch check is the first guard so observability for "wrong mesh queried" is unambiguous in error logs.
- `pageSize` is normalised at the call site (not at construction time) so a single retriever instance can serve heterogeneous query shapes.
- The retriever filters by sequence range rather than slicing by index because `SignedLog.entries()` is `seq`-aligned (entry index === seq) but we treat that as an implementation detail rather than a contract — should `SignedLog` ever support truncation/checkpoint reset, the retriever continues to work correctly without changes.

View File

@@ -0,0 +1,48 @@
# conflict.types
## Purpose
Captures the inputs and outputs of `ConflictResolver`. When the central server reconciles state after an outage, it pulls the per-host audit chains from every Tier 0 host. Hosts that were partitioned during the outage may have produced divergent suffixes, so the server needs a deterministic tiebreaking rule to pick the canonical chain. These types describe that rule's surface area.
The two-step resolution (longest chain wins; if equal length, the chain whose last entry hash sorts lexicographically smallest wins) is intentionally simple and side-effect-free so the same input is guaranteed to produce the same output across servers — important when the reconciler is itself replicated.
## Features
- `DivergentChain` carries one host's contribution to the conflict: the chain of entries it produced and a `lastHash` (the SHA-256 hex of the final entry) used as the tiebreak key.
- `ConflictResolution` carries the winning chain plus a `reason` discriminant (`"longest-chain"` vs `"lex-tiebreak"`) for audit log output, and a `losers` array so callers can issue rollback instructions to the hosts whose chain was discarded.
- All fields are `readonly` and the chain arrays are `readonly LogEntry[]` to keep resolver semantics referentially transparent.
## Usage: Basic Example
```ts
import type {
ConflictResolution,
DivergentChain,
} from "@suaveplan/network-plugin-audit-mesh";
const chains: DivergentChain[] = [
{ hostId: "host-1", entries: [...], lastHash: "abc..." },
{ hostId: "host-2", entries: [...], lastHash: "def..." },
];
```
## Usage: Advanced Example
```ts
import { ConflictResolver } from "@suaveplan/network-plugin-audit-mesh";
const resolver = new ConflictResolver();
const result: ConflictResolution = resolver.resolve(chains);
console.log(result.winningHostId, result.reason, result.losers);
```
## API Reference
- `ConflictResolutionReason` — `"longest-chain" | "lex-tiebreak"`.
- `DivergentChain` — `{ hostId; entries: readonly LogEntry[]; lastHash: string }`.
- `ConflictResolution` — `{ winningHostId; winningChain: readonly LogEntry[]; reason; losers: readonly string[] }`.
## Implementation Notes
- `lastHash` is provided by the caller (typically computed by the integrity checker as it walks the chain) so the resolver does not depend on hashing utilities and stays trivially testable.
- An empty chain set throws `NET_AUDIT.CONFLICT_UNRESOLVED` rather than returning a sentinel — there is no meaningful "no winner" answer.

View File

@@ -0,0 +1,66 @@
# log.types
## Purpose
Defines the wire-stable shape of every record that flows through the audit-mesh subsystem: append-only `LogEntry` records, the seven `LogEventType` discriminants the mesh emits, the two-level `LogSeverity` axis that drives synchronous-vs-asynchronous replication, and the periodic `LogCheckpoint` that compaction emits to bound replay volume after a long server outage.
These types are the contract every other module in `@suaveplan/network-plugin-audit-mesh` agrees on, so they live in a shared `types/` directory rather than next to any one consumer.
## Features
- Discriminated `LogEventType` covering peer lifecycle, host election, session lifecycle, and state changes
- Two-tier `LogSeverity` (`"critical"` / `"normal"`) drives replicator mode selection (sync for critical, async for normal)
- `LogEntry.prevHash` is a hex string (not bytes) to keep equality checks straightforward and make the chain inspectable in logs
- `LogEntry.hostSignature` is the raw `Uint8Array` Ed25519 signature over `entryHashBytes(entry)` so consumers do not pay for base64 round-trips
- `LogCheckpoint` carries both `stateHash` (snapshot fingerprint) and `entryHashAtSeq` (chain anchor) so the server can validate the checkpoint without replaying the chain
## Usage: Basic Example
```ts
import type {
LogEntry,
LogEventType,
} from "@suaveplan/network-plugin-audit-mesh";
const entry: LogEntry = {
seq: 1,
timestamp: 1_700_000_000_000,
meshId: "mesh-a",
sessionId: null,
eventType: "host-elected" satisfies LogEventType,
severity: "critical",
payload: { electedHost: "host-1", term: 7 },
prevHash: "",
hostId: "host-1",
hostSignature: new Uint8Array(64),
};
```
## Usage: Advanced Example
```ts
import type { LogCheckpoint } from "@suaveplan/network-plugin-audit-mesh";
const checkpoint: LogCheckpoint = {
seq: 1024,
timestamp: Date.now(),
meshId: "mesh-a",
hostId: "host-1",
stateHash: "abcd1234...",
entryHashAtSeq: "ef567890...",
signature: new Uint8Array(64),
};
```
## API Reference
- `LogEventType` — union of `"peer-joined" | "peer-left" | "host-elected" | "session-created" | "session-closed" | "state-update" | "policy-change"`.
- `LogSeverity` — `"critical" | "normal"`.
- `LogEntry` — append-only record with `seq`, `timestamp`, `meshId`, `sessionId`, `eventType`, `severity`, `payload`, `prevHash`, `hostId`, `hostSignature`.
- `LogCheckpoint` — signed snapshot `{ seq, timestamp, meshId, hostId, stateHash, entryHashAtSeq, signature }`.
## Implementation Notes
- All fields are `readonly`. Mutation should never happen post-append; downstream replicators copy entries by reference.
- `prevHash` of the suaveplan entry is the empty string (`""`). Hash chain validators must allow this exactly once per `(meshId, hostId)` chain.
- `hostSignature` covers the SHA-256 of every other field on the entry, so flipping any bit (including `seq` or `prevHash`) breaks signature verification — the integrity checker relies on this.

View File

@@ -0,0 +1,65 @@
# replication.types
## Purpose
Describes the data the `LogReplicator` needs to pick replication targets and to route an entry through the mesh under the right delivery guarantees. The two-mode `ReplicationMode` axis is what bridges audit-mesh to the broader topology contract: critical events (host election, session creation) ship synchronously so the originating host has confirmed durability before acknowledging the action upstream; normal events (peer join/leave, state updates) ship asynchronously to keep the mesh fast.
These types are a thin shape contract — no logic lives here. The replicator implementation reads `ReplicationConfig.localTier` to compute `tier-aware` routing rules (Tier 0 to other Tier 0s + a Tier 1 child; Tier 1 to its Tier 0 parent + a sibling Tier 1).
## Features
- `ReplicationMode` discriminates the two delivery guarantees (`"sync"` blocks the caller until R replicas ack; `"async"` fires-and-forgets and surfaces failures via OTel + bus events).
- `ReplicationTarget` carries the minimum information the replicator needs to make a routing decision: peer identity + tier (for cross-tier rules) + mesh.
- `ReplicationConfig` is read once at plugin construction time and supplies the local host's identity and the replication factor.
## Usage: Basic Example
```ts
import type {
ReplicationConfig,
ReplicationTarget,
} from "@suaveplan/network-plugin-audit-mesh";
const config: ReplicationConfig = {
replicationFactor: 2,
localTier: 0,
localMeshId: "mesh-a",
localHostId: "host-1",
};
const candidate: ReplicationTarget = {
hostId: "host-2",
tier: 0,
meshId: "mesh-b",
};
```
## Usage: Advanced Example
```ts
import { LogReplicator } from "@suaveplan/network-plugin-audit-mesh";
const replicator = new LogReplicator({
config: {
replicationFactor: 3,
localTier: 1,
localMeshId: "mesh-a",
localHostId: "subhost-c",
},
sendFn: async (target, entry) => {
await network.send({ to: target.hostId, payload: entry });
return true;
},
});
```
## API Reference
- `ReplicationMode` — `"sync" | "async"`.
- `ReplicationTarget` — `{ hostId: string; tier: number; meshId: string }`.
- `ReplicationConfig` — `{ replicationFactor: number; localTier: number; localMeshId: string; localHostId: string }`. The `replicationFactor` is the `R` in "R-copy"; the entry is stored on `R + 1` hosts (originator + R replicas).
## Implementation Notes
- Replication factor is intentionally not bounded at the type level. Callers should validate at construction time (`>= 1`) — the `LogReplicator` raises `NET_AUDIT.REPLICATION_FAILED` if no candidates remain after filtering.
- `tier` is a number rather than an enum so future N-tier topologies (Tier 2+) can extend without a breaking change.