Skip to content

ADR-0086: Local tool controls and session authorization observation

  • Status: Accepted
  • Kind: Retrospective
  • Area: governance
  • Date: 2026-07-09
  • Relations: supersedes v0-0068, v0-0069, v0-0070

Context

LionAGI ships useful controls around tool execution, but they grew at different layers and do not form one governance system. This ADR records those controls as they are. It deliberately does not reinterpret operational signals, mutable logs, or data adapters as evidence-backed governance.

P1 — Permission decisions have a local, untyped policy vocabulary. Agent construction can attach a PermissionPolicy to tool preprocessors. Its result is a mutable dataclass whose behavior field is a string, and enforcement is ultimately a PermissionError. There is no policy version, operation ID, or durable decision identifier (lionagi/agent/permissions.py).

P2 — Security behavior depends on factory wiring. AgentSpec.coding() adds destructive command and workspace-path guards when secure=True. create_agent() converts a configured permission policy into a security hook, attaches hooks while registering local tools, and orders security hooks around user argument transforms. The Tool itself does not know whether the factory performed this wiring (lionagi/agent/spec.py; lionagi/agent/factory.py; lionagi/tools/coding.py).

P3 — The callable boundary has processors, not an authorization protocol. Tool exposes optional pre- and postprocessors. FunctionCalling invokes them around the callable and the generic Event lifecycle captures ordinary exceptions as a failed event. A preprocessor can enforce a local rule, but the result is not a typed verdict and alternate ActionManager entry points do not consult session authorization (lionagi/protocols/action/tool.py; lionagi/protocols/action/function_calling.py; lionagi/protocols/action/manager.py; lionagi/protocols/generic/event.py).

P4 — Session authorization is a separate optional plane. A SessionObserver can hold one caller-supplied gate. The action operation asks the branch to authorize a ToolInvocation before it invokes ActionManager. A false result or an exception denies; a missing observer or gate allows. The gate has no declared identity, level, reason code, or policy provenance (lionagi/session/control.py; lionagi/session/observer.py; lionagi/operations/act/act.py).

P5 — Observation is lossy by design. The same observer manages lifecycle signals, subscriptions, routes, and optional persistence to session_signals. Persistence is a subscriber and is explicitly best-effort. Payloads may be reduced or truncated, and a persistence failure cannot change a run outcome (lionagi/session/signal.py; lionagi/session/observer.py; lionagi/state/schema.sql; lionagi/state/db.py).

P6 — Adjacent facilities have narrower contracts. The adapter package converts data representations. DataLogger holds mutable operational logs. MemoryStore stores caller content. completion_evidence probes local Git state to decide whether a run appears to have produced work. None supplies an immutable evidence chain, historical policy lookup, or process certificate (lionagi/adapters/_base.py; lionagi/protocols/generic/log.py; lionagi/protocols/memory.py; lionagi/state/completion_evidence.py).

Concern Decision
Local permission vocabulary D1: Retain PermissionPolicy as a per-tool, preprocessor-based control with its shipped matching semantics.
Factory-installed guards D2: Retain construction-time guard wiring: an explicit PermissionPolicy gets the security → user → security composition; the built-in coding guards register in the ordinary pre bucket and run once.
Callable interception D3: Treat Tool processors and FunctionCalling as local invocation mechanics, not a universal governance boundary.
Session authorization D4: Retain the optional, fail-closed-when-present session callback and its denied action response.
Signals and persistence D5: Treat observer flows and session_signals as best-effort operational observation only.
Adjacent facilities D6: Keep adapters, logs, memory, and completion evidence within their shipped narrow meanings.

This ADR does not decide:

  • The evidence-backed target architecture. ADR-0087 owns that aspirational design.
  • Authenticated actor identity or cross-caller isolation. No shipped control establishes either property.
  • Policy authoring syntax or an external policy service. The current policy is an in-process Python object.
  • A guarantee over direct calls to arbitrary Python functions. Only LionAGI invocation paths described below are in scope.
  • A new persistence or migration format. The SQL shown here records the current observer table, not a proposed evidence schema.

Decision

D1 — PermissionPolicy remains a local preprocessor policy

The shipped permission contract is two dataclasses and a pre-hook adapter (lionagi/agent/permissions.py):

@dataclass
class PermissionDecision:
    behavior: str  # "allow" | "deny" | "escalate"
    tool_name: str
    action: str
    reason: str
    matched_rule: str | None = None


@dataclass
class PermissionPolicy:
    mode: str = "allow_all"  # "allow_all" | "deny_all" | "rules"
    allow: dict[str, list[str]] = field(default_factory=dict)
    deny: dict[str, list[str]] = field(default_factory=dict)
    escalate: dict[str, list[str]] = field(default_factory=dict)
    on_escalate: Callable | None = None

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> PermissionPolicy: ...

    @classmethod
    def allow_all(cls) -> PermissionPolicy: ...

    @classmethod
    def deny_all(cls) -> PermissionPolicy: ...

    @classmethod
    def read_only(cls) -> PermissionPolicy: ...

    @classmethod
    def safe(cls) -> PermissionPolicy: ...

    def check(self, tool_name: str, action: str, args: dict) -> PermissionDecision: ...

    def to_pre_hook(self) -> Callable: ...

from_dict() reads mode, allow, deny, and escalate; missing values default to "allow_all" and empty rule maps. It does not restore an on_escalate callable. Rule-map keys normalize these aliases at construction time:

_TOOL_ALIASES = {
    "bash_tool": "bash",
    "reader_tool": "reader",
    "editor_tool": "editor",
    "search_tool": "search",
    "context_tool": "context",
}

Exact semantics:

  • mode == "allow_all" returns allow immediately. It does not build a match string and therefore does not reject shell-control operators on its own.
  • mode == "deny_all" returns deny immediately.
  • Every other mode value follows rules-mode behavior; the dataclass does not validate mode against a closed enum.
  • Rules mode canonicalizes the requested tool name, then builds one match string. Bash uses str(args.get("command", "")); editor uses file_path; reader uses path; search joins pattern and optional path; other tools join the action and argument values in mapping iteration order.
  • In rules mode, a bash command containing a token matched by _SHELL_CONTROL becomes a deny decision before wildcard rules are considered.
  • Matching is by fnmatch against the original text or a lowercase copy. "*" always matches.
  • All deny rules are checked first, then all allow rules, then all escalation rules. Tool-specific rules precede "*" rules within each tier. The first match supplies matched_rule.
  • No match returns PermissionDecision("deny", ..., "no matching rule, default deny").
  • to_pre_hook() returns an async hook. Allow returns None. Deny raises PermissionError. Escalate calls on_escalate(decision, args) when present: literal True allows, a dict replaces the downstream arguments, and every other result denies. With no handler it raises PermissionError explaining that escalation is required.
  • Empty arguments are not special. They yield empty match strings for the named coding tools; the configured rules determine the outcome, with rules mode defaulting to deny.
  • The policy is in-memory configuration. Reconstructing an agent after process restart requires supplying the policy again; AgentSpec.to_yaml() does not serialize it.

The presets are convenience constructors, not stronger policy types. read_only() allows reader, search, and context and denies editor and bash. safe() allows the non-bash coding tools, denies five bash pattern families, and escalates all remaining bash commands. The exact five safe() patterns are rm *, sudo *, chmod *, kill *, and mkfs *.

Why this way: the dataclass and pre-hook conversion make a policy usable without changing Tool or ActionManager. They also preserve zero-configuration behavior through allow_all. The source records no stronger rationale for the string vocabulary or preset rule sets, so this ADR does not infer one.

D2 — Factory wiring chains policy and guard hooks per tool

The relevant construction-time shape is the AgentSpec dataclass (lionagi/agent/spec.py):

@dataclass
class AgentSpec(HooksMixin):
    profile: Profile
    model: str | None = None
    effort: str | None = None
    tools: tuple[str, ...] = ()
    permissions: PermissionPolicy | None = None
    grant_emissions: bool = True
    emits: tuple | None = None
    pack: str | Pack | None = "default"
    lion_system: bool = True
    extra_prompt: str | None = None
    hook_handlers: dict[str, list[Callable]] = field(default_factory=dict)
    cwd: str | None = None
    yolo: bool = False
    mcp_servers: list[str] | None = None
    mcp_config_path: str | None = None
    context_management: bool = True

    @classmethod
    def coding(
        cls,
        *,
        model: str | None = None,
        effort: str | None = "high",
        system_prompt: str | None = None,
        cwd: str | None = None,
        secure: bool = True,
        context_management: bool = True,
        **kwargs: Any,
    ) -> AgentSpec: ...

Hook registration uses string keys in hook_handlers:

def pre(self, tool_name: str, handler: Callable) -> HooksMixin: ...
def post(self, tool_name: str, handler: Callable) -> HooksMixin: ...
def on_error(self, tool_name: str, handler: Callable) -> HooksMixin: ...

# Stored keys:
# pre:<tool>, post:<tool>, error:<tool>, and factory-added security_pre:<tool>

The factory entry point and pre-hook compositor are (lionagi/agent/factory.py):

async def create_agent(
    config: AgentSpec,
    *,
    load_settings: bool = True,
    project_dir: str | None = None,
    trust_project_settings: bool = False,
    trusted_hook_modules: set[str] | frozenset[str] | None = None,
    chat_model: Any = None,
    log_config: Any = None,
) -> Branch: ...


def _chain_pre_hooks(
    tool_name: str,
    security_hooks: list[Callable],
    user_hooks: list[Callable] | None = None,
) -> Callable | None: ...

The effective preprocessor is equivalent to:

user_hooks = user_hooks or []
hooks = [*security_hooks, *user_hooks]
if user_hooks:
    hooks.extend(security_hooks)

async def chained(args: dict, **_kw) -> dict:
    for handler in hooks:
        result = await handler(tool_name, args.get("action", ""), args)
        if isinstance(result, dict):
            args = result
    return args

Exact semantics:

  • permissions is None adds no policy hook. A PermissionPolicy is inserted at the front of security_pre:*; unsupported runtime values in the field are ignored by _apply_permissions.
  • AgentSpec.coding(secure=True) registers guard_destructive for bash and one guard_paths instance for reader and editor (_wire_secure_guards in lionagi/agent/spec.py; the guards are defined in lionagi/agent/hooks.py). These register through .security_pre() into the security_pre:<tool> bucket, so the built-in guards participate in the security → user → security composition the same way an explicit PermissionPolicy does. The path guard's only allowed root is cwd, or Path.cwd() when cwd is absent. secure=False skips these three registrations.
  • Only hooks in the security_pre:<tool>/security_pre:* bucket participate in the security → user → security composition; the factory writers of that bucket are _apply_permissions (an explicit PermissionPolicy) and _wire_secure_guards (the built-in coding guards). Those security hooks run before user pre-hooks, and when at least one user pre-hook exists the same security chain runs again against the final transformed argument mapping; with no user pre-hook it runs once.
  • Consequently, a user hook that rewrites command or a path argument after a security hook has passed is rechecked against the final transformed mapping — for the built-in coding guards as well as an explicitly configured PermissionPolicy (tests/agent/test_coding_preset_guard.py pins guard_destructive to the security_pre:bash bucket and asserts the post-mutation recheck).
  • A hook return that is a dict replaces the argument mapping for later hooks and the callable. Any other non-exception result leaves the current mapping unchanged.
  • guard_destructive(tool_name, action, args) inspects only args["command"] when present. It rejects the compiled destructive pattern set with PermissionError; an empty or unmatched command returns None.
  • guard_paths(allowed_paths, denied_paths) returns an async hook. No path or file_path allows. Relative paths resolve under the first allowed root; absolute paths resolve directly. Allowed roots accept the root itself or descendants. Absolute denied paths reject the path or descendants; relative denied glob patterns match path components and plain relative text matches the raw path or resolved filename.
  • Paths use Path.resolve(strict=False), so normalization occurs even when the target does not exist.
  • Factory hooks attach while local tools are registered. MCP tools loaded later by _load_mcp() are registered directly on ActionManager and do not pass through _attach_hooks; the factory policy must not be claimed to govern that later registration path.
  • AgentSpec.to_yaml() explicitly omits hook callables and also omits permissions. A process restart or YAML round-trip therefore does not preserve these runtime controls unless the caller supplies them again or loads them from trusted settings.

Why this way: the second security pass closes the concrete mutation gap in which a user hook turns allowed arguments into denied arguments after the first check. Both an explicit PermissionPolicy (_apply_permissions) and the built-in coding guards (_wire_secure_guards) register into the same security_pre bucket, so both get the same security → user → security recheck — delta row 1's "each configured control runs exactly once" contract, closing the former asymmetry in which only an explicit PermissionPolicy was re-run after a mutating user pre-hook, is resolved on main. Reusing Tool.preprocessor keeps the mechanism local to existing tool invocation. The cost is construction-path coupling: a tool registered outside that path does not inherit the controls automatically.

D3 — Tool processors are the local callable interception mechanism

Tool is a Pydantic Element with these invocation-relevant fields (lionagi/protocols/action/tool.py):

class Tool(Element):
    func_callable: Callable[..., Any] = Field(..., exclude=True)
    mcp_config: dict[str, dict[str, Any]] | None = None
    tool_schema: dict[str, Any] | None = None
    request_options: type | None = None
    preprocessor: Callable[[Any], Any] | None = Field(default=None, exclude=True)
    preprocessor_kwargs: dict[str, Any] = Field(default_factory=dict, exclude=True)
    postprocessor: Callable[[Any], Any] | None = Field(default=None, exclude=True)
    postprocessor_kwargs: dict[str, Any] = Field(default_factory=dict, exclude=True)
    strict_func_call: bool = False

FunctionCalling supplies the executable event (lionagi/protocols/action/function_calling.py):

class FunctionCalling(Event):
    func_tool: Tool = Field(..., exclude=True)
    arguments: dict[str, Any] | BaseModel

    @property
    def function(self): ...

    async def _invoke(self) -> Any: ...

    def to_dict(self, *args, **kw) -> dict[str, Any]: ...

The manager exposes the independent route (lionagi/protocols/action/manager.py):

class ActionManager(Manager):
    registry: dict[str, Tool]

    def match_tool(
        self,
        action_request: ActionRequest | BaseModel | dict,
    ) -> FunctionCalling: ...

    async def invoke(
        self,
        func_call: BaseModel | ActionRequest,
    ) -> FunctionCalling: ...

The annotation on invoke() is narrower than runtime behavior: it forwards to match_tool(), and tests exercise dictionaries successfully. This ADR records the accepted runtime input as ActionRequest | BaseModel | dict while retaining the source annotation above as shipped.

Exact semantics:

  • A Tool must have exactly one callable source: a Python callable or a one-entry MCP config. Providing both, a non-dict MCP config, or multiple MCP entries fails model validation.
  • When tool_schema is absent, it is generated from the callable and optional request_options model. strict_func_call=True requires exactly the schema's required field set; non-strict mode requires only Python parameters without defaults.
  • A Pydantic argument model is dumped with exclude_unset=True. request_options, when set, validates and dumps the arguments again before invocation.
  • Invocation order is preprocessor → callable → postprocessor. The postprocessor is reached only after the callable returns successfully.
  • Async detection uses is_coro_func. A synchronous callable or processor that returns a coroutine object is not automatically awaited.
  • Event.invoke() changes PENDING to PROCESSING, captures ordinary Exception values in execution.error, sets FAILED, records duration, and does not re-raise. BaseException sets CANCELLED and is re-raised.
  • Consequently, PermissionError raised by a tool preprocessor becomes a failed FunctionCalling. It is not the same outward shape as a session-gate denial. On the action path, the error remains in func_call.execution.error; because _act() does not call assert_completed(), its response payload is func_call.response, normally None when the preprocessor failed before the callable.
  • Empty arguments are valid only when the callable has no required fields. Unknown tools fail in match_tool() before a FunctionCalling is created.
  • ActionManager.invoke() does not consult Branch.authorize() or SessionObserver. A caller holding branch.acts can invoke a registered tool directly and bypass the session plane.
  • func_callable, preprocessors, postprocessors, and their kwargs are excluded from serialized Tool output. Serialization is not a restartable enforcement record.

Why this way: processors are general transformation hooks and fit schema validation plus sync/async callables without adding another manager. Their generality is also the limitation: they have no common verdict or evidence contract and cannot make an authorization claim about routes that do not use the configured Tool instance.

D4 — Session authorization is optional and fail-closed only when installed

The proposal passed to the gate is a frozen dataclass (lionagi/session/control.py):

@dataclass(frozen=True, slots=True)
class ToolInvocation:
    function: str
    arguments: dict = field(default_factory=dict)
    branch_id: str | None = None

The public installation and authorization surface is (lionagi/session/session.py; lionagi/session/observer.py; lionagi/session/branch.py):

def Session.gate(self, check: Callable) -> SessionObserver: ...
def SessionObserver.gate(self, check: Gate) -> SessionObserver: ...
async def SessionObserver.authorize(self, action: Any) -> bool: ...
async def Branch.authorize(self, action: Any) -> bool: ...

The action operation constructs the proposal and denial payload (lionagi/operations/act/act.py; lionagi/operations/fields.py):

class ActionResponseModel(HashableModel):
    function: str = Field(default_factory=str)
    arguments: dict[str, Any] = Field(default_factory=dict)
    output: Any = None


ToolInvocation(
    function=_request["function"],
    arguments=_request["arguments"] if isinstance(_request["arguments"], dict) else {},
    branch_id=str(branch.id),
)

{"error": "denied by governance gate", "function": _request["function"]}

Exact semantics:

  • SessionObserver.gate(check) stores exactly one callable and returns the observer. A later call replaces the previous gate; there is no built-in composition.
  • A branch included in a Session receives that session's observer. Removing the branch clears the observer reference. A standalone branch has no observer.
  • Branch.authorize() returns True when no observer exists. SessionObserver.authorize() returns True when no gate exists.
  • With a gate, the observer calls it through maybe_await, converts its result with bool(), and treats every raised Exception as False. It does not preserve a reason or exception in the returned boolean.
  • On denial, authorize() adds GateDenied(data=action) directly to the observer's in-memory Flow and returns False.
  • _act() performs authorization before branch._action_manager.invoke(). Denial therefore creates no FunctionCalling and does not invoke the tool callable.
  • A denied action is represented as ActionResponseModel.output containing the fixed error dictionary above. The action request and denial response are added to branch chat history so a subsequent reasoning round can observe the denial.
  • When the incoming arguments are not already a dictionary, the gate sees {} even though the original argument object remains on the later invocation path. The session gate therefore must not claim inspection of non-dict argument models.
  • Direct ActionManager.invoke() and direct calls to the underlying Python function do not execute this session check. An unattached Branch also permits by definition.
  • The installed callback and GateDenied flow are in memory. They are not restored after a process restart.
  • The same _gate callable is also used by SessionObserver.emit(), but emit() passes an event payload rather than a ToolInvocation. Callers that use both surfaces must accept both input shapes themselves.

Why this way: an optional observer callback adds a small pre-invocation stop without changing plain Branch construction. Returning a denial as a tool result lets iterative operations react instead of treating policy denial as an unexpected Python exception. The trade-off is that the check is tied to _act(), not to the callable boundary.

D5 — Signals are observation, not authority

Signals inherit identity, timestamp, and metadata from Element and add this envelope (lionagi/protocols/generic/element.py; lionagi/session/signal.py):

class Element(BaseModel, Observable):
    id: UUID = Field(default_factory=uuid4, frozen=True)
    created_at: float = Field(default_factory=lambda: now_utc().timestamp(), frozen=True)
    metadata: dict = Field(default_factory=dict)


SIGNAL_SCHEMA_VERSION: int = 1


class Signal(Element):
    data: Any = None
    emitter_role: str | None = None
    schema_version: int = SIGNAL_SCHEMA_VERSION


class GateDenied(Signal):
    pass

Observer and persistence contracts (lionagi/session/observer.py):

_PAYLOAD_BYTE_CAP: int = 16_384

async def SessionObserver.emit(self, event: Any) -> list[Any]: ...

def SessionObserver.bind_db_persistence(
    self,
    session_id: str,
    db: Any = None,
) -> None: ...

def SessionObserver.unbind_db_persistence(self) -> None: ...

The optional table is (lionagi/state/schema.sql; mirrored by lionagi/state/schema_meta.py):

CREATE TABLE IF NOT EXISTS session_signals (
  id          TEXT    PRIMARY KEY,
  session_id  TEXT    NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  seq         INTEGER NOT NULL,
  kind        TEXT    NOT NULL,
  op_id       TEXT    NOT NULL DEFAULT '',
  ts          REAL    NOT NULL,
  payload     JSON    NOT NULL DEFAULT '{}'
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_session_signals_seq
  ON session_signals(session_id, seq);
CREATE INDEX IF NOT EXISTS idx_session_signals_session_ts
  ON session_signals(session_id, ts);

Exact semantics:

  • emit() wraps a non-Observable object in Signal(data=event). For a Signal, filters and the event gate see signal.data; other observable events are their own payload.
  • When an observer gate exists, a false result or exception denies dispatch. The event is still added to the in-memory Flow, but routes and subscribers are skipped and emit() returns an empty list.
  • Persistence is one ordinary Signal subscription. Therefore a gate-denied emitted signal is not persisted because subscriber dispatch is skipped.
  • authorize() adds GateDenied directly to the flow rather than calling emit(). That denial does not reach routes, subscribers, or the database persistence subscription.
  • bind_db_persistence() persists only Signal instances. With a supplied database handle it reuses that handle. Without one it opens the default state database only if the path exists.
  • Every persistence exception is swallowed. Missing database state, serialization fallback, or insert failure cannot alter the producing run.
  • Signal payloads are converted to JSON-safe data. MessageAdded stores only a compact message_ref with available identity/role endpoints, not the message body. Unknown values fall back to string or repr forms.
  • The payload column is capped at 16,384 serialized UTF-8 bytes. Oversize content becomes a dictionary with truncated, original_bytes, and a clipped data string. The cap bounds the stored payload; the surrounding SSE frame can be larger. The code records the bounding purpose but no rationale for choosing exactly 16 KiB.
  • insert_session_signal() assigns a one-based seq as MAX(seq)+1 inside the write transaction. SQLite uses its immediate transaction serialization; PostgreSQL takes an advisory transaction lock by session ID. Reads use seq > after_seq, ascending order, and a default page limit of 500. Pagination bounds one read response; the source records no rationale for selecting exactly 500.
  • Rows disappear when their parent session is deleted because of ON DELETE CASCADE. The table is append-oriented operational history, not immutable evidence.
  • On restart, persisted rows remain when the selected database remains, but the observer flow, subscriptions, routes, and gate must be rebuilt.

Why this way: the observer bus prioritizes lifecycle progress and debugging without allowing telemetry failures to fail work. Payload reduction and best-effort writes are appropriate for that role. Those same properties preclude completeness, immutability, and authority claims.

D6 — Adapters, logs, memory, and completion evidence keep narrow meanings

The generic adapter protocol converts representations (lionagi/adapters/_base.py):

@runtime_checkable
class Adapter(Protocol[T]):
    adapter_key: ClassVar[str]
    obj_key: ClassVar[str]

    @classmethod
    def from_obj(
        cls,
        subj_cls: type[T],
        obj: Any,
        /,
        *,
        many: bool = False,
        adapt_meth: str | Callable = "model_validate",
        adapt_kw: dict[str, Any] | None = None,
        **kw: Any,
    ) -> T | list[T]: ...

    @classmethod
    def to_obj(
        cls,
        subj: T | list[T],
        /,
        *,
        many: bool = False,
        adapt_meth: str | Callable = "model_dump",
        adapt_kw: dict[str, Any] | None = None,
        **kw: Any,
    ) -> Any: ...

It defines no invocation wrapper, policy input, or execution lifecycle.

Operational logging is configurable and mutable (lionagi/protocols/generic/log.py):

class DataLoggerConfig(BaseModel):
    persist_dir: str | Path = "./data/logs"
    subfolder: str | None = None
    file_prefix: str | None = None
    capacity: int | None = None
    extension: str = ".json"
    use_timestamp: bool = True
    hash_digits: int | None = Field(5, ge=0, le=10)
    auto_save_on_exit: bool = True
    clear_after_dump: bool = True

DataLogger.dump() clears entries after a successful dump when configured, and some JSON serialization failure paths also clear unless the caller explicitly passes clear=False. DataLogger.adump() snapshots under a lock, writes outside it, and clears only snapshot IDs after a successful write. capacity=None deliberately leaves collection size unbounded until a caller sets a cap. The five-digit filename hash default and accepted zero-to-ten range reduce filename collisions, but the source records no rationale for those exact numbers. These are operational retention choices, not append-only evidence.

The memory protocol stores and searches caller content (lionagi/protocols/memory.py):

@runtime_checkable
class MemoryStore(Protocol):
    async def store(self, item: MemoryItem) -> UUID: ...
    async def retrieve(self, item_id: UUID) -> MemoryItem | None: ...
    async def search(self, query: MemoryQuery) -> list[MemoryItem]: ...

It has no compare-and-append, ordered chain read, historical policy version, or verification operation.

Completion evidence is a local Git-state result (lionagi/state/completion_evidence.py):

_GIT_TIMEOUT = 5


class CompletionEvidence(TypedDict):
    checked: bool
    ahead_of_base: bool | None
    commits_ahead: int | None
    dirty: bool | None
    base_ref: str | None
    reason: str


def check_completion_evidence(
    cwd: str | None,
    *,
    base_ref: str | None = None,
) -> CompletionEvidence: ...


def has_completion_evidence(evidence: CompletionEvidence) -> bool: ...

Exact completion semantics:

  • The probe runs fixed local Git commands with no shell and a five-second timeout per command. The timeout exists to bound a local completion check; no recorded rationale selects exactly five seconds.
  • Missing cwd, a non-repository directory, a failed status probe, or a failed decisive revision-count probe returns checked=False. has_completion_evidence() then returns false, meaning no opinion rather than proof of no work.
  • Base resolution tries a caller value, the remote default symbolic ref, then origin/main, origin/master, main, and master.
  • With no resolvable base, the probe can still return checked=True and report working-tree dirtiness while ahead_of_base and commits_ahead remain None.
  • Evidence is positive when the checked repository is ahead of the resolved base or dirty. It carries no tool-call decisions, policy identity, chain hash, or verification method.

Why this way: each facility solves a concrete local need—representation conversion, operational logging, recall, or completion signaling. Reusing their names for governance would broaden their contracts without changing the behaviors that make them non-authoritative.

Shipped component boundaries

flowchart LR
    AS[AgentSpec] --> AF[create_agent]
    PP[PermissionPolicy] --> AF
    GH[Built-in and user hooks] --> AF
    AF --> TL[Configured Tool]
    ACT[act operation] --> SO[SessionObserver authorize]
    SO -->|allow| AM[ActionManager]
    SO -->|deny| DR[Denied action response]
    AM --> FC[FunctionCalling]
    TL --> FC
    FC --> PY[Python or MCP callable]
    SO --> FL[In-memory Flow]
    SO -. allowed Signal subscriber .-> SS[(session_signals)]

The tool-local path and the session path meet only because _act() performs session authorization before invoking the configured Tool. Neither component owns the other.

Execution sequence as shipped

sequenceDiagram
    participant Caller
    participant Act as act operation
    participant Obs as SessionObserver
    participant AM as ActionManager
    participant FC as FunctionCalling
    participant Pre as Tool preprocessor
    participant Tool as Python callable

    Caller->>Act: action request
    Act->>Obs: authorize(ToolInvocation)
    alt no observer or no gate
        Obs-->>Act: true
    else gate false or raises
        Obs-->>Act: false + in-memory GateDenied
        Act-->>Caller: ActionResponseModel with denial payload
    else gate allows
        Obs-->>Act: true
        Act->>AM: invoke(request)
        AM->>FC: create and invoke
        FC->>Pre: security → user → security
        alt preprocessor raises
            FC-->>AM: FAILED event with execution.error
        else processors allow
            FC->>Tool: call once
            Tool-->>FC: result or exception
            FC-->>AM: COMPLETED or FAILED event
        end
        AM-->>Act: FunctionCalling
        Act-->>Caller: response field as action output
    end

Consequences

Positive consequences:

  • D1 gives library callers allow-all, deny-all, rules, safe, and read-only choices without a policy service.
  • D2 protects factory-wired coding tools before invocation; both a configured PermissionPolicy and the built-in guards are rechecked after user transforms.
  • D3 preserves one processor mechanism for both synchronous and asynchronous callables.
  • D4 lets an attached session deny a proposed action before ActionManager and returns a form iterative operations can observe.
  • D5 provides typed lifecycle observation, in-memory routing, and optional ordered persistence without making telemetry a run dependency.
  • D6 keeps generic infrastructure small and independently useful.

Negative and maintenance consequences:

  • D1 and D4 expose different result types, reason behavior, and configuration owners. A contributor debugging a denial must identify which plane fired.
  • D2 controls are properties of the constructed Tool instances. New registration paths must opt into the same factory wiring or remain outside its coverage.
  • D3 captures processor exceptions inside FunctionCalling, while D4 returns a denial payload before a FunctionCalling exists. Consumers cannot treat the two paths as one verdict.
  • D4 can be bypassed through direct manager invocation or a standalone branch. Reversing this requires moving enforcement to the callable boundary, not merely adding another session callback.
  • D5 cannot support an audit completeness claim: authorization denials do not pass through persistence, gated emissions skip subscribers, payloads can be truncated, writes can fail silently, and parent-session deletion cascades.
  • D6 means a future evidence system needs its own protocol and store rather than a mode flag on an adjacent facility.

Cost of reversal by decision:

Decision Reversal cost
D1 Medium: existing policy constructors and hook behavior are public library surface and need adapters or compatibility shims.
D2 Medium: hook ordering is regression-tested; changing it can reopen transformed-argument bypasses.
D3 High: the processor/callable lifecycle is shared by all tool invocation and MCP wrappers.
D4 Medium: denial response and standalone-allow behavior are visible to action callers and tests.
D5 Low for adding a separate authoritative store; high for trying to make the observer table itself authoritative.
D6 Low if new governance types remain separate; high if generic adapter, log, or memory protocols are widened.

Current-vs-ideal delta

# Delta Size Issue
1 Define one immutable GateResult contract and adapters for PermissionPolicy, built-in guards, and the session gate; accept when each configured control runs exactly once, an evaluator exception produces a recorded deny result, and every security-relevant control (built-in coding guards included) evaluates the final post-mutation arguments — closing the current asymmetry in which only an explicit PermissionPolicy is re-run after a mutating user pre-hook. Security-relevant. M Resolved on main (lionagi/agent/factory.py::_chain_pre_hooks)
2 ActionManager.invoke() is already the de facto single controller for every route that reaches it: grep finds exactly one production call site (operations/act/act.py:90), and FunctionCalling is constructed only inside ActionManager.match_tool()/_resolve_plugin_tool(), never elsewhere in lionagi/. The remaining work is not building a new chokepoint but closing the registration/resolution paths that still reach a Tool without the controller's attached security controls: MCP-discovered tools skip _attach_hooks() on main (a fix is in flight, unmerged); the public branch.acts property lets a caller invoke a registered tool directly, skipping _act()'s session-gate check; and _resolve_plugin_tool() builds a fresh, preprocessor-less Tool per call by design, so it never receives the spec-level chain. Whether session-gate enforcement moves inside ActionManager.invoke() itself or stays an external pre-check at _act() is an open design fork, not decided here. L (filled at issue-open time)
3 Add an append-only evidence store and verifier independent of DataLogger and session signals; accept when permit, denial, callable failure, and completion records verify as one chain, mutation is detected, and an append failure blocks governed execution. L (filled at issue-open time)
4 Bind an immutable policy snapshot and operation provenance to governed execution; accept when concurrent calls cannot exchange gate results, every evidence record names the active policy version, and historical snapshots remain retrievable. L (filled at issue-open time)
5 Mint a process certificate only at an explicit task boundary; accept when missing evidence, failed verification, or unavailable policy history prevents minting and an approved exception remains permanently marked degraded. L (filled at issue-open time)
6 Project governance evidence into observer signals without making signals authoritative; accept when projections carry evidence identifiers, export failure cannot alter evidence, and ordinary lifecycle persistence remains best-effort. M (filled at issue-open time)

Alternatives considered

Keep only tool hooks as the long-term governance design

This would reuse the smallest shipped mechanism and preserve local enforcement. It lost because hooks do not share a typed verdict, policy version, operation identity, or authoritative record; they also cannot describe callable failures after a preprocessor has returned. Alternate tool registration and invocation routes remain a concrete coverage gap.

Make the session gate the sole mandatory authorization layer

This would produce one obvious callback at session scope and retain the explicit denied action response. It lost because standalone branches permit, removed branches lose the observer, direct ActionManager.invoke() does not consult the observer, and one callback is also reused for heterogeneous event payloads. Moving the same callback to more call sites would still leave no typed result or evidence chain.

Treat session_signals as the evidence store

This would reuse ordered per-session sequence numbers and an existing database table. It lost because the persistence handler is best-effort, gate-denied emissions skip subscribers, authorization GateDenied values never call emit(), payloads are capped and can be lossy, and session deletion cascades to the rows. Those are observed storage semantics, not missing documentation.

Treat DataLogger as the evidence store

This would reuse existing file persistence and immutable-on-deserialization Log entries. It lost because live logs are mutable collection members, dumping normally clears them, some serialization failures may also clear them, and there is no atomic chain append or verifier.

Treat MemoryStore as the evidence store

This would provide an injected protocol and an in-memory reference backend. It lost because store/retrieve/search is caller-content storage with no ordered append, expected-head conflict rule, immutable policy history, or tamper verification. Widening it would couple recall and governance retention.

Reinterpret the generic adapter protocol as an execution wrapper

This would reuse a registry and from_obj/to_obj extension points. It lost because adapters are stateless representation conversion. Adding invocation, authorization, and evidence lifecycle semantics would violate that stable boundary and force unrelated adapters to understand tools.

Use completion evidence as an audit record

This would reuse a simple, local completion signal. It lost because the payload answers only whether a Git worktree is dirty or ahead of a base. Probe failure deliberately means “no opinion,” and no field identifies a tool call, policy decision, or causal chain.

Change the unconfigured default to deny

This would reduce accidental execution when a caller forgets configuration. It lost for the retrospective contract because plain Branch and AgentSpec construction have established zero-configuration behavior. ADR-0087 instead makes fail-closed behavior conditional on an explicit governed binding, preserving ordinary library use.

Notes

The two authorization planes are independent, not layered guarantees. A call can pass a session gate and fail a tool-local preprocessor, or bypass the session plane and still encounter a preprocessor. No current record binds both outcomes to one operation or policy snapshot.

The public runtime defines no policy-scope, identity-isolation, evidence-chain, certificate, or governance-tracing contract. That absence is part of the retrospective truth, not a proposal to fill all adjacent concerns in this ADR.