C.W.K.
Stream
Lesson 04 of 05 · published

The UI Cannot Mutate

~11 min · ui-cannot-mutate, snapshot, boundary

Level 0Lost in Finder
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"The button's job ends the instant it describes what you want. It never gets to do it."

A Hard Architectural Wall

In most apps, a button handler that moves a file just... moves the file, right there in the callback. Waygate forbids it. Views, menu items, keyboard handlers, and drag/drop handlers may do exactly one thing when a mutation is requested: snapshot the intent into an immutable OperationPlan and submit it to the engine. A UI callback that calls a mutating FileManager method is not a bug to fix later — it is an architecture violation, the kind a code review rejects on sight.

Why the Wall Exists

Not because the UI is untrusted, but because the UI is the wrong place. The engine is where journaling, apply-time revalidation, off-main-thread execution, and crash recovery live. A mutation started from a view callback runs on the main thread, skips the journal, and has no recovery story. The wall guarantees that every change — no matter how it was triggered — inherits all of those protections, because there is only one place a change can actually happen.

Snapshot, Then You Can Let Go

There's a second reason, subtle but vital. A command resolves against an immutable snapshot of the active pane, tab, selection, and destination taken at submission time. So if focus shifts a heartbeat after you press the key — you click into the other pane, a tab switches — the operation still targets exactly what you meant. The plan froze the intent; later focus changes cannot retarget it onto the wrong folder.

UI submits plans; only the engine mutates. Every view, menu, shortcut, and drag/drop handler snapshots intent into an immutable plan and hands it off. Direct filesystem mutation from UI code is an architecture violation — because the engine is the single place that journals, revalidates, runs off-main, and recovers.
Focus is not a safe target. If a 'Move to Other Pane' command read the active pane at EXECUTION time instead of submission time, a focus change in the gap could send your files to the wrong destination. Waygate resolves the target against a snapshot taken when you invoked the command — so what you saw when you pressed the key is what gets acted on.

Code

Wrong vs right: the view's only move·swift
// WRONG -- a mutation from a UI callback. No journal, main-thread, no recovery.
@objc func moveButtonClicked() {
    try? FileManager.default.moveItem(at: selectedURL, to: otherPaneURL) // VIOLATION
}

// RIGHT -- snapshot intent, submit, let go.
@objc func moveButtonClicked() {
    let plan = OperationPlan(
        kind: .move,
        sources: activePane.selectionSnapshot(),     // frozen NOW
        destination: otherPane.locationSnapshot(),    // frozen NOW
        collision: .ask,
        expected: activePane.identitySnapshot()
    )
    Task { await engine.submit(plan) }               // the engine does the rest
}
The command resolves against a snapshot, not live focus·swift
struct CommandContext {   // captured at INVOCATION, immutable thereafter
    let activePaneID: PaneID
    let activeTabID: TabID
    let selection: [LocationReference]
    let destination: LocationReference?
}

// If the user clicks into the other pane a moment later, `ctx` is unchanged,
// so the operation still targets what they saw when they invoked it.
func perform(_ command: Command, _ ctx: CommandContext) async {
    let plan = command.plan(from: ctx)
    await engine.submit(plan)
}

External links

Exercise

In an app you've built, find a UI callback that directly performs a risky side effect (writes a file, hits an API, deletes a record). Rewrite it, on paper, into two parts: a snapshot of intent, and a submission to a single actor that owns the side effect. What did you have to capture at snapshot time so that a later state change couldn't corrupt the action?
Hint
You need to freeze everything the action depends on — the target, the selection, the destination — at the moment of intent, because any of them can change before the action runs. Once the intent is a frozen value handed to one actor, the callback can return immediately and later UI changes can't retarget it. That's 'UI describes, engine acts' in miniature.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.