Skip to this page's content

Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Client architecture

How the iced client is organised, and where to put things.

Status: written incrementally as feature 021 lands. Sections marked (Tier N — pending) describe work not yet merged; the rest describes the codebase as it stands.

Tier structure

The client is being moved onto The Elm Architecture in four tiers, each landing on its own. The order is not arbitrary: every tier needs the one before it.

TierWhat it establishesState
1Feature modules — one module per feature, holding its types together with the functions over themlanded
2Overlay registry — floating surfaces register themselves instead of being enumerated in a matchlanded
3Reducer modules + outcomes — per-feature reducers, and cross-feature effects expressed as returned outcomes rather than direct writespending
—Shell split — main.rs divided by external system, with capabilities assembled at bootlanded

Tier 1 is the foundation: without per-feature boundaries there is nothing for the overlay registry to register into, and nothing for a per-feature reducer to be a reducer of.

Where a feature lives

One module per feature, under crates/micold-client/src/features/. A feature’s types live there together with the functions over them. There is no parallel state.rs / update.rs / view.rs split — a type and the operations on it stay in one file.

FeatureModule
Daemon connectionfeatures/connection.rs
Notificationsfeatures/notifications.rs
Project switching, its context menu, renamefeatures/project.rs
Sessions, foreground, terminal selectionfeatures/session.rs
Settings formfeatures/settings.rs
Sidebar rows, tag filters, tree projectionsfeatures/sidebar.rs
Worktree visibility, naming, tags, renamefeatures/worktree.rs
Worktree-creation formfeatures/worktree_form.rs
Help menu and the About dialogfeatures/help.rs
Window size, pointer, which field holds the keyboardfeatures/window.rs
Overlaysoverlay/mod.rs + overlay/registry.rs — the surface type, and the one place surfaces are named

Views are not in these modules. They live in crate::ui, beside the feature they draw rather than inside it, because they need the rendering framework and feature modules must not.

Two rules, and why they are checked rather than trusted

Feature modules name no rendering framework in code. tests/features_are_render_free.rs reads the source and fails on the mention; comments are exempt. This is what lets application state live in the client crate rather than the render-free core — the modules could sit in the core, and the only reason they do not is that being in the client is more convenient for code that the shell drives. That argument holds exactly as long as the property does, so it is a test and not a convention.

Group by feature, not by name or by neighbourhood. Three helpers called worktree_tree, filtered_worktree_tree and available_tag_filters live in features/sidebar.rs, not features/worktree.rs: they return WorktreeNode and TagFilter, read sidebar_filters, and build sidebar rows. SelectKind lives in features/session.rs rather than features/project.rs despite having sat between two project types in the old file. Both placements were decided by what the code is about, and both went the other way in the original task list — grouping by name or by line range is the specific failure this structure exists to prevent.

The worktree-creation form is its own module rather than part of features/worktree.rs. It is the one feature whose intermediate state nothing else reads, which is also why it was extracted first.

Answering “where does this feature live?”

Name one module from the table. All of them can be answered that way now. Overlays were the holdout through Tier 1 — Overlay and ClosingOverlay were enumerated in app.rs, which is not a module anything lives in — and Tier 2 is what fixed it: each surface is described in the feature module that owns it, and overlay/ holds only the shared type and the registration list.

If a feature needs two modules, that is the signal something is misfiled — with one exception, recorded rather than hidden: the Settings form’s validation lives in shell/persist.rs::on_settings_saved rather than in features/settings.rs, because it parses a draft and returns a Task that writes the file, and a feature module may do neither. Tier 3 was named as where this would be fixed and it was not, which is worth saying plainly rather than leaving the promise standing: splitting the parse from the write is a change to the settings feature, not to this architecture, and it wants its own task. features_settings.rs says the same thing from the test side.

What is still in app.rs

Two shared vocabularies and a routing table: State, Message, State::update, and the outcome plumbing (interpret, drain, on_escape). Overlay and ClosingOverlay were here through Tier 1 and are gone as of Tier 2; every reducer arm is gone as of Tier 3, and root_is_routing_only.rs pins the number of arms that still decide anything at an exact 0.

The file is 1,334 lines, of which the Message enum is 408 and the State struct 188 — so its length measures the size of the application’s vocabulary, not the root’s logic. That is why it is not split: FR-005 asks whether a file holds more than one feature, and this one holds none.

Some feature modules still carry impl State blocks. That is not a boundary violation: State is one struct, and Rust resolves inherent methods on the type rather than the module, so moving them would change no call site. What it does mean is that those features cannot be tested without building a State, and their isolation tests say so rather than asserting something weaker to look cleaner.

Visibility widening is a signal, not a cost of doing business

Four helpers went from private to pub(crate) to cross a module boundary: rematch_branches and reset_branch_search (worktree form), worktree_tags (worktree, read by the sidebar), and session_mut (session, called by seven reducer arms). A helper that has to widen is telling you the boundary does not fall where the code assumes it does.

Tier 3 was expected to answer most of them and answered none — all four are still pub(crate), and the reason is the same in each case: the reaching caller turned out to be a legitimate read rather than a misplaced arm. worktree_tags is the clearest, and it is the single entry in ALLOWED_CROSS_FEATURE_NAMES in tests/feature_registration_cost.rs: the sidebar renders a worktree row’s tags and does not get to decide what they are. A widening that survives the restructuring it was blamed on is a widening that was never the restructuring’s fault, and the signal is still worth having — it just says “read across a boundary” here, not “misfiled”.

Adding a floating surface

A floating surface is anything the window stacks over its content: a dialog, a panel popover, a context menu. Adding one costs its own module, and one registration line — that is what Tier 2 exists to make true, and the steps below are the whole of it. A dialog also needs a view, which lives in crate::ui beside the feature, exactly as every other view in the client already does.

(The snackbar floats in a band of its own and is not registered. It has no state anyone opens and nothing dismisses it but its own timer, so there is nothing for a registration to say; ui::view pushes it directly from state.notify.)

1. Describe the surface where the feature lives

In the feature module that owns it, a marker type implementing two traits:

pub struct HelpMenu;

impl FloatingSurface for HelpMenu {
    fn id(&self) -> SurfaceId { SurfaceId::new("help_menu") }
    fn layer(&self) -> Layer { Layer::Popover }
    fn dismissal(&self) -> DismissalRules {
        DismissalRules::for_layer(Layer::Popover).cancelled_by(Message::HelpMenuToggled)
    }
}

impl Registered for HelpMenu {
    fn open_in(state: &State) -> Option<Self> { state.help_menu_open.then_some(HelpMenu) }
}

Four facts, and nothing else:

  • id — a &'static str name, not an enum variant, because an enum is the central list Tier 2 removed. It is never shown to the user; it keys the exit animation and names the surface when a guard fails.
  • layer — which band it belongs to, from micold_core::overlay::Layer: Popover, ContextMenu, Dialog, Snackbar, bottom to top. The band decides stacking and priority. Registration order decides nothing, and registration_order_does_not_decide_anything proves it by running every state through a reversed list.
  • dismissal — a chainable builder ending in the message that cancels the surface. It decides nothing: which triggers close which kind of surface is micold_core::overlay::dismisses, and DismissalRules forwards every question to it. What it adds is the part the core cannot know — this surface’s cancel message. .protecting_input() marks a dialog non-dismissible, for one holding input an accidental close would destroy.
  • open_in — how to tell, from the state, that this surface is open. A popover reads its own flag; a dialog reads the state it draws from, which since Tier 2 is what says it is open (there is no separate slot to keep in step).

2. If it is a dialog, write its view in crate::ui

// crates/micold-client/src/ui/rename.rs — declared `pub(crate) mod rename;`
pub fn dialog<'a>(
    state: &'a State,
    scheme: ColorScheme,
    _env_include_outcome: &'a EnvIncludeOutcome,
) -> Option<Element<'a, Message>> {
    state.rename_draft.as_ref().map(|draft| modal(draft, scheme, state.focused_field))
}

Every dialog wrapper has that exact signature — the registration line stores it as a function pointer, so they have to. Take env_include_outcome whether or not you need it; only the Settings form does.

The two halves live in different modules on purpose: a feature module may not name the rendering framework (tests/features_are_render_free.rs reads the source and fails on the mention), and views belong beside the feature in crate::ui. None means the surface is open but the live state it draws is absent — nothing is drawn, rather than an empty dialog.

Popovers register no view. A panel popover’s panel is pushed by ui::view whether or not it is open, because the panel owns its own fade and has to outlive the flag that opened it; a context menu is pushed only while open, since it is anchored at a cursor position that only exists then. Either way the drawing comes from the feature’s own field and not from the registry, and a_popover_is_not_drawn_from_the_registry holds that line — a popover given a registered dialog view would be drawn a second time, inside the modal band.

3. Add one line to the registry

In overlay/registry.rs, inside register!:

crate::features::session::SessionContextMenu,                                // a popover
crate::features::project::RenameProjectDialog => crate::ui::rename::dialog,  // a dialog
crate::features::help::HelpMenu {                                            // ...that displaces
    displaces:
        crate::features::project::ProjectSwitcher,
        crate::features::sidebar::SidebarFilterPanel,
        crate::features::project::ProjectContextMenu,
},

A type name, and two optional clauses: for a dialog the view that draws it, and for a surface that closes others when it opens, what it closes. This is the only list, and a macro rather than a plain array so the line can be a type name and nothing else — no closure to get subtly wrong, no place to tuck in a per-surface special case.

Both clauses are here rather than on the surface, and for the same reason twice. A view cannot be named in a feature module because FR-006 forbids one naming the rendering framework. Displacement cannot be declared there either: it is a fact about the relation between two surfaces, so it belongs to neither of them, and tests/surface_registration_cost.rs holds that a surface may be named only in its own module and here — a feature module saying what it displaces breaks that guarantee once per surface it names.

There is no rule to derive displacement from, which is why it is declared. The three panel popovers are mutually exclusive and each closes the project row menu; the row menu closes two of the three and the worktree menu but deliberately not the switcher it was right-clicked in; the worktree menu closes only the row menu; the session and terminal menus close nothing. tests/popover_displacement.rs states all forty-two ordered pairs.

4. Open it

A popover: set its field. A dialog: state.clear_for_dialog() first, then set up the state the dialog draws from. The order matters — clear_for_dialog closes whatever is already floating, including any open dialog, so running it afterwards closes the one you just prepared.

That call is also where the “one dialog at a time” invariant lives. It was a type guarantee until Tier 2 — the Overlay enum was one slot — and is now a mechanism, held by one_dialog_at_a_time and the_reducer_opens_a_dialog_through_that_mechanism in tests/overlay_registry.rs.

What you do not do

No match arm to extend, anywhere. Escape, scrim clicks, scroll-beneath dismissal, stacking order, “opening a dialog closes the popovers”, which popovers a new popover displaces, and the exit-animation snapshot are all rules over the registry. Six central matches used to have to hear about a new surface; there are none.

The guards, and what each would catch

GuardCatches
overlay_registration.rsa popover-shaped State field with no registration — the one that opens and cannot be closed, since it is drawn from its own field and only the registry closes it
overlay_registry.rsdispatch: each surface’s identity and cancellation, a dialog registered without a view or with the wrong view, two dialogs open at once
overlay_builder_api.rsa surface configured by a public field or a &mut self setter instead of the builder (Principle VIII)
overlay_dismissal_rules.rsdismissal decided locally rather than derived from the core rule
features_are_render_free.rsa feature module naming the rendering framework
one_overlay_implementation.rsa second floating-surface primitive

An unregistered dialog is the one failure with no guard of its own, and the honest reason is that it needs none: a dialog is drawn only through its registration, so an unregistered one is simply not drawn — it fails the first time anyone opens it, rather than trapping the user behind a surface with no exit.

Adding a capability

A capability is a narrow trait in micold-core naming one thing the application needs from outside the process: run a git command, read the settings file, list a directory, ask the desktop whether it prefers dark. The core declares what it needs; the shell supplies it. Everything between those two — every feature module, every reducer arm, every view — sees only the trait.

Adding one costs a trait and a fake in the core, one line in the port list, one line in Capabilities::real, and an accessor. The steps below are the whole of it.

1. Declare the trait in the core, beside what it is about

micold-core/src/git.rs holds Git; env_include.rs holds EnvIncludeResolver; os_theme.rs holds OsThemeProbe; script_path_check.rs holds ScriptPathProbe. Not a ports/ directory — a capability lives with the domain it serves, for the same reason a feature’s types live with the functions over them.

Narrow, and the test is stated rather than judged. specs/021-mvu-slice-architecture/contracts/service-capabilities.md: if a test must implement a method it does not exercise merely to satisfy the trait, the capability is too wide and must be split. That is FR-016, and it is checked — no_test_is_forced_to_supply_an_operation_it_does_not_use flags any port method in a test whose parameters are all _-prefixed and whose body never mentions self, because such a method can only return a constant.

It fired six times when it was written, and the answers went two different ways. FolderScanner genuinely was too wide: every consumer that took it as a trait asked only is_git_repo and is_available, while the one caller of list_subdirs reached for StdFolderScanner concretely — so listing became FolderBrowser, a second capability the same type implements. ProjectStore was not too wide; its only trait consumer exercises all three operations, and the stubs were hand-rolled fakes written because no shared fake existed. Splitting it would have made the codebase worse to score a check green. A width failure is a question, not a verdict — but the check is never the thing that gets relaxed.

2. Write the fake next to it

/// A resolver that spawns nothing and remembers what it was asked (FR-019).
pub struct FakeEnvIncludeResolver { inner: RefCell<FakeResolverState> }

An ordinary pub item in the core, beside the capability. Not behind a cfg, not in a separate crate — a fake any crate’s tests can reach without configuration is worth more than the dead code it costs, and FakeGit set that precedent long before this feature.

Record what it was asked, not only what it answered. FakeEnvIncludeResolver::calls() returns every (path, cwd, timeout) in order, and that is what lets a test assert the absence of a call: the env-include short-circuit’s claim is not that the outcome is Disabled, it is that no subprocess was spawned at all. An outcome can be right for the wrong reason. Where answers change over time, script them — FakeOsThemeProbe consumes a list and repeats the last entry, so a test says how many distinct answers it cares about rather than how many times something polls.

The Fake prefix is load-bearing. It is how the guards tell a double from a real implementation, so a real one named FakeSomething would be waved through every check in no_concrete_implementations.rs. the_only_excluded_implementations_are_fakes holds a list of the nine that exist; add yours in the commit it appears in.

3. Add the port to tests/inventory/mod.rs::PORTS

pub const PORTS: &[&str] = &["Git", "ProjectStore", …, "EnvIncludeResolver", "OsThemeProbe"];

One list, read by both capability guards, because two scanners that happen to agree today is exactly how a check keeps passing while its idea of the subject quietly diverges. Every gate that needs to know what a capability is shares this definition rather than writing a second one — the same reason tests/inventory/ exists at all. Add the name the moment the trait exists: a capability the guards do not know about is one FR-017 and FR-019 are not holding, and the omission is silent — every check still passes, having looked at nothing.

4. Choose the real implementation in Capabilities::real, once

// crates/micold-client/src/shell/capabilities.rs
pub fn real() -> Self {
    Self {
        git: Arc::new(GitCli::new()),
        env_include: Arc::new(SubprocessResolver),
        …
    }
}

/// Sourcing the environment-include script.
pub fn env_include(&self) -> &dyn EnvIncludeResolver { &*self.env_include }

This function is the single assembly point (FR-018), and “once” is literal: each_implementation_is_chosen_in_exactly_one_place counts occurrences of the type name across the shell and fails at anything but exactly one. That is why StdFolderScanner — which implements both folder capabilities — is built into a local and shared rather than named twice.

Return &dyn Trait from the accessor. Return an Arc only when a consumer genuinely cannot borrow the application: browser() does, because the folder listing runs inside Task::perform’s async move, which is 'static. Wrap in Option only when the real implementation can fail to locate itself — both stores resolve from a per-user data directory that a headless container may not have. That is not an error the application reports; it runs without persistence, and the value of the Option is that the question is asked once instead of at every call site.

Capabilities are supplied by dynamic dispatch on purpose (FR-019b). Every call here is already gated behind disk, a subprocess or an OS query, none is reachable from the rendering path, and threading generic parameters through to preserve static dispatch would make FR-018’s single assembly point harder to express for a cost that does not exist.

5. Use it from the shell module for its external system

ModuleThe system it addresses
shell/capabilities.rsnone — it is the list of which implementation to use
shell/startup.rsboot: the window, the assembly, the first frame
shell/persist.rsthe on-disk catalog and settings file
shell/daemon_sync.rsa running session daemon, over the protocol
shell/service_control.rsthe session service as an OS process — stopping a mismatched build by pid
shell/legacy_units.rsthe user’s service manager, once, to clear an enablement a past release left (feature 028)
shell/subscriptions.rsthe iced runtime and the OS events it carries
shell/workspace.rsthe user’s filesystem and their git working copies
shell/env_include.rsa subprocess running the user’s own script
shell/os_theme.rsthe desktop’s light/dark preference
shell/clipboard.rsthe system clipboard
shell/issues.rsGitHub, through the user’s own gh on the host, and the RemoteList ask that decides whether it may be reached — one conversation (feature 034)

One module per external system, and never per feature (FR-019a). The question the split answers is “what can a change to this one outside thing reach”, so the module boundary follows the system, not the caller: default_resolution_cwd is in env_include.rs because it exists to answer which directory to source in, though its five call sites span boot and a settings save.

That rule cuts both ways. daemon_sync and service_control are two modules for one service, because a daemon speaking the protocol and a daemon that must be killed by pid are different systems — the second is used precisely when the first is unusable. The OS theme is split the same way: subscriptions owns the clock that decides when to ask, os_theme owns the asking. And two systems can share one module when they are one conversation — workspace.rs holds FolderBrowser and Git together because the picker exists to find a repository and the same arm asks git what worktrees it holds, so splitting it would put two halves of one decision in two files.

If two capabilities are one conversation, one module. If one capability is reached two ways, two.

Which process runs it: the GitHub issue fetch runs in the client, on the host

Most of what a project needs from outside runs in the daemon, because the daemon is where the project’s files are: BranchList, worktree creation and every git write go through the protocol. The GitHub issue list (feature 034) deliberately does not. It is split in two, by what each half reads:

  • The daemon answers only RemoteList: the repository’s remote URLs, raw, from git config --local --get-regexp ^remote\..+\.url$ (Git::remote_list). It is local git metadata, read-only and correlated like BranchList, and it is asked when the add-worktree form opens so the GitHub issue chip can be disabled with a reason before the user picks it. Which remote counts, and its parse into owner/name, are pure micold_core::github functions run in the client; the daemon ships the facts only.
  • The client runs gh, from shell/issues.rs, off the update thread, through Capabilities::issue_tooling. It locates gh itself (the environment include’s PATH, then its own PATH, then well-known install directories — the .desktop or Dock launch case) and hands it only owner/name.

Why the fetch is never in the daemon: feature 027 can place the daemon in a container (PlacementKind::Sandbox). There gh is not in the image, the user’s keychain and GitHub sign-in are not reachable, and the sandbox’s network and credential-share settings decide what the container may reach. FR-026 requires the issue list to behave the same with or without the sandbox, and to be decided by neither its network nor its credential settings. The client always runs on the host, in the user’s desktop session, next to their sign-in, so running gh there makes that true by placement rather than by configuration — and forwarding a GitHub credential into the container, a new credential share 027 keeps opt-in, is never needed.

Why the remotes are not read in the client too: when the daemon does not see the project at the paths the client calls it by — a Linux container on a Windows host, or any remote daemon — the client may not run git at all (Capabilities::without_local_git, applied in shell/startup.rs). gh is outside that rule because it reads nothing from the project’s filesystem — its only input is the owner/name the daemon’s answer produced.

Why git config --local and not git remote -v: remote -v applies url.<base>.insteadOf rewrites, which usually live in ~/.gitconfig, and that file reaches a sandboxed daemon only when the user opts into CredentialShare::GitConfig. An aliased remote would then be GitHub on one placement and not on the other. The repository’s own .git/config is in the mounted project on both, so the answer does not depend on where the daemon runs. The cost is documented for users: a remote spelled through a global insteadOf alias is not recognised as GitHub.

The decision record is specs/034-github-issue-worktree/research.md R4 and R5.

What you do not do

Name a real implementation outside shell/. The guard no_code_outside_the_shell_names_a_real_implementation reads every client source and fails on the mention — not the construction, the mention, because a file that names one has already decided which is used. Comments are exempt.

Add a from_parts constructor so a test can inject fakes. One was written and deleted for want of a caller. A seam nobody drives is not evidence that anything is substitutable; what the capabilities actually bought is demonstrated where it is real, in shell/persist.rs’s boot_drops_a_session_the_provider_has_no_conversation_for, a pruning rule that could not be tested at all while it reached ClaudeProvider and the user’s home directory directly. Add the constructor when a caller needs it.

Reach a capability from a feature module. They cannot name a real implementation and must not name the rendering framework either; a feature that needs something from outside says so in its return value.

The three ports that are not in Capabilities, and why each is not an oversight

OsThemeProbe is chosen at its call site. Its only consumer is os_theme_poll’s Subscription::map closure, and iced panics on boot if a subscription’s mapping closure captures anything — a capturing closure has no stable identity, so the runtime restarts the underlying timer every frame. A closure that cannot capture cannot be handed a capability. It is a real exception to FR-018, recorded at shell/capabilities.rs rather than enforced anywhere, because the guard cannot see it either way: that derivation reads micold-core, and SystemThemeProbe is defined in the client — dark-light is a client dependency and the core deliberately has none on it.

TerminalBackend and TerminalHandle have only fakes in the workspace, so there is no real implementation for an assembly point to choose. They predate this feature and FR-015 lists them as already satisfactory.

The one I/O concern that is not a capability at all

The clipboard. All three of its call sites return a deferred Task rather than a value, so a synchronous port cannot wrap them without blocking — which is what FR-015a is for: where the GUI framework makes a synchronous capability impossible, the concern becomes an explicit effect request in the outcome vocabulary, and the shell interprets it.

// crates/micold-client/src/shell/clipboard.rs
pub fn interpret(outcome: Outcome) -> Task<Message> {
    match outcome {
        Outcome::ClipboardWrite(text) => iced::clipboard::write(text),
    }
}

The obligations do not weaken. FR-017 still applies (no feature reaches iced::clipboard), and so does FR-019/SC-005 — the request must be assertable with zero real I/O, which is what tests/clipboard_request.rs checks. interpret is the whole translation, one arm per variant and no branch, and the_shells_translation_decides_nothing reads its body to keep it that way: a body that grew an if would still compile and still pass every behaviour test.

The paste does not convert. Reads arrive back as an ordinary message exactly as before, and no feature requests one — clipboard::read is a shell call, not an effect a reducer asks for.

The guards, and what each would catch

GuardCatches
no_concrete_implementations.rsa real implementation named outside shell/, or chosen in more or fewer than exactly one place
service_capability_fakes.rsa capability with no fake, a fake no test constructs, a capability wider than its consumers
clipboard_request.rsa feature reaching the framework’s clipboard, and a shell translation that decides anything
features_are_render_free.rsa feature module naming the rendering framework

Both capability guards derive their subject rather than listing it — every impl <Port> for <Type> in the core, minus the fakes. The argument for that is one line long: a hardcoded list of four was already missing ClaudeProvider on the day it was written. Each file carries a vacuity test holding the derivation to finding what it must, so a scan blinded by a reformatted impl header fails loudly instead of passing everything.

Reading and writing across features

A feature may read any other feature’s data. It may not write it. That asymmetry is the whole of the rule, and it is not arbitrary: a read cannot leave the app in a state its owner did not choose, and a write can.

State is one struct, so nothing in the type system enforces this — every field is reachable from every &mut State. What holds the line is tests/feature_write_isolation.rs, which scans the feature modules, resolves what each operation writes (following calls to a fixed point), and fails on any write whose field belongs to another feature.

Why a guard test rather than the type system

Splitting State into per-feature structs would make the rule a compile error, and that is the eventual destination. It is not where this codebase is, and a guard test buys the rule now at a cost the split does not: it can carry an allowlist. ALLOWED names each pre-existing violation with the task that will convert it, so the rule is enforced for new code while the backlog burns down rather than blocking on a rewrite. the_allowlist_names_only_live_violations fails when an entry stops being a violation, so the list cannot outlive what it permitted.

The list is empty now — 43 to zero over feature 021’s Phase 6 — but the mechanism is what mattered: a split State would have had to land in one commit, and this landed in fifteen, each of which built and passed. What the empty list buys going forward is that a new cross-feature write is a failing test rather than a line in a backlog.

What a feature returns instead: Outcome

A feature that needs another’s data changed returns a value saying so, and the root applies it:

pub fn loaded(state: &mut State, worktrees: Vec<Worktree>) -> Vec<Outcome> {
    state.set_worktrees(worktrees)          // its own data, written directly
}                                           // -> vec![Outcome::WorktreesReplaced(names)]

The root is the only interpreter (app::interpret), and app::drain applies outcomes to a fixed point with a bound, so an outcome may raise further outcomes without the root knowing which features are involved.

Mark these returns #[must_use]. A dropped Vec<Outcome> is a silent behaviour loss — the code compiles and does less — and the attribute turns it into a compile error at every production call site. It caught all thirteen when switch_active was converted.

Three traps, each of which cost real time here

A row count is not a violation count when the writer is code the guard cannot name. The scan attributes a write to the feature operation it appears in. When several features call one root helper, the helper’s write is reported against each caller, and the burn-down looks like N violations when it is one misplaced function. Two of feature 021’s largest clusters — eight rows and five — were retired by moving a function into the feature that owned its data, with no outcome written at all. Before designing an Outcome, ask where the code belongs; the answer is often that the guard was pointing at callers.

Ownership can be wrong, and a plausible name is how it stays wrong. worktree_error reads like the worktree feature’s, and is the add-worktree modal’s — crate::ui::worktree_form is its only render site. Correcting OWNERS retired three rows that had been queued for conversion. When a field’s writers and its one reader disagree about who owns it, believe the reader.

A test that calls a feature function directly stops exercising what moved. Converting a write to an outcome moves behaviour out of the function, so any test that calls it rather than going through State::update silently tests less — and still compiles, because #[must_use] is satisfied by an assert! that consumes the value and never applies it. Four files hit this in one task. Tests that assert a moved consequence need a helper that drains the way the root does:

fn switch(st: &mut State, path: &str) -> bool {
    match st.switch_active(Path::new(path)) {
        Some(outcomes) => {
            drain(outcomes, |o| interpret(st, o));
            true
        }
        None => false,
    }
}

Tests that assert on no other feature’s data need no such helper, and dropping the outcomes there is correct rather than lazy — features_session.rs says so in its header and turned out to be right.

A core type’s own operation is not a cross-feature write

OWNERS is keyed by path, not by field, because Workspace holds the project catalog, the session lists and two worktree maps in one value — six members answering to three features. That split is right for an ordinary write and wrong for Workspace’s own methods: forget clears everything held against a project’s path because that is its invariant, and reporting it as the project feature writing session data would have three features each apply one clause of it, making a half-applied forget expressible for the first time.

So a write a feature reaches only through a core method is exempt — the same principle that always exempted a feature writing its own field: the code that performs the write owns what it writes. The exemption is narrow in both directions. It does not cover a path the operation also writes on a line of its own (the scan keeps two closures over the call graph to tell those apart), and it is not silent: CORE_MEDIATED lists every one with the method that carries it, so reaching a neighbour through some other core method costs a line and an argument rather than nothing.

The guards, and what each would catch

GuardCatches
no_feature_writes_another_features_dataa feature writing a field it does not own, directly or through a call
the_allowlist_names_only_live_violationsan ALLOWED entry that no longer names a real write
core_mediated_writes_are_inventorieda feature reaching a neighbour through an unlisted core method — the exemption’s only silent path
the_exemption_is_narrowa CORE_MEDIATED line that mediates nothing, or a write reported and exempted at once
every_state_field_has_an_ownera new State field nobody claimed in OWNERS
every_workspace_field_has_an_ownerthe same for Workspace’s members, which three features hold
every_method_called_on_state_is_classifieda method the scan cannot tell reads from writes
the_scan_finds_the_operations_it_is_meant_to_readthe scan going blind — a vacuity floor

That last one exists because this guard has failed silently twice. Once it could not see free functions at all (a reference it never peeled), reporting every feature clean while its own header promised otherwise. Once it suppressed a violation whenever the operation also called a sibling writing the same field — correct for an inherited write, wrong for one made on the spot as well, and a suppressed row is indistinguishable from no row. A green guard is evidence only in proportion to what you have shown it can still fail on. Probe it by reintroducing a write and confirming it fires on the row you expect; for a single-assertion guard, distinctness lives in the reported violation, not in the set of failing tests.