audit

package
v0.8.83 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: AGPL-3.0, AGPL-3.0-or-later, Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package audit lets moderators spot-check each other. It is wired: the moderator answers challenges on ChallengeRoute, spot-checks a random peer every few minutes, and files every decided round as reference material. One thing is deliberately NOT wired — no verdict, vote or connection is refused because of a Ledger standing. See "What is missing" below for why that step needs more than this package can prove on its own.

The problem it targets: the vote round (see the moderator package) stops a forged verdict, but a moderator that actually joins the network and votes dishonestly — or a bot that "votes" without running any model at all — is only outvoted, never identified. Audit closes that: any moderator may pick a random peer and hand it a spot-check ("challenge"): moderate exactly this text. The signed answer is compared against the probe's expected class and accumulated per peer in a Ledger; a peer whose answers are systematically wrong, absent or cryptographically invalid loses standing down to Banned.

Three constraints shape everything here:

  • Challenges must not be predictable. An earlier draft shipped a list of invented probe texts, which is worthless: the source is public, so anyone can tabulate "this text means unsafe" and answer correctly with no model at all. Challenges are therefore drawn from Corpus — real texts a vote round already ruled on, with the quorum's verdict as ground truth. That pool is unique to each node, changes as traffic does, and costs nothing to build, since the round already fetched the text and already agreed on the answer.

  • Verdict CLASSES are compared, never bytes or scores, and only statistically: a single disagreement is noise, a systematic pattern is a fake. The thresholds in ledger.go encode that tolerance. The current tolerance assumes every moderator runs the same model — see "What is missing" below.

  • Cross-platform: no float comparisons, no runtime assumptions. The challenge binds to hex(sha256) over the raw UTF-8 bytes of the text, which is identical on every architecture, and the response is a signed JSON event like every other Warpnet wire message.

What is missing for a sound fake-moderator test

Under the same-model assumption an audit catches a peer with no model at all (constant answers, coin flips, silence) and a peer answering under someone else's identity. That is worth having, and it is all this package claims. Everything below stands between that and a verdict trustworthy enough to disqualify anyone — which is exactly why no vote, verdict or connection is currently refused on a Ledger standing.

  1. A single auditor must not judge. A standing here is one node's opinion, formed from references only it has seen. Acting on it lets a malicious auditor disqualify honest moderators — a worse attack than the one being defended against. A ban has to come from independent auditors agreeing, which means gossiping the signed challenge/response transcripts and deciding by quorum, the same shape the vote round already uses for content.

  2. Model identity has to be established, not assumed. The moment moderators legitimately differ (a newer Llama Guard, another guard model, a different quantization), a class disagreement no longer separates a liar from an honest peer. Options, roughly by cost: pin a model version per network epoch and make the version part of the protocol; or have peers attest what they run and calibrate the tolerated disagreement per model pair from observed rates; or restrict challenges to references the network agreed on unanimously, where model differences matter least.

  3. References must be beyond dispute. Corpus trusts the quorum, so a round decided by a colluding majority poisons every reference it produces. Weighting references by how lopsided the round was, and dropping those carried by a bare majority, limits the damage without removing it.

  4. A correct answer is not proof of work done. A peer can relay the text to a third party, or recognise a reference it already holds in its own corpus. Nothing here separates "runs a model" from "can obtain an answer"; only economic or latency-bound arguments do, and both are weak.

  5. Identity still costs nothing. Audit measures behaviour per key, so a banned peer returns under a fresh one. Standing starts at Probation for that reason, but a probation period raises the price of a new identity without setting one. Absent a real cost (stake, proof of work, a vouching web), a patient attacker outlasts any behavioural test.

Until (1) and (2) hold, the honest reading of a Banned standing is "I should not rely on this node", not "this node is a fake".

Index

Constants

View Source
const (
	ErrEmptyChallenge        warpnet.WarpError = "audit: empty challenge text"
	ErrChallengeHashMismatch warpnet.WarpError = "audit: challenge text does not match its content hash"
)
View Source
const ChallengeRoute = "/public/get/moderate/challenge/0.0.0"

ChallengeRoute is the moderator-to-moderator audit spot-check route. Reserved for the audit protocol; no handler is registered for it yet.

View Source
const ErrChallengeRefused warpnet.WarpError = "audit: peer refused the challenge"
View Source
const ErrResponseSignature warpnet.WarpError = "audit: challenge response signature invalid"

Variables

This section is empty.

Functions

func ContentHash

func ContentHash(text string) string

ContentHash is the cross-platform binding between a challenge and its response: hex(sha256) over the raw UTF-8 bytes of the text. Identical on every architecture, no normalization games.

func StreamChallengeHandler

func StreamChallengeHandler(engine Engine, sign ResponseSigner) warpnet.WarpHandlerFunc

StreamChallengeHandler answers audit spot-checks: run the local engine on exactly the challenged text and return a signed verdict. NOT REGISTERED anywhere yet — wiring it under ChallengeRoute on the moderator node is the integration step.

Types

type Auditor

type Auditor struct {
	// contains filtered or unexported fields
}

Auditor drives moderator-to-moderator spot-checks: pick a random peer, hand it a probe, judge the signed answer, feed the ledger.

func NewAuditor

func NewAuditor(selfID string, challenger Challenger, ledger *Ledger, corpus *Corpus, rng *rand.Rand) *Auditor

func (*Auditor) ChallengeRandomPeer

func (a *Auditor) ChallengeRandomPeer(peers []string) *Result

ChallengeRandomPeer picks one eligible peer at random and runs a single spot-check against it. A nil Result means there was nothing to run: no eligible peer (empty set, all on cooldown, or only self), or a corpus too thin to judge anyone by. An audit never fails: a peer that cannot answer, or answers garbage, has simply earned that outcome.

type Challenge

type Challenge struct {
	ChallengeID string    `json:"challenge_id"`
	Text        string    `json:"text"`
	ContentHash string    `json:"content_hash"`
	TimeAt      time.Time `json:"time_at,omitzero"`
}

Challenge is a spot-check one moderator sends to another: "moderate exactly this text". ContentHash is hex(sha256) over the raw UTF-8 bytes of Text, so the response binds to the same content on any platform — no float math, no runtime assumptions.

NOT WIRED YET: no handler is registered for the challenge route; this is the wire shape for the future moderator-audit protocol. It lives in this package rather than the shared event package because only moderators exchange challenges — no member node ever sends or receives one.

func BuildChallenge

func BuildChallenge(rng *rand.Rand, corpus *Corpus) (ch Challenge, expectUnsafe, ok bool)

BuildChallenge draws a reference from the corpus and wraps it as a spot-check. The bool is false while the corpus is too thin to audit anyone with; expectUnsafe is the verdict a round already reached on that text, which is what the answer gets compared against.

type ChallengeResponse

type ChallengeResponse struct {
	ChallengeID string                  `json:"challenge_id"`
	ContentHash string                  `json:"content_hash"`
	Result      domain.ModerationResult `json:"result"`
	Reason      *string                 `json:"reason,omitempty"`
	Model       domain.ModelType        `json:"model"`
	ModeratorID domain.ID               `json:"moderator_id"`
	TimeAt      time.Time               `json:"time_at,omitzero"`
	// Signature is base64(ed25519) over SigningBytes with the responder's
	// node key; it makes the answer non-repudiable audit evidence.
	Signature string `json:"signature,omitempty"`
}

ChallengeResponse is the challenged moderator's signed verdict on the probe text. Moderators are free to run different models (Model reports which one answered), so auditors must compare verdict classes statistically, never byte-for-byte.

func (ChallengeResponse) Signed

func (e ChallengeResponse) Signed(privKey ed25519.PrivateKey, moderator domain.ID, model domain.ModelType) ChallengeResponse

Signed returns a stamped and signed copy of the response, answering as moderator with the model that actually judged the text.

func (ChallengeResponse) VerifiedFrom

func (e ChallengeResponse) VerifiedFrom(peer string) error

VerifiedFrom checks that the response really came from peer: the signature must verify against the public key carried by that peer id, so a valid answer signed by anyone else counts for nothing.

type Challenger

type Challenger interface {
	Ask(peer string, ch Challenge) (ChallengeResponse, error)
}

Challenger delivers a challenge to a peer and brings its answer back. Everything about how that happens — streams, routes, encoding — lives behind this interface, so the audit logic never has to know.

type Corpus

type Corpus struct {
	// contains filtered or unexported fields
}

Corpus is the reference material an audit draws on: real texts a vote round has already ruled on, with the quorum's verdict as ground truth.

It replaces the synthetic probe list an earlier draft carried. A fixed list of made-up texts is worthless as a challenge: the code is public, so anyone can tabulate "this template means unsafe" and answer correctly with no model at all. References taken from live traffic cannot be tabulated ahead of time — the auditor picks from an ever-changing pool only it has seen — and they cost nothing to produce, since the round already fetched the text and already agreed on the verdict.

Keeping the two classes in separate rings matters: a challenger draws them in equal measure, so a peer that answers a constant class scores about half and lands under the ban threshold either way.

func NewCorpus

func NewCorpus() *Corpus

func (*Corpus) Len

func (c *Corpus) Len() (safe, unsafe int)

Len reports how many references are on hand per class.

func (*Corpus) Remember

func (c *Corpus) Remember(text string, verdict domain.ModerationResult)

Remember files a text a round has ruled on. Call it when a round decides, with the text that was judged and the quorum's verdict: the agreement of several independent moderators is a stronger reference than any single node's opinion, which is exactly what an audit needs.

Texts already in the corpus are not re-filed, so a report storm over one tweet cannot flood the references with copies of it.

func (*Corpus) Sample

func (c *Corpus) Sample(rng *rand.Rand) (text string, expectUnsafe bool, ok bool)

Sample draws one reference, alternating classes at random so neither is predictable. The bool is false while either class is still empty: until this node has seen both a clean and a moderated text there is nothing to audit anyone against, and guessing would be worse than waiting.

type Engine

type Engine interface {
	Moderate(content string) (bool, string, error)
}

Engine is the moderation engine slice the respondent side needs — the same shape the moderator package uses.

type Ledger

type Ledger struct {
	// contains filtered or unexported fields
}

Ledger accumulates audit outcomes per moderator peer. In-memory and local-first by design: every node judges from its own evidence; sharing signed transcripts across nodes is a later layer.

func NewLedger

func NewLedger(events warpnet.PeerEmitter) *Ledger

NewLedger reports what it concludes into events, the fan-out of the moderator that owns it.

func (*Ledger) Record

func (l *Ledger) Record(peerID string, o Outcome)

func (*Ledger) Snapshot

func (l *Ledger) Snapshot() map[string]PeerReport

func (*Ledger) StandingOf

func (l *Ledger) StandingOf(peerID string) Standing

StandingOf reports the local trust state; peers never audited are on probation, not trusted.

type Outcome

type Outcome int

Outcome is the auditor's classification of one challenge exchange.

const (
	// OutcomeCorrect: answered with the expected verdict class.
	OutcomeCorrect Outcome = iota
	// OutcomeWrong: answered, cryptographically valid, but with the wrong
	// class on a flagrant probe. One of these is model noise; a pattern is
	// a fake — the thresholds below draw that line.
	OutcomeWrong
	// OutcomeUnreachable: no usable answer (transport error, timeout, or
	// an error reply). Could be the network's fault, so it degrades
	// standing only in bulk and never to Banned.
	OutcomeUnreachable
	// OutcomeInvalid: an answer that is wrong cryptographically or
	// protocol-wise — bad signature, foreign responder id, mismatched
	// challenge binding. Never accidental; two of these ban the peer.
	OutcomeInvalid
)

type PeerReport

type PeerReport struct {
	Correct     int
	Wrong       int
	Unreachable int
	Invalid     int
	Standing    Standing
}

PeerReport is a read-only snapshot row, the shape a future reputation gossip or admin surface would consume.

type ResponseSigner

type ResponseSigner func(ChallengeResponse) ChallengeResponse

ResponseSigner stamps identity, model and signature onto an outgoing challenge response.

func NewResponseSigner

func NewResponseSigner(privKey ed25519.PrivateKey, selfID domain.ID, model domain.ModelType) ResponseSigner

NewResponseSigner builds the production signer: the responder's node key and the model it actually runs (self-reported — audits judge classes, not model claims).

type Result

type Result struct {
	Peer     string
	Outcome  Outcome
	Expected bool // expectUnsafe: true means the honest class is FAIL
	Response ChallengeResponse
}

Result is one finished challenge exchange, kept alongside the raw signed response — the transcript a future reputation gossip can re-verify.

type Standing

type Standing int

Standing is the local trust state derived from a peer's audit history.

const (
	// StandingProbation: not enough evidence yet (the "trainee" period —
	// fresh identities must earn weight before their votes should count).
	StandingProbation Standing = iota
	StandingTrusted
	StandingSuspect
	StandingBanned
)

func (Standing) String

func (s Standing) String() string

type StreamChallenger

type StreamChallenger struct {
	// contains filtered or unexported fields
}

StreamChallenger is the wire side of an audit: it carries a challenge over a Warpnet stream and decodes the answer. It is the only place in this package that knows about routes, streams or encoding — the audit logic sees nothing but Challenger.

func NewStreamChallenger

func NewStreamChallenger(node Streamer) *StreamChallenger

func (*StreamChallenger) Ask

type Streamer

type Streamer interface {
	GenericStream(nodeIdStr string, path stream.WarpRoute, data any) (_ []byte, err error)
}

Streamer is the slice of the moderator node this transport dials with.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL