ADR-0078: Studio application-service boundary¶
- Status: Proposed
- Kind: Aspirational
- Area: studio
- Date: 2026-07-09
- Relations: extends ADR-0076 and ADR-0077
Context¶
Studio's current service modules are effective HTTP endpoints but weak application boundaries. They combine FastAPI adaptation, Pydantic bodies, validation, raw SQL, filesystem access, subprocess/scheduler calls, and product decisions. The web client, VS Code extension, and li schedule client then reproduce parts of the same contract in TypeScript or urllib code.
This aspirational ADR answers six implementation problems without claiming its target is already shipped.
P1 — Application behavior cannot be reused without HTTP. Listing runs, saving a file definition, mutating a schedule, and resolving an artifact are currently functions near route handlers, but their error mapping and persistence choices remain transport-aware. The CLI either duplicates validation or calls the daemon even when an in-process service would suffice (lionagi/studio/services/*.py; lionagi/studio/cli.py).
P2 — Validation has more than one owner. Schedule payload rules are parsed in the daemon and again in li schedule; the CLI validates recursive chain actions against scheduler-private limits. Workflow compilation imports execution metadata rather than a published application contract (schedules.py; cli.py; workflow_compile.py).
P3 — Persistence differences are hidden behind shared nouns. Some services call StateDB, some execute SQL, project routes issue DDL, file definitions are DB-first/file- second, and workflow definitions are DB-current. A generic repository would erase rather than solve those differences (ADR-0077).
P4 — Scheduler integration can leak engine mechanics upward. Studio must create, enable, disable, and inspect schedules, but application code should not own firing loops, leases, overlap, or worker recovery. The existing SchedulerStateService is the proper narrow persistence seam (scheduler_state.py).
P5 — Two clients independently encode a public daemon contract. The web and VS Code clients each know trailing slashes, response fields, bearer headers, and SSE parsing. A route change can break one client while the Python service and the other client continue to run (apps/studio/frontend/src/lib/api.ts; apps/vscode/src/api/*).
P6 — A flag-day repository rewrite would amplify risk. The daemon currently has 96 registered API routes. Moving every query at once would mix architectural migration with behavior changes and make compatibility regressions difficult to attribute.
| Concern | Decision |
|---|---|
| Package boundary | D1: Introduce lionagi.studio.application between transports and state. |
| Command/query contract | D2: Publish typed, transport-independent request/result models and one explicit error taxonomy. |
| Persistence ports | D3: Use capability-shaped StateDB and filesystem ports; keep file and workflow definitions separate. |
| Scheduler boundary | D4: Submit and inspect schedules through application services backed by SchedulerStateService. |
| Client compatibility | D5: Make OpenAPI plus behavior fixtures the versioned contract for web, CLI, and VS Code adapters. |
| Migration quality | D6: Migrate operation-by-operation, keep coupling below 0.3, and require fake-port and cross-client tests. |
Out of scope:
- Replacing StateDB or changing its schema solely to satisfy this layering decision.
- Redefining scheduler firing, budget, lease, retry, or worker semantics.
- Building the unified execution UI in ADR-0081 or the operator protocol in ADR-0083.
- Introducing a general event bus, ORM repository framework, dependency-injection container, or network service separate from the Studio daemon.
- Converging file-backed agent/playbook definitions with database-backed workflow definitions; that requires a later migration decision.
Decision¶
D1 — lionagi.studio.application is the reusable boundary¶
The target module tree is:
lionagi/studio/
├── application/
│ ├── __init__.py # public re-exports only
│ ├── errors.py # closed application error taxonomy
│ ├── models.py # shared ids, pages, command/query/result models
│ ├── runs.py # run queries
│ ├── definitions.py # file-definition commands/queries
│ ├── workflows.py # DB workflow-definition commands/queries
│ ├── schedules.py # schedule commands/queries
│ ├── artifacts.py # metadata + safe content resolution
│ ├── projects.py # project commands/queries
│ └── ports.py # capability protocols
├── services/ # FastAPI adapters; no DDL or domain policy
├── scheduler/ # firing/lease/worker mechanics
├── registry.py
└── app.py
The composition root constructs application services once and makes them available to route dependencies and in-process CLI adapters. Application modules may import StateDB data types and the narrow ports below. They do not import FastAPI, Starlette, VS Code/web types, or CLI parser namespaces.
Dependency direction is:
flowchart LR
Web[Web HTTP/SSE adapter] --> App[Studio application]
CLI[CLI local/remote adapter] --> App
VS[VS Code HTTP/SSE adapter] --> Contract[Published daemon contract]
Contract --> Web
App --> State[State ports]
App --> Files[File/artifact ports]
App --> Scheduler[SchedulerStateService] Exact semantics:
- A route parses HTTP input, calls exactly one application operation, and maps its result or application error. It does not execute DDL, open
aiosqlite, or choose a file path. - An in-process CLI calls the same operation directly. A remote CLI serializes the same request model through the versioned HTTP adapter.
- Application services are async because StateDB, filesystem thread offload, and scheduler calls are async from the caller's perspective.
- Import cycles from application modules back into
servicesare forbidden. Compatibility adapters may call old service functions only during a bounded migration and must be namedlegacy_*so the dependency can be removed mechanically.
Why this way: the boundary corresponds to operator use cases, not tables. It permits reuse and fake-port tests while keeping the running FastAPI daemon as one deployment unit.
D2 — Typed commands, queries, results, and failures¶
The target base models are Python-native Pydantic models:
from typing import Generic, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, Field
T = TypeVar("T")
JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
class ContractModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class Page(ContractModel, Generic[T]):
items: tuple[T, ...]
offset: int = Field(ge=0)
limit: int = Field(ge=1)
has_next: bool
class RunQuery(ContractModel):
status: tuple[str, ...] = ()
playbook: str | None = None
project: str | None = None
project_is_null: bool = False
tags: tuple[str, ...] = ()
offset: int = Field(default=0, ge=0)
limit: int = Field(default=20, ge=1, le=5000)
class RunSummary(ContractModel):
run_id: str
status: str
effective_health: str | None
playbook_name: str | None
agent_name: str | None
invocation_kind: str | None
invocation_id: str | None
project: str | None
started_at: float | None
ended_at: float | None
updated_at: float | None
branch_count: int = 0
message_count: int = 0
status_reason_code: str | None = None
status_reason_summary: str | None = None
class SaveDefinition(ContractModel):
kind: Literal["agent", "playbook"]
name: str = Field(min_length=1)
content: str
message: str | None = None
expected_version: int | None = Field(default=None, ge=0)
class SaveResult(ContractModel):
kind: Literal["agent", "playbook"]
name: str
version: int = Field(ge=1)
saved_at: float
reconciliation_id: str | None = None
class ArtifactQuery(ContractModel):
artifact_id: str
include_preview: bool = False
preview_limit_bytes: int = Field(default=262_144, ge=1, le=1_048_576)
class ArtifactResult(ContractModel):
id: str
kind: str
name: str
content: dict[str, object]
display_path: str | None
sha256: str | None
media_type: str | None
preview: bytes | None
preview_truncated: bool = False
262_144 bytes is the target default bounded preview (256 KiB), with a 1 MiB hard cap. It is a conservative UI responsiveness and memory bound, not a measured optimum; changing either value is a contract change with tests.
Schedule commands are a discriminated union rather than one bag of optional fields:
class CronTrigger(ContractModel):
kind: Literal["cron"] = "cron"
expression: str
class IntervalTrigger(ContractModel):
kind: Literal["interval"] = "interval"
seconds: int = Field(ge=1)
class GithubPollTrigger(ContractModel):
kind: Literal["github_poll"] = "github_poll"
repository: str
poll_interval_seconds: int = Field(ge=1)
filters: dict[str, JsonValue] = Field(default_factory=dict)
class ScheduleAction(ContractModel):
kind: Literal["agent", "flow", "fanout", "play", "flow_yaml"]
model: str | None = None
prompt: str | None = None
agent: str | None = None
playbook: str | None = None
flow_yaml: str | None = None
project: str | None = None
extra_args: tuple[str, ...] = ()
on_success: dict[str, JsonValue] | None = None
on_fail: dict[str, JsonValue] | None = None
class SchedulePatch(ContractModel):
# Every field is optional, but model_fields_set distinguishes omitted from
# explicit null so nullable fields can be cleared without clearing others.
name: str | None = None
description: str | None = None
trigger: CronTrigger | IntervalTrigger | GithubPollTrigger | None = None
action: ScheduleAction | None = None
missed_fire_policy: Literal["skip", "run_once"] | None = None
overlap_policy: Literal["skip", "allow"] | None = None
max_runs: int | None = Field(default=None, ge=1)
budget_usd: float | None = Field(default=None, gt=0)
budget_tokens: int | None = Field(default=None, ge=1)
project: str | None = None
class CreateSchedule(ContractModel):
kind: Literal["create"] = "create"
name: str
trigger: CronTrigger | IntervalTrigger | GithubPollTrigger
action: ScheduleAction
enabled: bool = True
max_runs: int | None = Field(default=None, ge=1)
budget_usd: float | None = Field(default=None, gt=0)
budget_tokens: int | None = Field(default=None, ge=1)
class PatchSchedule(ContractModel):
kind: Literal["patch"] = "patch"
schedule_id: str
expected_updated_at: float
changes: SchedulePatch
class SetScheduleEnabled(ContractModel):
kind: Literal["set_enabled"] = "set_enabled"
schedule_id: str
enabled: bool
expected_updated_at: float | None = None
ScheduleCommand = CreateSchedule | PatchSchedule | SetScheduleEnabled
class ScheduleResult(ContractModel):
schedule_id: str
name: str
enabled: bool
updated_at: float
next_fire_at: float | None
The minimum service protocol remains compact:
class StudioApplication(Protocol):
async def list_runs(self, query: RunQuery) -> Page[RunSummary]: ...
async def save_file_definition(self, command: SaveDefinition) -> SaveResult: ...
async def mutate_schedule(self, command: ScheduleCommand) -> ScheduleResult: ...
async def resolve_artifact(self, query: ArtifactQuery) -> ArtifactResult: ...
The target error taxonomy is closed at the adapter boundary:
class FieldFailure(ContractModel):
path: tuple[str | int, ...]
code: str
message: str
class StudioApplicationError(Exception):
code: str
class ValidationFailure(StudioApplicationError): # code="validation"
field_errors: tuple[FieldFailure, ...]
class NotFound(StudioApplicationError): # code="not_found"
resource: str; identifier: str
class Conflict(StudioApplicationError): # code="conflict"
resource: str; expected: str | None; actual: str | None
class StateUnavailable(StudioApplicationError): # code="state_unavailable"
retryable: bool
class ReconciliationRequired(StudioApplicationError): # code="reconciliation_required"
reconciliation_id: str
class PermissionDenied(StudioApplicationError): # code="permission_denied"
reason: str
Exact error semantics:
- Invalid input never reaches a port and maps to HTTP 422.
- Missing resources map to 404.
- Name conflicts, stale expected versions, and stale
updated_atvalues map to 409. - State unavailability maps to 503 and carries whether retry is safe.
- A definition DB commit followed by file-write failure creates a persisted reconciliation record and returns
ReconciliationRequired, not a success-shaped result. - Authentication remains the daemon middleware's responsibility; application-level
PermissionDeniedrepresents operation authorization or containment denial, not a missing bearer header. - Adapters serialize stable
codeplus structured fields. Human text is diagnostic and is not the discriminant.
D3 — Capability-shaped state and file ports¶
The target ports expose behavior required by operations, not generic CRUD:
class RunReadPort(Protocol):
async def list_runs(self, query: RunQuery) -> Page[RunSummary]: ...
async def get_run(self, run_id: str, *, message_limit: int, cursor: str | None) -> RunDetail | None: ...
class FileDefinitionPort(Protocol):
async def read_current(self, kind: str, name: str) -> FileDefinition | None: ...
async def current_hash(self, kind: str, name: str) -> str | None: ...
async def write_current(self, kind: str, name: str, content: str) -> FileWriteResult: ...
class DefinitionHistoryPort(Protocol):
async def latest_version(self, kind: str, name: str) -> int: ...
async def append_version(self, draft: DefinitionVersionDraft) -> DefinitionVersion: ...
async def record_reconciliation(self, failure: DefinitionWriteFailure) -> str: ...
class WorkflowDefinitionPort(Protocol):
async def create(self, command: CreateWorkflow) -> WorkflowDefinition: ...
async def update(self, command: PatchWorkflow) -> WorkflowDefinition: ...
async def delete(self, workflow_id: str) -> bool: ...
class ArtifactMetadataPort(Protocol):
async def get(self, artifact_id: str) -> ArtifactMetadata | None: ...
class ArtifactFilePort(Protocol):
async def preview(
self, relative_path: str, *, max_bytes: int, expected_sha256: str | None
) -> VerifiedPreview: ...
RunDetail is the application-owned superset of RunSummary with branch/message/graph, cursor, artifact, and reason projections; EvidenceRequest contains the selected pane, opaque cursor, and bounded page size. They are defined in application/models.py and are serialized by the existing run-detail adapter without exposing StateDB rows directly.
The value types used by those ports are exact transfer shapes:
class FileDefinition(ContractModel):
kind: Literal["agent", "playbook"]
name: str
relative_path: str
content: str
sha256: str
version: int
class FileWriteResult(ContractModel):
relative_path: str
sha256: str
bytes_written: int = Field(ge=0)
class DefinitionVersionDraft(ContractModel):
kind: Literal["agent", "playbook"]
name: str
relative_path: str
content: str
content_sha256: str
message: str | None = None
class DefinitionVersion(DefinitionVersionDraft):
id: str
version: int = Field(ge=1)
created_at: float
class DefinitionWriteFailure(ContractModel):
version_id: str
kind: Literal["agent", "playbook"]
name: str
expected_sha256: str
error_code: str
class CreateWorkflow(ContractModel):
name: str = Field(min_length=1, max_length=120)
description: str | None = None
spec: dict[str, JsonValue] | None = None
class PatchWorkflow(ContractModel):
workflow_id: str
expected_updated_at: float
name: str | None = Field(default=None, min_length=1, max_length=120)
description: str | None = None
spec: dict[str, JsonValue] | None = None
class WorkflowDefinition(ContractModel):
id: str
name: str
description: str | None
spec: dict[str, JsonValue] | None
created_at: float
updated_at: float
class ArtifactMetadata(ContractModel):
id: str
kind: str
name: str
content: dict[str, JsonValue]
relative_path: str | None
sha256: str | None
media_type: str | None
size_bytes: int | None
class VerifiedPreview(ContractModel):
data: bytes
sha256: str
truncated: bool
media_type: str
Exact semantics:
- StateDB owns all schema and migration DDL. No HTTP adapter or application operation executes
CREATE TABLE,ALTER TABLE, or index DDL. - File-backed definitions and workflow definitions have different ports. No
DefinitionRepository[Any]erases source-of-truth or history differences. SaveDefinition.expected_versionis checked before appending. A mismatch is a conflict;Noneexplicitly requests last-write-wins compatibility behavior.- Definition save remains DB-history first until a separately accepted migration changes the storage protocol. The new persisted reconciliation record makes the partial failure observable and recoverable.
- Artifact metadata is fetched before file access. The file port accepts a relative path, resolves it under an approved root, rejects escape/symlink containment violations, reads at most the requested cap, and verifies a supplied SHA-256 before returning bytes.
- Empty result sets return typed empty pages. Missing individual resources return
Noneto the application service, which converts them toNotFound.
D4 — Scheduler application operations stop at SchedulerStateService¶
The application layer may validate and submit schedule definitions, enable/disable them, trigger an explicitly supported manual fire, and query schedule/firing state. Its adapter depends on the existing SchedulerStateService protocol recorded in ADR-0077 D6.
Exact boundary:
- Application validation owns public schedule shapes and optimistic conflict checks.
SchedulerStateServiceowns persistence calls required by scheduler behavior.lionagi.studio.schedulerowns cadence calculation, polling, budget evaluation, overlap, chain depth, leases, worker execution, and recovery.- Application code does not import private scheduler constants. Limits required for public validation are published from a small scheduler contract module and used by both daemon and CLI adapters.
li scheduleeither invokes the application in-process or uses the remote contract. It does not keep an independent recursive validator.- A state-unavailable result is explicit. No adapter interprets an empty schedule list as proof that persistence is healthy.
D5 — One versioned daemon contract for all clients¶
OpenAPI describes request/response schemas and status codes; checked-in behavior fixtures describe semantics OpenAPI cannot express, especially SSE and redirects. The contract artifact set is:
studio-contract/
├── openapi.json
├── routes.json # method, canonical path, trailing-slash policy, auth class
├── examples/
│ ├── runs-page.json
│ ├── run-detail.json
│ ├── invocation-detail.json
│ └── application-errors.json
└── sse/
├── session.ndjson
├── signals.ndjson
├── show.ndjson
└── leo.ndjson
The exact version rule is additive within major version 1:
- Adding an optional response field or a new endpoint is compatible.
- Removing/renaming a field, changing type/nullability, changing method or canonical path, altering auth class, or changing an SSE terminal/reconnect rule requires a versioned compatibility change and an explicit migration.
- The web and VS Code client parsers run against the same fixtures in CI.
- Trailing-slash redirects are tested from the client-visible origin because an absolute redirect can become cross-origin.
- Bearer mode and tokenless mode are both exercised for JSON and SSE.
- Generated clients are permitted but not required; passing the behavior suite is the normative enforcement mechanism.
D6 — Incremental migration, coupling, and testability gates¶
Migration is strangler-style:
- Add application models, ports, and adapter tests for one changed operation.
- Implement the application operation by calling current StateDB/file capabilities.
- Make the existing HTTP route a thin adapter without changing its wire contract.
- Point the in-process or remote CLI path at the same request model.
- Run daemon, web, and VS Code contract fixtures.
- Remove the superseded service helper or mark a bounded compatibility adapter.
No operation migrates merely to satisfy a directory quota. Changed behavior and the application extraction should be separate commits or separately testable steps even when delivered together.
For the target six-component subgraph—web adapter, CLI adapter, VS adapter/contract, application services, state ports, and file/scheduler ports—with six intended direct dependencies, κ = 6/(6×5) = 0.20. This is below the required 0.3. (The six components collapse the D1 diagram's finer-grained nodes — the state ports count as one component and the file/scheduler ports as another — so the diagram's edge count and this κ edge count differ by construction.) Adding direct transport →StateDB or transport→filesystem edges increases coupling and fails the design review unless explicitly justified.
Testability target is τ ≥ 0.85, evidenced by:
- application tests using fake state, file, scheduler, and clock ports;
- error-path tests for empty, miss, stale version, DB failure, file-after-commit failure, containment denial, and hash mismatch;
- adapter tests pinning HTTP status and payloads; and
- cross-client fixture tests for JSON, trailing slashes, auth, SSE EOF, terminal frames, and reconnection.
Consequences¶
- Route handlers become small and application behavior becomes reusable without HTTP.
- Schema ownership moves to StateDB while optimized query ports remain possible; the design does not require an anemic generic repository.
- File-definition and workflow-definition differences become visible in types. A caller cannot accidentally assume rollback or version history exists for both.
- The definition partial-failure window remains until storage changes, but it becomes a typed, durable reconciliation state instead of an ambiguous exception.
- Client compatibility is checked once against shared artifacts. The cost is maintaining fixtures whenever a deliberate contract change occurs.
- The migration adds interfaces and temporary adapters. Reversing D1 later is moderately costly because transports will depend on these models, but ports keep persistence replaceable.
- Contributors must decide whether logic is HTTP adaptation, application policy, or scheduler/state capability. That additional classification is deliberate maintenance work.
Alternatives considered¶
Keep HTTP as the only application boundary¶
This preserves the current deployment and makes remote and local behavior identical. It lost because local CLI commands must serialize through HTTP or duplicate validation, and application tests require constructing transport state. HTTP remains an adapter, not the only callable form.
Generic repository per table¶
Uniform get/list/create/update/delete protocols would be quick to scaffold and easy to mock. They lost because important semantics live above CRUD: append-only version allocation, status-transition transactions, artifact natural-key upserts, safe path resolution, and schedule conflict policy. Capability ports make those decisions explicit.
One repository for every definition kind¶
This would simplify UI and API vocabulary. It lost because files are current for agents and playbooks while StateDB rows are current for workflows. A common interface would either omit version/file semantics or pretend they apply universally.
Generated clients as the sole compatibility mechanism¶
Generation would reduce duplicated TypeScript shapes. It lost as the sole mechanism because OpenAPI does not capture SSE parsing, reconnect, terminal frames, host/auth behavior, or redirect-origin failures. Generation remains allowed alongside behavior fixtures.
Introduce a central event bus with the application layer¶
Commands and queries could publish events for clients and scheduler integration. It lost because the boundary can be delivered without a new durability, ordering, and retention system. Endpoint SSE normalization is a separate delta in ADR-0076.
Flag-day migration of all 96 routes¶
This would reach a uniform architecture sooner and avoid temporary adapters. It lost because the blast radius spans every daemon client and mixes contract changes with structural moves. Operation-by-operation extraction keeps each step reversible and contract-testable.