Skip to content

Studio Internals Reference

Non-obvious invariants, protocol contracts, and design rationale for lionagi/studio/ that don't belong inline as long-form comments. Terse reference — not a narrative. Organized by module path.

lionagi/studio/_host.py

normalize_host — the one reader of a host value's spelling: whitespace and one bracket pair removed, lowercased, and, when ipaddress parses what is left, printed in its compressed lowercase form with an IPv4-mapped address unwrapped to its IPv4 address. Every reader of LIONAGI_STUDIO_HOST compares values through it, so a bind written in brackets, in a non-canonical IPv6 spelling or as an IPv4-mapped address names the same admitted Host as any other spelling of that address. Deliberately imports only ipaddress: the CLI imports this module and must not pull the web stack in with it. A value that is not an address is returned as the stripped lowercase name, and a blank one stays blank.

lionagi/studio/app.py

Host-header validation (_HOST_AUTHORITY_RE/validate_host_header) — strict authority grammar (host or host:port, or bracketed IPv6 [addr] with optional :port), deliberately stricter than request.url.hostname, which normalizes/mis-parses authorities like "127.0.0.1:8765.evil.com" or "[::1]evil.com" into an accepted hostname. validate_host_header defends against DNS rebinding, where a malicious page points a browser at http://127.0.0.1:<port> with an attacker-controlled Host and, once past CORS/auth, reaches the daemon as if same-origin — registered outermost (added after CORSMiddleware, so it runs first under Starlette's LIFO middleware wrapping) so every request, including preflight OPTIONS, has its Host checked before CORS can answer. The grammar is unchanged by normalize_host: it still refuses a dotted IPv4 inside brackets and an authority carrying two ports, and the normalizer only says which address an authority the grammar admits names.

Middleware order in create_app(): Host validation -> CORS -> Content-Type/CSRF check -> bearer-token gate -> route. A real preflight never reaches the bearer-token/Content-Type middlewares because CORS answers it first.

Origin rule with no token (_origin_accepted_without_token) — with no LIONAGI_STUDIO_AUTH_TOKEN, the bearer-token gate still refuses /api and schema requests (401) from a browser origin that is neither loopback (localhost, 127.0.0.0/8, ::1; an IPv4-mapped address is judged as its IPv4 address) nor the daemon's own origin (Origin authority equal to the request's Host, as for the SPA a daemon bound to another address serves itself). null, a second Origin header, or an origin carrying userinfo, a path, a query or a fragment is refused. CORS only decides which pages may read a response, so it cannot stand in for this check.

A request without an Origin header is judged by its Sec-Fetch-Site (_fetch_site_accepted_without_origin), because a browser sends no Origin on a GET or HEAD made outside CORS (an image, a script, a top-level navigation) from any site. With no Sec-Fetch-Site, as from the CLI or a script, it passes; so do none (a typed address or a bookmark) and same-origin (the SPA the daemon serves). same-site, cross-site, any other value and a second header are refused like a foreign origin. A browser that sends no Sec-Fetch-Site is not told apart from a script. The SPA shell and its assets stay public, so a link from another site still opens Studio.

The Operator decision routes (services/operator.py's confirm_operator_proposal and decide_operator_proposal) call the coordinator's decide() with no principal check of their own, so in open mode this admission is the whole gate in front of them. Without LIONAGI_STUDIO_AUTH_TOKEN, any process that can reach the daemon's listener, including programs run by other local accounts, can allow or deny Operator proposals. The permission prompt is a workflow safeguard, not isolation from local programs; set a token on a shared machine. Do not auto-allow a tool that can make HTTP requests to the daemon: a run holding such a tool can approve its own proposals. This is a disclosure of existing behaviour — the routes, the admission rule and the schemas are unchanged.

A refused origin, or a request without one refused by its Sec-Fetch-Site, is admitted only with the launch bearer (launch_access.py), minted per process and held in memory, never put in the environment or written anywhere. A page receives it by pairing. li studio mints a one-time code in process before it starts the daemon, or asks a daemon already running on the port for one over the daemon's reconnect socket, then opens the hosted page with #pair=<code>. No HTTP route issues a code: POST /api/studio/pairing-codes answers 410 after admission, whatever the headers or token. A browser never sends the fragment to a server. The page reads it once before the router starts, removes it with history.replaceState, and redeems it at POST /api/studio/pair, the one API path the gate lets through without a bearer. That route requires an Origin in CORS_ORIGINS (checked before the code is spent); a code is bound to the origins the launcher named, works once, and expires after 120 seconds. The answer is the configured token when one is set, else the launch bearer, plus a decision capability bound to the page's origin (see below). The answer also carries token_source: "launch" for the launch bearer, "configured" for the configured token.

The page stores the bearer it is given, whatever token_source says ("launch", "configured", missing or any other value); it does not read the field. An answer from a daemon released before the field carries none, and its bearer is stored the same way. The stored bearer is kept in localStorage for the page's own origin, bound to the daemon's API base, and survives reloads, tabs and bookmarks. Before the app mounts, the page checks a stored bearer with one authenticated request (GET /api/casts/), so nothing renders on it unchecked. Only a 2xx answer mounts the app. The first 401, to that check or to any later request that carried it, clears that value; a bearer another tab stored since is kept. A restarted daemon draws a new launch bearer, so its first answer sends the page back to the access-required screen with li studio as the remedy. Any other answer to the check, or none within 15 seconds (PAIRING_TIMEOUT_MS in apps/studio/frontend/src/lib/pairing.ts), shows the same screen and keeps the stored bearer for Retry, and a check that does not reach the daemon keeps it too.

Why storing the bearer is acceptable: a daemon with no configured token already admits any local caller that sends no Origin, a command-line client for one, and pages on loopback origins, so the launch bearer gates only pages on other origins. The stored value is readable only by scripts served from the page's own origin, which receive it at every pairing anyway. It is bound to one API base, so a request to any other base does not carry it, and the first refusal forgets it. A launch bearer also stops working when the daemon restarts. A configured token is a long-lived operator credential, so it stays in the browser until the daemon refuses it or the site's data is cleared; an operator who does not want it kept there clears the site's data or pairs in a private window. The capability is kept in sessionStorage for that tab, so it survives a reload and is gone with the tab.

The reconnect socket (reconnect.py) — a unix socket in a studio/ directory beside the StateDB file, created with mode 0600 inside a 0700 directory in the lifespan before the daemon serves, removed at exit, and replaced at start when a socket file left by a dead daemon is there. On each connection it reads the peer's user id from the socket (SO_PEERCRED on Linux, LOCAL_PEERCRED on macOS, both through socket.getsockopt), refuses any id but its own, and for its own user mints one code in process and writes it, with the channel's port-proof key (256 random bits drawn when the channel starts), as the only content of the reply. It is not an HTTP route and no TCP listener serves it. li studio probes /health to tell a daemon from another program, reads one code and the key over the socket, then sends /health?challenge=<32 hex digits> on its port; the daemon answers with HMAC-SHA256 under the key over the challenge and the port the request arrived on (port_proof). Only when that matches does it open a page: a second daemon sharing the state directory, or a listener relaying to the right daemon from another port, is refused and li studio exits 1 without printing the code. It never stops a running daemon; on a platform without unix sockets it says to restart the daemon. A process running as the daemon's own OS user can obtain a code this way, which is outside what the HTTP-only guarantee covers. The HTTP requests li studio sends its daemon, the li schedule clients and the li doctor daemon check open a URL whose host is localhost, a 127.0.0.0/8 address or ::1 without any proxy (open_url in lionagi/ln/_ssrf.py): a proxy named in the environment or, on macOS, in the system settings cannot reach this machine's loopback interface, so with one configured a running daemon would read as absent. Other hosts keep the proxy settings, and a redirect from loopback to any other host is not followed. When nothing answers as a daemon, the hosted launch (li studio without --no-frontend, --dev or --docker) tries to bind its port once, the way the daemon will, before it mints a code: a port it cannot bind stops the launch with an error that names the cause, and no link is printed or opened. This is a probe at launch, not a reservation: another program can still take the port before the daemon binds.

Decision capabilities (launch_access.py) — a page pairs with an admission bearer and, separately, a decision capability: a random 256-bit secret the daemon keeps only as its hash, bound to a random principal id, this start's epoch, the frontend origin it was redeemed from and the scope operator:decide, expiring eight hours after redemption and revoked by POST /api/studio/disconnect on an explicit disconnect. A daemon restart invalidates every code and capability. LIONAGI_STUDIO_AUTH_TOKEN mints none, and a decision that carries only that token is accepted in this release and records no principal. The two Operator decision routes read it from X-Studio-Decision-Token when it is there, resolve it to its principal and pass that to decide(), whose store transaction records the principal id, epoch, method, origin and scope on the proposal row and in the audit row; a request without the header is still accepted and records no principal. Nothing refuses a decision yet: the router contract stays version 1, and the no-token admission rule above is unchanged for every route.

require_json_content_type — rejects every state-changing /api request that does not declare application/json, including bodyless action routes. FastAPI parses bodies as JSON regardless of declared Content-Type, and a cross-site "simple request" can also reach an empty-body trigger, enable, disable, delete, or cancel route without a CORS preflight. The shared SPA fetchJson transport adds the JSON media type to every unsafe method, so first-party callers satisfy the contract even when they have no body.

GET /api/identity — authenticated, state-store-free desktop launch probe. It returns only the fixed daemon identity and LionAGI version; desktop startup uses it after /health instead of invoking the database-backed /api/stats.

_mount_spa — uses a 404 exception handler, not a catch-all route, for the SPA fallback: a catch-all /{full_path:path} route would intercept /api/shows before FastAPI's trailing-slash redirect fires, whereas an exception handler runs only after every route has been tried and missed.

Startup and the 501 store guard — StoreNotAddressableError becomes a 501 at the route, which only helps if startup gets far enough for routes to be reachable at all. A subsystem that can read only a local SQLite file must therefore skip itself during lifespan rather than raise: operator_startup checks require_file_store() and returns empty against a server-backed or in-memory store, because raising there aborts the whole lifespan and the daemon serves nothing, including the routes whose whole job is to say this condition cannot be served. OperatorStore.path() raises the same StoreNotAddressableError as the rest of the SQLite-direct layer rather than an OperatorStoreError, so the routes that open the store answer 501 (permanent) instead of 503 (retryable), and the definition of which stores this layer can open stays in one place. The qualifier is doing work: a route that never opens the store is unaffected and answers normally in every mode. GET /operator/models returns its catalog from operator/catalog.py and so returns 200 against a server-backed store like any other, which is worth knowing before reading a 200 there as evidence that the store is reachable.

lionagi/studio/cli.py

_validate_chain_action_node — Validates one chain_action node, recursing into nested on_success/on_fail the way the engine's chain-fire would reach them. chain_depth mirrors the engine's own gate (scheduler/engine.py, chain_depth < _MAX_CHAIN_DEPTH); recursion stops there because a node beyond that depth never has its own on_success/on_fail read by the engine. self_field tracks which chain field a node was reached through, for the re-fire warning (does the node set its own copy, or inherit the parent's via shallow merge?).

_base_url — Tolerates a base URL that already carries an /api suffix (older documented workaround) so requests don't double up to /api/api/... and 404. Warns once instead of stripping silently, since a reverse proxy whose public prefix genuinely ends in /api needs visibility into the rewrite.

_await_vite_ready_url — A background thread drains Vite's stdout for the life of the dev-server process so Vite never blocks on a full pipe buffer; once the function itself returns (ready URL found, or timed out) that thread stops parsing lines and only keeps draining. It's a daemon thread with no explicit join, so it exits with the process rather than needing shutdown wiring.

lionagi/studio/config.py

System timezone fallback — When the localtime symlink points outside zoneinfo.TZPATH, a zoneinfo component in its path can still supply a loadable IANA name. This fallback checks both the spelling and the canonical keys reached through configured aliases. Every candidate must match the actual localtime file's rules. Configured duplicate keys and canonical aliases must agree. Two files are compared by what they read as rather than by how they are encoded: the UTC offset, the daylight offset and the name at every transition of either file, plus the rule past the last stored transition, must be equal, and the POSIX footer text must be identical. The daylight offset is read from the loaded rules, so two builds of one release that disagree only there — a rearguard and a vanguard build of the same tzdata, say — are not equal. A file that leaves a transition to its footer and one that stores that transition therefore match, as do files differing in a compatibility block, unused padding or trailing bytes. Where one file reads its footer and the other still reads stored transitions, the two are also compared once a week between 1900 and 2100, so a year one table leaves out is not passed over; a rule lasting under a week there is not looked for. Different footer expressions for the same recurring rule are not normalized. A loadable file outside the bounded reader's supported format requires exact byte identity instead. Once ambiguity is found, another spelling cannot bypass the refusal. Inaccessible search entries are skipped, matching the standard library's filesystem lookup. When no configured file supplies a key, the public Python tzdata package resource may supply the same evidence. Filesystem reads require regular files, use nonblocking opens with a second file-type check, and are capped at 1 MiB. Package resources have the same size cap. A readable localtime file with no accepted, unambiguous name raises SystemTimezoneUnreadableError; LIONAGI_SCHEDULER_TZ can select the desired zone explicitly.

lionagi/studio/scheduler/engine.py

_reserve_max_runs_budget / _release_max_runs_claim — The single-process analogue of a DB-backed compare-and-set for schedule['max_runs']; only one scheduler process runs today, so no DB-backed reservation exists. Guarded by an engine-wide lock so the tick loop, fire_now(), and GitHub polling can't both read the same count and both claim it before either claim is visible. Reads inflight (this process's outstanding claims) before the awaited count_schedule_runs() call, not after — release() is deliberately lock-free (a claim must still release from a cancelled/failing _fire()'s finally without depending on this lock), so a concurrent fire's claim can vanish mid-await. Reading inflight first means the sum can only ever over-count (a spurious refusal, self-correcting on the next tick), never under-count into an actual overshoot — reading it after the await would let a fire that both persists its row and releases its claim inside this call's await window disappear from both the persisted count (read too early) and the in-flight snapshot (read too late). The claim is released from _fire()'s own finally, not from inside _check_max_runs(): a fire failing before ever reaching _check_max_runs() (e.g. create_invocation raising) would otherwise leak the claim for the life of the process. Chain children never call this — only top-level fires consume budget.

_fire_inner delivery contract — At-least-once up to confirmed process launch, at-most-once past it, across three windows: (1) before the occurrence transaction commits, a crash leaves nothing durable, so a restart fires fresh, never a duplicate. (2) Between commit and spawn_and_wait() confirming launch (on_launched stamping dispatched_at), the row is durable but undispatched; _recover_undispatched_fires() finds it on startup and re-fires via supersedes_run_id, which routes the occurrence insert through tombstone_and_replace_schedule_run() to tombstone the orphan and insert the replacement atomically (its CAS also requires dispatched_at IS NULL, so a launch confirmed in the race against recovery wins and the tombstone is a no-op). (3) Once dispatched_at is confirmed, the process genuinely exists and is never re-fired — resolved by the stale-run reaper or its own terminal write. A duplicate real-world side effect is worse than one unretried outcome, hence the asymmetry. The occurrence-insert and any extra_schedule_fields cursor advance land in the same transaction as this delivery boundary — see _write_occurrence() for the atomic-with-tombstone recovery path.

_reconcile_dispatched_orphans — Startup-only; handles schedule_runs rows that were confirmed dispatched but never reached a terminal status, which _recover_undispatched_fires() (safe to blindly re-fire) can't touch since a row here may have a genuinely live child. Resolves only from positive completion evidence: when every session linked to the invocation has independently reached a terminal status (each session's own teardown writes its own terminal status, independent of whether the dispatching scheduler survived to see the exit code), the schedule_run is finalized via resolve_invocation_terminal(). Unknown liveness is never treated as death — a row with no sessions yet, or any non-terminal session, falls through unchanged to the wall-clock stale reaper (reap_stale_schedule_runs).

_recompute_armed_cron_schedules — Startup-only re-resolution of every enabled cron schedule's next_fire_at under the current timezone interpretation, guarding against stale fire times after LIONAGI_SCHEDULER_TZ (or the host timezone) changed since the schedule was last armed. A schedule already due (next_fire_at <= now) is left alone here so it flows through _check_missed_fires() first — missed_fire_policy ("run_once"/"skip") must get a chance to run before anything advances the timestamp, and _check_missed_fires() runs immediately after this method returns in _tick_loop, synchronously advancing next_fire_at via the recovery path before the following _tick() call. Only schedules still ahead of now — the timezone-migration correction case this hook exists for — are recomputed here; the method never fires anything itself.

_check_budget — Pre-fire cumulative spend gate, not a mid-run interrupt: a run already in flight is never killed when it crosses the budget line, since its cost is unknown until it terminates, so a schedule may overshoot by up to one run's cost before the next fire is refused. Pair with LIONAGI_STUDIO_INVOCATION_DEADLINE_SECONDS to bound a single run's worst-case spend. spend["cost_usd"] sums only reported cost — a session whose engine never priced itself contributes nothing, not a confirmed $0 — so this deliberately does not force exhaustion over unreported sessions alone (that would turn a data gap into an outage); it only surfaces the gap via a log line and unreported_sessions in the schedule's spend rollup, so a near-zero reading with many unreported sessions reads as "unknown," not "cheap."

_threshold_alert_update_fields — Stamps last_alert_at into the same update_schedule() call that already writes last_fired_at/next_fire_at inside _fire_inner(), deliberately placed after create_schedule_run() has durably persisted the run row. Stamping the cooldown any earlier (e.g. before _fire() starts) risks consuming it on a pre-persistence failure (e.g. create_invocation() raising) that leaves zero durable record an alert was ever attempted — the exact silent-loss shape the feature exists to prevent. Only top-level fires (chain_depth == 0) of a threshold-configured schedule stamp the cooldown; on_success/on_fail chain children are follow-on actions of the same alert cycle, not a new one.

lionagi/studio/scheduler/subprocess.py

spawn_and_wait — Both stdout/stderr streams are captured and drained concurrently, bounded rather than buffered whole: a single communicate() call on both pipes would hold an entire streaming leg's output in memory, and draining one stream at a time deadlocks once the other fills its buffer. When action_kind="command", the command allow-list check re-runs here, immediately before spawn, closing the window where an awaited DB call between build_argv() and this function gives a revoked LIONAGI_SCHEDULER_COMMAND_ALLOWLIST env var a scheduling point to land in. A command child also gets the daemon's environment without LIONAGI_STATE_DB_URL, in any letter case (env_without_state_db_url in lionagi/ln/_proc.py). A command often runs another interpreter with its own lionagi, and an inherited store URL would open the daemon's store with that lionagi: one on a newer schema migrates the file on open, after which the daemon's own code refuses it. Every other path that runs a configured or agent-chosen command starts the same way: notify commands, external hooks, MCP terminal notices, secrets lookup commands, configured stdio MCP servers (the Studio connection check and the MCP connection pool), the commands run through communicate_in_process_group (tool hooks, outbox delivery), and the commands an agent runs through lionagi's own shell tool. A command that needs the daemon's store names it itself, in its argv or its own environment; an environment a caller hands a command explicitly (a server's configured env, a sandbox cell's env) is passed as given. Every li child (the other action kinds) still inherits the URL, so its run records into the daemon's store.

Two limits remain. A command that runs in the daemon's working directory (one given no cwd of its own) reads the .env, .env.local and .secrets.env files there as its own settings, so a store URL in those files still reaches it: keep the store URL out of the dotenv files in the daemon's working directory, or give the command a cwd. Every command, whatever its cwd, also reads $LIONAGI_HOME/.env below those files, so a store URL there is a user-wide default that reaches every lionagi that reads that file. And a child without the variable falls back to the default store, $LIONAGI_HOME/state.db: a daemon whose store is at that path, by default or through the variable, shares it with any lionagi its commands run under the same home directory, and a lionagi of the same line on a newer schema migrates it on open, as an upgrade does.

lionagi/studio/services/schedules.py

  • Recent-run list contract v1 — GET /api/schedules/?include_recent_runs=true adds optional recent_runs_summary to each definition: {version: 1, limit: 25, status, has_next, runs}. Available slices contain up to 25 occurrences in fired_at DESC, id DESC order, including chain children and every status. has_next: true means older history exists; fewer than 25 with has_next: false is a complete short slice, and an available empty slice means no run rows were found. A failed slice has status: "unavailable", has_next: null, and runs: [], and so does a slice whose version the client does not support. A row that omits the field altogether -- a daemon that predates it -- is read through that definition's own history endpoint and marked unavailable only when that read fails; that read asks for one row past the displayed count so it can state has_next truthfully, and the client holds the result to the same rules as a field-bearing slice. None of these is evidence of empty history. That read is one request per such definition, so a page whose daemon omits the field costs the list request plus one history request each; only a page the daemon answers with the field costs a single request. The flag defaults to false; Mission, operator MCP, and machine list callers retain their existing response and do not compute these histories. One batched statement selects at most 26 keys per listed definition through a per-group ordered LIMIT 26, then loads occurrence payloads and error details only for its displayed 25. The statement's collection functions are dialect-specific: SQLite aggregates and expands the keys with json_group_array/json_each, PostgreSQL with jsonb_agg and jsonb_array_elements_text ... WITH ORDINALITY. Each group uses the schedule/time index, and the limit is applied after the ordering: a set of runs that share one fired_at at the boundary is read in full to be ordered by id, so a tie group is not a constant-cost read however many rows it holds. Invocation and session evidence is joined in two additional set queries. All linked sessions participate in the existing outcome reducer; session count is not capped because an omitted active child could change its verdict. Public rows retain occurrence status, timestamps, exit code, and chain depth; error classification uses the same reconciled outcome as detail history. Occurrence status is not a combined child-session status. An unreported child failure need not produce an error class when the occurrence has no error text; the detail view retains the separate reconciled outcome. On the field path, raw error text, prompts, event payloads, and artifact paths stay withheld. A row read through the older daemon's history endpoint arrives as the wider run view that endpoint serves, so the client projects each row onto the fields the list contract's slice carries -- dropping duration, artifact paths, session ids, and the reconciled outcome -- which leaves a field-path row and a fallback row the same shape. The raw error text stays withheld there too. A shared batch read failure is followed by one liveness read that does not touch the failing statement. When that read fails too the failure is treated as store-wide, and every slice is left unavailable without a read each, so an outage costs two reads rather than one per listed definition. When the store answers, the batch failure is attributed by retrying each definition on its own: a definition whose own rows fail is marked unavailable without hiding the other summaries on the page. A retry that fails for a reason outside the definition's rows (an intermittent store error) marks that definition unavailable too, so an intermittent failure can mark healthy definitions unavailable for that poll. A statement that fails for every definition while the store answers therefore costs N + 2 reads for N listed definitions -- the batch, the liveness read, and one retry each -- and its failing ids are named on one line per page rather than a traceback each; the retries' own errors are not logged. Only the store-wide case is two reads. A store-wide read failure leaves every slice unavailable, never an available empty slice. Projection failures are isolated to one schedule. Existing health/count queries, budget rollups, schedule-list limits, and filters are unchanged. Cards and table mark an unavailable schedule's own row; the shared banner names every unavailable schedule for all views, including the calendar. A failed list load with no schedules suppresses the empty-state claim, which would otherwise read as a successful load that found none. Refreshes are single-flight with at most one queued refresh. Explicit detail history keeps its separate pagination and does not use this slice; its ordering gains the same id tie-break (fired_at DESC, id DESC, where it was fired_at DESC), so it orders an exact tie the same way.
  • _svc_validate_action_command — Delegates to the subprocess validators so charset/allow-list rules live in one place. build_argv re-checks the allow-list again at spawn time since LIONAGI_SCHEDULER_COMMAND_ALLOWLIST can change between schedule creation and fire.
  • _svc_recompute_next_fire_guarded — The caller's DB write has already committed, so a recompute failure must not surface as a 500. One retry covers transient contention; if both fail, the row keeps its stale next_fire_at — healed only by the daemon-startup recompute, or by firing once on the stale timestamp and recomputing from there.
  • _svc_validate_github_repo — Delegates to github._validate_github_repo (owner/name regex, CWE-918 guard) so the pattern lives in one place. None = field not supplied (no-op); "" = explicit invalid value, forwarded for rejection.
  • github_filter allowed keys — event narrows which PR lifecycle moment fires the trigger; the rest narrow which PRs are considered. Only pr_merged has real dispatch semantics in github_poll() today — pr_opened/pr_updated/pr_closed are accepted because the frontend's create-schedule form ships all four, but are currently inert server-side. same_repo_only excludes fork-origin PRs (head repo ≠ polled repo), whose diffs are attacker-controlled input.
  • _svc_validate_prompt — Rejects action_prompt == '--': that literal end-of-options token is silently consumed by argparse and never reaches the runner as prompt text. All other content, including leading -, is safe because the structural argv fix places a -- sentinel before positionals.
  • ADR-0070 delta 1 (execution root snapshot) — An explicit action_cwd always wins. Otherwise a registered action_project's path is captured once at write time, not re-resolved at fire time, so a later project-registry change or daemon restart can't move the schedule's spawn cwd. If neither resolves, action_cwd stays None and the engine falls back to LIONAGI_SCHEDULER_CWD / the daemon's cwd, same as a pre-migration row.
  • update_schedule PATCH semantics — Uses exclude_unset, not exclude_none: a field the client never mentioned is untouched, while an explicit null passes through so the field can be cleared or rejected. exclude_none would make an all-null PATCH indistinguishable from an empty one.
  • _svc_validate_action_cwd — An explicit action_cwd (ADR-0070 delta 1's persisted execution root) must be an existing absolute directory; None means "not configured" and is allowed, but an empty/whitespace value is rejected outright rather than persisted, since the scheduler fails closed on any non-None root it cannot resolve. Mirrors scheduler.engine._is_usable_execution_root's two conditions by hand (kept in step manually) rather than calling it, because this validator's product is the error message telling the caller which rule they broke, where the predicate can only answer "no".
  • compute_schedule_health — States: disabled (not enabled), never-fired (enabled, zero schedule_runs rows AND no retained last_fired_at watermark), no-evidence (enabled, but the table can't tell whether the schedule genuinely never ran or its run history was pruned by retention after firing), overdue (enabled, cadence known, no liveness evidence within grace of the expected cadence: an executed run, a threshold schedule's last evaluation, or a GitHub poll schedule's last healthy poll), failing (the latest executed run's outcome was failed/timed_out), healthy (otherwise). schedules.last_fired_at is a retained per-schedule column that survives schedule_runs retention pruning even after every run row is gone. never-fired is the strongest claim the table can make, so it requires both signals to agree nothing was recorded (zero rows AND no watermark); either one alone being non-null means the schedule executed at some point, and the honest verdict is no-evidence, not never-fired.

lionagi/studio/services/runs.py

Run identity projection — list and detail rows keep id equal to the backing Session ID, which is the value accepted by every v1 Studio run and Session route. run_id carries the raw nullable sessions.run_id correlation to a CLI run. It is provenance only: multiple Sessions can share it, and an unlinked Session reports JSON null rather than fabricating a second copy of its Session ID. This is intentionally narrower than ADR-0123's future canonical Run model.

_open_regular_file_no_follow — resolve_workspace_path only validates at check time; nothing stops the target being replaced with a symlink before a later path-based open() follows it out of the artifact root (CWE-367/59). Walks each path component via os.open(..., dir_fd=parent_fd) off a descriptor obtained before that component, never by re-walking a path string, with O_NOFOLLOW refusing a symlink at any position.

_build_steps_from_db — message_count/roles come from message_stats, falling back to message_total then windowed page length for legacy payloads. The message_stats.message_count check is key-presence based, not truthy: a legitimate 0 (stale progression referencing pruned message ids) must not fall through to 0 or fallback.

lionagi/studio/services/sessions.py

  • _fetch_action_messages — The +m.lion_class query hint disqualifies the lion_class index so the planner uses the id primary key for the IN list; without it SQLite drives the query off idx_messages_lion_class and rescans every action-class row table-wide per chunk.
  • get_session_messages_after — Joins each branch's progression via json_each rather than binding every message id into an IN (...) clause, since a branch with thousands of messages would exceed SQLite's 999 bound-variable limit.

lionagi/studio/services/admin.py

  • process_liveness — Tri-state: True = observed alive; False = confirmed dead (recorded pid gone, start-time verified when recorded); None = unknown (no recorded pid/match — normal for externally-driven sessions). A bare recycled pid with no recorded start time reads alive (fails toward live, not falsely dead).
  • transition_sessions CAS block — WHERE status='running' can only move running→target (legal forward transition, never overwrites terminal status) — do not widen or drop that predicate. The last_message_at/ updated_at equality guards stop this reconcile from clobbering a session that went active again between classification and write (the oscillation fix); update_status()'s expected_statuses guard doesn't compare on these columns, so routing through it would regress the protection. Intentional specialized CAS, not a bypass of the shared chokepoint.
  • Stale-lock scanning — _RUNTIME_LOCK_NAMES (job.lock, finalize.lock) matches lionagi's own runtime locks by name, not by the .lock suffix: suffix matching also reads dependency lockfiles (uv.lock, poetry.lock, Cargo.lock) as dead runs, and since a run's artifacts_path is routinely a repository root, one checked-in uv.lock marked every completed session in that repo a zombie. The resume lock ({digest}.lock) lives in resume-locks/ beside the state DB, never under an artifact root, so it's unreachable from this scan by construction. Certain directory names (.git, node_modules, build caches) are pruned outright — matching by name after pruning measured 100s vs. 3ms for an unpruned suffix-based walk. _ScanBudget is a wall-clock ceiling shared by every walk in one scan (monotonic, so a system clock adjustment can't extend or collapse it), sized per-scan rather than per-root because cost concentrates in a few roots (measured: 76s across 3780 artifact roots, 70 of those seconds in just 4 of them). _find_stale_lock's cache is caller-owned and per-scan (sessions repeat artifact roots heavily — one root was 152 of 500 recent sessions), never module-level, since a scan is a snapshot at one cutoff and a process-lifetime cache would answer against a filesystem that's since moved on.
  • _code_identity_report — Answers "which code is this daemon actually running, and has it fallen behind": with an editable install the daemon's one startup import resolves to a working tree, so the version string alone can't distinguish a stale checkout from a current one. Read fresh on every call (never cached at start, since the question is necessarily about now), shelling out to git off the event loop under its own budget so a daemon never stalls on its own health check.

lionagi/studio/services/invocations.py

list_invocations_route — The usage aggregates are opt-in through the non-schema include_usage query flag, which the library usage panel passes and the Fleet and Mission Control polls leave off. Without it the route returns only the plain total and completed_total counts a row-rendering poll reads, and both finished_total and latest_started_at are null — so a 3-second poll over the unfiltered invocation list runs the two COUNT(*) reads it renders and not the MAX(started_at) aggregate or the terminal-status CASE count that would scan the same matching rows for numbers no poll displays. Both paths count every matching row; the flag drops the aggregate work, not the scan. With it the route additionally returns latest_started_at, a nullable Unix-second timestamp for the maximum recorded start across every row matching skill, plugin, and status; it is computed with total in a combined COUNT(*)/MAX(started_at) aggregate SELECT within the existing page/count read snapshot, so limit, offset, update order, and cleanup end times cannot change it. No matches return null. completed_total keeps its status-independent count of completed records. The additive finished_total counts the canonical invocation terminal statuses (completed, completed_empty, failed, timed_out, aborted, and cancelled) with the same skill/plugin scope, ignoring the page's status filter. Both completion counts share one SELECT in that snapshot, without increasing query count. Recorded success rate is completed_total / finished_total; empty completions and cancellations are finished but not successes. Older clients can ignore the new fields; newer clients show unknown last use or an unavailable rate when their respective aggregate is omitted, rather than estimating from a bounded page. Cancellation end times record observation of cancellation, including delayed cleanup; cancelled invocations are excluded from the scheduler p95_latency_ms metric so cleanup delay does not become execution latency, and so are the rows the lifecycle reaper ends: its timed_out write carries a {"kind": "invocation"} entry in status_evidence_refs, which a run's own deadline write does not, so the metric excludes a reaper-observed end time by that entry while a run that recorded its own timeout still counts. A scheduled fire writes its trigger type into plugin (lionagi/studio/scheduler/engine.py), so the stored-identity filters match a same-named plugin to those rows; correcting producer identity and historical rows is separate work. Last tracked use counts every matching recorded start, including a scheduled fire that lost its claim and never launched: its row keeps its start time, and what it does carry — a reason code shared with cancellations that did launch, and the scheduler's own status_evidence_refs kind — is not a dialect-neutral key this aggregate filters on.

get_invocation — One invocation with its child sessions, artifacts and derived health. readonly opens the store read-only for callers whose contract says they only read; the ordinary open runs schema application, which takes a write lock and can issue migration statements. Defaults False because read-only mode is available only on an on-disk SQLite store — the decision belongs to a caller that has already checked read_only_open_supported(), since passing True unconditionally would fail at open elsewhere rather than degrade.

lionagi/studio/services/engine_runs.py

  • Canonical runtime identity — A persisted Engine execution has one engine_runs.id, one signal session whose id is the same value, and optional links to its outer Studio invocation and caller-supplied parent session. li engine run --invocation wins over LIONAGI_INVOCATION_ID; the signal session is persisted with invocation_kind="engine". Embedded Workflow Engine nodes stay inside the workflow's canonical session and expose a per-node engine_span_id instead of creating a second top-level run row.
  • Outcome envelope — outcome_json is a bounded, versioned summary of status, degradation, timing, result shape, effective-model provenance, and a configuration fingerprint. It never stores prompt or result content. A run that completed with degraded branches keeps status="completed" and error=NULL; terminal error is reserved for total failure/cancellation.
  • List/detail split — GET /api/engine-runs/ reads a seekable summary projection and never selects stored spec_json, export paths, or raw error text. Its cursor is opaque and bound to the active filters. Detail returns a redacted, byte-capped preview by default; the larger redacted stored input is available only through the explicit include_spec=true request made by the Studio reveal control.

lionagi/studio/services/operator.py

report_operator_view — Records where the human is now, so the Operator can read it mid-turn. A turn's context is frozen at submit; without this the Operator answers "where am I" with wherever the human was when they hit send, wrong precisely when they've moved since. A report that doesn't count higher than the one already stored by the same page is discarded, since reports race and the loser of that race is the stale view.

lionagi/studio/services/schedule_declaration.py

_resolve_path — Resolves a manifest-relative path to an absolute one. A manifest may write relative paths, unlike a stored schedule row: they mean "relative to this manifest" (a location that exists), not "relative to wherever the daemon started" (which is not). The declarative path converts to absolute here instead of consulting _is_usable_execution_root before writing action_cwd, so what reaches storage already satisfies that predicate; Path.resolve() always returns an absolute path even when manifest_dir is itself relative.

lionagi/studio/services/workflow_run.py

  • Module purpose — Runs a compiled WorkflowDef through lionagi's Session.flow, persisted like any other run. Deliberately does not reuse _orchestration.setup_orchestration_persist/teardown_persist verbatim: those close a process-wide shared StateDB singleton meant for one-shot CLI processes, but the Studio server is long-lived with several runs in flight — this module opens/closes its own request-scoped connection instead.
  • Engine-operation registration ordering — ctx must exist before the "engine" operation is registered: engine sub-agent branches (Engine.make_agent) are born mid-run, like flow-cloned branches, so they need the same on_branch_created seam used for session.flow().
  • flow_progress_signals — run_workflow_def drives session.flow directly, bypassing the engine (the usual signal source), so without this emit the run persists structure+results but no node-progress rows.
  • Clone-branch persistence — Flow-created clone branches (predecessor, no explicit branch_id; see FlowExecutor._preallocate_all_branches) are born after _setup_run_persist already registered setup-time branches. Without on_branch_created, a clone's transcript never persists even though run-DAG signals still render (those persist via the session-level observer, not per-branch hooks).
  • CancelledError handling — A cancelled request/task aborts session.flow with CancelledError (a BaseException, bypasses except Exception); the run must be recorded cancelled, not the optimistic "completed" default, before re-propagating.
  • run_workflow_def error contract — Raises WorkflowNotFoundError (404) or WorkflowCompileError (422, carries node_id/edge_id) on compile failure, never a bare 500. base_dir is a run-level containment root for node config.cwd, never a spec field. _session is a private testability seam; real callers never pass it.

lionagi/studio/services/workflow_compile.py

  • Security surface — The only new surface is StudioExprCondition, a restricted-grammar expression evaluator for designer-authored edge conditions. Never calls eval/exec/compile/__import__; the AST is walked against a closed node-type allowlist before evaluation.
  • Safe expression grammar — Allowed: comparisons, boolean and/or/not, literals (str/int/float/bool/None), list/tuple literals of those, names, attribute access, subscript/key access, in/not in. Everything else (calls, lambdas, comprehensions, f-strings, walrus, imports, dunders) is rejected before evaluation.
  • _resolve_node_cwd — Containment order matters: raw-string traversal check before path resolution, then symlink resolution before the containment check, then existence check.
  • StudioExprCondition.__init__ — Parses before pydantic's validation machinery runs; a model_validator would wrap UnsafeExpressionError into pydantic_core.ValidationError, breaking callers matching on the former.
  • compile_workflow_def — Returns (graph, id_map) mapping authored node ids to internal Operation ids. Raises WorkflowCompileError (node_id/edge_id set) on any problem. base_dir is a run-level containment root, never read from the spec itself — a spec carrying its own base_dir is rejected so a shared/contributed def can't pin its own containment root.
  • Engine node re-validation — Node-level config overrides never went through engine_defs' creation-time checks (allowed keys, no CLI-flag/ shell-metachar injection, budgets in [1, 100]); re-validated here so a saved workflow can't smuggle a hostile test_cmd or unbounded budget.
  • make_engine_operation — Runs the resolved engine class in-process against the same session, so sub-agent branches wire into this run. on_branch_created, when given, threads into engine.run() so spawned sub-agent branches register for persistence like flow-cloned branches.

lionagi/studio/services/approvals.py

  • Lifecycle — An action is proposed (pending) → a human grants or denies → the consuming endpoint must consume the granted approval exactly once. Expiry and single-use are enforced here, not by caller convention: a granted approval that's expired, already consumed, or whose params don't hash-match the action being executed is rejected.
  • Principal separation — A request carrying the operator/service principal marker header is rejected for grant/deny before the row is touched (the browser frontend never sends it); additive to the bearer-token gate, not a replacement.
  • Evidence chain — Every lifecycle event appends a hash-chained row to approval_evidence in the same transaction as the status change (chain_hash = sha256(content_hash + previous_hash), genesis = "0"*64). Evidence rows never store raw params. Optional HMAC-SHA256 signing is off by default (LIONAGI_STUDIO_EVIDENCE_HMAC_KEY).
  • Service-principal header — Presence alone (any non-empty value) disqualifies grant/deny; there is no "correct" value, so a caller can't guess past the check.
  • _require_human_principal — With no bearer token configured, granting is unavailable entirely (fail closed) rather than open to any local caller.
  • _write_evidence — Caller must already hold the write lock on db (a preceding BEGIN IMMEDIATE in the same transaction as the status change) so the tail read is race-free; this function opens no transaction of its own.
  • require_approval — Validates a granted approval for exactly this action and consumes it atomically. A mutating route must call it before its side effect, passing the same action_kind/params it's about to act on.

lionagi/studio/services/definitions.py

  • Per-(kind, name) concurrency lock — Shared across all requests in this process; spans the DB write inside StateDB.save_definition() and the subsequent disk write so both are atomic from the service's perspective — a crash between them can't leave disk ahead of history.
  • save_definition ordering (ADR-0077 D2) — DB write must succeed before the file is written; the per-(kind, name) lock serializes concurrent saves.
  • _find_definition_file — Candidates are literal-path joins, not glob patterns. Symlinks outside base are intentionally left unresolved and unrestricted — restricting them would break symlinked agent definitions.
  • The mixed-source reads — list_definitions and get_definition answer from two places at once: current content from disk, version history from the store. Both halves now resolve the same way, because history is read through StateDB exactly as save_definition writes it. Reading SQLite directly was the failure this replaces: writes went through StateDB and landed in the configured server while reads fell back to the default local path, so an old local database left over from a previous deployment was reported as this definition's versions, laid over content read live from disk. Nothing in that payload looks wrong; the two halves simply came from different stores.

When the store cannot be read at all, the disk half is still answered and the history half is null rather than empty. The distinction is the point: an empty history is a claim about the definition, and a caller told there are no versions concludes nothing was ever saved. The true statement is about the store, so get_definition returns versions: null with history_available: false, and list_definitions reports has_versions: null for the same reason. A client that does not handle it fails on a null instead of quietly believing the definition was never versioned.

Routes whose whole answer is history have no disk half to fall back on, so they refuse: get_version and rollback_definition raise HistoryUnavailableError and the routes map it to 503. 503 rather than the 501 the Operator routes use, and the two are not interchangeable — every store this deployment can be configured for is one StateDB reads, so failing to read it is an operational condition a retry can outlive, where the Operator's SQLite-only store makes the refusal permanent for that deployment. The refusal body is a fixed string and carries nothing the driver said.

  • _read_history — Reads through StateDB, the same way save_definition writes, so file/in-memory/server deployments all answer from the store the deployment is actually configured for; reading SQLite directly could only ever see a local file, which for a server-backed deployment is a database nobody serves. Raises rather than returning an empty list when the store can't be read — "no versions" is a claim about the definition, while the true statement is a claim about the store, and a caller told "no versions" would reasonably conclude nothing was ever saved.
  • save_definition — validate gates the cast role/mode check only, not the system-agent guard (which always runs). It defaults on for the direct save route, the door a client posts arbitrary content through, but rollback_definition/snapshot_current pass validate=False since they replay content already accepted once (a stored version, a pre-existing disk file), and a validator tightened since would make an old version un-rollback-able and an existing file un-importable.
  • _save_skill_definition — Always writes <SKILLS_DIR>/<name>/SKILL.md regardless of what shape currently exists on disk, normalizing to the one shape li skill actually resolves (skills.py::_find_skill_md / lionagi/cli/skill.py) rather than preserving a legacy layout. Plugin-bundled skills live under a plugin directory, never under SKILLS_DIR, so they're unreachable through this path by construction, not by an extra check.

lionagi/studio/services/schedule_export.py

Read-only conversion of schedules rows into a ScheduleSet document, two modes, neither touching the database: legacy conversion (managed_by IS NULL, rows created before the declaration layer) reconstructs a typed ScheduleMember per row from its raw action_*/trigger columns, running each candidate through the same resolve_member static resolution a real apply would use so a malformed row is caught here rather than emitted half-valid — a row with on_success/on_fail is never converted, since chained follow-up actions have no v1 equivalent and must be redesigned as a flow by hand. Declaration/cli re-export (managed_by IN ('cli', 'declaration')) simply re-validates each row's already-typed authored_spec back into a ScheduleMember.

resolve_member reads an execution.project written as YAML null (null, ~ or a blank value) as no project; an omitted project or an empty string inherits metadata.project (none under scope: global). The managed export writes each member's project from the row's stored action_project (null when it has none) rather than as authored: the export's document is project-scoped even for a global set, so an omitted or empty project written back would re-apply under the export's project. Reading the null as "inherit" did the same for a row stored without a project. Either way the re-applied row changed its project, which is the fallback execution root it uses only when its own stored action_cwd is unusable and, for the agent and flow_yaml targets a declaration produces, the --project its command receives. A row that a null-spelled member had given the document's project loses both on its next apply: with no project and an action_cwd that is set and is not an existing absolute directory, the scheduler refuses the fire (SchedulerCwdInheritRefusedError) where it would have run in the project's registered path, if that path was usable; with no usable path the fire was refused before as well. A row whose action_cwd is an existing absolute directory still runs there, and its agent or flow_yaml command, now without --project, takes the project detected in that directory.

Round-trip identity across export/re-apply rests on three helpers. _effective_project picks the project namespace a row is grouped under: the stored project column when set, else the qualified name's own prefix — a grouping heuristic only, not a round-trip guarantee by itself. _group_into_documents splits ready rows into one document per distinct effective project (bare-named rows share a single base-name-keyed group), grouping before computing member keys so a row's effective project always matches its document's project, avoiding double-qualification on re-apply; it also returns a {row_name: reconstructed_qualified_name} map so callers can compare it against the row's stored name and disclose a rename. _member_key strips a leading "{doc_project}/" from the row's globally-unique name, so reapplying reconstructs the identical qualified name; a stripped local name colliding with one already used in the same document falls back to the full original name (still unique) so two members are never silently merged. convert_legacy_rows reports on_success/on_fail, an unsupported action_kind, a legacy-only field with no v1 equivalent, or a malformed trigger as BLOCKED and omits them, never half-emitted. A row that stores no project is written with execution.project: null, which apply reads as no project. Applying a --legacy export back as its own set (with --adopt, since the row has no owner) then adopts a row that stores its working directory and model without changing any column it ran with: adoption writes only the owner and the declaration it adopted (owner_key, managed_by, spec_version, resolved_digest, authored_spec, resolved_target, and resolved_timezone, the zone the row was recorded firing in, confirmed only when the scheduler runs in that zone), and the next plan reports UNCHANGED.

lionagi/studio/services/playbooks.py

  • _check_spec_fields — Mirrors lionagi/cli/orchestrate/__init__.py::_validate_spec_fields() exactly, implemented inline to avoid loading the full orchestrate module at import time. The two must be kept in sync by hand.
  • update_playbook — Conservative merge writing a playbook YAML back to disk: description overwrites when present; graph keys (use/steps/links) only when non-empty; declarative keys overwrite or clear on None/""; all other disk keys preserved. Writes through symlinks to the real source file.

lionagi/studio/services/task_applications.py

  • ADR-0071 D1 architecture — TaskApplication is the frozen submit shape every binding shares. This module wires the in-process binding (submit_task/cancel_task); any other binding calls these same functions rather than duplicating the contract. submit_task writes a durable queued row into schedule_runs (ADR-0071 D2 generalized task entity, schedule_id NULL) as a plain INSERT (no prior CAS state to guard); every status move after routes through lionagi.state.transitions.transition(). No worker/lease loop or remote execution lives here — execution_target/library_ref record provenance (ADR-0073). required_capabilities derives the D4 host-scoped concurrency_key at submit time only (capabilities.py); claim-time eligibility/affinity matching lives in worker.py.
  • Action-kind vocabulary widen — ADR pair adds "workflow" (ADR-0073 registry-resolved definitions) to the launcher vocabulary — a CHECK widen, reusing the launcher's closed set + "playbook" alias rather than a second copy.
  • idempotency_key — Part of the ADR-0072 dedup submit contract: submit_task rejects a non-None value rather than silently double-enqueueing a retried application.
  • _derive_concurrency_key (D4 rule) — Only serialization-class tokens (per capabilities.py's token→class map) fold into a host-scoped concurrency_key; eligibility/affinity-only tasks get none.
  • cancel_task — Only queued -> cancelled is permitted (transitions.py's ADR-0071 vocab gate rejects any other move, e.g. out from a leased/running row).
  • Admission pre-check (D3) — submit_task() adds a synchronous admission pre-check for the two conditions cheaply checkable at submission time: the duration guard (D6) and the waiter cap (D-Cap) when a holder is already running for the derived concurrency_key. A violation raises AdmissionRejectedError immediately (D-Reject's "typed error to the caller"), giving a submitter fast, observable feedback instead of a silent later vanish. This is best-effort early rejection only — the authoritative gate is scheduler.admit.admit(), run again inside the worker claim loop with whatever concurrency configuration the worker actually uses, which is why claim-time rejections must also surface observably (worker._reject_claim).

lionagi/studio/services/db_maintenance.py — retention lineage cleanup

  • prune_old_data FK safety — branches CASCADE on sessions; artifacts/plays/team_messages/dispatch_outbox have soft FKs (no CASCADE), so session_id is nullified before DELETE. schedule_runs.chain_parent_id and dispatch_outbox.schedule_run_id are nullified before parent delete.
  • dispatch_outbox retention (ADR-0059 delta 3) — Two separate windows: terminal success (delivered/acked) and dead-lettered/expired. pending/delivering rows are excluded from both. Unlike the session branch, status_transitions rows for purged dispatch ids are left in place — no FK from status_transitions to dispatch_outbox (ADR-0057 D2), and the dispatch transition trail is the compact audit record this delta exists to keep, not the high-volume history the session branch cascades away.
  • Orphan cleanup — Scoped to pruned lineage only; never touches rows outside it, to avoid a newborn-orphan race where _persist.py commits a progression before the session row exists.
  • Audit event ordering — Runs after the prune transaction commits; insert_admin_event opens its own write transaction, and nesting it inside the prune transaction would self-deadlock on the sqlite write lock.
  • _session_retention_predicate — What makes a session prunable (terminal status AND no activity since a cutoff), built as one reusable SQL fragment + params rather than a full statement, because the prune asks the same question twice: once to select candidates, and once to recheck under lock with an id restriction. Coming from one place matters because either condition can stop holding in between (a resume, or any write, moves updated_at forward) — a second spelling that drifted even slightly could admit a row the selection had already decided to spare.
  • checkpoint_state_db — Runs PRAGMA wal_checkpoint(<mode>) and writes an audit event with the PRAGMA result plus wal_bytes_before (read before the connection opens, so it reflects the WAL this checkpoint was actually asked to deal with) and elapsed_ms (covers connection-open time too, since a checkpoint can wait in either place). For TRUNCATE, a successful checkpoint reports busy/log_pages/checkpointed all zero regardless of how much was drained — that is the success signature, not evidence there was nothing to do. The event is written only after the checkpoint returns, so this can record a slow checkpoint but never a hung one.
  • prune_old_data — All three root kinds (sessions, schedule_runs, dispatch_outbox) are pruned the same way: chunks of at most PRUNE_CHUNK_ROWS candidate ids are archived (if PRUNE_ARCHIVE_DIR is set) and deleted in their own short transaction, so the write lock releases between chunks and an interrupted run keeps every chunk that already committed. A chunk's archive write commits durably before its DELETE runs; a failed archive write aborts the remainder of the whole pass (later chunks and later root kinds are never attempted), while already-committed chunks stay deleted. Soft-FK children (artifacts/plays/team_messages/dispatch_outbox) are nullified before DELETE since they lack CASCADE.

lionagi/studio/services/attention.py

Durable discharge lifecycle for Studio's needs-attention queue. The queue itself stays client-derived (boardReducer.buildAttentionItems); this module only persists what an operator decided about one derived item — acknowledged / resolved / expected / snoozed — keyed by the item id the reducer already builds (run:<id> | inv:<id> | sched:<id>). It never writes to a run, invocation, or schedule's own status, which stays the honest record of what actually happened. Every write also appends to an append-only history ledger so a discharged item can explain who discharged it, when, and why.

upsert_disposition is idempotent under retry. Its revision parameter fences only one case: resurrection after delete. A PUT that finds no active row for an item but a last-operation revision recorded for it (created, then deleted) must carry a revision at least that high, or it is rejected (409) rather than resurrecting a stale disposition — e.g. a delayed retry of a pre-delete PUT arriving after the undo. An already-active row is deliberately last-writer-wins with no fencing at all: a stale-revision PUT against a still-active row always applies, because the fence exists to stop resurrection, not to arbitrate between concurrent edits to a row that's still there to retry against. Guarding active-row updates too would break the idempotent-retry promise — an operator's own retried PUT looks, from the server's point of view, exactly like a stale revision against an active row.

lionagi/studio/services/leo.py

  • Security boundary — Mutating tools never execute; they return a proposed_action dict as an SSE payload — confirmation and the actual endpoint call belong to the client, not this service. ui_command tools return a declarative command dict intended for client-side handling (navigation, form prefill) — commands never mutate server state. Sessions are in-memory (server restart clears history); auth is the studio bearer-token gate at app-level middleware, same as every other route.
  • No from __future__ import annotations — Leo tool callables are introspected by function_to_schema, which requires real (non-string) parameter annotations.
  • Session registry bounding — Capped at _MAX_SESSIONS so a long-running server doesn't grow the dict forever: capacity eviction drops the least-recently-used session, idle eviction sweeps sessions untouched for _IDLE_EXPIRY_SECONDS. Both run lazily on create/access — no background timer.
  • _run_turn — Scans only the messages Branch.ReAct() appends during this turn for proposed_action/ui_command outputs, so a proposal surfaced on an earlier turn never resurfaces later. Must only be called while holding sess.lock.

lionagi/studio/services/agents.py

_is_protected_system — True only when frontmatter carries a present, truthy lion_system key (any YAML/Python-truthy value, not just True), matching the runtime's own bool(frontmatter.get("lion_system", True)) check in lionagi/cli/_providers.py. An absent key does not count as protected — treating a missing key as protected would lock down every pre-feature agent file (plain markdown with no frontmatter is common). Agents created through the Studio API stamp lion_system: false explicitly so they're unambiguously editable. This is the single place both write-protection call sites (this module and definitions.py's agent save path) resolve the predicate, so they can't drift apart from each other or from the runtime.

lionagi/studio/services/shows.py

  • _live_play_meta — Reads a DB-known play's live _meta.json, giving disk precedence over the DB row. on_disk=False means the play's directory has disappeared since import (unavailable, not "never started"); a _meta.json that exists but fails to parse (e.g. truncated by a crashed writer) is likewise unavailable, not empty. A play directory that legitimately has no _meta.json yet is a normal, available empty read.
  • list_gated_plays — Every play, across every show, currently in the gated lifecycle status. The plays table is populated once by import_shows() and never resynced (a show already in the DB is skipped on re-import), so it can't be the source of truth for a live queue: a play created after import has no DB row, one rewritten on disk has a stale DB row, an unimported show has none at all. Enumerating every show directory on disk (unioned with every DB topic) and going through get_show() per show answers this the same way get_show() itself resolves a play, so the two can never disagree. A DB-known play whose live state can't currently be read is included too, tagged live_state: "unavailable", rather than dropped or shown with a stale status — whether it's actually gated can't be established from this queue, so it's a "look here" entry, not a gate verdict.

lionagi/studio/services/scheduler_state.py

flush_run_telemetry — Computes and persists one run's coordination telemetry exactly once, riding the invocation's own terminal write (engine.py calls this only after its own terminal-status guard returns True). Pops the scheduler signal bus's accumulated counters for the run and merges them with the invocation's files-read overlap under a "coordination" key in invocations.node_metadata, through the store's own merge_invocation_node_metadata UPDATE, so a key another writer added meanwhile (a closer's result) survives. Returns None — leaving node_metadata untouched — when there is nothing to report (no signal emitted, no file overlap), matching the measure-only surfacing rule. Best-effort: it rides an already-committed terminal write, so a failure computing overlap or persisting is logged and swallowed rather than propagated or retried; CancelledError still propagates since it's a BaseException, not an Exception.

lionagi/studio/services/stats.py

_ACTIVITY_WINDOWS folds the ADR-0057 D1 seven-value session status vocabulary into four Pulse-sparkline buckets: timed_out joins failed (both terminal non-success), aborted joins cancelled (both deliberate stops). get_stats_route intentionally reads the runs count from SQLite sessions (not runs_svc.list_runs(), which reads filesystem dirs and returns a different count) so the dashboard matches the Runs list page.

lionagi/studio/services/mcp_servers.py

Registry vs. derived file — Studio keeps one authoritative store, LIONAGI_HOME/mcp_servers.json (full configs including secret env values, enable state, last connection status), and derives the CLI-facing LIONAGI_HOME/.mcp.json from it on every write, containing only enabled servers in the {"mcpServers": {...}} shape lionagi/cli/_mcp_resolve.py parses. A disabled server is simply absent from the derived file, not flagged — a run pointed at --mcp-config ~/.lionagi/.mcp.json never sees one. The registry is kept separate from the derived file (not a passthrough onto a project's own .mcp.json) because Studio manages many named projects from one process; per-project cwd discovery would pick an arbitrary launch directory.

Concurrency — Every mutation is a read-modify-write over the whole registry file, so two interleaved writes lose one wholesale unless serialized. _REGISTRY_WRITE_LOCK is a reentrant thread lock (routes run in worker threads) spanning load-through-save; reads outside it are safe because _write_private saves via os.replace, so a reader always sees one whole file or the other. The lock is never held across a network probe — a connection attempt can run for seconds, and blocking every save behind it would trade a lost write for a frozen UI.

Unreadable registry — A missing registry, one holding null or {}, or one whose only entry is servers, written once and holding null, reads as no servers: it has nothing to lose. Any other registry that cannot be read, is not UTF-8, does not parse, writes a key twice in one object (a parse keeps the last value alone), is not a JSON object, holds an entry other than servers (a save writes servers alone), holds a servers entry that is not an object, or holds a server entry that is not an object with an object config raises McpRegistryUnreadableError from every read and every change that reaches the registry, and the routes answer 503 naming the file. Reading it as empty would list no servers, and the next registration would save one entry over all of them.

Secrets — _write_private writes atomically via mkstemp (always 0600 regardless of umask) + os.replace, then an explicit chmod 0600 to repair files created by an older code path that used write_text and inherited 0644. _mask_config never lets an env value leave the process — clients see env_keys only; values are read back off disk solely to attempt a connection. _scrub_secrets strips configured env values out of connection-error text, since a failed probe can echo its own environment (subprocess stderr, a rejected token) back through an error message.

Merge semantics (_merge_config) — A save is a patch onto the stored config, not a replacement, because clients never receive env values back and so cannot echo a full config. Per field, an explicit None removes the key (the client's way to drop a secret it can't see) — except args, where None is written through as-is for _validate_shape to reject; treating null as "absent" would silently launder a malformed value into a valid empty list instead of rejecting it. env merges key-by-key with the same None-removes rule. A transport switch (patch carries url or command) clears the other transport's leftover fields that the patch itself didn't touch, so a stdio-to-http switch doesn't leave stale args/env sitting in a config that would still pass shape validation and leak into the derived .mcp.json. A field the patch explicitly supplies, well-formed or not, is always left for _validate_shape to judge — the merge never discards it pre-validation.

Connection checking (check_server_connection) — Reloads the registry after the probe rather than writing back the pre-await snapshot, so a concurrent save isn't reverted. The reloaded entry is matched against the exact config that was probed before the outcome is kept — a server can be edited or replaced under the same name mid-probe, and matching by name alone would stamp a stale result onto it. Only the reload-compare-save step holds the registry lock; the probe itself does not.

lionagi/studio/services/retention_archive.py

Self-verifying ZIP64 archival of rows a prune chunk is about to delete. Each call publishes one self-contained .zip file containing a manifest.json (format version, per-table row counts, per-member SHA-256 digests) plus one rows/<table>.jsonl member per non-empty table, canonical UTF-8 JSON (sort_keys=True, compact separators), one row per line.

Publication is crash-safe: the archive is built in a temp file in the destination directory, fsynced, digest-verified by reopening it, atomically renamed into place, the destination directory fsynced, then verified again by reopening the final path. A partially written or unverifiable archive never appears under its final name, so a crash or corruption mid-write leaves nothing a caller could mistake for a completed, trustworthy archive. Filenames are unique per chunk and never reused, so a rerun after interruption cannot overwrite an already-published archive.

write_archive_chunk's preimages parameter captures the pre-mutation state of rows a caller is about to NULLIFY (soft-FK columns) rather than delete — e.g. artifacts/plays/team_messages/dispatch_outbox rows whose session_id a session-prune chunk is about to null out. They are written as sibling preimages/<table>.jsonl members alongside the rows/<table>.jsonl members for deleted rows, so a restore can recover the original linkage instead of leaving those rows permanently orphaned. Raises ArchiveWriteError (or the ArchiveVerificationError subclass) on any failure and leaves no partial or unverifiable file under the final name — including when the failure is caught only after the rename, in which case the published path is removed too.

_encode_value escapes values that would collide with the bytes/escape codec markers, at any depth: on backends whose driver deserializes JSON columns, a legitimately stored value shaped exactly like a marker dict would otherwise be misread on restore. It must recurse to every depth because json.dumps(default=_json_default) converts bytes into marker dicts at every depth too — a shallow escape paired with the deep bytes conversion would leave nested collisions ambiguous.

lionagi/studio/services/run_resume.py

FLOW_RESUME_KINDS lists the invocation_kind values that replay a checkpointed flow instead of reopening a single agent branch, kept separate from the DB CHECK constraint's vocabulary (schema.sql) so a new kind must be classified here explicitly before it can be resumed at all. fanout is deliberately excluded: _run_fanout (cli/orchestrate/fanout.py) never stamps a run_id into node_metadata and never instantiates a CheckpointWriter, so a real fanout session can never satisfy _resolve_flow_checkpoint's prerequisites — there is no future in which one does. Routing it through the checkpoint-resolution path anyway would only ever fail with flow-specific wording ("...or never reached _build_dag") that misdescribes why; treating it as unsupported instead is the honest answer, decided by what the kind can ever produce.

_require_resumable_snapshot is the one prerequisite check for an agent-kind resume, shared by GET (resume_availability) and POST (_resume_agent_run) instead of each doing its own version. It returns whether the source run is still queued: a queued source has no snapshot to check yet (a queued resume writes its own worker config, and the snapshot is verified once the source finishes, matching _resume_agent_run's own launch-time branching); only when the source is already terminal does li agent -r need a snapshot to reopen right now. GET previously only checked branch membership and could answer "resumable" for a run POST would then 409 on, because the branch's CLI snapshot was never written or had since been pruned — sharing the check means GET's answer and POST's outcome can only disagree when something about the run genuinely changed between the two calls.

lionagi/studio/services/_db.py

store_path() resolves LIONAGI_STATE_DB_URL rather than naming DEFAULT_DB_PATH directly, so a route reads the same file the daemon actually opens; with the URL pointing elsewhere, naming the default directly would read a database the daemon never opens (and aiosqlite would create that unrelated file on connect if absent). This layer talks to SQLite directly, so the only store it can reach has a file behind it — when the configured store is server-backed there is no file to name, and store_path() falls back to the default path, which is equally wrong for that deployment but tracked as a separate, route-level concern rather than a path-resolution one. store_exists() stays in step with store_path() by construction, so a guard and the connection it protects can't disagree about which store is in play.

StoreNotAddressableError (raised by require_file_store()) marks a route that reads or writes rows straight through a SQLite connection when the configured store is server-backed or in-memory and so has no file behind it — connecting anyway would either report on a store nobody is serving, or create a file whose rows nothing else will ever see. require_file_store() slots in front of a route's existing if not store_exists(): return [] guard: a path that exists or is merely absent both pass through unchanged (absent still means "no store yet", answered the same empty way as before); only a resolution with no path at all (a server URL, or :memory:) raises, because that is the one condition this layer can never satisfy by waiting or by creating the file.

lionagi/studio/services/redaction.py

Server-side redaction for a demo-safe view of Library agent-profile content, enabled by LIONAGI_STUDIO_DEMO_MODE — a process-wide switch read fresh on every call, never something a request can select. When on, every route that reads agent-profile content projects through a single classification table (_SAFE_KEYS) instead of returning frontmatter and body verbatim, so a screen-share or recorded demo of the Library never surfaces owner-authored prompts, guidance text, internal paths, or unrecognized frontmatter values. Classification starts from field name: an unrecognized key is dropped even when its value looks like a harmless bool or number, because what leaks is the key existing in the response at all, not any property of what it happens to hold this run. A safe key's name is not enough on its own — its value must also match the scalar shape the name implies, or a mapping/list nested under that key name would ride through the allowlist unexamined.

abbreviate_path reduces a filesystem path to its bare filename, shared by every route carrying a path/disk_path/symlink_target field. It raises TypeError for anything not path-like: a mapping or list under one of these keys is unrecognized content wearing a path key's name, not a path with an unusual shape, and str()-serializing it would carry that content through in the returned filename. Callers reading these keys from owner-authored data must treat the error as "drop this field", not fall back to serializing it.

lionagi/studio/services/lifecycle.py

Four independent reapers, each scoped to the kind of orphaning its own process model can produce:

  • reap_null_status_sessions — Sessions get status=NULL when their process crashes before writing a terminal status (crash, OOM, SIGKILL). The status IS NULL guard never touches already-terminal rows. Liveness honors the recorded node_metadata.pid via process_liveness(), and a not-observably-alive row still gets a staleness grace (mirroring _classify_phantom) so a fresh/quiet session isn't reaped for a momentary window before it writes its own status.
  • reap_stale_schedule_runs — Transitions schedule_runs rows stuck at running to timed_out. The scheduler process can die between committing a schedule_run row and its own terminal write, orphaning the row with no process-liveness signal to check (the "process" here is the scheduler daemon; its restart triggers reaping), so this is a pure wall-clock deadline against updated_at (falling back to fired_at), with the same optimistic-lock expected_updated_at guard as reap_stale_plays. Scoped to schedule_id IS NOT NULL — the ad-hoc task queue has its own lease-based recovery (worker.reap_expired_leases) and is excluded so a live-leased task isn't marked timed_out before its lease even expires.
  • reap_stale_shows — shows.py computes show_status only once, at mirror-row creation time (import_shows()): a show mirrored while its plays are still in flight gets status="active" and is never re-evaluated once those plays later merge or abort on disk, unlike sessions/plays/invocations/schedule_runs, which all have their own reapers. This fills that gap by re-deriving status from the exact on-disk rules import_shows() already applies (_recompute_show_status_from_disk: an _ABORT marker means aborted; a passing _final_verdict.json means completed; every child play reaching merged also means completed; anything else is still in flight and is skipped). Liveness-first, like reap_stale_plays: a show with any child play whose session process is still observably alive is never reaped, regardless of the on-disk snapshot or how stale the row looks.

lionagi/studio/scheduler/admit.py

ADR-0071 D3: admit(row, worker, db) -> AdmissionDecision is the worker claim loop's admission predicate, extracted to one named, StateDB-backed, unit-testable function. It borrows Processor.handle_denied's terminal-vs-deferred return shape only (True = terminal, False = deferred/re-enqueue) — never the Processor class itself, which is asyncio.Queue-backed and in-process only, useless for a fleet of independent CLI processes claiming from a shared schedule_runs table.

Conditions evaluated, in this order:

  1. Duration guard (D6) — a job declaring max_duration_seconds at or above the worker's lease TTL is terminal-rejected: lease renewal is not yet shipped (ADR-0071 delta #5), so an admitted long-runner would just lose its lease mid-flight.
  2. Capability match (capabilities.worker_can_serve) — a mismatch defers (row left queued, never faked).
  3. Concurrency-key block — a matching key currently running (this pass or a prior one) defers the row to the next tick.
  4. Waiter cap (D-Cap) — per concurrency_key, at most key_concurrency * waiter_cap_multiplier rows may sit queued/retry_wait behind a running holder. Over cap is a terminal rejection unless the submission opted into deferred/parked semantics (D-Reject).

GPU/bench-window locks are never consulted here: admit() only ever reads StateDB. Machine-local lock acquisition and arbitration stay a worker-side execution responsibility (ADR-0071 D5's own stated limit, reaffirmed by D3).

action_args["admission"] payload convention (documented shape inside the existing free-form args/action_args dict, no schema change):

{
    "max_duration_seconds": <float>,       # duration guard input
    "allow_deferred_over_cap": <bool>,     # opt out of terminal rejection
                                            # when the waiter cap is hit
    "notify": {
        "deliver_to": <str>,                # required, non-empty
        "kind": <str>,                      # optional, default "terminal_notify"
        "dedup_key": <str | None>,          # optional
    },
}

A notify payload with a field of the wrong type (e.g. deliver_to as an int) is dropped by notify_request() rather than surfaced — it must never crash the claim loop for a row that is already correctly skipped. A claim-time terminal rejection must still surface observably even though the submitter is no longer on the wire by then: worker.py's claim loop, on a terminal AdmissionDecision, transitions the row queued -> skipped carrying the reason and — whenever notify_request() finds a notify payload — emits a dispatch_outbox row via lionhub.orchestration.dispatch.outbox.enqueue_dispatch.

lionagi/studio/scheduler/engine.py — admission and dispatch details

_reserve_max_runs_budget — reserves one top-level fire against a schedule's max_runs cap. A fire consumes budget the instant it fires, not when it resolves, so the count it checks is fired + inflight: fired is the persisted count of running-or-terminal schedule_run rows, and inflight is an in-process counter of fires that have claimed budget but whose occurrence row has not yet committed. The two are disjoint views of the same fire — a claim is released the moment the occurrence row lands — so summing them counts each fire exactly once, except for a brief instant during the handoff where a fire can appear in both; that only ever over-counts, which just causes a spurious refusal that self-corrects on the next tick. Only one scheduler process runs today, so this reservation is in-memory (an asyncio.Lock-guarded dict), not a database compare-and-set.

The order of the two reads inside that lock is the load-bearing part. inflight is read before the await on count_schedule_runs(), not after. release() (called from _fire()'s finally block on every exit path, so a claim always gets freed even from a cancelled or failing fire) does not take the lock — it must work even while a fire is mid-cancellation, where acquiring a lock from a finally block risks a deadlock. That makes it possible for a concurrent fire to release its claim while this call is suspended awaiting the database. If inflight were read after that await, a fire that both writes its occurrence row and releases its claim entirely inside the suspended window would vanish from both counts at once: too late for the in-flight snapshot (already released) and too early for the persisted count (the read started before the write landed) — letting a bounded schedule fire one more time than max_runs allows. Reading inflight first means it still captures that other fire's claim before it can disappear, so the sum can only ever over-count, never under-count.

_tick()'s ad-hoc task-worker pass runs single-flight: a second _tick() firing while the first pass is still in progress must not start a second pass, and must not await the first pass either — a slow or hung worker pass would otherwise stall every schedule's due-time evaluation for the whole tick. _tick() starts the pass as a background task and returns promptly regardless of whether it is still running.

resolve_terminal (child-session outcome inference) does not trust a leader process's exit code as evidence that a still-running child session's own work has finished — the terminal stamp comes from the leader's stderr pipe closing, not from the child's work actually ending. A child session that has not reached any terminal status of its own is reported as completed_empty (no positive evidence) rather than being inferred as completed.

_recover_missed_fire_run_once — reserves its admission claims and then next_fire_at, synchronously, before queueing the recovery fire. The order is load-bearing in both directions. Claims come first because a rate or slot refusal has to leave the row still due, and clearing an at trigger's next_fire_at ahead of a refusal would strand its single run permanently. The reserve comes before the fire because _tick_loop() runs _check_missed_fires() and then _tick() with nothing awaited in between, so a next_fire_at left for the recovery fire's own background task to persist is still the past-due value when the very next _tick() reads it, and the schedule double-fires.

The crash window this leaves was weighed and accepted. If the process dies between the reserve and the recovery fire landing, the run is lost for that cycle but the schedule is not stuck: one skipped run rather than starvation. The exception is an at trigger, where the reserve has already cleared next_fire_at and there is no later occurrence, so that crash loses the run permanently. That was accepted rather than reopen the duplicate-fire window, and a later change that closes the crash window by deferring the reserve would be trading this decision away rather than fixing an oversight.

_guarded_terminal_status — writes a terminal schedule_run or invocation status guarded on the row still being running, because a concurrent writer such as the deadline reaper may have finalized it first. Returning False is the expected outcome of losing that race, not an error: callers get a checked no-op instead of an exception. extra_fields carries same-row columns belonging to the same finalization, ended_at and error_detail, and they ride the same guard and the same transaction as the status precisely so that a lost race leaves the winner's values intact rather than overwriting them with ours. Both properties are extension hazards, since this is the shared helper every terminal write goes through.

_next_fire_field — returns the field or fields to merge into an update_schedule() call, and its two None behaviours are different on purpose. For interval, cron and github_poll, a None next fire can only come from a malformed row, so it returns an empty dict and leaves next_fire_at alone rather than blanking a value another write has set. For at, None is the terminal and correct answer, so it returns an explicit {"next_fire_at": None}: the one-shot has to be persisted as no longer due, or it reads back as still pending forever.

lionagi/studio/scheduler/worker.py

claim_and_execute's D4 match rule: row R is claimable iff its capability tokens are a subset of advertised_capabilities AND its execution_target is in execution_targets (NULL/empty target = claimable by anyone). Candidates are paged oldest-first through a (queued_at, id) keyset cursor until limit eligible candidates are found or the queue is exhausted, bounded by _MAX_CLAIM_SCAN_ROWS (a fairness/latency cap, not a correctness cap — a later pass resumes the same order); a long prefix of unservable rows never permanently hides an eligible row behind it, unlike a fixed-size prefetch window.

If worker_id's heartbeat is older than heartbeat_ttl, the pass claims nothing (in-flight leases still recover via reap_expired_leases); a worker with no heartbeat history yet is not treated as stale.

Returns the number of rows claimed, regardless of execution outcome. Each claim is one guarded CAS (queued -> running); a lost race or a row another caller already moved is skipped, not retried within this pass. A terminal admission rejection never counts toward the returned total.

lionagi/studio/scheduler/signals.py

Mint site: SchedulerEngine._fire_inner(), immediately after each of the three _guarded_terminal_status("schedule_run", ...) calls returns True — the one choke point every scheduled run's terminal write already passes through (in-process, synchronous with the commit, no polling latency). The module stays agnostic about where that mint happens: the signal classes and SchedulerSignalBus only need the same status/reason_code/entity_id fields a generic post-commit hook on LifecycleService.transition() would eventually carry, so promoting the mint site later is a call-site move, not a redesign of this module. LifecycleService and StateDB schema are untouched by this module — it imitates the shape of the existing in-run DAG signal bus (lionagi.session.signal/observer) without reusing its Flow/route/stream machinery, none of which a scheduler daemon process needs (schedule_runs is already the durable record).

Failure semantics: SchedulerSignalBus.emit never swallows a handler exception. Handlers run concurrently with return_exceptions=True; any failures are raised together as an ExceptionGroup after every handler has had a chance to run. A handler-raised CancelledError cannot be nested in ExceptionGroup, so it is surfaced as the distinct SchedulerHandlerCancelled marker instead — the mint call site (engine.py) records either form. Cancellation of the emitter task remains a plain CancelledError and propagates, so a broken handler is visible without stopping unrelated schedules or swallowing scheduler shutdown.

lionagi/studio/operator/

The Operator (ADR-0083) is a durable, human-gated chat surface over Studio: one long-lived conversation per project view, backed by SQLite (store.py's OperatorStore), that can read run state and propose mutating application actions through explicit human decisions. Its own conversation title and description are presentation metadata that the active turn can update directly; their write and applied effect persist in one transaction. The tool writes no extra result frame. The provider's result identifies the call, and the frontend re-reads conversation metadata on that result or on turn completion if it is missing. The package splits into the durable store, a Branch-backed engine that drives one provider turn (engine.py), a coordinator that wires turns to proposals (coordinator.py), a set of MCP-exposed read/write tools (application_mcp.py plus the per-action adapters), and shared redaction/catalog helpers.

Turn identity and the propose/poll/execute pattern

Every mutating tool in this package — cancel_run, resume_run, rename_session, launch_playbook (application_mcp.py), and the stdio permission bridge (permission_mcp.py) — follows the same shape. The tool reads its durable turn identity from three environment variables (LIONAGI_OPERATOR_DB_PATH, LIONAGI_OPERATOR_CONVERSATION_ID, LIONAGI_OPERATOR_REQUEST_ID) set by the daemon around the provider subprocess; a real MCP call always has them, and their absence is treated as a hard configuration error, not a soft fallback. The tool then resolves its target, builds a command payload, and calls OperatorStore.create_proposal with a risk tier (mutate/execute/admin) and an idempotency key derived from canonical_hash — the same command submitted twice (e.g. a retried HTTP request) reuses the first proposal rather than creating a second one. It polls get_proposal until the status leaves pending (expiring it past its deadline if needed) and returns a redacted result. The proposal only carries the decision; the actual state-changing act lives in a paired execute_*_command function (execute_cancel_command, execute_resume_command, execute_rename_session_command), invoked by the coordinator only once a human allows the proposal. This split matters for ownership: a command captures the project it resolved against at proposal time, but every execute_* function re-fetches the target row and re-checks that project match immediately before writing, because a human's approval window is a gap the target's project (or status) can change across. Re-checking only at resolution time would let a race during that gap either write to a run that changed ownership or silently no-op in a way the caller couldn't distinguish from success.

cancel_run additionally reuses lionagi.cli._kill_service's exact primitives (kill_one, list_running_children) so a cancellation from the Operator is indistinguishable from one issued through li kill --recursive: running children are signalled deepest-first so none are orphaned, and _persist_cancel's guard against a non-running row means a race that lets the process finish naturally during the approval window degrades to already_terminal rather than a double- or wrong-run cancel. resume_run is a distinct operation from un-pausing: the session lifecycle policy (lionagi/state/lifecycle/policy.py) has no edge back out of a terminal status such as cancelled, so resuming never reopens the old run's status — it launches a new invocation that either continues an agent run's branch with a new instruction (li agent -r) or replays a play/flow/show-play run's persisted checkpoint (li o flow --resume), and the two argument shapes are mutually exclusive because the checkpoint owns the plan for the second kind. rename_session writes the approved name to the run's explicit user_label through the dedicated setter. It replaces a previous label without changing the system name or activity timestamps. This is separate from renaming the Operator conversation itself (OperatorStore.update_conversation), since one conversation can discuss many runs across its life.

Resolving a run reference

run_progress.py::resolve_run (reused by run_findings.py, resume_run.py, and rename_session.py) and cancel_run.py's own _resolve_run both accept the same reference vocabulary: an exact run/session id, an id prefix, a label, name or playbook substring (minimum 3 characters), or the literal "current". Id/prefix resolution reuses lionagi.cli._util.fetch_unique_row, the same primitive li kill uses, so a reference this tool accepts resolves identically to one typed at the CLI. When the turn names a project, each arm checks that project from the stored turn context rather than accepting it as a tool argument, and prefix resolution carries that project into the query itself, so its bounded id-ordered window is filled from that project's rows alone. Foreign rows are excluded from resolved ids and ambiguity candidates, and cannot push an in-scope match out of the window or hide one behind them. The resolver's failure reasons can differ for a foreign id and a nonexistent reference; identical not-found responses are not guaranteed. A turn with an owner but no declared project never gets that far: it resolves an exact full-UUID reference (or "current") only through the read path, and prefix and substring lookup require project context on every path. A read tool that reuses the resolver keeps that allowance. The write tools that mutate the run they name — cancel_run, rename_session, resume_run — pass allow_unscoped_exact_id=False, so on such a turn they refuse with missing_owner_context before resolving anything, for a full id and "current" included: the same position run_control takes, and for the same reason, that a mutation must prove it owns the run it names rather than rely on an id being un-enumerable. An ambiguous match returns candidates rather than choosing one: substring results are capped at MAX_CANDIDATES (10), and id-prefix candidates — for both the shared resolver and cancellation's own resolver — at the CLI helper's own listing size (5), with truncated true only when more rows matched than are returned. Truncation for an id prefix is counted over that project's rows, because the project predicate is what the prefix query's window counted.

run_progress.resolve_run("current") calls get_current_view, which can use a later observation from the same browser page. Tools that reuse this resolver share that selection behavior, including resume, rename, and run control. Project authorization still comes from the frozen turn context. Cancellation uses a separate resolver: cancel_run._current_run_id reads the turn's frozen selection and does not follow a later view report. The frontend carries the run selection under "s" and the Library selection under "sel"; run_progress._resolve_current additionally accepts the longer run/session id keys used by other callers.

View freshness: observation count, not wall clock

The Operator tracks "where is the human right now" (space, route, selection, filters) two ways: as a snapshot frozen onto each turn's context, and as an out-of-band report a page can send any time via OperatorStore.record_view. Ordering between a turn's frozen snapshot and a later view report — and between two view reports from the same page — is by observation_seq, an integer each page increments for the views it sends, never by server arrival time or a wall clock. Arrival order is wrong because a page can queue a report before an instruction is sent and have it land after; a wall clock is wrong because it can step backwards and leave a stale view holding the higher timestamp. The observer_id that travels with every count identifies which page produced it, because a count means nothing compared across pages — two browser tabs on one conversation are two independent counters, and a reload starts a new one. record_view gives each observer its own row for the same reason: a shared row would let a delayed report from an older navigation overwrite a newer page's high-water mark. A report that does not count higher than that observer's own stored count is discarded outright.

application_mcp.py::get_current_view is the read side: it starts from the turn's frozen context and only swaps in the stored view when that view's observation_seq is both present and strictly greater than the turn's own — otherwise the turn's snapshot is the most defensible answer available. The response reports whether the returned context is "turn" (nothing newer confirmed) or "live" (a fresher report exists), but never echoes the raw sequence number itself, since a bare count invites exactly the cross-page comparison this mechanism exists to prevent. OperatorViewReport (types.py) requires both observation_seq and observer_id for this reason — a report that cannot say who saw it or where it fell in their sequence can never be ordered against anything, so it is rejected outright rather than stored with a misleading freshness label.

Model catalog, provider selection, and effort clamping

catalog.py is the single source of truth GET /operator/models renders from and the coordinator validates every turn's selection against before it reaches a provider CLI; model ids and effort ceilings are grounded in the provider request models themselves (ClaudeEffort for claude_code, the codex effort-ceiling tables in service/providers.py for codex, and resolve_agy_model — which folds effort into the model name rather than taking a separate parameter — for gemini_code). model_effort_choices derives the efforts it offers for a given model from the same clamp functions the request-build path applies (_clamp_claude_effort, _clamp_codex_effort, _clamp_gemini_effort), rather than restating a second copy of the ceilings: an effort is offered only when clamping it for that model is a no-op. This matters because the request path clamps silently — a non-Opus Claude model turns xhigh into high, most Codex models turn max/ultra into xhigh, Gemini Pro has no Medium tier — and offering a value the request would silently change is worse than not offering it: the operator picks one level and a different one runs with nothing said. resolve_selection validates a client's requested (provider, model, effort) the same way: when a model is named, effort is checked against that specific model's ceiling rather than its provider's whole vocabulary, so a stale client can't pin an effort the request path would then quietly reduce.

Provider session identity vs. durable branch identity

OperatorStore tracks two different kinds of continuity for a conversation, and conflating them was a real bug each fixes. A provider session id (claim_resolved_pair, set_provider_session_id) is a resumability token that belongs to the exact (provider, model) pair that created it — resuming a Claude session against Codex is meaningless. Before claim_resolved_pair existed, only an explicit pin change could invalidate a stored session; an unpinned conversation runs on whatever the environment resolves to, re-read every turn, so moving the process-wide default provider silently tried to resume a session that belonged to the old pair. claim_resolved_pair compares the pair about to run against the pair that last ran and drops the stored session (returning None to resume with) exactly when they differ. A NULL stored pair — no turn has recorded a resolution yet, true for every conversation alive when this shipped — is not treated as a mismatch, which would otherwise drop every live session once at upgrade. clear_provider_model removes an explicit pin (an omitted model on a turn means "keep the current pin", so there has to be a separate way to ask for "no pin"), and drops the provider session as a consequence of the pair changing, not as its own separate effect — clearing an already-unpinned conversation is a no-op.

A branch id (claim_branch_id) is a different, longer-lived identity: the id every turn of a conversation builds its in-process Branch with. Before this existed, every turn constructed a brand-new Branch() with a fresh random id, so the CLI's own persistence path (setup_agent_persist in lionagi/cli/_runs.py) saw a never-before-seen id each time and created a new sessions row per turn instead of one row per conversation. Feeding back the same claimed id lets that existing "resume" logic run instead — it looks up the id in the branches table and appends to the existing session when found. claim_branch_id mints the id once (a fresh UUID) and persists it; every call after that returns the same value. It is race-safe the same way claim_resolved_pair is — wrapped in the store's BEGIN IMMEDIATE transaction, so two turns racing to claim the first id block against each other rather than minting two. Note that this only ever stores an identity, never a live Branch object: turns arrive as separate HTTP requests, the daemon restarts between them, and two browser tabs can drive one conversation at once, so no Python object can be assumed shared across turns.

fork_conversation copies a source conversation's turns into a new, independent one, but only turns that reached a terminal status (completed/failed/cancelled) — a turn still streaming is left out, so forking mid-turn ends the fork at the last completed turn rather than copying a half-written one, and the source conversation keeps streaming untouched. up_to_sequence, when given, additionally caps the fork at an earlier point in history so a user can branch from any prior turn, not only the tip. The fork copies the source description, which can then be edited independently. It starts with no provider session of its own, so it never silently resumes the source's provider-side session.

Redaction

redact.py is shared by every read tool (run_detail, run_progress, run_findings) and by the tool-argument/artifact payloads those tools and the write adapters project. scrub_text catches secrets and paths by shape: known token prefixes (sk-, ghp_, JWT-shaped strings), header and Bearer forms, KEY=value/key: value assignments under a secret-marker name, and absolute POSIX/Windows paths (collapsed to their leaf filename, so a path containing spaces, such as /home/someone/My Project/notes/secret.txt, is matched and redacted as a whole rather than only its first space-free segment). Shape-based matching alone misses a secret that doesn't look like one — an arbitrary passphrase or short internal token echoed back verbatim from a run's own environment, which a Studio-launched run inherits from this server process. known_secret_values reads that environment directly and returns every value stored under a secret-marker-named key (4+ characters, to exclude incidental short matches), and scrub_text strips any of those literal values in addition to the shape-based patterns.

Whether a field name itself means a credential is decided once, by is_secret_field_name, and every redaction path shares it — two divergent copies of this rule used to exist, and a value under an auth key was withheld on some projections and served on others. fold_field_name normalizes separators first (X-API-Key, api.key, and api_key all fold to the same spelling); the marker list also includes run-together spellings such as apikey. The names auth and authentication match exactly unless another substring marker matches, so unrelated names such as author and authorized_keys_count stay visible. bearer is also a substring marker. HMAC-key and signing-key names are credential markers in separated, run-together and camel-case spellings, including as substrings. This catches webhooksigningkey and signing_key2, but also classifies signing_keyword, hmac_keyboard, designing_key, and redesigning_key as credentials. A bare key is not a marker. The free-text rule asks the same question: a name=value or name: value assignment in prose is matched generically and then judged by is_secret_field_name, so a name the mapping layer calls a credential cannot be one the prose layer serves. That gap was real — Authorization=Token ... written into a spec's own free text kept its value while the same name used as a key had it removed, and auth_token=, credential= and MY_API_KEY= behaved the same way. Two consequences follow from sharing the rule. An auth header keeps its scheme (Authorization: Bearer [redacted]), because the scheme names a mechanism and not a credential, and an unrecognized scheme is taken along with the credential rather than left standing in front of it. And a purely numeric value is left alone, since the marker test matches by substring and max_tokens: 4096 is a count — the mapping layer already lets that through, because redact_scalar only redacts strings. redact_arguments applies the same field-name judgment recursively, carrying the parent key down into nested containers — without that, {"auth": "..."} was withheld while {"auth": {"value": "..."}} was served, the same field-name gap recurring one level down. Two independent byte caps (cap_by_bytes for a list of items, cap_payload_by_bytes for one payload) bound aggregate response size after redaction; cap_by_bytes fails closed on a single oversized item — an item that alone exceeds the limit is elided rather than admitted whole, so one huge newest item can't blank out every older, smaller one that would otherwise fit.

Bounded read projections: run_detail, run_progress, run_findings

All three read tools report why a run couldn't be read, not just that it couldn't: run_detail returns {"known": False, "source": "unavailable"} when the configured store cannot honestly be opened read-only (checked both before and after the underlying carrier call, so a store that disappears mid-call is reported as unavailable rather than as an empty result), versus {"known": False, "source": "store"} when the store is fine but no run matches — get_run collapses both situations into a bare None, so run_detail runs its own preflight to tell them apart. Every free-text field goes through scrub_text and project through public_project; manifest (an unbounded mapping) goes through the recursive redactor plus a byte cap. These fields are redacted even though today's StateDB-backed carrier already fills most of them with safe placeholders, because the projection contract must remain safe for future backing carriers rather than depending on the values one current path happens to supply.

run_progress reports operation counts two ways depending on what the run has: for an ordinary run it counts branches by status; for a DAG run (one with a graph) it instead derives per-node state from the session's recorded lifecycle signals, because a planned node with no branch materialized yet would otherwise be invisible to a branch count. Node state is read from the same NodeQueued/NodeStarted/... signal kinds the frontend's live SSE view and the in-run Signal bus both use, replayed over persisted session_signals rows since a bounded read tool can't subscribe to a live stream. skipped folds into the completed bucket (the node will never run, but the edge condition that skipped it did what it was written to do) while still being counted separately in skippedCount, and escalated folds into pending while being counted in escalatedCount — both counts are always present, including as zero, so a caller can tell "nothing happened" from "the field doesn't exist yet."

run_findings derives tool-call outcomes (success/error/pending) from message content via the shared _detect_status heuristic retained in lionagi.studio.services.runs for Session/operator projections, since plain session messages carry no structured ok: bool. Every section here is bounded by a message window (the carrier is called with a fixed message_limit) before any byte cap ever applies — the byte cap almost never fires in practice, so truncated reports both, and the response carries total alongside returned/items so a caller can tell "the last 50 of 50" from "the last 50 of 48,000." The errors section's partial flag propagates from the same window: branch and session status rows are read in full, but the tool-call-derived half of the errors list was built from a possibly-incomplete message window, and failing to say so would let "no errors found" silently mean "no errors observed in what we happened to load."