# Alma Architecture
Alma is a Bevy-native modal text editor, but the editor semantics should not be
Bevy-shaped. Bevy gives the crate an event loop, an ECS dataflow, and rendering
integration. The editing model itself lives in small Rust modules with explicit
ownership, narrow contracts, and tests that do not need a window.
The guiding rule is: model editor behavior in pure code first, then adapt it
into ECS through typed messages at the boundary.
## Dataflow
The main runtime model is the ordered editor pipeline in `ecs::schedules`:
```text
Input -> Intent -> Edit -> Layout -> Shape -> Render
```
Each stage has one job.
- `Input` reads platform input and normalizes it into editor-facing input
events.
- `Intent` interprets input as Vim/editor intent and emits typed effects.
- `Edit` owns authoritative mutations: buffer edits, cursor moves, commands,
status changes, and lifecycle requests.
- `Layout` derives visible byte ranges and viewport state from buffer, cursor,
and viewport intent.
- `Shape` turns visible text plus Vim presentation state into styled text spans.
- `Render` syncs derived presentation data into Bevy render/UI entities.
Startup follows the same explicit ordering:
```text
Buffer -> Scene -> Chrome
```
The buffer comes first because it owns the authoritative text. Scene/view
entities attach to buffers. Chrome observes the already-created editor view.
The windowed app composes this dataflow with presentation plugins. The headless
app uses the same editor dataflow without windowing or rendering, which keeps CI
smoke tests and deterministic replay close to the real editor path.
## Ownership Boundaries
`TextByteStream` owns the validated UTF-8 text and a monotonic revision. Byte
indices are the common coordinate system. Any code that edits text must preserve
UTF-8 character boundaries and advance the revision only through the stream's
mutation APIs.
`BufferFile` owns persistence metadata: the optional backing path and the saved
revision. It answers whether a buffer is modified by comparing the saved revision
with the stream revision, and it is the place where successful writes advance
the saved revision.
Persistence paths and status text are separate types. Raw `PathBuf` values stay
inside filesystem and buffer metadata, while `BufferDisplayPath` carries only
escaped, single-line text for UI status rendering. Status output must not
interpolate raw paths, user text, register contents, or backend text directly.
ECS buffer entities own authoritative editor text. An `EditorBuffer` carries
`BufferText`, `BufferRevision`, and `BufferFile`. Systems should treat this as
the mutation boundary for file contents and persistence.
ECS view entities own per-view state over a buffer. An `EditorView` points at a
`BufferEntity`; the view owns cursor position, viewport state, visible range,
text span layout, focus marker, and `VimModalState`. Vim registers currently
live inside `VimModalState`, making unnamed and yank registers view-local editor
state rather than grammar state or buffer state. This split is what lets the
crate grow toward multiple views over one buffer without moving text ownership
into the view.
The `vim` module owns modal editing semantics independent of Bevy. Grammar,
motions, modes, search, commands, selection, registers, leader bindings, cursor
movement, and action dispatch should remain testable with ordinary Rust tests,
property tests, and golden fixtures. Bevy systems may carry Vim state, but they
should not become the place where Vim rules are invented.
## Adapter Boundaries
`adapters::bevy::vim` is an adapter, not the editor core. It normalizes Bevy keyboard
input, feeds the pure Vim state machines, collects `VimEffect`s, and then emits
typed ECS messages. The effect collection step is important: it keeps
interpretation separate from mutation and makes the side effects visible.
`ecs::events` are the contracts between interpretation and mutation. Features
request work through events such as cursor moves, buffer edits, commands,
viewport intents, status messages, and lifecycle requests. Owner systems in the
`Edit` or `Layout` stages decide how those requests affect authoritative state.
Owner systems should also make failed target resolution observable through typed
rejection messages rather than silently dropping stale entity references. The
request/rejection types should carry only redacted diagnostic shapes and closed
reason enums, so stale ECS messages are testable without leaking buffer text,
paths, registers, or backend content. ECS rejection reason enums live in
`ecs::events::error` and are re-exported by the message modules that use them;
execution-only errors stay private to the owning module, such as
`ecs::buffer::error`.
`domain` holds pure derivations that are useful to the editor but should not own
mutation. `domain::viewport` tracks visible byte ranges from text and cursor
position. `domain::render_text` derives rendered Vim spans for cursor,
selection, and search presentation. These functions are deliberately easier to
test than Bevy systems.
`editor::EditorBackendAdapter` is the backend-neutral synchronization boundary.
It records the last synchronized text revision, pushes authoritative stream
changes into a backend snapshot, and applies backend text back into the stream
without feedback loops. The Bevy presentation path does not yet center this
abstraction, but it is the right shape for future backend integration.
## Wasm Plugins
Wasm plugins are untrusted guests, not Bevy plugins. They should not register
systems, query ECS state, hold Bevy `Entity` values, or emit editor messages
directly. The host owns loading, scheduling, authorization, and all conversion
from guest requests into Alma's typed ECS messages.
The Zellij plugin model is the closest prior art to borrow from: plugins are
loaded explicitly, expose lifecycle entrypoints, subscribe to host events, and
must hold permissions before using host commands. Alma should copy that
discipline, not Zellij's exact API. For v1, permission grants are static data in
plugin config and manifests rather than runtime prompts. Missing, unknown, or
disabled capabilities fail closed.
The editor pipeline grows one explicit plugin point:
```text
Input -> Intent -> PluginIntent -> Edit -> Layout -> Shape -> Render
```
`PluginIntent` is where the host runs loaded guest components and drains their
requested effects. Guest code may propose work, but authoritative mutation still
happens later in `Edit` or `Layout` owner systems. This keeps plugins from
becoming a hidden side channel around the same owner boundaries that Vim and UI
features use.
Guests interact through a host capability layer. Capabilities are smaller than
ECS messages and map to editor/user authority, not implementation details.
- `buffer.observe` observes redacted metadata or current text through a host-issued
buffer handle.
- `buffer.propose_edit` submits revision-scoped edit proposals against a scoped
buffer handle. The buffer owner applies or rejects them later.
- `workspace.observe` observes files under configured workspace-relative grants.
- `workspace.artifact_write` writes attributed generated output under configured
workspace-relative grants through filesystem policy and atomic-write handling.
- `status.publish` publishes scoped status messages for the plugin's source view.
Workspace capabilities are intentionally two-step. Plugin authorization may
produce a host-owned workspace access token for a normalized relative path, but
that token is not filesystem authority. The runtime adapter must still pass the
request through the filesystem policy layer so canonicalization, no-follow
opening, workspace-root checks, platform file identity, and atomic writes remain
centralized in `fs_utils`. Any guest-visible workspace read result must also
carry explicit filesystem policy context at the host-import boundary and return
only bounded bytes or a closed rejection while spending the per-update
workspace request budget shared by deferred I/O and task-style read requests.
These are deliberately not v1 capabilities: lifecycle shutdown, raw ECS access,
raw filesystem paths, process execution, arbitrary configuration mutation,
clipboard access, environment access, and synthetic user input. Each future host
import must add a named capability and a testable authorization boundary before
it can be exposed to guests.
Plugin configuration is a registry, not discovery. Each enabled plugin entry
declares a stable identity, component path, WIT world version, and maximum
capability grants. It also declares runtime execution limits, so resource
controls are reviewed with the authority they constrain. A plugin manifest may
declare requested capabilities, but the application config is the final
authority. A plugin receives only the intersection of manifest requests and
config grants. Workspace intersections keep the narrowest overlapping prefix, so
config can narrow a broad manifest request and a manifest can narrow a broad
configured ceiling. Broad workspace grants must be explicit rather than hidden
behind empty defaults, using a named marker in JSON and a dedicated constructor
in Rust. Rust grant types should own their invariants: normalized path prefixes
stay private, broad grants have no `Default` path, and callers receive read-only
accessors rather than public fields that can bypass validation. Runtime limit
types should follow the same shape: config may provide bounded defaults, but
enabled plugin entries must validate to non-zero values under host caps before a
runtime spec can be built.
WIT is the ABI contract. The world should be versioned from the beginning, for
example `alma:editor/plugin@0.2.0`, and host imports should be named after
capabilities rather than ECS internals. Prefer WIT resources for buffers,
views, and workspaces so guests cannot forge Bevy entities, retain raw paths,
or depend on Wasmtime's internal resource representation. Use WIT variants,
enums, options, and results for closed operation shapes and expected domain
failures instead of scalar tags, sentinels, status-code records, or generic
payload envelopes. Guest exports should be lifecycle-shaped, such as load and
update hooks, instead of an open-ended function table.
Detailed plugin runtime, security, and testing records live in
`docs/plugins/runtime.md`, `docs/plugins/security.md`, and
`docs/plugins/testing.md`. This file keeps only the stable architectural
constraints.
## Error Boundaries
Errors should describe the boundary that produced them instead of collapsing
into one editor-wide failure type. Follow the same ownership rule as dataflow:
pure text and Vim modules return semantic errors, filesystem helpers return
policy and I/O errors, and ECS owner systems translate those failures into
user-facing status messages only at the UI boundary.
Model recoverability and disclosure in the type system. A Vim command error is
safe to render directly because it is part of the editor contract. A filesystem
or process-boundary error should retain its source chain for diagnostics while
mapping to a small, stable Vim-facing error such as `E212`; status lines should
not expose arbitrary paths, user text, register contents, or low-level OS
messages unless that is the intended user-facing behavior. This keeps security
and correctness aligned: callers can branch on precise variants, while the UI
does not accidentally leak data.
Shared owner-boundary diagnostics carry a coarse failure class, a redacted
lower-boundary source shape, and retry advice for the same validated operation.
Raw paths, payloads, and OS messages stay in typed source errors; diagnostic
events use closed source variants and `io::ErrorKind` only when a lower
filesystem boundary is the causal source.
Target resolution failures are boundary errors too. For ECS requests, missing
entities are represented as typed rejection variants such as
`BufferEditError::MissingTarget`, `CommandRejectionReason::MissingTarget`, and
`CursorMoveRejectionReason::MissingTarget`. Dependent stale references should be
encoded just as explicitly, such as `CursorMoveRejectionReason::MissingBuffer`
or `ViewportRejectionReason::MissingBuffer` when a view exists but its buffer
does not. Focus and viewport requests follow the same contract through
`FocusRejected` and `ViewportIntentRejected`. This keeps stale messages visible
to tests and diagnostics without inventing stringly status text.
Filesystem identity is platform-specific. Unix paths are opened by walking from
the workspace root through no-follow directory handles. Non-Unix policy
revalidation must compare stable platform file identities where available and
fail closed where the platform does not expose one; file type, size, or display
path equality is not a sufficient identity proof.
Future operation-layer errors should use the same shape. Target resolution,
validated text ranges, registers, undo, and backend synchronization should each
define narrow error enums that encode their invariants, then adapt into ECS
messages at owner boundaries. Prefer preserving `source()` chains over stringly
wrapping, and avoid adding catch-all variants until there is a specific
boundary that needs one.
Public and owner-boundary APIs should keep returning concrete error enums. Use
`thiserror` to derive the mechanical `Display`, `Error`, `source()`, and `From`
implementations while keeping variants, fields, invariants, and causal sources
explicit in the type definition. `#[source]` should mean a true causal source,
not related context; domain facts belong in variant fields. Do not introduce
`Box<dyn Error>`, `anyhow::Error`, `eyre`, or crate-wide erased error aliases
for library-facing paths. The binary entry point should continue returning
`AlmaAppError`, so startup failures remain typed until Rust's top-level reporting
prints them.
Design each error type around what the caller at that layer is expected to know
or do. If the caller should branch, retry, redact, recover, or preserve a domain
invariant, expose a concrete variant with the semantic fields needed for that
decision. If a lower-level failure is only causal evidence, keep it in
`#[source]`. Avoid public error enums that merely mirror implementation details
such as `Io`, `Json`, or `Sql` when the operation can instead name the failed
abstraction, such as missing config, invalid syntax, unsupported version, denied
capability, stale revision, or persistence rejection.
Use the AWS/Smithy shape at module boundaries, not as a mandate to split Alma
into many crates. AWS separates generated service crates, shared runtime crates,
and low-level operation machinery because its scale and versioning pressure are
different. Alma should borrow the invariant discipline: keep errors next to the
module that can prove or violate the invariant, keep the public front door small,
and map failures once when crossing from pure semantics into owner mutation or
UI status.
Local `error.rs` modules are appropriate when a module has more than one error
type or when callers need to branch on disclosure, recoverability, retry, target
resolution, or persistence policy. They are not a goal by themselves. For small
modules, an inline error enum can be clearer than adding another file. For owner
boundaries, private execution errors such as `ecs::buffer::error` may classify
failures before mapping them to stable Vim-facing status errors; public event
rejections live with the message contracts that other modules can observe.
The main pitfall is decorative structure. Do not create symmetrical `error.rs`,
`types.rs`, or `operation.rs` files unless the boundary has multiple reasons to
change. Do not let names such as `operation` become junk drawers: a future
`vim::operation` module should mean pure Vim semantic operations, target
resolution, outcomes, and operation errors. It should not know Bevy, ECS event
types, filesystem config, UI status rendering, or backend implementation
details.
Good boundary APIs should be precise and boring. Prefer domain nouns such as
`OperationTarget`, `ResolvedTarget`, `OperationOutcome`, `TargetResolutionError`,
and `BufferCommandError`. Avoid vague catch-all names such as `EditorError` or
`HandlerError` unless the boundary genuinely owns a broad adapter concern.
Expose only the types another boundary needs through module re-exports; keep
helper types `pub(super)` where possible. Tests should assert the contract, not
the file layout: invalid edits leave streams unchanged, stale targets emit typed
rejections, persistence failures preserve source chains while status output
stays redacted, and multi-buffer requests remain target-specific.
Validated byte ranges are part of that boundary story. `TextRange` represents
raw byte coordinates, while `ValidatedTextRange` is produced by
`TextByteStream::validate_range` only after ordering, bounds, and UTF-8
character-boundary checks. Destructive editor operations should prefer carrying
the validated form across pure resolution layers, then adapt to `BufferEdit` at
the buffer owner boundary.
## Extending The Editor
Add new behavior where its invariants belong.
- New Vim behavior belongs first in `vim`: grammar, command representation,
motion/search logic, action dispatch, and tests.
- New text mutation belongs first in `buffer` or `text_stream`, then flows
through `BufferEditRequested` and the buffer owner systems.
- New viewport behavior belongs in `domain::viewport`, then flows through
`ViewportIntent` and layout systems.
- New render derivation belongs in `domain` or `render` depending on whether it
is pure presentation data or Bevy entity synchronization.
- New UI features should observe editor state or emit typed requests; they
should not directly mutate buffer text, cursor state, or modal state owned by
another boundary.
When adding Bevy systems, put them in the existing schedule sets. Hidden
ordering through incidental queries or change detection is fragile; explicit
stage placement is the architecture.
Carry these abstractions forward:
- byte-indexed, UTF-8-validated text
- explicit revisions for cache, sync, and persistence invalidation
- typed entity wrappers such as `BufferEntity` and `ViewEntity`
- view-local modal state
- pure command, motion, search, and render derivation logic
- typed ECS messages between interpretation and mutation
- collected effects before message emission
- golden and property tests for behavior that should be stable
## Current Strengths And Risks
The strongest part of the crate is its separation of concerns. The dataflow is
named and ordered, the headless path exercises real editor plugins, Vim behavior
is substantially outside Bevy, and ECS messages make side effects explicit. The
golden Vim tests and property tests are also the right kind of pressure: they
test behavior and invariants rather than implementation trivia.
The main pressure point is `adapters/bevy/vim/system.rs`. It is doing useful
adapter work, but it is becoming dense as it handles keyboard input, prompt
handling, normal/visual dispatch, leader state, repeat behavior, effect
collection, and status emission. As new modes or commands arrive, prefer
extracting smaller mode or prompt adapters while keeping the pure semantics in
`vim`.
Some presentation code is Vim-specific today, especially rendered text spans for
cursor, selection, and search. That is fine for the current editor, but future
non-Vim presentation features should avoid depending on Vim names unless they
really mean Vim semantics.
Multi-buffer and multi-view support is structurally anticipated through
`BufferEntity`, `ViewEntity`, `EditorBuffer`, and `EditorView`, and some systems
already target entities explicitly. It is not yet the fully exercised default.
New code should preserve target-specific requests and avoid assuming there is
only one buffer or view unless the type or query says so.
Backend synchronization exists as a clean abstraction, but it is not yet the
center of the Bevy presentation path. Treat it as a contract to reuse when a
real editable backend needs to round-trip text, not as dead code to bypass.
The desired direction is conservative: keep ownership obvious, keep mutations
at owner boundaries, keep Bevy as orchestration, and keep editor semantics
small enough to test without rendering a frame.