11. umwelt — Policy as a Cascade¶
umwelt lets you author policy the way you author a stylesheet: a world of entities plus
a cascade of rules with specificity, compiled into a DuckDB policy.db that the
enforcers (kibitzer, lackpy) read. One authored source; two consumers; no divergence.
World + stylesheet¶
world (YAML): the entities policy resolves over —
{type, id, classes}, e.g. tools (Read,Edit,Bashwith classdangerous) and modes (implement,review).stylesheet (
.umw): CSS-like rules over those entities:
tool { allow: true; max-level: 8; }
tool.dangerous { max-level: 5; }
mode#review tool { allow: false; } /* in review, deny tools... */
mode#review tool[name="Read"] { allow: true; } /* ...except Read */
mode#implement { writable: "src/,tests/"; strategy: "tdd"; }
Compile and query:
from umwelt.policy import PolicyEngine
eng = PolicyEngine.from_files(world=world, stylesheet=style)
eng.save("policy.db") # the compiled contract
eng = PolicyEngine.from_db("policy.db") # consumers reopen it
eng.resolve(type="tool", id="Bash", property="allow", context={"mode": "review"})
The cascade¶
Resolution follows CSS-like specificity: more specific selectors win, mode-scoped rules
(mode#review tool) apply when that mode is active. resolve returns one property;
resolve with no property returns the whole resolved dict; resolve_all returns every
entity of a type. Mode is passed as context={"mode": ...} (the older mode= kwarg is
deprecated).
Why a cascade. Policy is naturally layered: a global default, a class-based tightening, a per-entity exception, a mode-scoped override. CSS already solved “layered rules with deterministic precedence.” Reusing specificity means policy authors reason about overrides the way they already do for stylesheets, and the resolution is explainable (
traceshows the competing candidates).
One policy, two enforcers¶
The compiled policy.db is read by kibitzer (governs the agent: get_mode_policy,
get_tool_policy) and lackpy (restricts generated programs: UmweltPolicySource). The
invariant — the same policy yields the same verdict on both surfaces — is enforced by a
conformance test that resolves every (mode, tool) three ways (the engine itself, the
kibitzer consumer, the lackpy source) and requires zero divergence. That harness is how a
real bug was caught: lackpy read a flat dict the engine never produced, and it crashed on
the first real policy. The conformance is now the tripwire.
The producer is the source of truth. When you test a consumer of a cascade, drive the real engine to produce the expected verdict; don’t hand-roll fixtures of “what the policy should say.” A fixture encodes your belief; the engine encodes the contract.
Opt-in by design¶
umwelt is opt-in and mutual — nothing in the suite depends on it by default. A consumer
that wants it registers its world (register_matcher/register_entity — umwelt does not
pre-enumerate the entities; the consumer, which knows them, supplies them) and umwelt
supplies resolution. With no consumer registered it degrades gracefully (raw rule scan),
and every consumer keeps a no-umwelt fallback (kibitzer falls back to its config). The power
stays behind the opt-in boundary.