Skip to content
C.W.K.
Lesson 02 of 05 · published

Prepare, Apply, Undo — the Lifecycle of One Change

~14 min · lifecycle, changeplan, engine, undo

Level 0Idle Hands
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Each capability prepares a serializable change receipt before acting. The receipt records both the intended change and its observed outcome." — Tinker architecture

Prepare: Describe the World You Are About to Change

prepare(input) runs before anything happens and returns a list of ChangePlan values, one per thing that could change. Each plan carries a key that names the target uniquely (for an app, its bundle ID, process ID and launch time), a payload holding everything apply and undo will need, and a needsApply flag. The flag is the quiet hero of the design: a plan whose target is already in the desired state is returned with needsApply: false. It is still described — the engine needs to know it exists, as you will see with shared ownership — but it is never applied and never gets a receipt of its own. (If Tinker already holds a receipt for that same target from another preset, the new owner joins it instead — track 3.)

Prepare is also where a capability refuses. Malformed input, an empty list, a standing condition that forbids the action right now — all of these throw from prepare, and because the engine prepares every step of a preset before it touches anything, one refusal anywhere means nothing happens anywhere.

Apply: One Planned Piece at a Time

The engine then walks the plans. For each one that needs applying it first writes a prepared receipt to the journal, then calls apply(payload), then rewrites the receipt as applied. A child's apply receives a single payload, never the whole list, so it cannot quietly do more than one planned change per call. If apply throws, the engine stops the preset and restores whatever it had already confirmed for that owner.

Undo: Take Back Exactly That Change

undo(payload) receives the payload saved in the receipt — not today's version of the preset. That matters: if Dad edits the editing preset while it is active, restoring it must still undo what was actually done, not what the edited list now says. Undo returns one of two results. .restored means it reversed the change. .noLongerApplicable means there was nothing left to reverse — the app quit, or someone already showed it by hand — and that is a success, not an error: the receipt is cleared either way. Restoration walks a preset's receipts in reverse order, the way you take off layers you put on.

The Shape of the Whole

Put together, the lifecycle reads like a careful person's checklist: write down what you are going to touch; for each item, note that you are about to touch it, touch it, note that you did; to undo, read your notes, not your intentions. The next lessons show the one child that implements it and the one exception the engine carves out of it.

Code

The engine's apply loop, reduced to its skeleton·swift
func apply(owner: String, steps: [CapabilityStep]) async throws {
  // 1. Validate and prepare EVERYTHING before the first change.
  var plans: [(Capability, [ChangePlan])] = []
  for step in steps {
    guard let capability = capabilities[step.capability] else {
      throw TinkerError("Unknown capability: \(step.capability).")
    }
    for needed in capability.permissions where !permission(needed) {
      throw TinkerError("Grant \(needed.rawValue) to Tinker in System Settings first.")
    }
    plans.append((capability, try capability.prepare(step.input)))
  }
  try markActive(owner)
  // 2. One planned piece at a time, receipt before effect.
  for (capability, changes) in plans {
    // (The real loop first looks for an existing receipt with the same key
    //  and, if Tinker already owns it, only adds this owner to it.)
    for change in changes where change.needsApply {
      try persistPrepared(change, capability: capability.id, owner: owner)
      try await capability.apply(change.payload)
      try persistApplied(change.key)
    }
  }
}
Restore reads receipts, newest first — never the edited preset·swift
func restore(owner: String) async throws {
  for receipt in journal.changes.reversed() where receipt.owners.contains(owner) {
    // (The real routine first drops only this owner from a shared receipt,
    //  and leaves a prepared receipt untouched for Recovery.)
    guard let capability = capabilities[receipt.capability] else { continue }
    // .restored or .noLongerApplicable: either way the change is no longer ours.
    _ = try await capability.undo(receipt.payload)
    try removeReceipt(receipt.key)
  }
}

External links

Exercise

Trace a preset with two steps — step A has two plans, one of which is already in the desired state; step B's prepare throws because its input is invalid. List every journal write and every OS call that happens. Then change the scenario so step B's apply throws on its first plan instead, and list them again.
Hint
In the first scenario the answer is: nothing at all — prepare runs for both steps before markActive, so B's throw ends the command with no journal write and no OS call. In the second, A's one real plan is prepared, applied and confirmed; B's plan gets a prepared receipt and then fails. Unless B proved it asked the OS for nothing, that receipt stays as an uncertain record for Recovery. The engine then restores A's confirmed change for that owner, and the owner stays active only because the uncertain record still names it.

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 in — Please sign in to comment.

No comments yet — be the first.