---
version: 0.21.0
date: 2026-06-18
headline: "The kernel hot path is now O(1) in feature count — the 1000th idea costs the 1st syscall nothing in framework overhead."
themes:
  - perf
  - abi
---

**TL;DR** — The registry read path the kernel walks on every syscall was O(features) with a mutex + heap allocation per call; it is now a single wait-free atomic-snapshot load with zero per-call allocation. Two additive opt-in seams (`EventSubscriber`, `CallScope`) make event fan-out and the adjudicator fold per-kind / per-tool, so adding feature N+1 no longer taxes calls that don't use it. All exported ABI signatures are unchanged; the suite is green.

## Wait-free registry reads (#perf)

- **Registry accessors no longer lock or allocate per call.** Every reader the kernel walks (`Adjudicators`/`FastPaths`/`Emitters`/`ResultAdmitters`/`Witnesses`/`Stewards`/`FoldRank`/`Engine`/`Supported`/…) is now a single `atomic.Pointer` load that indexes a pre-built immutable snapshot.
  - *Why:* the hot path took `reg.mu` and `make([]T, n)` repeatedly per syscall (`FastPaths` per Submit, `Adjudicators` per miss, `Emitters` ~3-5× via `emit()`, `ResultAdmitters` per Reap) — framework overhead grew with feature count, so the 100th observer taxed the 1st syscall. The file even claimed "reads are lock-free in practice"; they were not.
  - *How:* writers (`Register*`, init-time, rare) rebuild one `snapshot` and publish it atomically; readers take no mutex and allocate nothing. `internal/abi/registry.go`.
- **Proven, not asserted.** `TestRegistryReadsZeroAlloc` fails the build if any read path reallocates (0 allocs/op with 256 drivers); `BenchmarkRegistryReadScaling` measures **1.39 ns/op, 0 allocs/op, flat across N=1→1000**.

## Per-kind / per-tool fan-out seams (#abi)

- **Event fan-out is indexed by kind.** `emit()` (called several times per syscall) now runs `O(observers subscribed to this kind)`, not `O(all observers)`. An observer scopes itself with the optional `EventSubscriber{ Subscriptions() }`; one that doesn't is universal (every kind) — the v0.1 default.
- **The adjudicator fold is per-tool, fail-closed.** A rung scopes itself with the optional `CallScope{ Tools() }`, so a call for tool *T* folds only the unconditional rungs plus the rungs scoped to *T* (`abi.AdjudicatorsFor`). A rung that does not implement `CallScope` is unconditional / always-run — the fail-closed default, so the current all-general rung set is unchanged.
  - *Why:* the fold ran every rung on every call — 100 tool-specific policies meant 100 adjudications on every unrelated call.
  - *How:* `internal/abi/registry.go` (snapshot index), `internal/kernel/kernel.go` (`decide`/`BatchDecide`), `cmd/fak/main.go` (hook paths).
- **Both seams are additive — zero existing observers/rungs change.** Backward compat and the safety equivalence are machine-checked: `TestEmittersForDefaultsToAll`, `TestScopedFoldEquivalentToFullChain` (per-tool chain yields the identical verdict to the full chain for every tool), `TestScopedRungRoutesByTool`, and a 500-scoped-rungs scaling proof where an unrelated call folds exactly 1 rung.

## Scaling contract recorded (#perf)

- **`fak/ARCHITECTURE.md` now documents the O(1) hot-path contract** and the rule for the next feature: declare your scope (`CallScope` / `EventSubscriber`) so the 100th tool-specific idea costs an unrelated call nothing.
