Immutable firewall configuration
Current ephemeral counter state (never mutated)
Normalized observation (the sole input surface — CORE-02)
Decision result with updated counter state
import { evaluate, defaultConfig, createState } from '@kehto/firewall';
const config = defaultConfig();
const state = createState();
const obs = {
napplet: 'chat',
opClass: 'relay:write',
focused: true,
now: injectedTimestamp, // caller supplies time; evaluate() never reads a clock
};
const result = evaluate(config, state, obs);
// result.decision === 'pass'
// result.newState has an updated token bucket for 'chat:relay:write'
Evaluate a single firewall observation and return the access decision.
PURE: no wall-clock reads (no system time APIs), no I/O, no mutation. All time comes from
observation.now. The originalconfigandstateare NEVER modified — everynewStateis returned via immutable spread.Precedence order (A1 — POLICY-03, first-match-wins, most→least specific)
Per-napplet policy (
allow/deny/ask) — hard override for the dTag.allow→ pass (bypass everything);deny→ reject (block);ask→ prompt. Policy returns do NOT advance any counters; newState = input state.Init-burst guard — if
observation.initElapsedMsis defined and less thanconfig.burstGuard.windowMs, the burst counter for this napplet is advanced. If the count exceedsconfig.burstGuard.maxOps, the burst action fires (defaultblock). The advanced burst counter is returned in newState.Content matchers —
config.matchersare evaluated in order; the FIRST matcher whose declared conditions (opClass, kinds, size, focus, msSinceFocusGain) ALL hold fires its action. Matchers do NOT advance the token bucket.Per-napplet × op-class rate limit (
config.napplets[napplet].rateLimits[opClass]) with ruleId'rate:opclass'.Per-napplet global rate fallback (
config.napplets[napplet].globalRate) for op-classes with no specific rateLimits entry. ruleId'rate:global'.Global default rate (
config.defaultRate) — applied when no napplet-specific rule exists. ruleId'rate:default'.Unfocused multiplier (A2 — FOCUS-02)
When
observation.focused === false, the effective bucket capacity is tightened:effectiveCapacity = limit.capacity * config.unfocusedMultiplier. Refill rate is derived aseffectiveCapacity / windowMsso the drip also tightens proportionally. The bucket KEY stays stable (napplet:opClass, no focus suffix). Because the multiplier is always> 0, an unfocused napplet's budget is reduced but never zero — focus alone NEVER hard-blocks.