Policy as a first-class concept: what nklave's policy engine enforces — and what it doesn't
When people first read nklave’s docs, they sometimes ask: “Why call it a policy engine? Isn’t it just slashing protection?” The answer is that slashing protection is a specific policy. The engine is the abstraction that makes it one rule among several, lets you add your own, and gives every refusal a deterministic reason code you can grep for in the audit log. The architecture decision to make policy first-class — rather than hard-coding slashing rules into the signing path — turns out to matter a great deal once your validator operation has its first odd-shaped requirement.
This post is about what “policy” means in nklave, what the built-in policies actually enforce, where the boundary sits, and how you write your own.
The policy engine in one sentence
The policy engine is the chain of evaluators that runs between an incoming signing request and the signing key. Each evaluator returns Allow or Refuse { reason }. The chain short-circuits on the first refusal. The key is only consulted if every policy in the chain returns Allow.
In Rust:
pub trait Policy: Send + Sync {
fn evaluate(&self, ctx: &SigningContext) -> PolicyDecision;
}
pub enum PolicyDecision {
Allow,
Refuse { reason: String },
}
That is the entire interface. A policy is a single function with read-only access to the signing context. It cannot mutate state, it cannot produce a signature, and it cannot side-step the rest of the chain. It can only refuse, or pass.
The SigningContext it receives carries: the requested validator pubkey, the signing root, the message type, the parsed fork-version, the wall-clock timestamp, and a read-only handle to the slashing-protection database. Read-only is enforced at the type level: a policy that mutated the slashing-protection state during evaluation would be a profoundly bad idea, and the trait makes it impossible.
The four built-in policies
nklave ships with four policies enabled by default. Each is configured under its own subtable in nklave.toml.
slashing-protection-attestation refuses any attestation that would violate the double-vote or surround-vote rules. It compares the requested (source_epoch, target_epoch) against the bounds the slashing-protection DB has recorded for that validator. The full rule set, including the surround-vote arithmetic, is in the slashing-policy docs; the policy implementation is a single SQL SELECT … FOR UPDATE (or RocksDB merge) per request.
slashing-protection-block refuses a beacon-block proposal if a block has already been signed for the same slot. The state required is two columns: proposal_min_slot and proposal_max_slot. For Cosmos / CometBFT validators, the equivalent rule fires on (height, round) — and is implemented in the same policy with chain-aware logic.
fork-allowlist refuses any signing root whose fork version is not in the operator’s configured allowlist. The configuration is a list of hex strings:
[policies.fork-allowlist]
enabled = true
allowed_forks = ["0x05000000", "0x06000000"]
This is the simplest policy on paper and one of the most useful in practice. The most common cause of accidental wrong-network signing is a misconfigured beacon node URL; the fork-allowlist policy catches it because the fork version on a testnet does not match the mainnet allowlist. It is a one-line config that costs nothing and prevents a class of incident.
rate-limit refuses signing requests when the validator’s signing rate exceeds max_signs_per_hour. The default is 240 (roughly four per minute), which leaves comfortable headroom for normal beacon-chain signing duties and catches runaway validator-client behavior — a misconfigured retry loop, a stuck client repeatedly asking for the same signature, or a compromised client trying to burst-sign.
[policies.rate-limit]
enabled = true
max_signs_per_hour = 240 # ~4/min
The rate-limit policy is the only built-in that uses wall-clock state, and its state is local to the process. In an HA deployment, each nklave instance maintains its own counter; the combined fleet rate ceiling is N × max_signs_per_hour for N instances. That is usually fine — the goal is to catch obvious runaways, not enforce a precise global ceiling — but it is worth knowing.
Why “policy” instead of “slashing protection”
We could have built the same four behaviors as hard-coded checks in the signing path, and the result would have looked roughly the same from the outside. We deliberately did not, for three reasons.
Every refusal becomes a named, configurable thing. When slashing-protection-attestation refuses an attestation, the audit log records policy: "slashing-protection-attestation" and the reason. That is greppable. You can ask “how many refusals from this policy in the last week?” and the answer is one query, not a multi-day forensic exercise. The four built-in policies are the public API of nklave’s refusal surface, and naming them that way makes the surface inspectable.
Custom policies are first-class. Operators have constraints that are local to their organization: regulatory cooldowns on withdrawal signatures, validator-group whitelists, signing-window restrictions tied to maintenance schedules. None of those belong in nklave’s core. All of them belong in the same chain as the built-ins, with the same trait, the same context, the same logging treatment.
The chain is a contract. A policy chain that runs in a defined order, short-circuits on refusal, and logs every decision is a contract that both operators and auditors can reason about. Hard-coded checks are not. The chain makes the answer to “what does nklave actually refuse?” be “look at the chain configuration, in order.”
Writing a custom policy
A worked example: suppose your organization wants withdrawal-credential signatures to be subject to a 24-hour cooldown after each request. Whether or not this is a good idea depends on your operational story — but if you decide it is, the policy is short:
use nklave::policy::{Policy, PolicyDecision, SigningContext};
struct WithdrawalCooldown { hours: u64 }
impl Policy for WithdrawalCooldown {
fn evaluate(&self, ctx: &SigningContext) -> PolicyDecision {
if ctx.message_type != "VOLUNTARY_EXIT" {
return PolicyDecision::Allow;
}
let last = ctx.db.last_signed("VOLUNTARY_EXIT", ctx.pubkey);
if last.elapsed_hours() < self.hours {
return PolicyDecision::Refuse {
reason: format!(
"withdrawal cooldown: {}h remaining",
self.hours - last.elapsed_hours()
)
};
}
PolicyDecision::Allow
}
}
// In main.rs
nklave.add_policy(Box::new(WithdrawalCooldown { hours: 24 }));
A few things to notice. The policy is Send + Sync because it runs in nklave’s async runtime; the struct holds no mutable state, only config. The context’s database handle is read-only, but it can answer “when was this validator’s last VOLUNTARY_EXIT request?” because the audit log already tracks that. The refusal carries a human-readable reason that will end up in the audit log verbatim. And the policy short-circuits on any message type other than VOLUNTARY_EXIT — every policy is invoked for every signing request, so being explicit about what you do not care about is just as important as being explicit about what you do.
The policy gets added to the chain at startup, and from then on it sits between the validator client and any voluntary-exit signature, exactly the same way the built-ins do.
What policy is not
There are things “policy” sounds like it should cover, and does not.
Policy is not key access control. Which validator keys are loaded, what custody backend they live in, and what auth tokens are required to reach the API — those are configuration, not policy. The policy chain operates on signing requests that have already been authenticated; it decides whether the signature is safe, not whether the requester is allowed to ask.
Policy is not consensus rules. The four built-in policies enforce the protocol’s slashing-prevention rules, but they do not enforce protocol participation rules. They do not check that an attestation is well-formed beyond the slashing-relevant fields, they do not check beacon-chain state, they do not check that the validator is actually in the committee for the requested slot. Those are the validator client’s job.
Policy is not threat detection. A spike in refusals from slashing-protection-attestation strongly suggests a misconfigured or compromised validator client, but the policy itself does not draw that conclusion. Drawing it is what monitoring is for: alert on rate(nklave_policy_refusals_total[5m]) > 0.5, and let the on-call engineer figure out which client misbehaved.
Policy is not a sandbox. Custom policies run in nklave’s process. A buggy custom policy can crash nklave; a malicious custom policy can leak signing contexts through its logging. The trait is small precisely because the surface needs to be small. Treat custom policies the same way you treat any code that runs in your signer’s address space, because that is what they are.
A useful default chain
For most operators, the default chain — slashing-protection-attestation, slashing-protection-block, fork-allowlist, rate-limit, in that order — is the right starting point. It catches the protocol-level slashing offenses with the cheapest checks first, layers on the configuration-error guard, and ends with the operational guard.
The order matters less than it might seem, because the chain is short and the checks are independent. But the convention is “protocol-mandatory first, organization-policy last,” and we keep it. New built-in policies, if we add them, will slot in by category. Custom policies go on the end unless you have a reason to interleave.
The reason to make policy first-class is that the layered structure is the entire mental model. Once you can talk about it as a chain, the questions get easier — for new operators reading the config, for incident reviews tracing why a signature was refused, and for audits asking what the signer will and will not do. Hard-coding gets you to the same correctness today and a confused conversation in six months. The chain pays off in the second conversation.