docs/internal/AUTHORITY_DESIGN.md
Supersedes DEVCONTAINER_INTEGRATION_PLAN.md. That plan predicted the
Authority Provider pattern; this document describes the architecture
as actually shipped.
The editor has exactly one Authority at any moment. It is the single
answer to "where does this primitive run?":
:term, plugin createTerminal)spawnProcessThe struct carries four fields and nothing else:
pub struct Authority {
pub filesystem: Arc<dyn FileSystem + Send + Sync>,
pub process_spawner: Arc<dyn ProcessSpawner>,
pub terminal_wrapper: TerminalWrapper,
pub display_label: String,
}
Empty display_label means the status bar renders nothing — the SSH
constructor leaves it empty so the existing
filesystem.remote_connection_info() path (which knows about
disconnect) stays the source of truth for user@host labels.
One authority on Editor. No Option. Local is an authority;
SSH is an authority; devcontainer is an authority; anything a plugin
invents is an authority. Local's terminal wrapper is
detect_shell() with no args and manages_cwd: false.
Evolution (Orchestrator / Cloud Workspaces). As editor state moves onto
Window/Session(seeorchestrator-sessions-design.md) and a single process holds many sessions — some attached to live cloud backends that stay warm in the background — this becomes one authority perSession, exactly one active. The "sole router" and "opaque to core" principles are unchanged; only "per-process, one alive" gives way. See §"Evolution: per-session authority".
Authority is the sole router for "where". Every primitive routes
through editor.authority(). Nothing reads a backend-specific
field. Nothing branches on "is this SSH / a container".
Authority is opaque to core. No string "docker" / "ssh" /
"container" in core logic that consumes the authority. The only
code that names a backend is the constructor for that backend's
authority.
Plugins own backend lifecycle; core owns the slot. Plugins
attach a container, parse a devcontainer.json, drive a rebuild.
Core just holds the authority and re-routes through it.
Modal per window, no composition. One authority, one workspace. Opening a non-project file while attached still routes through the active authority — this is the contract, not a bug.
Startup is local; plugins upgrade. The editor always boots
Authority::local() and renders immediately. The SSH CLI form
(fresh user@host:path) substitutes Authority::ssh(...) at
startup. Devcontainer attach is a plugin op and happens post-boot.
Authority transitions are destructive. See next section.
The core shrinks. This refactor net-deleted ~400 lines: the
services/devcontainer/ module, DevcontainerConfig, the
connect_devcontainer block in main.rs, and every per-backend
branch in the terminal manager, render code, and plugin dispatch.
Identity lives in the authority. Whoever constructs the
authority fills in display_label. SSH intentionally leaves it
empty so disconnect annotations flow through one place.
Every authority is constructible in isolation. Authority::local(),
Authority::ssh(...), Authority::from_plugin_payload(...) — all
available for unit testing without a running editor.
Principle 7 says "atomic and destructive". The spec's original
phrasing — "installing a new authority closes all terminals spawned
under the previous one, restarts LSP servers, invalidates cached
spawner handles; pointer-equality against Editor.authority is the
'still attached to the same thing?' check" — describes an in-place
swap.
We chose the more conservative option: transitions drop and rebuild
the whole Editor, piggy-backing on the existing
change_working_dir / request_restart flow.
In-place swap means enumerating everything that holds an
Arc<dyn FileSystem> or an Arc<dyn ProcessSpawner> at the moment of
swap and invalidating each one. As of this refactor that set
includes, at minimum:
EditorState (captured filesystem at load time)FsManager (file explorer)FileProvider in Quick OpenLspManager (server handles spawned through the old spawner)TerminalManager (every PTY)Arc to the old spawnerAny cache holding a closure over the old authority's filesystem would silently keep using the old backend after a "successful" transition. Enumerating them is doable, but easy to miss, and the miss manifests as "files save to the wrong place" — a trust-destroying class of bug.
The request_restart path already drops the entire Editor, calls
Drop on every resource, rebuilds from scratch, and reloads plugins.
Session restore brings buffers back. LSPs cold-start, but they were
going to restart on authority change anyway. The visible cost is one
frame of "Restarting editor…" status and a ~1-second pause — a price
we pay once per attach/detach, not per keystroke.
Editor::install_authority(new) stashes the replacement in
pending_authority and calls request_restart(self.working_dir).Editor::clear_authority() is sugar for install_authority(Authority::local()).main.rs drains take_pending_authority()
from the old editor before dropping it, threads the result into
current_authority, then builds the next Editor with
set_boot_authority(current_authority) immediately after
construction so plugins load with the new backend from the first
tick.The in-place swap remains a future optimization. The single-line
escape hatch is at install_authority — replace the pending + restart
with a direct swap once every cache-holder is audited.
Session mode (fresh --session / fresh server) runs the editor in a
long-lived daemon with thin clients attaching over IPC. The daemon
must not exit on every authority transition or working-dir change —
that would disconnect every attached client.
EditorServer (crates/fresh-editor/src/server/editor_server.rs)
mirrors the standalone restart loop: when the editor sets
should_quit via request_restart (either from
change_working_dir or from install_authority), the server takes
the pending fields off the old editor, calls rebuild_editor(...),
and clients stay attached. rebuild_editor:
self.config.working_dir and/or self.current_authority.build_editor_instance with the new
authority already installed (set_boot_authority).If neither a pending authority nor a restart dir is present,
should_quit is treated as a real shutdown request and the daemon
exits as before. Tests cover both the authority-swap and the
working-dir-swap paths (test_session_rebuild_swaps_editor_and_authority
and test_session_rebuild_switches_working_dir).
EditorServerConfig has two optional slots for callers that want
the daemon to boot into something other than local:
startup_authority: Option<Authority> — installed as
current_authority before the first editor is built. Defaults to
Authority::local().session_keepalive: Option<Box<dyn Any + Send>> — an opaque
bundle held for the server's lifetime alongside startup_authority.
SSH authorities back this with the Tokio runtime, the
SshConnection, and the reconnect task; dropping any one of those
would tear the remote session down, so the server just holds the
bundle until shutdown. Local authorities leave it None.When a client command (fresh -a <files> or
fresh --cmd session open-file <name> <files>) sees any remote spec
in the file list, extract_ssh_url_from_files:
parse_location.ssh:// URL via
remote_location_to_ssh_url (line/column are per-file and stay
out of the authority URL).That URL is forwarded to the detached child as
--ssh-url <URL> (a hidden internal flag) by
spawn_server_detached(session_name, ssh_url). The file list sent
to the daemon over the OpenFiles control message is stripped to
bare paths — the daemon's active authority already knows the host.
On the daemon side, run_server_command uses parse_ssh_url_arg
(URL-form only, hard error on anything else) to build a
RemoteLocation, calls the same create_startup_authority /
connect_remote used by standalone mode, and wraps the resulting
RemoteSession in the session_keepalive slot. The remote path
becomes the daemon's working_dir; local cwd keeps its role as the
config-layering key.
Existing servers are not re-attached through a remote URL: a URL
passed to fresh -a is only consumed when the client starts a new
daemon. If a local-authority session is already running under the
target key, the URL is ignored. Callers wanting isolation should
pass --session-name (or equivalent) so the new SSH daemon gets a
distinct socket.
change_working_dirchange_working_dir uses the same restart machinery to switch project
roots. Authority transitions and project-root changes are the same
primitive at the main-loop level — drop the Editor, rebuild — with
different "what changes" semantics (working directory vs. the
current_authority slot). Keeping them separate entry points means
callers don't have to care about each other; each can evolve
independently.
Status within this doc: design direction, not yet shipped. The sections above describe the per-
Editor, one-alive model as it ships today. This section describes where the Orchestrator session model and Cloud Workspaces (K8S_WORKSPACE_UX_DESIGN.md) take it. It is recorded here so the foundational principles above are read with the evolution in view.
Cloud Workspaces make a workspace a Session
whose authority is a remote (EKS/kubectl exec) backend, and the UX
decision D4 — background cloud sessions stay warm requires several
sessions to hold live backends concurrently (each its own
kubectl exec channel, agent, reconnect task, keepalive). A single
Authority per process can keep exactly one backend alive, so that
model cannot express warm background sessions. The authority must be
owned per Session/Window.
This is not a reversal — it continues the migration that moved buffers,
splits, file explorer, LSP, and terminals from Editor onto Window.
Where the code actually is today (reassessed against the current
tree): the per-window authority field already exists —
WindowResources holds an authority, exposed as Window::authority()
(app/window/mod.rs). What is not yet true:
Editor still holds a top-level self.authority that
Editor::authority() returns directly, and the active session is
pinned to WindowId(1) ("until the multi-session migration step
lands" — editor_accessors.rs). So multiple sessions don't yet run
concurrently.Editor-level restart:
install_authority queues pending_authority and calls
request_restart; main.rs drops and rebuilds the whole editor.So the remaining work for warm Cloud Workspaces is not "move authority
onto Window" (the field is there) — it is (a) land live multi-session
so several windows coexist, (b) give each window its own keepalive so
a background window keeps its live backend, and (c) replace the
destructive-restart transition with per-window activation.
Implementation status. The per-window activation primitive has landed:
Editor::set_session_authority(window_id, authority)(app/editor_accessors.rs) swaps a single window's authority and re-points that window's LSP, mirroring into the editor-wide cache only when it's the active window — the per-session counterpart to the all-windows boot fan-out. Tested (tests/e2e/per_session_authority.rs: active-window swap propagates; non-active swap leaves the foreground untouched). Still gated: live multi-session, the per-window keepalive, and cache-invalidation of buffers/terminals opened under the old authority (so production attach still uses the destructiveinstall_authorityrestart).
Session, exactly one active. The active
session's authority is still the sole router for every primitive
(principle 2 intact); background sessions hold dormant authorities
— live connection, routing nothing. "Modal per window" (principle 5)
becomes literally true once one process runs many windows (today it's
pinned to one).Today's conservative "drop and rebuild the whole Editor"
(install_authority → pending_authority → request_restart) exists
because enumerating every cache holding an Arc<dyn FileSystem> at swap
time was error-prone. The session migration bundles that state per
window — so the unit of teardown can shrink from "the process" to "a
session":
install_authority / clear_authority retarget the active
session's authority slot and rebuild that session, not the Editor.
The old whole-Editor restart remains the mechanism for a genuine
process-level change (e.g. config reload), not for an authority swap.set_boot_authority replaces the authority without a restart, then
fans it out to every window's resources.authority — i.e. it sets
one global authority across all windows at construction time, and is
explicitly "not from the event loop." The per-window-activation path
generalizes from this (swap one window's authority, re-point its LSP /
path-translation), rather than reusing it as-is.Three things currently shared process-wide are passed into every
authority constructor as one Arc the server owns. Per-session,
divergent backends likely make some of them per-session — to be designed
deliberately, not assumed:
WorkspaceTrust — trusting a cluster/host is a per-workspace
decision; trust probably becomes per-session (with a shared registry
for "remember this cluster").EnvProvider — venv/direnv/mise activation is per-project; almost
certainly per-session.session_keepalive / startup_authority (daemon). The
EditorServer single-slot model (one keepalive, one startup authority)
becomes a per-session collection: each warm session owns its keepalive
bundle; the daemon holds them for the lifetime of their session, not
the process.kubectl/ssh child + agent + tokio task each). The warm-set cap
(UX D4) suspends the least-recently-active beyond a configurable max.Three ops, intentionally small:
editor.setAuthority(payload) — payload is a tagged
AuthorityPayload (filesystem kind + spawner kind + terminal
wrapper + display label). The concrete schema lives in
crates/fresh-editor/src/services/authority/mod.rs; TS types are
mirrored in plugins/lib/fresh.d.ts. Fire-and-forget — the editor
restarts before any follow-up code on this call's return could run.editor.clearAuthority() — restore Authority::local() with the
same restart semantics.editor.spawnHostProcess(command, args, cwd?) — run on the host
regardless of the current authority. Reserved for plugin internals
that must do host-side work (e.g. devcontainer up) before the
authority they want even exists. Same calling shape as
spawnProcess, a thenable returning a SpawnResult.type AuthorityPayload = {
filesystem: { kind: "local" };
spawner:
| { kind: "local" }
| {
kind: "docker-exec";
container_id: string;
user?: string | null;
workspace?: string | null;
};
terminal_wrapper:
| { kind: "host-shell" }
| { kind: "explicit"; command: string; args: string[]; manages_cwd?: boolean };
display_label?: string;
};
New kinds go here and in Authority::from_plugin_payload.
serde's tagged-enum representation means old payloads keep parsing
as new variants are added.
Example of the plugin-owned backend lifecycle, in full:
Authority::local().plugins/devcontainer.ts loads, calls findConfig(), sees
.devcontainer/devcontainer.json.getCwd() — reopening the project doesn't
re-prompt.editor.spawnHostProcess("devcontainer", ["up", "--workspace-folder", cwd]).
This always runs on the host, even if the call originates from
inside a container (important for rebuild).AuthorityPayload, calls editor.setAuthority(payload).status.detected in the status bar.Detach / rebuild follow the same path with different args:
clearAuthority() for detach, up --remove-existing-container for
rebuild.
Vec<Authority>, no path-prefix
routing). Principle 5.devcontainer.auto_detect / devcontainer.cli_path
in user config are ignored on load; the plugin now owns both.