Skip to main content

guides/developer-guide-runtime-layers.md

# Developer Guide: Runtime Layers and Core Boundaries

This guide explains the internal layer boundaries in `cli_subprocess_core`.

It is intended for people reviewing the core architecture, extending the core,
or trying to understand where a change should land.

## The Core Layers

`cli_subprocess_core` has a small set of important layers:

1. model policy
2. provider profile adaptation
3. command/session normalization
4. execution-surface routing and lower runtime execution
5. normalized event and payload emission

These layers exist so the runtime can stay consistent while still supporting
multiple external CLI families.

## Schema Ownership Inside The Layers

The shared schema boundary sits beside the existing runtime layers, not above
them as a second architecture.

- `CliSubprocessCore.Schema` owns the shared `Zoi` validation contract for
  core-owned dynamic boundaries.
- `CliSubprocessCore.Event`, `CliSubprocessCore.Payload.*`,
  `CliSubprocessCore.ModelRegistry.Model`,
  `CliSubprocessCore.ModelRegistry.Selection`, and
  `CliSubprocessCore.ModelInput` use that schema layer at map ingress and then
  project back into the existing public structs.
- Forward-compatible shared wire surfaces use
  `Zoi.map(..., unrecognized_keys: :preserve)` plus projection so future fields
  survive in `extra` where the boundary needs them.
- Closed boundaries may still use direct struct validation, but evolving wire
  surfaces should not depend on `Zoi.struct/3`.
- Provider-native app-server, control-protocol, and orchestration schemas stay
  in the downstream repos that own those boundaries.

## Layer 1: Model Policy

Owned by:

- `CliSubprocessCore.ModelRegistry`
- the internal model catalog loader and catalog data under `priv/models`

This layer answers:

- which models exist
- which model should be used
- whether the request is valid
- which reasoning values are allowed

## Layer 2: Provider Profile Adaptation

Owned by:

- `CliSubprocessCore.ProviderProfile`
- built-in provider profile modules

This layer answers:

- how to translate normalized intent into provider-specific CLI behavior
- how to interpret provider-specific output inside the shared runtime

## Layer 3: Command and Session Normalization

Owned by modules such as:

- `CliSubprocessCore.Command`
- `CliSubprocessCore.Command.Options`
- `CliSubprocessCore.Session`
- `CliSubprocessCore.Session.Options`

This layer gives the core a provider-agnostic API for one-shot commands and
longer-lived sessions.

## Layer 4: Lower Runtime Execution

Owned by the lower owner for the selected lane:

- `ExecutionPlane.Process` for the covered local one-shot command lane
- `ExecutionPlane.Process.Transport` for the shared session-bearing lane and
  non-local command/session placement

This layer starts the external process, manages stdin/stdout/stderr, and
captures process exit information through the shared substrate.

It should remain blind to provider policy.

That includes provider-native approval and sandbox posture. The transport layer
owns how the process is attached or started. It does not own whether a
particular provider should run with a permissive mode such as "danger full
access."

If a remote CLI launched over `:ssh_exec` later fails inside its own sandbox
backend, that is already above the transport-placement boundary. A common real
example is a remote Linux host where the provider CLI tries to use `bwrap` and
the host's AppArmor or userns policy blocks the loopback/userns setup. The
core transport succeeded; the remote runtime or host policy did not.

## Layer 5: Event and Payload Emission

Owned by:

- `CliSubprocessCore.Event`
- `CliSubprocessCore.Payload.*`
- `CliSubprocessCore.Runtime`

This layer turns provider/runtime activity into the shared event model that the
rest of the stack consumes.

## The Practical Boundary Rule

When deciding where a change belongs, use this rule:

- if the change affects model choice, put it in the registry/catalog
- if the change affects provider CLI syntax, put it in the provider profile
- if the change affects the covered local one-shot subprocess lane, put it in the execution-plane-backed lower runtime
- if the change affects the shared session-bearing or non-local placement transport seam, put it in the execution-plane-backed transport layer
- if the change affects normalized output shape, put it in payload/runtime

That rule prevents policy leakage across layers.

## Example Integration Shape

External repos should consume the core in this order:

1. prepare normalized options
2. call the core’s model registry
3. pass the resolved selection into provider-facing command building
4. let the core route the request to the execution-plane-backed command lane or
   the execution-plane-backed transport lane as appropriate

The core is therefore both:

- a policy owner
- and a command/session boundary above lower runtime owners

But those are still separate internal responsibilities.

## What Reviewers Should Watch For

Architecture drift usually shows up as one of these mistakes:

- a provider profile starts choosing fallback models
- transport code learns provider policy
- consumer-facing behavior bypasses normalized payloads
- multiple layers define overlapping defaults

If one layer can be removed without changing the others, the boundaries are
probably healthy. If a small change requires editing policy, profile, and
transport logic together, the responsibilities are probably leaking.