# Runtime internals reference

Terse, per-module reference for invariants, protocol contracts, and non-obvious design
rationale that used to live as long-form docstrings/comments in the runtime source
(`lionagi/state/`, `lionagi/service/`, `lionagi/providers/`, `lionagi/tools/`,
`lionagi/agent/`, `lionagi/testing/`, `lionagi/dispatch/`, `lionagi/hooks/`). The source
now carries a 1-2 line pointer; this file carries the substance. Organized by module path.

## lionagi/state/

### `state/engine.py`

- `make_readonly_engine()` — read-only `AsyncEngine` over an **existing** SQLite file, opened
  through SQLite's own URI read-only mode (`mode=ro`), not through `make_engine()`. This
  matters because: (1) no schema/PRAGMA write ever reaches the file — the OS-level open is
  read-only and SQLite raises on any write attempt; (2) none of `make_engine()`'s mutating
  connect-time PRAGMAs (`journal_mode`, `synchronous`, `wal_autocheckpoint`) are applied here
  on purpose, since they persist into the database file itself; (3) only `busy_timeout`
  (session-scoped, never persisted) and `query_only` (belt-and-suspenders — SQLite itself
  rejects any write statement) are set. SQLite only — a genuinely read-only Postgres
  connection should use a read-only DB role instead; there is no equivalent "connect without
  side effects" mode to fake at this layer for Postgres.
- `has_wal_reset_fix()` — SQLite documents a data race between a starting checkpoint and a
  commit that resets the WAL file: the checkpoint misses the reset, mis-sets a WAL-index
  header field, and a later checkpoint then skips part of the committed transaction,
  corrupting the database. It reaches every WAL-mode release from 3.7.0 up to and including
  3.51.2, fixed in 3.51.3 with backports on the 3.44 and 3.50 branches. Exposure needs two
  connections writing or checkpointing at the same instant, which is exactly what this store
  does — hence the startup warning when an unfixed SQLite is linked.

### `state/health.py`

`classify_session_health()` — pure function; `process_alive` is tri-state: `True` = observed
alive, `False` = confirmed dead (positive evidence — a recorded pid that is no longer
running), `None` = unknown (no recorded pid and no process match, the normal case for
externally-driven sessions mirrored into the DB). Decision order matters:

1. Terminal sessions (`completed`/`failed`/`timed_out`/`aborted`/`cancelled`): done means done
   unless stale locks were left behind (→ `ZOMBIE`). `has_artifacts` alone is not zombie
   evidence — artifacts are a *good* outcome; stale locks are the operational signal.
2. Orphan check runs first among the non-terminal branches: a session advertised but never
   producing a single message AND no artifacts on disk crashed before doing anything;
   transitioning it to `failed` is harmless, deleting it is also safe.
3. Confirmed dead (`process_alive is False`) outranks the activity guard below it — the
   process is gone no matter how fresh the last message is.
4. Unknown liveness (no matchable pid) trusts recent messages more than process visibility,
   because externally-driven sessions (mirrored into the DB from another process) never expose a
   matchable pid — an unmatched process only means dead once activity has also gone quiet
   past the kind-aware threshold.

### `state/transitions.py`

Guarded compare-and-swap state transitions (ADR-0059) — a minimal
counterpart to the entity-agnostic `transition()` API proposed in ADR-0058. Carries the
same request/result shape and reason-code discipline so ADR-0058 can absorb it as a
refactor, not a migration. Scoped to `entity_type='dispatch'` (`dispatch_outbox`) and
`entity_type='schedule_run'` (`schedule_runs`) only — not a general TransitionStore. The
guarded read/CAS/vocabulary/write algorithm itself lives in `lionagi.state.lifecycle` (shared
with `StateDB.update_status()`); this module keeps its own narrower entity-type boundary, its
`guard`/`patch` column allowlist, and the legacy `TransitionResult` return shape.

- `_ENTITY_TABLES` — `"schedule_run"` is ADR-0071 D2's generalized task-application entity
  (`schedule_runs` table, `schedule_id` now nullable), registered here so ALL status movement
  on it routes through this guarded CAS store rather than a second, parallel implementation.
- `_GUARD_PATCH_COLUMNS` — guard/patch column names are interpolated directly into SQL text
  (values stay bound params) inside the lifecycle service. Production call sites pass
  literal dicts, but this module is a generic surface, so a per-entity allowlist closes
  the latent injection surface instead of trusting the caller's dict keys outright.
- `transition()` — `UPDATE ... WHERE id=:id AND status=:from`, writing the row status and an
  atomic `status_transitions` append inside one transaction. A mismatched current state
  reports a conflict rather than raising or silently overwriting (the CAS guard). An
  undeclared status move (per the shared policy registry's edge graph) raises `ValueError` —
  this surface has no override mechanism, unlike `StateDB.update_status()`. `guard` adds extra
  `column = :expected` equality constraints to the WHERE clause beyond `status` — required
  whenever a transition can be a same-state no-op (e.g. `delivering -> delivering` recovery
  claims), where the status guard alone would match trivially and let two concurrent callers
  both believe they won the claim; callers pass the value they read *before* the transition as
  the expected guard value, and only the caller whose guard value still matches at UPDATE time
  wins. `patch` adds extra `column = :value` assignments to the SET clause, applied atomically
  with the status change and the `status_transitions` append — for callers (e.g. an
  operator-forced retry resetting attempt counters) that would otherwise need a second,
  non-atomic write.
- `"rejected"` is unreachable through this surface: `run_legacy_transition()` passes
  `raise_on_undeclared_edge=True`, so an undeclared edge (terminal or not) raises `ValueError`
  above rather than resolving to `"rejected"`, and `TransitionRequest` carries no override
  field to trigger the override path either.

### `state/claude_mirror.py`

Mirrors Claude Code session transcripts (`~/.claude/projects/*.jsonl`) into StateDB. A Claude
Code session is just another writer to `state.db`: each JSONL event maps to one or more
lionagi messages, written under a session/branch/progression with deterministic ids so
re-reading the same transcript never duplicates rows. The studio SSE reader polls the same
tables, so mirrored sessions stream live in the dashboard and the VS Code extension with no
studio-side change.

- `mirror_session()` — re-calling with already-seen events is a no-op: message ids are
  deterministic (upsert), and progression appends dedupe. Creates the session/branch on first
  call with a rich row (project, model, agent_name) so it groups correctly in the runs
  explorer. Live/idle transitions are owned by `reconcile_session_status`, not this writer.
  Scaffold writes (progressions → session → branch) are `INSERT OR IGNORE` and re-run every
  call in dependency order, so if an earlier pass died mid-scaffold (e.g. the branch write
  raised after the session row committed) the next pass repairs the partial state instead of
  skipping scaffolding just because the session row now exists. The project-backfill branch
  (`elif project and not existing.get("project")`) exists because `INSERT OR IGNORE` never
  updates an existing row — without it, an already-seen "(no project)" session stays that way
  forever; the backfill write does not disturb the liveness clock.
- `reconcile_session_status()` — a mirror session's `completed` means dormant, not terminal:
  when the transcript resumes, the next reconcile brings it back to `running`. Liveness is
  judged by `last_message_at` (the timestamp of the newest mirrored message) so an idle
  session converges to `completed` before the reaper can mark it failed, and an active one
  shows `running` (a live spinner in studio and the VS Code extension). **It must NOT read
  `updated_at`**: the status write below bumps `updated_at`, so keying liveness off it would
  let a just-marked `completed` session read as fresh again on the next pass and oscillate
  back to `running`. ADR-0035's integrity floor treats every session terminal status (not just
  `completed`) as terminal on the sessions table for orchestrated runs, so reactivating a
  mirror session out of any of them goes through the sanctioned override path — a real,
  deliberate, well-understood write (not a repair), attributed to the recorded automated
  override identity, landing in `admin_events` like any other override. A mirror session
  that is idle and already sitting on a non-`completed` terminal status (e.g. independently
  marked `failed` or `cancelled`) is left alone rather than rewritten to `completed` — only a
  live transcript resuming can justify pulling it back to `running`.
- `link_session_lineage()` — a continued conversation (after compaction, `--resume`, or a
  fresh window picking up an earlier thread) starts a new transcript whose first message
  points, via `parentUuid`, at the last message of the session it continues. When the mirror
  resolves that pointer to a different session it stores a `lineage` link on the child's
  `node_metadata` so studio and the VS Code extension can show provenance and walk the chain
  back. Written without moving the liveness clock; idempotent (re-linking rewrites the same
  value).

### `state/completion_evidence.py`

Completion-trust gate: cheap, local, no-network evidence that a run produced something. A
session can exit its loop cleanly with nothing to show for it — no commits, no artifacts, no
diff — and stamping that `completed` makes the status meaningless as evidence of delivered
work. This module gives the teardown path a lightweight git-based
signal to fall back on when no artifact contract caught the emptiness: is HEAD ahead of the
base ref, or does the working tree carry uncommitted changes? A probe that actually runs and
fails (transient error, timeout, git hiccup) must never be read as "ran and found nothing" —
that would silently turn a git error into a false `completed_empty` on real work. Only a probe
that *succeeds* is allowed to report an absence of evidence; any decisive failure bails the
whole check out as unchecked (`checked=False`) so the caller keeps trusting `completed`.

### `state/lifecycle/`

Unified lifecycle transition service: one guarded read-check-write-history
algorithm shared by every managed entity type's status transitions,
replacing per-surface transition logic. Public surface: immutable
command/result records (`models`), the policy registry (`policy`), the
SQLAlchemy transaction implementation (`service`), and the
StateDB/legacy-transition compatibility mapping (`adapters`).

- `service.py` — `transition()` is the public entry point, enforcing the
  policy's declared-edge graph: an undeclared move is a "rejected" outcome
  with a rejection audit row, not a raise, so callers get the same outcome
  shape for terminal-exit and undeclared-edge refusals alike; a valid
  override is the audited escape hatch for either. `_transition()` accepts
  additional keyword-only parameters outside the public `TransitionCommand`
  shape, used only by `lionagi.state.lifecycle.adapters` to keep the two
  legacy compatibility wrappers behaviorally identical to their
  pre-existing selves: `extra_guard` (an arbitrary per-column WHERE-clause
  guard, e.g. dispatch's `delivering -> delivering` crash-recovery claim
  guarding on `attempt`, which the public typed command has no generic
  field for), `enforce_edges` (`StateDB.update_status()` never enforced a
  declared-edge graph, only terminal-exit-requires-override and vocabulary
  membership, so it calls with `enforce_edges=False`; the legacy
  `lionagi.state.transitions.transition()` did enforce one, for
  schedule_run, so it calls with `enforce_edges=True`), and
  `raise_on_unguarded_conflict` (an unguarded zero-row UPDATE is a storage
  anomaly `StateDB.update_status()` has always raised `RuntimeError` on,
  from inside the transaction so a same-transaction rollback still occurs).
  A self-edge's `required_guard_fields` must be satisfied by either
  `extra_guard` covering those exact columns or a generic
  `expected_version` guard (`updated_at`, which the write always bumps) —
  either is an equally strong optimistic-concurrency guard against two
  callers holding the same snapshot both winning a crash-recovery claim;
  missing both is a caller-contract violation, so it raises rather than
  returning a conflict/rejected outcome. Commit (via the `_tx()` context
  exit) happens strictly before the terminal-callback registry push, so a
  handler can never delay, observe-before-commit, or roll back the write.
  `_write()`'s `write_reason_columns` mirrors legacy per-surface behavior:
  `StateDB.update_status()` always denormalized the reason onto the
  entity row's own `status_reason_*` columns (the default); the legacy
  `lionagi.state.transitions.transition()` surface never did, and
  `dispatch_outbox` (only reachable through that surface) doesn't even
  have those columns.
- `callbacks.py` — a `RunTerminalEnvelope` is constructed by the lifecycle
  service only after a guarded transition commits and lands on a terminal
  status for an execution entity (session, invocation, schedule_run, play);
  the registry then pushes it to every matching handler concurrently under
  one shared deadline (best-effort — a handler failure, timeout, or
  cancellation is logged and swallowed, never affecting the already-committed
  transition or delaying the caller past budget). Within `schema_version ==
  1` the envelope's guaranteed fields never change name/type/semantics/
  requiredness; new optional fields may be added without a version bump.
  `register()`'s `override` marks a per-run override: for any envelope it
  matches, only override registrations fire, replacing any non-override
  match for that run's scope only — other envelopes the override doesn't
  match are unaffected. In `emit()`'s handler fan-out, a plain
  (non-async-def) handler is offloaded to a worker thread rather than run
  directly (which would block the event loop and starve the shared
  `move_on_after` deadline); `abandon_on_cancel=True` is required (not the
  default) so the deadline can still cut the await short without waiting for
  the thread to finish on its own — the thread itself is only abandoned, not
  killed, and may keep running in the background after return.
- `notify_settings.py` — resolves `notify.on_terminal` (a string
  compatibility form, or a mapping `{enabled, adapter: {kind: exec|python,
  ...}, filter: {kinds, ids}}`) into a handler installable on a
  `TerminalCallbackRegistry`. Precedence is per-run override > project
  settings > global settings > disabled; absent key and explicit `enabled:
  false` are both disabled. No configuration shape ever reaches a shell: a
  plain command string is POSIX-word-split (`shlex.split`) and launched via
  `asyncio.create_subprocess_exec`, never `create_subprocess_shell`; a
  string that fails to split or needs shell features (pipes, redirection,
  conjunction, variable expansion) warns with a migration diagnostic and
  resolves to disabled, as does any resolution producing an empty argv. A
  resolution error never fails or delays the run it would have described. In
  the exec adapter, on cancellation the registry's own shared deadline races
  the call's identical `wait_for` timeout (the outer one started first and
  typically wins); either way the child (launched with
  `start_new_session=True`, its own process group) must be reaped or it
  orphans a live subprocess, so cleanup runs inside a shielded `CancelScope`
  since the enclosing scope is already cancelled.
- `deliveries.py` — acknowledgment is durable state written only by a named
  reconciliation consumer, never by the in-process push path
  (`TerminalCallbackRegistry` stays fire-and-forget and records nothing
  here). The reconciliation query is a read-only anti-join: terminal
  transitions on execution entities with no delivery row yet for the
  requesting consumer, with no age filter on either side — a late-committing
  older row, or an event from a long-offline consumer, stays unacknowledged
  indefinitely (this module never expires an unacked event on its own).
- `schema_meta.py`'s `session_controls` table — apply/stamp ordering is
  verb-classed: `pause`/`resume` are idempotent (apply, then stamp — safe to
  re-apply on a poller crash); `message` is not (stamp `'applying'`, then
  apply, then finalize — a crash surfaces as an unapplied `'applying'` row
  rather than risking a double injection). `'stop'` is schema-reserved and
  rejected by the current poller as unsupported; no CLI verb emits it yet.
- `policy.py`'s `ImmutableEdgeMap` — deliberately not a `dict` subclass: dict's
  C-level mutators reach the underlying storage without going through
  Python-level overrides (`dict.__setitem__(m, ...)`, inherited `__ior__`,
  re-invoking `__init__`), so a subclass can never actually guarantee
  immutability. Wrapping a private dict behind the `Mapping` interface leaves
  no inherited mutation surface at all — no `__setitem__`, `update`, or
  `__ior__` to reach, and re-invoking `__init__` is refused — while
  `pickle`/`copy.deepcopy` still round-trip via `__reduce__` (reconstructing
  through the constructor) and `dataclasses.asdict()` deep-copies the map
  rather than raising. `PolicyRegistry.register()` wraps every policy's edge
  map this way before storing, so a caller holding a policy from `get()`
  cannot mutate global transition behavior for the process.
- `notify_settings.py` stderr/argv redaction contract — a `notify.on_terminal`
  adapter's argv routinely carries secrets (webhook URLs, tokens passed as
  args), and its stderr is adapter-controlled free text whose most common leak
  shape is the adapter echoing its own invocation back on failure. No surface
  (warn-channel line, persisted `notify_outcome.json`, or the log) carries raw
  argument values or an unfiltered stderr line: adapters are identified by
  `argv[0]`'s basename, and any argument value appearing verbatim in a stderr
  or exception snippet is replaced (longest values first, so a substring never
  leaves a partial value behind) before that snippet goes anywhere. This is
  not a general secret scanner — a secret the adapter obtains elsewhere and
  prints cannot be recognized. Argument values shorter than
  `MIN_REDACTABLE_ARG_LEN` (4 chars) are never redacted, since replacing them
  would corrupt unrelated text (a bare `-v` or `0` occurs everywhere). Raw
  adapter stderr is captured to an owner-only file and referenced by path
  instead of surfacing directly, for the same reason.
- `notify_settings.py`'s `register_run_notify_outcome_scope()` — returning
  `None` says only that nothing was registered; a notifier this run asked for
  and could not have is recorded onto the run before returning (an
  unsuccessful outcome carrying the reason). The two benign cases — nothing
  configured, and this entity excluded by the configured filter — write
  nothing, which is what tells them apart from a refusal. The scoped
  registration is an override, so it dispatches on its own match rather than
  deferring to the process-wide registration's filter, and therefore
  re-applies the configured filter itself.

### `state/artifact_verifier.py`

- `_resolve_produced()` — an entry naming a directory is matched exactly. A
  bare filename is matched at the root first, then in any immediate
  subdirectory: in a multi-agent run each worker writes into its own
  subdirectory, and which worker produces a given artifact is decided when the
  plan is cast, so the author of a playbook contract cannot name that
  directory in advance — requiring one would make a bare filename impossible
  to satisfy. Declaring *what* is expected and knowing *who* produces it are
  held by different parties; only the first belongs in the contract.
  Subdirectories are searched in sorted order, so a filename produced by more
  than one worker resolves to the same one on every run rather than to
  whatever the filesystem happened to list first.

### `state/reasons.py`

- `ScheduleReasons` — the `schedule.skipped.` prefix is **not** the full set of
  reasons a skipped `schedule_run` can carry. `DEFERRED_CAPACITY` and
  `BUDGET_EXHAUSTED` land on skipped rows without that prefix, and a third
  path — the task-admission path — stamps a `schedule_run` to `skipped` with
  the admission decision's own code, falling back to
  `RunReasons.SKIPPED_WAITER_CAP_EXCEEDED`. No enumeration here can be closed:
  the admission writer takes its code from a decision object rather than a
  literal, and the only bound anywhere in the system is `VALID_REASON_CODES`,
  the union across every reason class in this module. A consumer filtering
  skipped rows by the `schedule.skipped.` prefix silently drops capacity
  deferrals, budget exhaustion, and admission rejections.
  - `SKIPPED_OVERLAP` — stamped when a fire arrives while the previous run of
    the same schedule is still going.
  - `SKIPPED_MISSED_FIRE` — stamped for a fire whose due instant passed while
    the scheduler was not running, on a schedule whose missed-fire policy is
    not to run it late. Detection is once per process start, not continuous —
    the missed-fire sweep runs in the tick loop's preamble, before the loop
    begins, and never again for the life of the process. A scheduler that
    stalls while still running records nothing at all. The row's timestamp is
    therefore bounded by time-to-restart, not by tick interval — closer to a
    restart timestamp than a detection latency, and not comparable to a fired
    row's lateness.
  - `SKIPPED_PRECONDITION` — no code path evaluates a precondition and stamps
    this; it is the default reason attached to a `schedule_run` that moves to
    `skipped` without an explicit code, so in practice it means "skipped, and
    the writer gave no reason." Treating it as evidence a precondition was
    checked and failed is reading a fallback as a finding.
  - `DEFERRED_CAPACITY` — same kind of trap: these rows are sampled, not
    one-per-event. The scheduler counts every deferral and records only the
    first, then one every N deferrals after that (N is a scheduler-module
    constant), so sustained saturation does not flood `schedule_runs`.
    Counting these rows undercounts deferrals, and a row's timestamp is the
    sampled deferral's rather than the first one's in that stretch.

### `state/schema.sql` / `state/schema_migrations.py`

- The `first_msg_id`/`last_msg_id`/`system_msg_id` message-pointer indexes —
  measured on a 3.9 GB store, indexing these columns took a message delete
  from 8.47 ms/row to 0.86 ms/row. Not partial indexes: the search SQLite runs
  for a foreign key is not the query planner's, and only a plain index is
  certain to serve it.

## lionagi/plugins/registry.py

Combines discovery + trust + settings into one snapshot, two-stage lazy like
`EndpointRegistry._ensure_loaded`. Stage 1 (`_ensure_loaded`): manifests are
scanned/parsed the first time any consumer asks, cached for the process.
Stage 2 (`activate_target`): a declared target/module is imported only when
that capability is actually invoked, never as a side effect of discovery or
an unrelated capability firing. Eligibility (compatible + enabled) and trust
are revalidated fresh on *every* call, re-reading `plugin.yaml`, settings,
and every declared file from disk — an already-activated target stops being
handed out the moment the plugin is disabled or a declared file/manifest
changes, not just refusing brand-new activations. The specific target file
is read exactly once: that read's hash (checked against the currently
recorded trust entry) and the bytes that get compiled/exec'd are the same
`read_bytes()` call (`_read_and_verify_target_bytes`), never a hash-then-
reopen sequence that would leave a TOCTOU window for the file to be swapped.
`_exec_bundle_module` compiles the pre-read bytes directly rather than going
through importlib's `spec_from_file_location`/`exec_module` path, which
writes/reads a `__pycache__` `.pyc` validated by second-granularity mtime —
two writes within the same wall-clock second are indistinguishable to it, so
a re-import right after a re-trusted edit could silently execute stale
bytecode. Import results (success or failure) are cached per `(plugin,
target, content hash)`, so re-trusting changed content is a guaranteed cache
miss rather than depending on an earlier call having evicted the old entry.
`_rescan()` re-reads and re-parses `plugin.yaml` itself rather than reusing
the cached `record.manifest` — a stale cached manifest object always
re-derives the same "trusted" hash regardless of what's actually on disk,
since the manifest hash is computed by re-serializing the parsed object, not
by re-reading the file. `_target_resolution_map` builds target ->
(module_path, attr) from the manifest's own typed capability lists using the
same `parse_tool_target` split the hashing path (`discovery
._collect_declared_paths`) uses — two independently written splitting
expressions could disagree on where a path ends and a callable begins; one
shared parser can't. Nothing in this module runs at `import lionagi` time.

## lionagi/service/connections/mcp_wrapper.py

Security-critical admission-control gate for MCP (Model Context Protocol) tool descriptors:
decides whether an externally-supplied MCP tool schema is safe to register, defending
specifically against a generic command/process/script executor (a "bash"-shaped tool)
masquerading as something narrower via an insufficiently-bounded JSON Schema. This is
orthogonal to two other security mechanisms: `MCPSecurityConfig` governs transport
authorization (is this command/URL allowed to connect at all), and `PermissionPolicy` governs
invocation-time authorization keyed by tool name. This admission rule sits before both: it
rejects a caller-shaped generic executor descriptor before the tool is ever admitted into the
registry, regardless of transport settings.

### Identifier/key classification

- `_is_identifier_like_key()` — identifies dynamic-but-benign resource identifiers
  (`service_id`, `resource_path`, `request_id`, matching `*_id`/`*_path`/`*_uri`/`*_url`/
  `*_uuid`/`*_slug`). Excluded from the strong-executor-name "must be affirmatively bounded"
  fallback: a tool with a strong executor name but whose only free-form field is an
  identifier-shaped key is NOT treated as executor-shaped — a fixed-operation tool addressing
  a resource by free-form ID differs legitimately from one taking a free-form command/script,
  even though the identifier field itself is an unbounded string.
- `_EXEC_TAINTED_KEY_TOKENS` — tokens that make an otherwise identifier-shaped key still
  executor-shaped. A key like `executable_path`/`script_path`/`command_path` lexically matches
  the identifier-suffix exemption but its root token names an executor channel — a
  caller-controlled executor target, not a benign locator — so it must NOT be exempted from
  the strong-name fallback.
- `_is_exec_tainted_key()` — companion to the above: true when a key's own `_`-split tokens
  name an executor channel (command, cmd, shell, script, program, binary, executable, argv,
  args), overriding the identifier-suffix exemption.
- `_UNBOUNDED_NON_OBJECT_TYPES` — non-object types (string/number/integer/array) whose
  instances aren't intrinsically finite. A root `type` union including one, without a
  top-level `enum`/`const` pinning the instance, has a branch admitting an arbitrary value,
  bypassing every object-shaped constraint entirely because the instance never has to be an
  object.
- `_ANNOTATION_ONLY_REF_SIBLING_KEYWORDS` / `_has_structural_ref_siblings()` — keywords that,
  as siblings of `$ref`, are annotation-only and never constrain the instance (description,
  title, `$comment`, examples, default, `$defs`, definitions). Per Draft 2020-12, `$ref`
  siblings are NOT discarded — they constrain the same instance and must be evaluated in their
  own right, so a non-annotation sibling forces the sufficiency proof to also evaluate it as
  its own schema node.

### Keyword registry for the sufficiency proof

Answers exactly one question: is this schema document provably closed against an undeclared
value? A per-property "is this value a key channel" discriminator — deciding which positions
deserve scrutiny — is the same defect class as enumerating "dangerous keywords," re-entered
through the traversal axis: omit the conditional applicators (`if`/`then`/`else`/`not`) or
array applicators (`items`/`prefixItems`) and a property carrying one is never visited, so
the allowlist check that would have denied it never runs.
The fix: classify EVERY Draft 2020-12 keyword into EXACTLY ONE of four classes (inert/
bounding/modeled/denied), then walk the ENTIRE document — every property value, composition
branch, `$ref` target, `$defs` entry — UNCONDITIONALLY, checking each node's own keywords
against the registry. There is no longer a discriminator deciding "should this node be
visited"; a keyword not in the registry is UNKNOWN and fails closed unless its value provably
cannot carry a subschema.

- `_INERT_ANNOTATION_KEYWORDS` (`contentSchema` caveat) — `contentSchema` is inert with an
  individually-argued exception, not by default: its value is a mapping describing the DECODED
  content of a string instance, like `contentEncoding`/`contentMediaType` — none assert
  anything about the instance itself, but ONLY while the content-assertion vocabulary stays
  disabled (the default dialect this module validates against). A future dialect enabling that
  vocabulary would require `contentSchema` to leave this class — a maintenance tripwire.
- `_DENIED_APPLICATOR_KEYWORDS` — applicators recognized BY NAME but deliberately NOT modeled
  (patternProperties, propertyNames, unevaluatedProperties, unevaluatedItems,
  dependentSchemas, if, then, else, not, contains, items, prefixItems, `$dynamicRef`,
  `$dynamicAnchor`, `$recursiveRef`, `$recursiveAnchor`): presence denies the node unless the
  bounded-array proof establishes that every `prefixItems` position and the `items` tail is
  enum/const-bounded (recursively for nested arrays), the tail is explicit, and
  `unevaluatedItems` cannot reopen evaluation. The structural walker then still visits each
  bounded item subschema, so an unrelated denied or unknown applicator remains fail-closed.
- `_classify_keyword()` — classifies a keyword into inert/bounding/modeled/denied;
  unrecognized names are UNKNOWN. The registry is a CLOSED enumeration, not a spelling
  heuristic — an unseen keyword fails closed rather than being guessed at.

### Object-boundedness proof

- `_property_value_may_be_object_shaped()` — true when a declared property's VALUE could
  itself resolve to an OBJECT instance, requiring the boundedness proof to recurse.
  Deliberately narrow: only answers "does closedness apply here," never "is a keyword modeled"
  — that's answered totally and unconditionally by `_structural_coverage_insufficient`
  regardless of whether this gate recurses, so an omission here (e.g. a value reachable only
  through a DENIED applicator) is harmless. A scalar/array/annotation/free-form-string
  property value returns False on purpose — it remains the walker's territory by key name
  (`service_id: {"type": "string"}` stays admitted).
- `_schema_is_insufficient()` — top-level gate: insufficient if EITHER the object-boundedness
  proof OR the structural-coverage proof fails. The two proofs are deliberately independent
  (type/closedness vs. keyword coverage), combined by OR.
- `_object_boundedness_insufficient()` — recursive, union-aware TYPE-GATE + CLOSEDNESS check,
  orthogonal to `_structural_coverage_insufficient` (never denies on keyword identity, only on
  whether the instance is provably constrained to a closed, finite object shape). Binding
  order of checks (applicator delegation MUST run before omitted-type denial, or a
  legitimately-type-omitting applicator-root would false-deny): (1) budget/depth cap fails
  closed; (2) `type` excludes `"object"` → insufficient; (3) `type` union has a free-form
  alternative with no `const`/`enum` pin → insufficient; (4) APPLICATOR DELEGATION — `$ref`
  (local only, intersect structural siblings), then `oneOf`/`anyOf` (UNION: every branch must
  prove sufficient), then `allOf` (INTERSECTION: one bounded branch suffices), each recursing;
  (5) top-level `const`/`enum` pins the instance → sufficient regardless of type; (6)
  LEAF-OBJECT branch: omitted type (no const/enum) → insufficient (bare non-object never
  reaches object keywords); non-empty `properties` only bounded if CLOSED
  (`additionalProperties: False` or itself enum/const-restricted); empty/absent properties
  still needs `additionalProperties: False`; once the outer object is closed, each declared
  property's own object-shaped value is re-checked by this same predicate recursively, or
  insufficient — non-object-shaped values are left to the walker's key-name policy since any
  denied applicator they carry is caught independently by `_structural_coverage_insufficient`.
  Returns True (fail closed) for external/cyclic/unresolvable/exhausted references or
  compositions.
  - Type-array handling: a Draft 2020-12 type array (e.g. `["object","null"]`) is still an
    object schema if `"object"` is among its types, and properties must still be inspected;
    only excluding `"object"` entirely is insufficient.
  - Type-union weak-link: a type union including `"object"` plus a free-form alternative
    (string/number/integer/array, absent enum/const) is only as bounded as its LEAST-bounded
    branch — an instance satisfying the non-object branch never reaches the object-specific
    keywords checked below.
  - `$ref` sibling re-check: Draft 2020-12 evaluates `$ref` siblings; a closed reference target
    doesn't make the node sufficient if a sibling keyword (evaluated against the same
    instance) reopens it. Pure annotation siblings contribute nothing and must not force an
    otherwise-open target's insufficiency onto an unrelated property.
  - const/enum pin: a top-level const/enum pins the instance to literal value(s), satisfying
    the type-gate regardless of type (or its absence).
  - Closedness default trap: `additionalProperties` defaults to permissive (implicit true); a
    fixed `operation` enum/const does NOT stop an undeclared `command` property from riding
    alongside it unless `additionalProperties` is explicitly `false` (or itself enum/const-
    restricted). This is the concrete attack shape: a schema that looks locked down because
    one property is enum-restricted while every other key name is still wide open.

### Structural-coverage proof

- `_structural_coverage_insufficient()` — total, registry-driven traversal: does any
  schema-bearing position carry a keyword the sufficiency proof does not model? Independent of
  `_object_boundedness_insufficient`'s type-gate/closedness reasoning — applies neither. A
  scalar leaf `{"type": "string"}` is sufficient on its own, preserving a free-form identifier-
  key property (`service_id`) alongside a fixed operation. Every schema-bearing position is
  visited UNCONDITIONALLY — every properties value, additionalProperties schema
  (Mapping-valued), composition branch, resolved local `$ref` target, and `$defs`/definitions
  entry (even unreferenced ones, a deliberate fail-closed choice). Totality argument: every
  position is reached by exactly one of three routes — (i) under a chain of MODELED
  applicators (this function's recursion visits it); (ii) under a DENIED applicator (ancestor
  already returned True before looking inside); or (iii) under an UNKNOWN keyword (checked for
  subschema-shaped value and denied there). No fourth route exists. The unconditional `$defs`
  visit specifically guards against a future reference, or tooling resolving `$defs` by
  convention rather than explicit `$ref`, smuggling an unmodeled keyword through an entry
  never inspected.
- `_property_is_bounded()` — deliberately has no array carve-out: array boundedness needs BOTH
  `prefixItems` members AND the `items` (rest-of-array) schema checked together (see
  `_array_reaches_free_form`); a bounded `items` alone says nothing about an unbounded
  `prefixItems` member alongside it.
- `_item_schema_reaches_free_form_string()` — true when an array's items/prefixItems member
  schema may admit an arbitrary string, i.e. a free-form argv-shaped channel. Item schemas are
  ALWAYS treated conservatively (unlike keyed object properties, which get the
  identifier-suffix exemption): an opaque/unconstrained/malformed item shape is presumed
  free-form. Local `$ref` and allOf/anyOf/oneOf/if/then/else/not composition are resolved/
  recursed so indirection is still caught. Critically, a nested `type: "array"` item is NOT
  automatically bounded — it's only bounded if its own item schema is bounded, otherwise a
  caller can smuggle a free-form channel one array level deeper
  (`args: [["sh", "-c", ...]]`).
- `_array_reaches_free_form()` — true when an array-shaped node admits a free-form element.
  Relies on Draft 2020-12 semantics: `prefixItems` validates only prefix positions; `items`
  validates everything at-or-after. A MISSING `items` keyword defaults to `true`
  (unconstrained rest), so an array whose prefixItems are all enum/const-bounded is STILL
  free-form unless `items` is explicitly present and bounded (or `false`). Every prefixItems
  member is checked too — bounded `items` says nothing about an unbounded prefix member.

### Walker (`_walk_schema`)

The walker is a WHITELIST, not blacklist. Closing specific evasions one at a time (nested
properties, anyOf, `$ref`, additionalProperties, patternProperties; if/then/else, not, items,
prefixItems) leaves every future keyword free to reopen the same bypass class — enumerating
"keywords we deny" loses that arms race by construction. Instead the
walker enumerates keywords it affirmatively understands; any other subschema-shaped key is
unresolvable — future/unsupported keywords (unevaluatedProperties, dependentSchemas, contains,
propertyNames) deny by default for executor-signaling tools instead of admitting by default.

- `_SCALAR_ONLY_SCHEMA_KEYWORDS` — keywords whose value never carries a subschema (or, for
  `$defs`/definitions, only reachable via `$ref` resolution). `contentSchema` included on the
  same footing as its `_INERT_ANNOTATION_KEYWORDS` inclusion — the two caveats must be kept in
  sync if the content-assertion vocabulary assumption ever changes.
- `_UNRESOLVABLE_REFERENCE_KEYWORDS` — `$dynamicRef`/`$recursiveRef` are schema-bearing like
  `$ref` but their VALUE is a plain string, not a Mapping — so `_could_carry_subschema`'s
  Mapping-shape test never flags them, and a command channel reachable only behind one would
  otherwise be SILENTLY ADMITTED. Recognized by keyword identity instead, always treated as
  unresolvable — deliberately conservative since dynamic-scope `$dynamicAnchor` resolution
  isn't something this walker can safely reproduce.
- `_NUMERIC_BOUND_KEYWORDS` — explicit enumeration, deliberately NOT a `min*`/`max*` spelling
  heuristic: a prefix test would exempt an arbitrary unknown key like `minCustomThing` from the
  could-carry-subschema check purely by name, reopening the whitelist bypass.
- `_could_carry_subschema()` — true when an unrecognized keyword's value is shaped like it
  could hold a schema (mapping, or list at any depth containing one). Recurses through nested
  lists under the walker's depth cap so a schema-bearing value can't be laundered past the
  whitelist via extra list nesting. Charges every element against the shared node budget and
  fails closed on exhaustion, preventing a pathologically wide/deep value from being used as a
  denial-of-service against the admission check itself.
- `_is_inert_annotation_value()` — true when a value cannot itself carry a subschema
  (recursively scalar, or list/mapping of such with no schema-vocabulary key anywhere inside).
  A vendor annotation with genuinely descriptive metadata is inert; one embedding real schema
  vocabulary is not, regardless of key spelling — shape decides inertness, not the key. Fails
  closed (not-inert) on budget exhaustion, mirroring `_could_carry_subschema`.
- `_is_vendor_annotation_keyword()` — true for annotation-only keywords whose value carries no
  schema-bearing content. Narrow on two axes: (1) only the `x-` vendor-extension convention and
  `$comment` are even considered; (2) even for those, the value must be demonstrably inert — a
  vendor extension whose value is itself schema-shaped is exactly the hidden channel this
  walker exists to catch, so it is NOT exempted just for starting with `x-`. NEVER exempts
  `$`-prefixed keywords generally — that's exactly where real reference/applicator keywords
  live, so a blanket `$*` exemption would reopen the reference-bypass class this walker
  closes.
- `_mark_unknown_schema_keywords()` — whitelist enforcement: any unrecognized keyword with a
  subschema-shaped value is unresolvable, applied to every schema node the classifier inspects
  (including leaf-treated property schemas). Two carve-outs: (1) `$dynamicRef`/`$recursiveRef`
  anywhere makes the node unresolvable outright regardless of value shape; (2) a
  vendor-extension keyword is exempt even when Mapping-valued, since it's never an applicator.
  Value inspection shares the walker's node budget; exhaustion fails closed via the same
  helpers, preventing an enormous/deeply nested non-subschema value from burning unbounded CPU
  as a side channel around the traversal budget.
- `_OBJECT_CONTAINER_KEYWORDS` — presence of any of these (properties, patternProperties,
  additionalProperties, `$ref`, allOf, anyOf, oneOf, if, then, else, not) on a property's own
  schema means the property IS ITSELF a restated/composed schema — recurse rather than treat
  as a scalar leaf.
- `_ARRAY_ITEM_KEYWORDS` — an array property can be BOTH a free-form leaf channel in its own
  right AND hide a command channel inside an object-shaped item (items/prefixItems); both must
  be checked, so — unlike object-applicator keywords — these do NOT short-circuit leaf
  classification.
- `_SchemaWalkResult.unresolvable` — true when a channel could not be proven bounded
  (unresolvable/cyclic `$ref`, budget/depth trip, malformed shape, unrecognized keyword). Fed
  into fail-closed handling specifically for tools whose name/description signals an
  executor; otherwise it's just insufficient evidence, not an automatic denial.
- `_SchemaWalkResult.nodes_visited` — total-work budget companion to the depth cap, bounding
  runtime against a harmless but extremely wide fan-out (e.g. tens of thousands of anyOf
  branches) that the depth cap alone wouldn't stop.
- `_consume_node_budget()` — counts one unit of walker work; returns False once the
  total-node budget is exceeded so callers can stop iterating early — this is the mechanism
  that actually enforces `_MAX_SCHEMA_WALK_NODES` per-iteration, not just once at entry.
- `_composition_branch_reaches_free_form()` — true when any composition/conditional/`$ref`
  branch of a keyed property resolves to a free-form leaf. `{"anyOf": [{"type": "string"}]}`
  or `{"if": ..., "then": {"type": "string"}}` constrains the same instance the key names, so
  the key is unbounded even though the leaf type is indirect — without this, wrapping a plain
  string schema in one applicator layer would strip the key-to-leaf-type association the
  classifier relies on, evading detection with one layer of indirection.
- `_consider_property()` — leaf-shaped property schema never reaches `_walk_schema`, so the
  unknown-keyword whitelist is enforced directly here too (redundant for container properties,
  already walked, but harmless — closes the coverage gap for leaf properties that would
  otherwise skip the whitelist entirely). A container property is not itself a command value;
  the walker recurses into its reachable properties instead of classifying the container's
  key — but BEFORE recursing, it first attributes to the key any free-form leaf reachable
  purely through composition/conditional branches, because those constrain the key's own value
  even though expressed indirectly (ordering matters so a composed free-form leaf isn't missed
  just because the property also looks like a container). Array handling walks
  items/prefixItems for a hidden object-shaped command channel nested in array elements, but
  deliberately falls through to the leaf free-form check rather than returning early — the
  array property itself may ALSO be free-form (e.g. `argv: array-of-strings`), so both checks
  must run.
- `_walk_schema()` — bounded, cycle-safe, budgeted traversal collecting classifier evidence.
  Recognizes properties (including nested objects), allOf/anyOf/oneOf, if/then/else/not,
  items/prefixItems, local `$ref` resolution, patternProperties, and additionalProperties
  (both as a scalar free-form map channel and, when object-valued, as a nested subschema). Any
  other subschema-shaped keyword is unresolvable — the walker's instance of the whitelist
  rationale above. Draft 2020-12 evaluates `$ref` siblings; after resolving/recursing into the
  target, the function falls through (no early return) so every other keyword on this node is
  still walked for a free-form channel reachable alongside the reference, mirroring the same
  rule in `_object_boundedness_insufficient`. An object-valued additionalProperties map-entry
  schema is a reachable subschema in its own right; walked so a command channel hidden behind
  a dynamic (unnamed) map key isn't missed — but no fixed key name exists for a free-form
  additionalProperties map channel, so it only counts as executor-shaped evidence when
  corroborated by the tool's own name or description, mirroring the same corroboration
  requirement `unbounded-script-payload` demands of payload keys (an uncorroborated free-form
  map alone is too weak a signal to deny registration).

### Top-level classification and API

- `_classify_generic_executor()` — an unbounded command-shaped field
  (`has_free_form_command`) is dangerous on its own; unrelated extra properties do NOT make it
  safe, and — unlike the strong-name/description signals — no corroboration is required to
  deny it. Highest-priority, least-corroborated denial reason (`unbounded-command-input`),
  checked first. A strong executor identity (e.g. `bash`, `exec`, `run_command`) must be
  AFFIRMATIVELY demonstrated safe: empty/no schema, every property bounded via enum/const, or
  only auxiliary/identifier-like free-form fields. An unresolvable channel or a remaining
  executor-shaped free-form property leaves the identity uncorroborated — i.e. the default
  posture for a strong executor name is DENY unless the schema actively proves itself safe,
  the inverse burden-of-proof from ordinary tools (`executor-identity-with-insufficient-
  schema`).
- `_SYNTHETIC_MCP_WRAPPER_PARAMETERS` / `is_synthetic_mcp_wrapper_schema()` —
  `create_mcp_tool()` wraps every MCP tool in `async def mcp_callable(**kwargs)`. When a Tool
  is built without an explicit `tool_schema` (e.g. constructed directly rather than via server
  discovery), `function_to_schema()` reflects that wrapper into this exact deterministic
  shape. It carries NO information from the remote server — it's a fixed artifact of the
  wrapper's own signature/docstring — and must not be treated as remote descriptor metadata by
  the admission rule; `is_synthetic_mcp_wrapper_schema` detects and exempts this case.
  `mcp_tool_name` is the key under which the tool was registered in `Tool.mcp_config` — the
  identity `create_mcp_tool()` used to name/document the wrapper, distinguishing it from
  `advertised_name` (what the caller claims the tool is called).
- `validate_mcp_tool_admission()` — raises `PermissionError` when an MCP descriptor exposes a
  generic executor. Explicitly PURE and SYNCHRONOUS: no reads of MCPSecurityConfig,
  environment, files, pool state, or client acquisition — zero side effects, trivially
  unit-testable, impossible to bypass via mocking transport state. Registration-time admission
  control ONLY; does not change transport authorization (MCPSecurityConfig) or
  invocation-time permissions (PermissionPolicy) — those remain separate, independently
  enforced gates.
- `MCPConnectionPool.load_config()` — returns the server names declared in THIS file only. The
  pool accumulates configs ACROSS loads (`_configs` is process-global class state), so callers
  meaning "the servers from the file I just loaded" must use this method's return value rather
  than enumerating `_configs` afterward, since it may contain servers from earlier, unrelated
  calls in the same process.

## lionagi/agent/, lionagi/dispatch/, lionagi/hooks/

### `agent/factory.py`

- `_chain_pre_hooks()` — security-hook composition contract (ADR-0086 delta row 1): every
  security control (PermissionPolicy pre-hook, guard_destructive/guard_paths) is adapted into
  a `GateResult` evaluator run through the shared gate pass runner — each control evaluates
  exactly once per pass, and an evaluator that raises unexpectedly is treated as a fail-closed
  deny. When user pre-hooks are present, the security pass runs *twice* (before user hooks,
  then again after against the final possibly-mutated args), so a user hook can never rewrite
  arguments past a control that already approved them.
- `_resolve_mcp_path()` — shared by `_load_mcp` and `_forward_mcp_to_cli_request` so both agree
  on the authoritative `.mcp.json` and trust gate: explicit `spec.mcp_config_path` always wins;
  project-scoped `.lionagi/.mcp.json`/`.mcp.json` only considered when
  `trust_project_settings=True`; user-home `~/.lionagi/.mcp.json` trusted unconditionally. An
  explicit `mcp_config_path` that doesn't resolve raises `ConfigurationError` (declared intent
  = configuration error, not a soft no-op); only auto-discovered candidates fall through
  silently.
- `_forward_mcp_to_cli_request()` — two-"island" MCP design: `_load_mcp` only reaches
  lionagi-native `branch.acts` tools (inert for CLI providers, which spawn their own subprocess
  and never call back into `branch.acts`); this function reaches the second island — the CLI
  subprocess's own per-turn request kwargs (`ClaudeCodeRequest.mcp_servers`, forwarded via
  `as_cmd_args()` as `--mcp-config`). It deliberately sets `mcp_servers` (a plain dict field)
  rather than `mcp_config` (path field): `mcp_config`'s validator unconditionally rejects
  absolute paths, and both resolved candidates here are always absolute, so using
  `mcp_config` would raise `ValidationError` on the very next turn. An explicit
  `spec.mcp_servers=[]` with no resolvable config file still forwards `{}` (forcing zero MCP
  servers) rather than leaving the CLI to fall back to its own discovery.
- `apply_forwarded_mcp_servers()` — the transport application itself, on a plain kwargs dict:
  `_forward_mcp_to_cli_request` resolves a config file and filters it, then delegates here, and
  so do the three CLI spawn paths that resolve a set of their own (`build_chat_model` for a
  plain `li agent` leg, the resume path in `cli/agent.py`, and `_hand_mcp_servers` for every
  flow/fanout worker). One function so that "can this provider be given a set?" has one answer
  — `provider_accepts_forwarded_mcp` — instead of each site re-deriving it from a provider
  name. Codex takes the set as `-c mcp_servers.<name>.<field>` overrides, with `env` and
  `http_headers` (possible secrets) routed to a 0600 profile file instead of argv. `exclusive`
  says the set is the whole set: the Claude lane adds `strict_mcp_config`, codex disables every
  server it would otherwise load by name, since `-c mcp_servers={}` merges rather than replaces.
- Mutating `chat_model.endpoint.config.kwargs` in place would corrupt any other Branch sharing
  the same iModel instance (Branch keeps a caller-supplied chat_model by reference, not copy).
  The fix copies chat_model before mutating, sharing `share_session`/`share_executor` with the
  original so only the endpoint config (MCP filter) becomes branch-local.

### `agent/gate.py`

`GateResult` is the one immutable verdict shape every tool-invocation security control
produces (ADR-0086 delta row 1); adapters convert PermissionPolicy, guard_destructive/
guard_paths, and the session-level gate into that shape. Legacy hooks signal denial by raising
`PermissionError` and an argument rewrite by returning a `dict`; any other exception is
treated as an evaluator failure and turned into a fail-closed deny rather than propagating
uncaught.

A control that cannot reach a verdict at all — misconfigured, or its backend unreachable —
raises `ControlUnavailableError`, which subclasses `PermissionError` so existing callers keep
failing closed on it unchanged. The gate catches it ahead of `PermissionError` and sets
`GateResult.errored`, which separates "your configuration cannot answer this" from "the answer
is no": both refuse the call, and an operator acts on them differently. `errored` has two
readers, the pass logging at error level where a plain denial logs nothing, and
`GateDeniedError`'s message. `PermissionPolicy.to_pre_hook` raises it for an escalate decision
with no `on_escalate` configured; an escalation a configured handler *declined* is an ordinary
denial and says so.

### `agent/hooks.py`

`_resolve_against_any_root()` / `guard_paths()` — multi-root path-containment contract: a
relative path formed against the first allowed root is accepted as long as the resolved
location falls under *any* configured root, not only the first. A symlink or protected-
basename denial fails identically against every root and always surfaces over a generic
denial. `guard_paths` validation is check-time only — a TOCTOU race (swapping a validated file
for a symlink after the check passes) is explicitly out of scope.

### `agent/nudge.py`

`NudgeEngine.evaluate()`/`_merge()` — firing bookkeeping (once/cooldown state) is committed
only for rules whose message actually survives the token-cap merge; a message dropped by the
cap must never be treated as delivered. A rule whose condition or render raises is skipped and
logged without breaking other rules.

### `agent/spec.py`

`_wire_secure_guards()` — guards are registered into the `security_pre` bucket, not the
ordinary user `pre` bucket, so they participate in the same security→user→security recheck as
an explicit PermissionPolicy (ties to the same ADR-0086 double-pass contract as
`_chain_pre_hooks` above).

### `dispatch/outbox.py`

Durability and delivery are separate guarantees: an outbox row persists in `state.db`
independent of consumer liveness; a surviving producer (Studio daemon scheduler tick)
re-attempts the notify template until success/backoff-exhaustion/`max_attempts`. Transport is
a shell command template (ADR-0059 D3), argv-safe: `payload`/`deliver_to` are substituted as
whole argv elements (never string-interpolated), and the template always runs via `exec` (no
shell), so shell metacharacters are inert.

- `enqueue_dispatch()` — idempotent on `dedup_key` (re-enqueue with the same key returns the
  existing row id). `max_attempts` bounds delivery regardless of `ack_required`: an
  ack-required row that keeps sending successfully but never gets acked still exhausts at
  `max_attempts` sends (`dead_letter`/`DEAD_LETTER_ACK_TIMEOUT`) rather than re-delivering
  forever. `expires_at` is an *additional* optional bound on top of `max_attempts`.
- `deliver_due_dispatches()` / `_deliver_one_due_row()` — ack-required rows loop back to
  `pending` (not `delivered`) on transport success, so the same due-scan re-attempts with
  backoff until acked/expired/`max_attempts` exhausted; the default tier stops at `delivered`
  on first success. `delivering` rows are re-scanned for crash recovery, but a claim is
  exclusive only for `_CLAIM_LEASE_SECONDS` — the guarded attempt-counter CAS in `transition()`
  (guard on pre-claim `attempt`, not just status) prevents two overlapping scans from
  double-running the transport for the same attempt. Race hardening: the due-row snapshot and
  each row's `transition()` call are separate transactions, so a concurrent
  `purge_dispatch(es)` can delete a snapshotted row; `transition()` raises `LookupError` in
  that case, caught per-row so one purged row is skipped without aborting the rest of the
  batch.
- `purge_dispatch()` / `purge_dispatches()` — `purge_dispatch` accepts any status (naming an
  exact id is already deliberate non-bulk intent) and writes one `admin_events` audit row on
  success. `purge_dispatches` requires `status` and/or `before` (bare call raises
  `ValueError`, guards against accidental full-table delete); status semantics are
  deliberately asymmetric — an explicit status is honored exactly as given (including
  in-flight `pending`/`delivering`, treated as deliberate operator override), while a
  status-less call defaults to terminal-only (`delivered`/`acked`/`dead_letter`/`expired`) so
  it can never implicitly sweep in-flight rows a live scheduler tick may still claim. Distinct
  from the automatic terminal-only retention sweep in `db_maintenance.prune_old_data`. Always
  writes an `admin_events` row, including on `dry_run` calls, and preserves
  `status_transitions` rows for purged ids.

### `dispatch/revival.py`

This is a plain library call, not a new schedule `action_kind`: wiring a first-class
action_kind through the scheduler's fire/subprocess-spawn machinery would require rebuilding
the `schedules.action_kind` CHECK constraint (the same SQLite rename-rebuild migration
`_drop_legacy_action_kind_check` performs for the existing enum) — heavier machinery than a
library call needs. Any schedule action that can call a Python function can invoke it;
nothing in the module assumes a dedicated action_kind.

### `hooks/builtins.py`

`persist_session_end()` — in the normal CLI flow, `teardown_persist()` always stamps the
terminal status via `_teardown_common()`'s `update_status()` call before emitting
`SESSION_END`, so the session row is already terminal by the time this handler runs. In that
case only pure usage fields are written (input/output tokens, cost, turns, duration) — the
status/reason_code/ended_at transition is skipped (avoids a duplicate `status_transitions` row
and a double-fire clobbering an already-recorded status), and `node_metadata` is left
untouched since `_teardown_common()` already owns it for a terminal row and `update_session()`
does a plain column SET, not a merge (writing `{"error": ...}` here would clobber richer
existing data). Related: `persist_session_start`'s explicit `reason_code` on the "running"
transition avoids tripping a deprecation shim that would otherwise raise and get swallowed by
the bus, silently dropping all the provenance fields passed alongside it.

## lionagi/providers/

### `_cli_subprocess.py`

`ndjson_from_cli` PGID capture: the process-group id must be captured immediately after
spawn, not at teardown — if the child has already exited and been reaped by the time teardown
runs, `os.getpgid(proc.pid)` raises `ProcessLookupError`. Since `start_new_session=True`,
`pgid == proc.pid`, so capturing `proc.pid` right after spawn is equivalent and safe. The
actual pid-guard/platform check lives in `aterminate_process_group`.

**`on_spawn` and `SpawnedProcess`.** A caller that has to supervise a leg it did not itself
spawn passes `on_spawn`, which is called exactly once with a `SpawnedProcess` as soon as the
child exists and before any output is read. Three things about that record are load-bearing:

- **It carries a start time, not just two integers.** `pid` and `pgid` are both recyclable —
  once a process is reaped the kernel may hand its numbers to anything — so a consumer holding
  only those cannot tell this child from a stranger that arrived later, and signalling on that
  basis reaches the stranger. `create_time` binds them, it is readable only while the child is
  known to exist, and a consumer acting on the record later must compare a live read against
  it. `None` means nothing was established and is never a claim about the process; this mirrors
  what `lionagi/mcp/jobs.py` does with `pid_create_time`.
- **The three fields are one observation, within limits worth stating.** The group and the start
  time are separate reads by pid, so `observe_spawned()` brackets the group read between two
  start-time reads and drops `create_time` to `None` if they disagree, the same shape as
  `_pinned_member` in `lionagi/mcp/jobs.py`. That rejects a replacement arriving *during* the
  observation. It does not speak for one that arrived before the first read, and the window
  where that is possible is a child that exits and is reaped between the spawn call returning
  and the first probe — covered not by the bracket but by the probe, which answers `None` for
  both a reaped pid and a zombie.
- **It may be a coroutine function, and the result is awaited.** A durable recorder is written
  in async style, and `Callable[..., None]` does not reject an `async def` at runtime: an
  un-awaited one returns a coroutine that is dropped, so the leg runs entirely unrecorded with
  nothing raised. The await completes before the first byte of output is read, so a consumer
  acting on the stream is never ahead of the record.
- **Its failure is not swallowed**, including `CancelledError` and `KeyboardInterrupt`. A
  recorder that fails has no record of a child that is now running, so the child is terminated
  and the exception propagates. A guard written against `Exception` would let a cancellation
  through and leave the child alive.
- **That termination does not depend on surviving a second cancellation.** The graceful path
  sends `SIGTERM` and waits out a grace before escalating, and that wait is itself a
  cancellation point: a runner being torn down is exactly where a second cancellation arrives,
  and a child ignoring `SIGTERM` would then outlive an escalation that never ran. So a
  synchronous `SIGKILL` backstop runs in a `finally` when the graceful path did not complete —
  no `await`, so nothing can interpose. It runs under the same evidence rule as the pass it is
  backing up rather than under anything about the direct child: an unreaped child is signalled
  on its own unrecyclable pid, a reaped one only where the group answers with a live member.
  Signalling a pid asyncio has reaped, with nothing to say the group still holds it, is how a
  stranger's group gets killed.

- **The child exists even when nothing recorded it.** The OS has already started the child by
  the time `create_subprocess_exec` resumes, so a cancellation landing in the window before it
  returns leaves a running leg that this process holds no handle for and that no callback has
  seen — unreachable by teardown and by any later sweep over the records. The creation is
  therefore shielded so the handle still arrives, and a done-callback ends that child's group
  from outside the coroutine that unwound. The callback retrieves the task's exception either
  way, or a cancelled spawn is reported at exit as a never-retrieved failure.

`end_child_group()` is what every teardown path calls, and it differs from the graceful helper
on two counts. It drains the *group* rather than the process: the graceful helper returns as
soon as the process it holds a handle to is gone, and a descendant that ignores `SIGTERM`
outlives a parent that does not, so the group is read afterwards and killed if anyone is still
in it. And it cannot be interrupted into leaving something running, via the synchronous
backstop described above.

Every signal fires only on *positive* evidence that the group id is still this child's,
because the other direction is worse than an orphan. There are exactly two things that
establish it: the child has not been waited, in which case its pid cannot have been reissued;
or the group answers with a live member, and an occupied group is never reissued. Nothing else
counts, and the graceful helper is therefore reached *only* on the not-yet-waited path. It
signals the id it is handed without checking anything, so calling it after a normal drain
would send `SIGTERM` to whatever now holds a recycled id.

The escalation keys on that membership evidence rather than on whether the direct child is
dead. Those are different facts: a leader that died to `SIGTERM` sets `returncode` while a
descendant ignoring `SIGTERM` is still in its group, and a backstop gated on the leader's
liveness reads that as nothing left to do.

Both checks matter, and checking only one leaves a hole where the other applies. An earlier
version of this had only the membership check, so the cancellation backstop refused to signal
wherever the process table could not be read — and on that path the direct child has not been
waited, so identity was never in question. The refusal is right after the reap and wrong before
it, and it read as caution rather than as a defect.

*After* the reap, a scan that could not read the whole process table and saw no members leaves
emptiness *unproved*, and nothing signals on that: an unprovable group and a reissued one look
identical from here. That refusal is logged rather than silent, because it is the one outcome
where something may still be running and nothing was done about it. It is a real platform limit
rather than a gap waiting to be closed: once the child is reaped, only a surviving member pins
the id, and proving one exists *is* the enumeration that was unavailable.

**The one case teardown cannot reach.** A cancellation landing inside
`create_subprocess_exec`, after the OS has made the child but before the call returns, leaves a
process whose pid was never handed to anyone here. Interpreter shutdown cancels pending tasks
and does exactly this. asyncio closes the transport on that path, which ends the direct child
but not the group it leads. Recording the handle as soon as the creation call returns was tried
and removed: it covers only the window between the call returning and the caller resuming,
which is not where the cancellation lands. Reaching it needs the pid *before* the creation call
returns, which means driving `loop.subprocess_exec` with a protocol that records
`transport.get_pid()` in `connection_made` — declined here because it pins this file to stdlib
classes outside that module's `__all__` across every supported Python.

Nothing recovers it afterwards either, and an earlier version of this section said otherwise:
that the orphan sits in the record the caller writes and a later sweep still finds it. It does
not. `on_spawn` fires only once the creation call has returned, which is the thing that did not
happen, so in this window there is no record of any kind — the same emptiness the bullet above
describes, written up two ways that contradicted each other. This is a stated hole rather than
a handled one. The test for the window asserts the emptiness rather than assuming it, so the
claim cannot quietly come back, and the log line on that path says what was lost and why.

The group in the record is the *initial* one. A process group is not a containment boundary: a
child or descendant that calls `setsid()` leaves it and the record then says nothing about that
process. A caller who needs "nothing the leg started survives" must either require
non-daemonizing CLIs or use a platform containment primitive.

**Runtime-only request fields.** `env` and `on_spawn` on `ClaudeCodeRequest` /
`CodexCodeRequest` are `SkipJsonSchema[...]` with `exclude=True` and `repr=False`. Each
qualifier answers a different channel and none implies the others: `exclude` keeps them out of
dumps, `SkipJsonSchema` keeps them out of the generated schema (a callable has none, so
`model_json_schema()` raises without it, which breaks every path that persists a request),
and `repr=False` keeps a complete child environment out of log lines and exception text.

Those qualifiers all govern *the model*, and the model is not the only thing that prints a
child environment. Pydantic renders the input of a failing `mode="before"` validator verbatim,
and a model-level one holds the whole raw mapping, so a request rejected for a reason having
nothing to do with `env` — an empty prompt, say — quotes every variable beside the reason. The
redaction therefore happens at the top of every model-level before-validator and before
anything there can raise. Carrying it on the value rather than at each error site is what stops
a validator added later from reopening the channel by not knowing about it, and it is why the
two providers each redact at their own validator entry.

The substitute is deliberately **not a mapping**. A `dict` subclass with a quiet `__repr__`
closes the rendering channel and leaves the serialization one wide open: `str(err)` and
`err.errors()` go through `repr`, but `err.json()` walks the structure and writes out every key
and value, and `err.json()` is what a structured logger emits. `Redacted` is neither `dict` nor
`Mapping`, so there is nothing for pydantic to walk, and it reports `<env: N variable(s)>` in
every channel.

`on_spawn` is wrapped for the same reason. A **bound method carries its receiver into its own
`repr`**, so a callback bound to a supervisor renders whatever that supervisor holds — and
receivers that render their attributes are the common case here. Both models unwrap the carrier
in a field validator; without that, pydantic rejects a perfectly good callback as not callable,
which is a failure a leak test alone would never show.

Two rejections stay explicit because the redaction cannot reach them:

- An `env` that is not a mapping at all is left alone by the substitution, so the field
  validator rejects it itself rather than leaving it for pydantic to quote.
- A mapping with bad entries is rejected as `TypeError`, not `ValueError`: pydantic converts
  `ValueError` and `AssertionError` into a `ValidationError` that quotes the rejected input and
  lets everything else propagate untouched. The message names string keys, because a string key
  is a variable *name* and naming it is what makes the error actionable; a key of any other type
  is not a name and is reported by position and type only. Values are never printed.

`_runtime_state_fields` on the endpoint does two jobs for those fields, and both are about a
value that must stay in memory:

- **Surviving `create_payload`.** It rebuilds the request from `to_dict(request)`, and that dump
  omits excluded fields by construction. A field not named here is silently lost, so a caller
  passing a fully populated request model gets one where the runtime wiring reverted to its
  defaults.
- **Staying out of what gets written to disk.** `iModel(**kwargs)` forwards anything it does not
  recognise into `EndpointConfig.kwargs`, which is a supported way to configure an endpoint and
  also exactly what `Endpoint.to_dict` serializes — so it reaches `iModel.to_dict`,
  `Branch.to_dict`, and the run snapshots. A child environment left there is a credential in a
  saved file, and a callback left there is a function something is about to JSON-encode.
  `_init_runtime_state()` moves the named values out of `config.kwargs` into `_runtime_state` at
  construction, where `create_payload` reads them at the same precedence they had before.

**The drain is not the whole of it, and the part it misses is the public one.** A drain has to be
*reached*, and it hangs off the endpoint's own `to_dict`. `EndpointConfig` is public and inherits
Pydantic's `model_dump`, so a caller that logs or persists the config directly never goes through
an endpoint at all. Two supported routes also put a runtime value back into `kwargs` after the
endpoint has already drained it once: a post-construction `EndpointConfig.update()`, which puts
unknown keys straight back, and `iModel.from_dict`, which assigns a freshly hydrated config over
the drained one (`imodel.py`, in the `match_endpoint` branch). Between either of those and the
next endpoint-level dump, the value is resting in a serializable field.

So the names are declared on `EndpointConfig` itself, as `RUNTIME_STATE_NAMES`, and that model
excludes them from its own `kwargs` serializer. The set of callers that can reach a drain is
open-ended because the model is public, where a serializer runs on every dump that model has. The
endpoint re-exports the same tuple rather than keeping its own — one list decides what gets
written down, and a second copy would eventually be the one that fell behind.

A serializer is not every route out, though, and the two it misses are both public. `dict(config)`
and `list(config)` go through `BaseModel.__iter__`, which yields the raw values held in `__dict__`
without running a field serializer at all; `json.dumps(dict(config), default=str)` is an ordinary
way to write an object to a log or a file. `repr` walks `kwargs` whole for the same reason, so a
config excluded from every dump still prints its environment into a traceback, a log line, or a
debugger. Both are closed on the model itself, through `__iter__` and `__repr_args__`, which is
what keeps the answer independent of which caller is asking.

What that covers is the conversion API: the ways this object offers to turn itself into something
else. It is not a claim about reflection. `config.__dict__` and `pickle` read the instance's raw
state and still contain these values, deliberately — the runtime value has to live somewhere for
the endpoint to use it, and a rule that emptied every raw read would take the working value with
it. The line is between a route that produces a structure for something else to hold, which is
what ends up in a log or a file, and a route that reads the object's own memory.

The three channels do not all leave the same thing behind. A dump and a `dict()` omit the key,
since both are structures a config can be rebuilt from and a placeholder string would hydrate as a
real value of the wrong type. `repr` reports that a value is set without its contents, because
nothing is rebuilt from a `repr` and a reader asking why an environment was not applied is
answered by the key alone.

`copy_runtime_state_to` carries `_runtime_state` shallowly on purpose. `iModel.copy` deep copies
the config, and a deep copy of a bound callback rebinds it to a copied receiver, so the original
supervisor would quietly stop hearing from the copy's legs while everything still looked wired.

That transfer only carries what the source is actually holding, which is why `iModel.copy` drains
the source endpoint *before* copying its config rather than after. A value that arrived through
`update()` is still sitting in `config.kwargs` and has never been drained, so the source's
`_runtime_state` is empty: the new endpoint drains the copied `kwargs` correctly at construction,
and then the transfer overwrites that with the empty mapping. The result is a copy whose child
gets a default environment and whose spawns are reported to nobody. Draining first means the
copied config has nothing runtime-only left in it and the live objects move across whole. Reading
the source's state this way does not take it away — a drain relocates a value without changing
which one wins in `create_payload` — so the original keeps working after being copied.

**The endpoint-instance route.** `iModel(endpoint=<instance>, ...)` is a supported signature,
and it takes a branch that keeps the endpoint and discards every other keyword. For most
keywords that only loses configuration the endpoint already has. For these two it hands the
child a default environment and leaves the supervisor hearing nothing, with nothing raised and
nothing logged, which reads exactly like a working leg. A caller who hands over a finished
endpoint has missed the window below entirely, so `adopt_runtime_state()` places the values on
the instance — the way that same branch already treats `provider` and `base_url` — and an
endpoint with no runtime state to hold them refuses rather than dropping them. A `None` is the
absence of a value there, not an instruction to clear one.

The same deep copy sits on the construction path: `Endpoint.__init__` does
`config.model_copy(deep=True)` before any subclass code runs, so an endpoint built from a
prepared `EndpointConfig` would hold a callback bound to a copy of the supervisor. The
constructors therefore call `take_supplied_runtime_state()` *before* `super().__init__()`, which
reads the declared names off the object the caller actually handed in, and those values win over
what the copy produced. Nothing is skipped — the copy still happens and the values still leave
the serialized config — this only decides which of the two receivers the endpoint ends up
notifying. A test written with a plain nested function cannot see any of this: `deepcopy` of a
function returns the same object, and only a bound method has a receiver to rebind.

### `_secret_resolution.py`

Every CLI provider authenticates from its child's own environment: a codex `model_providers`
entry names an `env_key` and the CLI reads that variable itself. When the value is kept in a
keychain or a vault rather than exported, the spawning process has nothing to pass and the child
dies on a missing variable that says nothing about where the value was meant to come from.

`secrets.lookup` in `~/.lionagi/settings.yaml` names a command that prints one secret to stdout,
and the variables it may be asked for:

```yaml
secrets:
  lookup:
    argv: [security, find-generic-password, -s, "{name}", -a, lionagi, -w]
    names: [OPENROUTER_API_KEY]
```

`fill_declared_secrets()` is awaited once inside `ndjson_from_cli`, which is the single spawn
seam for all four CLI providers, so this is wired in one place rather than four. It is purely
additive: with nothing configured it returns the caller's `env` unchanged — `None` included, so
an inheriting child stays inheriting instead of being frozen to a snapshot — and a lookup that
fails leaves the child to fail exactly as it already did.

- **Global settings only** (`load_settings(include_project=False)`). The project-local file is
  the content of whatever tree happens to be checked out, and a repository must not get to name
  the program that reads this machine's secret store. Where a secret lives is a property of the
  machine.
- **A refusal is distinguishable from silence.** `SecretLookupResolution.reason` is set iff a
  lookup was configured and rejected; nothing configured and `enabled: false` both carry no
  reason. Otherwise a typo'd block and an unconfigured machine both arrive as "the environment
  was not changed". Reasons are short stable identifiers and never interpolate configured values.
- **Every refusal is total.** One malformed name rejects the whole block rather than being
  dropped from it, because a silently skipped name leaves the block reading as configured while
  resolving less than it says.
- **argv is a list, never a command string**, so nothing is ever split and no shape reaches a
  shell. At least one argument after the program must contain `{name}` (a lookup that cannot say
  which secret it wants is refused), and `argv[0]` must not, since the program to run may not
  vary with the variable being looked up.
- **A variable that already has a value is never looked up and never overwritten**, so exporting
  one is still how a single run overrides the store.
- **The value reaches the child's environment and nothing else.** Never a file, never an argv,
  never a log line: `_run_lookup` reports only the program name, the variable name and the exit
  status, and discards stdout on every failure path — a store that prints its errors to stdout
  uses the same channel the secret arrives on.

### `anthropic/claude_code.py`

- **CLI flag metadata protocol.** Every CLI-mappable `ClaudeCodeRequest` field carries a
  `json_schema_extra` dict built by `_cli()`/`make_cli_flag()`, consumed by
  `build_declarative_cli_args()`. Kind semantics: `value` → `--flag <str(val)>`; `bool` →
  `--flag` when truthy else omitted; `bool_pair` → `--flag` when True, `--neg-flag` when False,
  omitted when None; `list_args` → one flag followed by many positional args; `json_value` →
  dict/list serialized to a JSON string; `repeat` → the flag repeated once per item. Anyone
  adding a new CLI-mappable field must pick the right kind or the arg won't round-trip.
- **`mcp_servers` None-vs-`{}` invariant.** Defaults to `None`, not `{}`, specifically so a
  request that never touched the field is distinguishable from a caller that explicitly
  forwarded an empty server selection. The `is not None` check (not truthiness) in
  `as_cmd_args()` means `mcp_servers={}` still emits `--mcp-config {"mcpServers":{}}`, forcing
  zero MCP servers, rather than silently omitting the flag and letting the CLI fall back to its
  own MCP discovery. Flattening this to a truthiness check would silently break "explicitly
  disable all MCP servers."
- **Repo-containment scope for `add_dir`.** The write-target path containment check
  (`contain_paths_in_repo`) covers `system_prompt_file`, `append_system_prompt_file`,
  `mcp_config`, `settings` — deliberately excluding `add_dir`. `add_dir` is a read-only grant
  validated separately by `_validate_add_dir`; absolute paths there are intentional grants, not
  escapes, and must not be rejected by the write-target containment logic.

### `google/gemini_code.py`

Google folded Gemini Code Assist CLI into Antigravity (`agy`). This provider drives `agy` in
headless print mode (`--output-format json`), which emits exactly one terminal JSON object
(one NDJSON record) consumed unchanged by the shared `ndjson_from_cli` plumbing.
`conversation_id` is stored as `session.session_id` so native resume works via `--conversation`.
The public names/aliases `gemini-code` / `gemini-cli` / `gemini_cli` are kept for backward
compat even though the underlying binary is `agy`.

- **`resolve_agy_model` resolution/precedence rules.** `agy` has no separate effort flag;
  effort is expressed only via a Low/Medium/High suffix baked into the model display name.
  lionagi's effort scale (`none|minimal|low|medium|high|xhigh|max`) is clamped onto Gemini 3.1
  Pro's Low/High-only range via `_clamp_gemini_effort`. An exact, already-`(...)`-qualified
  `model` (a concrete agy display name) wins over `effort` by default. `reapply_effort=True`
  exists specifically to let a new `effort` replace the suffix baked into a *persisted* prior
  resolution (e.g. `li agent -r ... --effort ...`), while a `model` the caller explicitly typed
  in the current turn still wins regardless.
- **No per-tool stdout events.** `stream_gemini_cli`'s `on_tool_use`/`on_tool_result`
  callbacks are accepted for interface parity with other CLI providers but never fire in this
  transport, because `agy`'s json output surfaces no per-tool events on stdout (they exist only
  in the per-session transcript file).
- **Relative-path/stdin quirks.** `agy` resolves relative `--add-dir` entries against the
  process cwd and has no `-C`-style flag to change that, so the resolved workspace must be
  passed as the subprocess `cwd`. Default stdin is `DEVNULL` since print mode reads nothing
  from stdin.
- **`streams_first_output_early` stays False.** `agy`'s json print mode only yields output
  after the entire result object arrives (no incremental streaming), so a healthy long-running
  call looks identical to a dead/hung worker to a first-chunk watchdog — the endpoint can't opt
  into the early-first-output fast path other CLI providers use.

### `openai/_chat_schemas.py`

`uses_developer_messages` — the `system`-vs-`developer` message-role gate is deliberately
conservative and prefix-based: only o1/o3/o4/gpt-5 families (including dated variants, matched
by prefix after stripping any provider prefix) are gated onto `developer`. Unknown or missing
models fail closed and keep `system` — the default behavior on an unrecognized model string is
the safe, backward-compatible one, not the newer `developer` role.

### `groq/audio_transcription.py`, `openai/audio.py`, `openai/images.py`

`_replayable_file_factory` retry-safety contract (identical helper duplicated in all three
files): returns a zero-arg callable that produces a *fresh* file object for each retry
attempt. Bytes/bytearray are snapshotted once and re-wrapped in a new `BytesIO` per attempt; a
seekable stream is seeked back to its starting position before each attempt; a non-seekable
stream cannot be replayed safely, so when a retry could occur (`require_replayable=True`) it
raises before any network I/O rather than silently resending an exhausted stream (single-shot
endpoints get the raw stream handed through once). The stream is snapshotted once and its
position restored immediately — handing the *live* stream object to each attempt isn't
sufficient, because an explicit `RetryConfig` retry re-invokes `_call`, which rebuilds this
factory around the now-consumed stream (already at EOF), and would silently upload an empty
file on retry without this snapshot.

### `openai/codex.py`

- **cwd/-C double-resolution gotcha.** `_ndjson_from_cli` deliberately does NOT pass `cwd=`
  to `ndjson_from_cli`, because the Codex CLI already receives the workspace via the
  `-C <repo>` argument emitted by `as_cmd_args()`. Setting `cwd=` as well would make the CLI
  resolve `-C repo` from inside `repo`, producing a bogus `repo/repo` path.
- **Error envelope shape varies by event type.** Codex CLI's error payload location differs by
  event type: `"error"`-type events carry a top-level `"message"` key with no nested `"error"`
  key, while `"turn.failed"` events nest the message under `error.message` — both must be
  checked. The raw `error` value is captured *before* null-normalization (`_raw_err`)
  specifically because the benign-EOS check further down must distinguish an explicit
  `"error": null` (a malformed envelope — a real error) from the bare `{}` sentinel (benign
  EOF).
- **Benign-EOS sentinel on resumed sessions.** Some Codex CLI versions emit
  `{"type": "error", "error": {}}` when a resumed session ends normally — this is a benign
  end-of-stream, not a real failure, and is tagged so `run()` treats it as clean EOS rather
  than raising `RunFailed`. All of the following must hold for the benign-EOS classification:
  `type == "error"` exactly (`"turn.failed"` is never considered benign); the *raw* payload is
  exactly `{}` (an explicit `null`, once normalised to `{}`, must NOT qualify, hence checking
  `_raw_err` pre-normalization); and no other failure-indicating keys (`code`/`message`/
  `status`) are present in the outer event. Getting any of these three conditions wrong either
  misclassifies a real failure as benign or vice versa.

### `pi/cli.py`

- **`_PI_MODEL_PROVIDER_MAP` design rationale.** Model-name prefixes are mapped to
  `pi --provider` values, but only for *unambiguous* prefixes where the model name uniquely
  identifies the provider. Ambiguous family names (`llama`, `gemma`, `mistral` — available on
  multiple providers) are deliberately omitted from the map, so callers must set the provider
  explicitly or let `pi` resolve it itself, rather than the code guessing wrong. `strip=True`
  entries remove the prefix from the model string, needed for `openrouter/`-style routing.
- **Pi CLI arg-parsing quirk.** Pi's CLI arg parser has no `--` terminator support, so the
  prompt is passed as a bare positional argument. Prompts starting with `-` or `@` may be
  misparsed by Pi's own CLI as flags/file-references — callers should avoid leading dashes (or
  `@`) in prompts passed through this provider.
- **Dual meaning of the `"done"` event type.** Pi CLI overloads the `"done"` event type: both
  the top-level `AgentEvent.done` (true end-of-stream) and a top-level
  `AssistantMessageEvent.done` (an individual assistant message finishing) use the same
  `"done"` string, and both may carry a final message with model/usage info that must be
  captured via `_remember_assistant_message`.
- **`streams_first_output_early` stays False.** Pi's transport emits an `"agent_start"` event
  right after spawn, but `stream()` discards raw dict events and only yields its first
  `StreamChunk` once a `PiChunk` actually carries text/thinking/tool content — so the first
  output a caller can observe may lag the process spawn by the model's full "thinking" time,
  making the early-first-output optimization unsafe here (same gotcha class as
  `gemini_code.py`'s `agy` note above).

## lionagi/service/ (remaining)

### `connections/registry.py`

`EndpointRegistry.match()` — on a registry miss, consults the plugin
registry (ADR-0088 D3) before falling back to the generic
OpenAI-compatible endpoint: `_consult_plugin_providers()` imports every
ACTIVE plugin's declared provider module (never at import time or
discovery, preserving import-time O(1)), exclusively through
`PluginRegistry.activate_target` — never a direct `importlib` call on
plugin code — so the trust/enabled/active chokepoints enforced there apply
here too. Each activation is cached by the plugin registry itself, so
repeated misses are cheap. A plugin supplying no matching provider (or none
at all) leaves the fallback identical to the no-plugin case.
`_revalidate_plugin_entry` keeps a plugin-sourced registry entry available
only while its declared target remains trusted, removing it on
`PluginActivationError`.

### Retry & sentinel-exclusion contract (`connections/endpoint.py`, `providers.py`, `resilience.py`)

- `endpoint.py` — 4xx (non-429) client errors are wrapped in `_NonRetryableClientError` so the
  original `aiohttp.ClientResponseError` stays inspectable via `__cause__` while retry logic
  sees an excluded type. The sentinel must stay excluded until whichever retry layer is active
  (the outer `retry_config`-driven wrapper in `call()`, or the native path in
  `_call_aiohttp()`) has given up — unwrapping earlier would let a broad
  `retry_exceptions=(aiohttp.ClientError,)` config replay a 400/401/403 the transport contract
  intends as single-shot. Request bodies are rebuilt inside the per-attempt function rather
  than before retry orchestration, because `FormData`/`BytesIO`/file-stream bodies can be
  consumed by the first POST and must not be silently replayed on a later attempt. In the
  native (no-`RetryConfig`) path, `config.max_retries` is a total-attempt cap (formerly
  backoff's `max_tries`), but `retry_with_backoff` runs `max_retries+1` attempts internally, so
  the call subtracts one to preserve the configured total attempt count. `_can_retry()`: true
  when an explicit `RetryConfig` wraps the call, or the native path's total-attempt cap allows
  a second attempt; single-shot endpoints (`max_retries<=1`, no `RetryConfig`) never replay a
  body, so callers may hand over non-replayable inputs like one-shot streams only in that case.
- `resilience.py` — in `retry_with_backoff`, `exclude_exceptions` membership is checked
  per-instance (`except exclude_exceptions`) rather than per-type, which correctly handles
  subclass hierarchies — e.g. `retry_on=(OSError,), exclude=(ConnectionError,)` must not retry
  a `ConnectionError` even though it IS-A `OSError`.

### `connections/agentic_endpoint.py`

`streams_first_output_early` — true when a CLI/agentic transport emits its first `StreamChunk`
shortly after the subprocess spawns (e.g. an ndjson "system"/"init" event), making a stalled
first chunk a reliable dead-worker signal. False for transports that buffer all output until
the run completes, where a slow-but-healthy call is indistinguishable from a dead one until
the whole result arrives. This flag gates `run.py`'s default liveness watchdog
(`LIONAGI_WORKER_LIVENESS_TIMEOUT`).

### `connections/endpoint_config.py`

`_FIELD_KEYS_BY_CLASS` is keyed on `id(cls)` rather than the class object itself, because a
dict lookup by class object would go through `__eq__`/`__hash__`, which a custom metaclass may
override; each cache entry retains a strong reference to the class (keeping the id stable) and
is only served when the stored class `is` the lookup class, guarding against id reuse after
garbage collection. The cached value is the set of accepted field keys (including declared
aliases) computed once per class from `model_json_schema()`, since subclasses may add
fields/aliases and rebuilding the JSON schema on every `EndpointConfig` construction would
cost more than the rest of validation combined.

### `imodel.py`

- `stream()` — the `finally` block pops the in-flight call from `executor.pile` without
  yielding inside the `finally`, because yielding inside a generator's `finally` would swallow
  a `CancelledError` arriving during generator cleanup, which would break `anyio.fail_after`
  timeout enforcement for callers wrapping the stream.
- `copy(share_session, share_executor)` — creates a new `iModel` with the same config but a
  fresh ID. `share_session=True` carries the CLI provider's `session_id` onto the copy so
  cross-turn continuation is preserved (only applies when both endpoints are
  `AgenticEndpoint`). `share_executor=True` reuses the exact same `RateLimitedAPIExecutor`
  instance instead of building a fresh one, so a caller-supplied executor's rate limits and
  queue capacity stay shared between original and copy. Default (`False`/`False`) gives the
  copy its own independent executor — this is what `Branch.clone()` relies on for CLI
  providers, where each cloned branch needs its own session and queue rather than contending
  with the parent's. `circuit_breaker` and `retry_config` objects are shared by reference (not
  deep-copied) between original and copy; only `config` is deep-copied.

### `manager.py`

`iModelManager.shutdown()` — without explicitly closing every registered `iModel`, each one's
background rate-limit replenisher task stays scheduled and prevents `anyio.run`/`asyncio.run`
from returning at process exit. Idempotent; per-model failures (including `CancelledError`)
are logged and swallowed so one broken endpoint's shutdown failure can't block the others.

### `providers.py`

`normalize_effort()` must be called once at every boundary where a raw effort string enters
lionagi (CLI flag, profile frontmatter, orchestration spec) because the clamp tables
downstream are keyed on lowercase effort levels and silently misclamp (no raise) on an
un-normalized value like `"High"`. Codex reasoning-effort ceilings are model-dependent per the
codex CLI's live model list: `gpt-5.6-sol`/`gpt-5.6-terra` accept `max`/`ultra`,
`gpt-5.6-luna` accepts `max` only, and every earlier model tops out at `xhigh`; unrecognized
(future) models intentionally pass through unclamped so a genuinely supported new tier is
never silently degraded. agy (the Antigravity CLI) has no effort flag/kwarg at all — effort is
expressed only as a Low/Medium/High suffix baked into the resolved `--model` name, and Gemini
3.1 Pro has no Medium tier, so lionagi's 8-level `none|minimal|low|medium|high|xhigh|max|ultra`
vocabulary collapses onto this 3-tier scale via `_GEMINI_EFFORT_CLAMP`.

### `rate_limited_processor.py`

- `start_replenishing()` — its cancellation handler wraps `await self.start()` too, so a
  cancel arriving before the main loop is reached is still caught inside the task instead of
  surfacing as an uncaught error on `stop()`. The periodic re-drive of the queue
  (`if not self.queue.empty(): await self.process()`) exists because `process()` re-enqueues
  rate-limited events instead of dropping them, but `forward()` is one-shot — without
  re-driving on each refresh, deferred events would sit `PENDING` until the caller's
  `invoke()` safety timeout instead of actually retrying once the budget replenishes.
- `stop()` — Python 3.11+ re-raises `CancelledError` on `await task` after `task.cancel()`
  even though the task body already suppressed it internally; this is swallowed explicitly so
  callers closing multiple iModels in sequence don't abort on the first one's close.
- `handle_denied()` — rate-limit denial is a deferral, not a rejection — returning `False`
  makes the base `process()` re-enqueue the event (stays `PENDING`) for retry once the limit
  replenishes, rather than terminalizing it the way a permission rejection would.

## lionagi/testing/

### `testing/_endpoint.py`

`ScriptedEndpoint.copy_runtime_state_to()` — when an `iModel` is cloned, the script must be
**deep-copied**, not shared, so the clone gets an independent positional cursor. If the script
were shared, positional matching would cross-contaminate between clones — clone A consuming
response entry 0 would advance the shared cursor, so clone B's first call would incorrectly
receive entry 1 instead of entry 0. Recorded calls (`self.calls`) are shallow-copied instead,
since each clone only needs its own future calls tracked, not a defensively-copied history.

### `testing/_script.py`

- `_build_entry()` — response entries are dispatched to their concrete subclass
  (`TextResponse`, `ToolCallResponse`, `StructuredResponse`, `StreamResponse`, `ErrorResponse`)
  by manually branching on the `type` field rather than relying on Pydantic v2's discriminated-
  union support. Deliberate: Pydantic v2 won't reliably select a discriminated-union member
  when fields beyond the discriminator (`type`) differ between candidate models, so manual
  dispatch is used to get clearer validation errors when a script entry is malformed.
- `ScriptModel.next()` — response-entry matching has a two-phase precedence contract that test
  authors writing scripts rely on. **Phase 1**: unless `mode == "positional"`, every entry with
  a non-empty `when:` matcher is checked (skipping entries already served by a `when:` match)
  and the first one whose predicate matches (`call_index`, `after_calls`, `prompt_contains`,
  `prompt_regex`, `has_tool`) is returned; if `mode == "when_only"` and nothing matched, it
  raises immediately. **Phase 2**: falls back to positional order over entries that do NOT have
  a `when:` matcher, advancing an internal cursor; raises `ScriptExhaustedError` once positional
  entries are exhausted. This order (when-matchers-first, then positional) is the core scripted-
  fixture replay semantic and isn't obvious from the fixture's public surface alone.

## lionagi/tools/

### `sandbox_backend.py`

The sandbox backend seam (ADR-0090): backend divergence (local worktree vs. Daytona vs.
future backends) is absorbed entirely in `provision()` and `capabilities()`; `run_cell()`'s
signature never changes per backend. A `Cell` declares a `kind`: `prompt_cell` (the provider
call runs host-side, already authenticated — no secrets ever cross into the box) or
`exec_cell` (untrusted code runs inside the box, secrets injected explicitly). Callers must
read `capabilities()` to decide what a backend can do and must never branch on a backend's
name/type — this is the load-bearing security/extensibility contract of the whole seam.
`_SAFE_ENV_KEYS`: `run_cell`'s subprocess never blanket-inherits the host environment
(credential-leak vector); only `PATH`, `HOME`, `PYTHONPATH`, `VIRTUAL_ENV` are forwarded
automatically, plus whatever `cell.env` explicitly allow-lists.

### `khive_injection.py`

Reference `ContextProvider` (ADR-0008): recalls/optionally composes from a khive daemon over
the same MCP transport lionagi already uses for tool servers
(`service.connections.mcp_wrapper`) — no new transport, and no khive/MCP import at module
load, so the core import path stays clean without the `mcp` extra installed. Every recall
emits `brain.auto_feedback` in the same round-trip with the policy's explicit `profile_id`
(khive's auto_feedback does no binding resolution, so an implicit/default profile
mis-attributes the event). `writeback()` is a separate opt-in POST-turn hook — rule-based tool
error/resolution pairs written to `memory.remember` at capped, low-provenance salience,
invoked by the `operate()` Middle (not `provide()`), and it is NOT the nudge plane. Both
`provide()` and `writeback()` fully swallow transport failures (logged only) so a turn always
proceeds.

`KhiveInjectionPolicy.namespace`, when set, is threaded onto every khive verb the provider
emits (recall, compose, auto_feedback, remember) to isolate its writes to a named store.
`auto_feedback` WRITES to the live brain store, so an unpinned "read-only" caller still mutates
posteriors — pinning a namespace is required, not optional, wherever writes must stay isolated.
Currently only the write verb honors namespace (read verbs reject unknown params); this is
forward-wired for when reads grow namespace scoping too.

### `sandbox.py`

Git worktree lifecycle (`_cleanup_worktree_sync`, `_merge_sync`, `sandbox_merge`) is
retry-safe: a resource that's already absent counts as cleaned up, so a partial failure (e.g.
worktree removed but branch deletion blocked by another checkout) can be completed by a later
retry instead of failing forever on the step that already succeeded. `SandboxSession.is_active`
only flips to `False` once both resources are actually gone — a partial failure keeps the
session marked active so a caller can't mistake it for cleaned up. Merge additionally refuses
when `repo_root` is in a detached HEAD state, isn't checked out on the session's recorded base
branch (no auto-checkout), or targets a protected branch name (`main`/`master`/`release*`)
unless the caller explicitly opts in via `allow_protected`. (`git rev-parse --abbrev-ref HEAD`
returns the literal string `"HEAD"` when the repo is detached — not an actual branch name — a
quirk both `_merge_sync` and `create_sandbox` special-case, since unhandled it would let a
merge move a detached HEAD forward with no branch ref pointing at the result, or let
`create_sandbox` record a nonexistent branch as its merge target.)

`_list_untracked_files()` uses `git ls-files --others --exclude-standard -z` (NUL-delimited
raw paths) rather than `git status --porcelain`, which quotes/escapes paths with spaces or
special characters (breaking naive `line[3:]` slicing) and reports an untracked directory as a
single `?? dir/` entry instead of the files inside it.

### `communication/messenger.py`

`_fire()` — two related logging design decisions: (1) for the `help` event specifically, a
raising coordinator callback is caught and logged rather than propagated, because the whole
point of `help` is fire-and-continue — it must never surface as an unhandled exception on the
emitting worker's tool-call turn. (2) When no callback is registered at all for an event,
that's logged at `debug` (not `warning`) — a mis-wired coordinator should stay discoverable
during bring-up, but debug-level avoids spamming normal runs where some events are legitimately
unused.

### `coding.py`

`CodingToolkit.__init__`'s `sandbox_allow_protected` — whether the bound sandbox tool's
`merge` action may target a protected branch name (`main`/`master`/`release*`) is an
operator-level trust decision, not something the agent can request per call. It's deliberately
absent from `SandboxRequest` so an in-band agent can never self-approve merging into a
protected branch — set it only when composing the agent from code you control (e.g. a CI job
that always merges into main).

`_invalidate_stale_reads()` drops `file_state` entries whose backing reader-read result was
evicted/compacted by the context tool; otherwise the read-before-edit guard would stay
"satisfied" for a read the model can no longer actually see, letting it edit blind.

### `_subprocess.py`

`_subprocess_sync()` — `env=None` inherits the full parent environment; callers pass an
explicit mapping to scope less-trusted commands (e.g. the ADR-0090 sandbox-backend seam) to a
minimal environment.
