Changelog¶
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning once it
reaches 1.0.0. Before 1.0.0, minor versions may include breaking changes.
[Unreleased]¶
[0.3.0] - 2026-08-18¶
First release published to PyPI. 0.1.0 and 0.2.0 below record earlier
states of the code; neither reached the index, so there is nothing to
upgrade from.
Changed¶
- Renamed from
chartertochokepoint— the distribution, the import package, the CLI entry point and the repository. PyPI refuses to registercharter("this project name isn't allowed"), so it could never be published under that name. The name is kept consistent across all three rather than publishing under one name and importing another.chokepointis the word this project's own architecture notes already used for the evaluation engine — "the one place every guarded tool call actually runs through".CharterInterceptor,CharterRegistry,CharterErrorandCharterWarningbecomeChokepointInterceptor,ChokepointRegistry,ChokepointErrorandChokepointWarning; the OTEL spancharter.evaluatebecomeschokepoint.evaluate; the__charter_*__marker attributes become__chokepoint_*__.
[0.2.0] - 2026-08-18¶
Production-readiness hardening pass, following an adoption audit of the initial implementation.
Tagged but never published: the release job reached PyPI and was rejected,
because the project name it was published under could not be registered. The
contents below ship in 0.3.0 instead.
Added¶
- A typed exception and warning hierarchy (
chokepoint.errors). Every deliberate failure now derives fromChokepointError, so a caller can catch the library's errors without also catching its own bugs. Each class keeps the stdlib exception it used to raise —ConfigurationErroris aValueError,EscalationErroraRuntimeError,LedgerEventNotFoundaKeyError,AdapterErroraTypeError— so existingexceptclauses still match.ConfigurationWarningis used for configurations that are legal but almost certainly a mistake, which alogger.warningleft invisible under the default logging setup. - Per-interceptor
ledger=andredactor=. One process can now run several interceptors with separate audit trails and scrubbing rules instead of every call funnelling into the process-wide singletons. args={...}oncall()/acall(), for passing a tool's arguments explicitly — see the corresponding fix below.__repr__on every public object.repr(policy_set)printed<PolicySet object at 0x7f…>for a library whose central objects exist to be inspected, logged and diffed.ActionLedger.sink_error_count,chokepoint.reset_ledger(),chokepoint.reset_otel(),chokepoint.reset_redaction(),chokepoint.ALL_TOOLSandchokepoint.policiesare now part of the public surface.configure_ledgeris a real documented function rather than a bare classmethod alias, which generated no signature in API docs.- A hand-authored architecture guide (
docs/architecture.md) and a generated API reference (mkdocstrings). The published "Architecture" page was previouslyCLAUDE.mdverbatim — a page titled "CLAUDE.md" that opened by addressing an AI coding agent.
Fixed¶
GuardBlockeddid not survivepickleorcopy. It passeddecision.reasontoException.__init__, and__reduce__replaysargson unpickling — so a round trip through Celery,concurrent.futuresor multiprocessing rebuiltexc.decisionas a barestr, turning any laterexc.decision.reasoninto anAttributeError. It now stores theGuardDecisionitself;__str__still renders the reason, sostr(exc)is unchanged.- A tool argument named
session_idordomaincould never reach its tool. Both are named parameters ofcall()/acall(), so they bound to the interceptor and the call failed with a confusing "missing required argument" — anddomainis an ordinary argument name for a real tool. Arguments can now be passed explicitly asargs={...}, passing a colliding keyword warns withConfigurationWarning, andtool_name/funcare positional-only so those names are free too. All three adapters forward throughargs=, so a wrapped tool is immune by construction. Breaking:wrap_tool-wrapped callables now treat every keyword as a tool argument; they previously consumedsession_id/domainas scope. @guardreported a signature it could not honor.functools.update_wrappersets__wrapped__, soinspect.signature()returned the original signature while the wrapper accepted keyword arguments only. Every framework that introspects a tool to build its JSON schema — LangChain, the OpenAI Agents SDK, MCP — reads that signature and emits positional calls from it, which failed at runtime with "takes 0 positional arguments". Positional arguments are now bound against the real signature.- A failing ledger sink turned an allowed call into a crash. Recording runs
after the guarded tool has executed, so an
OSErrorfrom a full disk or an unwritablesink_pathpropagated out of a call the policies had explicitly allowed — losing the tool's result to protect a copy of a record that was also in memory. Sink failures are now logged and counted insink_error_count. - Secrets survived redaction inside sets, bytes and dict keys. Only
str/Mapping/Sequencewere walked, so a credential in aset,frozenset,bytesvalue or dict key reached the ledger, the JSONL sink and the Slack escalation message verbatim — contradicting the module's stated guarantee.contains_placeholder()walks the same shapes, soreplay()'sredactedflag cannot under-report. configure_redaction(include_pii=True, redact_credit_cards=False)ignored the explicitFalse(it wasredact_credit_cards or include_pii). The parameter now defaults toNoneand followsinclude_piionly when unset.- The async escalation path leaked threads and used a deprecated API. It
called
asyncio.get_event_loop()inside a coroutine and dispatched sync handlers to the default executor; on timeoutwait_forcancels the future but cannot stop the running thread, so an abandoned handler occupied a shared worker and could hangloop.shutdown_default_executor()at exit. It now usesget_running_loop()and a disposable pool, matching the sync path. Both paths copy the caller'scontextvars, so a handler readingcurrent_scope()sees the identity that triggered the escalation. allow_sample_rate=0.0could still sample.random()can return exactly0.0and the comparison was<=. Sampling also used the sharedrandommodule, silently consuming the process-wide random stream; it now uses a private, lock-guarded RNG.pick_decision()broke ties by registration order. Between two rules that both BLOCK, the severity recorded on the ledger event depended on which policy happened to be registered first. Ties within a precedence level are now broken by severity.- A schemeless
escalate_tosilently blocked everything.resolve_handler()matches on the URI scheme, soescalate_to="security-team"matched nothing and fell through to the fail-safe denier. Construction now warns, andregister_handler()rejects a "scheme" that is itself a URI. - A tool named
"*"double-counted against rate limits.CallStateused"*"as the dict key for a session's total; the total now lives in its own counter. - A corrupt line aborted a whole JSONL ledger read.
chokepoint report --ledgernow skips unparseable lines with a warning — the sink is an append-only log a process can be killed partway through writing. - The escalation-handler registry and the OTEL instrument cache were unsynchronized, unlike every other shared structure in the package.
Changed¶
ChokepointInterceptor's constructor options pastmodeare keyword-only, so their order is no longer frozen.@guardpreserves the wrapped function's types viaParamSpecinstead of returningCallable[..., Any]. The package shipspy.typed; a decorator that erased types made downstream checking worse than not using the library.AgentScopedPolicy.policy_hashis memoized, and composite policy hashes cache keyed on their children's hashes. The former recomputed a SHA-256 on every guarded call, whichPolicySetalready avoided.current_redactor()no longer takes a lock to read one module global on the path of every recorded event.- The linter's
Severityis nowLintSeverity(the old name still resolves).chokepoint.Severityis a differentLiteralwith disjoint values, and two exported types sharing a name is a trap. - CI installs the library with no extras in a dedicated job, and runs on
macOS and Windows and Python 3.14. Every graceful-degradation path was
previously unreachable in CI, and
path_within— built onPath.resolve(), whose semantics differ per platform — had only ever been tested on Linux. Coverage is gated, lockfile drift fails the build, wheels are smoke-tested andtwine checked, releases are SHA-pinned and attested, and CodeQL,pip-auditand dependency review run on every change. budget_policycould not express an LLM token budget.amount_fromwas evaluated at both hooks, and at the pre-hookctx.resultis stillNone— so the naturallambda ctx: ctx.result["usage"]["output_tokens"]raisedTypeError, fail-closed, and blocked every call. Addedactual_from, read only in the post hook. Withactual_fromalone the pre-hook check becomes "is the budget already exhausted?", so the cap is stop once spent rather than never exceed — inherent to not knowing a price before paying it. Passing both bounds the overshoot: the estimate gates the call, the actual figure supersedes it when charging.budget_policy()with neither argument now raisesValueErrorinstead of silently doing nothing.dry_run/observefired real escalation side effects._engine.pycalled_resolve()— which contacts the escalation handler — before testingmode == "enforce", so a supposedly no-op rollout would post to Slack, hit the approval webhook, or block oninput()for up totimeout_sper call, exactly the opposite of whatexamples/dry_run_rollout.pypromises. Outsideenforce, the engine now records the ESCALATE it would have raised (reason suffixed"(escalation not resolved — ... mode)") without contacting any handler; the audit trail is unchanged. Breaking (behavior): a registered handler is no longer invoked indry_run/observe. Code relying on that side effect must switch toenforce.delegation_chainhad two contradictory conventions, so direct delegations drew no graph edges. The registry and_build_scope()treated the chain as ancestors-only, whilereport/graph.py'szip(chain, chain[1:])and the ledger's documented["orchestrator", "executor_agent"]shape assumed it included the acting agent — a parent→child hop is a one-element tuple under the former, which yields zero edges._is_cross_agentnever fired on a single hop either.ChokepointInterceptornow appends its ownagent_idwhen building the scope (leaving an already self-inclusive chain alone, so theexamples/multi_agent_orchestrator.pyworkaround keeps working), anddelegation_depth()counts hops (len - 1) rather than entries — so every existingmax_delegation_depth_policythreshold keeps its meaning. Callers register ancestors only; everything downstream reads the full path.report --delegation --format jsonsilently emitted mermaid instead of erroring, hiding the fact thatdelegation_graph()has no JSON writer. It now exits with a message naming the supported formats. The JSON report also serializesPolicyStatsviadataclasses.asdictrather thanvars().- ALLOW events were sampled twice, and ESCALATE spans used the wrong rate.
_record_allow()rolled againstallow_sample_ratefor the ledger, thenevaluate_span()rolled again at the same rate — the effective span rate wasallow_sample_rate², and the ledger and the traces disagreed about which events survived. Separately,evaluate_spansampled anything that wasn't a BLOCK atallow_sample_rate, so dialing allows down silently thinned ESCALATE spans too. Sampling is now a single roll via the newotel.spans.should_sample(), passed intoevaluate_span(sampled=...), and every non-ALLOW decision samples atblock_sample_rate. ChokepointInterceptorwas not thread-safe._build_scope()read-modify-wrote_step_counters(get→+1→move_to_end→ possiblepopitem) with no lock, so one interceptor shared across request threads — the normal server shape — produced duplicate or skippedstep_indexvalues, and concurrentpopitemcould raise._wrapped_toolswas mutated unlocked too. Both are now guarded by a per-interceptor lock, held only for the dict updates and never across policy evaluation or the tool call.- Process-global registries leaked between tests and had no way to be
cleared. Added
unregister_handler()/registered_handlers()/reset_handlers()for the escalation registry,registered_adapters()/reset_adapters()/register_default_adapters()for the adapter registry, and madereset_otel()also drop the cached metric instruments (which bound to whichever meter provider was live when first created).register_adapter()now replaces a same-typed adapter instead of stacking duplicates, and its docstring no longer claims registration order when the behavior is most-recent-first. - A post-BLOCK on an action with no
undo_fnrecorded a successful undo.ReversibleAction.undo()silently no-ops whenundo_fn is None, but the engine recordedundo_op="<name>.undo"andundo_executed=Trueregardless — a false success in the audit trail at exactly the moment nothing was reverted. The engine now checks the newReversibleAction.is_undoable, recordsundo_op=None, appends"(no undo_fn configured — action NOT reverted)"to the reason, and logs a warning. - A tool raising after being authorized left no ledger entry at all.
invoke()was called bare, so an exception skipped every post-hook and the whole recording step: an authorized call that ran and failed was invisible to an audit. The engine now records adecision="ERROR",hook="invoke"event carrying the exception type, message and full caller identity, then re-raises unchanged.LedgerEvent.decisiongained"ERROR"andhookgained"invoke";Decision/GuardDecisionare unchanged, since this is not a policy decision. policy_hashwas never populated in any ledger event or span. The field existed onLedgerEvent, thechokepoint.policy_hashspan attribute was emitted, andPolicySet.policy_hashwas implemented and tested — but the engine built everyGuardDecisionwithout it, so the value was permanentlyNone.RuleResultnow carriespolicy_hash, stamped byPolicySet.evaluate()/AgentScopedPolicy.evaluate()and propagated through the engine. An aggregate ALLOW reports the hash only when exactly one policy contributed.PolicySet.policy_hashis memoized (invalidated byrequire()) since it is now read on the hot path.GuardDecision.rule_resultswas never populated, so simultaneous failures vanished. Only the single worst rule survivedpick_decision(); if three rules failed at once, the audit trail recorded one. The engine now attaches every failing rule, andLedgerEventgainedcontributing_rules: list[ContributingRule]recording each one's policy, reason,on_fail, severity and hash.- The ledger's
sink_pathwas unreachable through the public API.ActionLedger.current()built its lazy singleton with no arguments and nothing could replace it, so a hand-constructedActionLedger(sink_path=...)was never the ledger the engine wrote to — the documented "full lossless history requiressink_path" was not actually achievable. AddedActionLedger.configure(sink_path=..., max_events=...), exported aschokepoint.configure_ledger, plus a read-onlysink_pathproperty.current()is now created under a lock (two threads racing on the first call each built a ledger, and one set of events was silently lost). ReversibleAction(irreversibility_level="high")could never escalate. Its intrinsicRuleResultwas built withescalate_to=None, whichresolve_handlermaps to the fail-safe denier — so"high"was in practice a synonym for"permanent", contradicting the documented "auto-escalates before every execution".ReversibleActionnow takesescalate_toandtimeout_sand propagates both into the intrinsic check.chokepoint lintgained a warning for a"high"action with noescalate_to, fed by an optional module-levelACTIONS: list[ReversibleAction].NotPolicy(~policy) incorrectly blocked on hooks the child policy doesn't apply to.PolicySet.evaluate()always includes oneRuleResultper matching rule, whether it passed or failed — an empty result unambiguously means "not applicable" (inactive, or no rules registered for this hook), never "applicable and everything passed".NotPolicy.evaluate()didn't make that distinction: a child with onlyhook="pre"rules produced an empty (not-applicable) result on the"post"hook, whichNotPolicymisread as "child raised no violation" and synthesized a block for — so~policywould spuriously block on hooks the underlying policy was never even meant to run on. Found while writingexamples/policy_composition.py. Now: an empty child result makesNotPolicynot-applicable too ([]).
Added¶
-
Token and cost policies for LLM tool calls (
chokepoint.policies.cost):token_budget_policy(dollars per session),token_limit_policy(raw tokens), plus thetoken_cost/token_count/extract_usagebuilding blocks.extract_usagereads the Anthropic (input_tokens), OpenAI (prompt_tokens) and Google (promptTokenCount) shapes, from mappings or SDK objects, and treats a missing usage block as zero rather than failing closed. Prices are parameters quoted per million tokens — no pricing table ships, because a stale constant in a security library would silently mis-bill. -
Redaction of secrets and PII on the way into the audit trail (
chokepoint.redaction). Tool arguments previously reached the in-memory ledger, the JSONL sink on disk, the JSON/CSV exports and the Slack escalation message completely verbatim, so an agent passing an API key leaked it into all four at once. Breaking (default behavior): credential-shaped values and values under names likepassword/api_key/authorizationare now replaced with[REDACTED]before being recorded. Free-text fields (reason,undo_op,contributing_rules[].reason) are scrubbed too, since a fail-closed predicate folds exception text — which routinely quotes the offending argument — into the reason. Redaction happens at record time: policies still evaluate against the realctx.args, or a predicate written to check a credential could not check it. PII patterns (email, SSN, IBAN, IP, formatted phone, Luhn-validated card numbers) are opt-in. Configure withchokepoint.configure_redaction(enabled=..., keys=..., include_pii=..., extra_patterns=..., redactor=...); pass your ownRedactorto delegate to an existing DLP service. Consequence:replay()rebuilds its context from the stored event, so replaying a redacted call evaluates policies against placeholders.ReplayResult.redactedflags this and logs a warning, andfixtures_from_eventsemits@pytest.mark.skipfor those events rather than generating tests that cannot pass. - Escalation summaries are capped at
MAX_ARGS_CHARS(2,000). Slack rejects a message over 40,000 characters outright, so a tool with a large payload would previously turn every escalation on it into a silent delivery failure. - CLI:
--version,report --fail-under, and anexportcommand.reportalways exited 0, so it was useless as a CI gate;--fail-under RATIOnow exits non-zero when tool coverage falls below the threshold (the report is still printed, so CI logs show what failed).chokepoint export --format json|csv|narrative|fixtures [--output PATH]reachesexport_compliance_report,narrative()andfixtures_from_events(), none of which had a CLI command before.linttakes--agentlike every other subcommand, still accepting its historical positional form. chokepoint.policies— a library of ready-made policies. Previously every policy was something you wrote from a blank lambda, so each project re-derived the same handful of rules. Shipsno_secrets_in_args,no_destructive_sql,no_destructive_shell,path_within,domain_allowlist,rate_limit_policyandbudget_policy, each returning an ordinaryPolicySetthat composes with&/|/~. All taketool_namesfor scoping and fail closed on a missing argument;path_withinresolves before comparing (so..and symlink escapes are caught) anddomain_allowlistmatches the parsed hostname (soexample.com.evil.comcan't slip past anexample.comentry).CallState— cross-call counters and spend accumulators (chokepoint.state), backing the rate-limit and budget policies that were previously listed as deferred. Lives besideGuardContextrather than in it: a lock-guarded, LRU-bounded object injected into theExecutionScopelike the existing checksum/consent providers, read throughctx.calls_this_session()/ctx.spent()/ctx.record_spend(). Replay reconstructs a scope without one, so history-dependent policies report zero instead of reading live counters.ChokepointInterceptorgained acall_stateparameter — pass a shared instance to enforce one quota across several agents.- MCP adapter (
chokepoint[mcp]), guardingtools/callon both sides of the protocol:guard_mcp_sessionwraps aClientSession(a denial raisesGuardBlocked),guard_mcp_serverwraps the server's registeredtools/callhandler (a denial returnsCallToolResult(isError=True), since an exception escaping a request handler would tear down the connection for every later request). Auto-detected byinterceptor.use()/chokepoint.wrap(). New exampleexamples/mcp_integration.pyand tests against real MCP objects over an in-memory transport. Both mcp 1.x and 2.x are supported, detected from the installed package rather than configured: 2.0 renamedFastMCPtoMCPServer, moved the low-level handle to_lowlevel_server, replaced the request-type-keyed handler table with a method-keyed one behindget_request_handler/add_request_handler, changed the handler contract to(ctx, params) -> CallToolResult, and renamed the result flag tois_error.guard_mcp_sessionalso accepts 2.x'sClientfacade and guards the session underneath it. - Escalation metrics, which did not exist at all:
chokepoint.escalations_total(attributed withoutcome—approved/denied/not_resolved— plus policy, tool andescalate_to) andchokepoint.escalation_latency_ms. An escalation is the one decision that puts a human in the request path, so its rate, approve/deny split and wait time are the highest-value operational signals Chokepoint can emit. chokepoint.delegation_depthis now actually emitted.record_delegation_depth()existed and was documented but no call site ever invoked it. The engine now records it per call, attributed to the calling agent.- Richer OTEL spans:
chokepoint.tool(previously absent — spans could not be grouped by tool in a backend),chokepoint.session_id,chokepoint.step_index,chokepoint.trust_level, and an ERROR span status on an enforced BLOCK so denials surface in a trace UI's error views (not set indry_run/observe, where nothing was actually denied). - Real
EscalationHandlerimplementations underchokepoint.escalation:SlackEscalationHandler(posts viachat.postMessage, pollsreactions.getfor a ✅/❌ from an allowlisted approver —approversis a required constructor argument, not optional),WebhookEscalationHandler(one synchronous POST, expects{"approved": bool}back), andCLIEscalationHandler(localinput()-based human-in-the-loop). All three use onlyurllib.request/stdlib — no new dependency or pyproject extra. Each takes its owntimeout_sconstructor parameter (independent of any given rule's owntimeout_sfrom@guard(timeout_s=...)) —Slack/Webhookbound their actual wait tomin(self.timeout_s, rule_result.timeout_s);CLIEscalationHandler's is informational only (stdlibinput()can't be cleanly interrupted mid-call). Exported fromchokepoint/__init__.py. New exampleexamples/real_escalation_handlers.py(Slack section mocks the Slack API in-process; webhook section runs a real local HTTP server; CLI section uses scripted input — all three genuinely runnable without external credentials) and new tests undertests/escalation/. - Real LangGraph and OpenAI Agents SDK adapters, replacing the
NotImplementedErrorskeletons —adapters/langgraph.pywrapslangchain_core.tools.BaseToolobjects (the type every LangGraph tool is) through.invoke()/.ainvoke();adapters/openai_agents.pywrapsagents.FunctionTool.on_invoke_tool. Both accept a barelist[Tool](wrap before constructing the graph/agent) or an object with.tools, and are registered by default (src/chokepoint/adapters/__init__.py, new) —interceptor.use(agent)/chokepoint.wrap(agent, interceptor)now auto-detect them, no manualregister_adapter()call needed. New optional dependency groupschokepoint[langgraph]andchokepoint[openai-agents]. New examplesexamples/langgraph_integration.pyandexamples/openai_agents_integration.py, and new tests undertests/adapters/(skipped gracefully viapytest.importorskipwhen the extras aren't installed) exercising the real framework objects, not mocks. - 8 new runnable examples under
examples/:quickstart.py,policy_composition.py(&/|/~),dry_run_rollout.py,custom_escalation_handler.py,reversible_levels.py,delegation_chain.py,multi_agent_orchestrator.py(a centralized registry with 4 agents, role + trust-level policies, and an exported delegation graph),audit_and_reporting.py. See the table inREADME.md's new "Examples" section. - Async support.
@guardandChokepointInterceptor.wrap_tool()/.use()auto-detect anasync deftool function (or aReversibleActionwith an asyncdo_fn) viainspect.iscoroutinefunctionand dispatch to a new async evaluation engine (_engine.evaluate_call_async) — same decorator, no@guard_async.ChokepointInterceptor.acall()is the async sibling of.call().ReversibleAction.do_fn/undo_fnand a customEscalationHandler.escalatemay bedeforasync def; predicates (pre/post,active_when,applies_to) remain sync-only. Seeexamples/async_tool.pyandCLAUDE.md's "Async" section. Addspytest-asyncioas a dev dependency. LICENSE(Apache-2.0),CONTRIBUTING.md,SECURITY.md, and a GitHub Actions CI workflow (lint/typecheck/test across Python 3.11–3.13, then build).pyproject.tomlnow declareslicense,classifiers,keywords, and[project.urls].chokepoint.__version__, sourced from installed package metadata.ActionLedger.dropped_count— how many in-memory events have been evicted sincemax_eventswas reached (see Changed, below).ChokepointInterceptor(max_sessions=...)bounds_step_countersmemory with LRU eviction (default 10,000 sessions).decisions.Severity(Literal["high", "medium", "low"]), replacing plainstronRuleResult.severity,GuardDecision.severity,LedgerEvent.severity, and theseverity=parameters onPolicySet.require(),AgentScopedPolicy, and@guard— catches typos (severity="hihg") undermypy --strict; no runtime behavior change.
Changed¶
- Breaking (default behavior):
ActionLedgernow bounds its in-memory event list tomax_events=10_000by default (a ring buffer — the oldest event is evicted once full). Passmax_events=Noneto restore the old unbounded behavior.sink_path, if configured, still captures every event losslessly regardless of the in-memory cap. - A policy predicate (
pre/post,active_when,applies_to) that raises an exception no longer crashes the tool call it was guarding. It now fails closed: the rule is treated as aBLOCK(severity"high", regardless of the rule's own declaredon_fail), andactive_when/applies_toraising is treated as "policy is active" (never silently skipped). Both are logged vialogging.getLogger("chokepoint.policy"). EscalationHandler.escalate()'stimeout_sis now actually enforced by the engine (previously advisory-only): a handler that hangs or raises is denied withintimeout_s, rather than blocking the tool call indefinitely. A sync handler that turns out to beasync defand is used via the sync engine is explicitly detected and denied, rather than silently approved (bool(coroutine)is alwaysTrue).- If
ReversibleAction.undo()itself raises during a post-BLOCK auto-undo, the ledger event for that decision is still recorded (previously, an exception inundo_fnwould prevent the ledger write entirely, losing the audit trail for exactly the moment that most needed one). ChokepointInterceptor(otel_tracer=...)is now actually wired to the evaluation engine (previously accepted but silently ignored).otel/config.py's global settings reassignment (configure_otel()/reset_otel()) is now guarded by a lock, for correctness under concurrent calls from multiple threads at startup.
[0.1.0]¶
Initial implementation: @guard, PolicySet/AndPolicy/OrPolicy/
NotPolicy, ReversibleAction, GuardContext, ChokepointInterceptor
(enforce/dry_run/observe modes), ActionLedger (JSON/CSV export, DOT/Mermaid
policy and delegation graphs, natural-language narrative, replay),
ChokepointRegistry/AgentScopedPolicy/delegation helpers for multi-agent
authorization, OpenTelemetry spans and per-event metrics, a policy linter, a
ledger-driven pytest fixture generator, a synthetic-context policy REPL, and
the chokepoint CLI (report/lint/replay/repl).