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

Held, Not Guessed — Corrupt Files and the Forget Button

~13 min · corruption, held-state, forget, two-click, consistency

Level 0Idle Hands
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"A corrupt settings file or journal is preserved and disables actions." — Tinker's house sign

The Tempting Recovery

When a JSON file fails to parse, the easiest code path is to log a warning and start from an empty document. For most apps that is merely rude. For Tinker it would be dangerous: an empty journal means "Tinker owns nothing", so every app it had hidden before the corruption would become unowned and would never be restored. Worse, the next save would write the empty document over the unreadable one and destroy whatever might still have been recovered from it.

Three Checks on Every Load

The journal is validated before the engine accepts it. The schema version must be 1. Receipt keys must be unique. Every receipt must have a non-empty key, at least one owner, and owners that all appear in the active set. A document that fails any of these is not "repaired"; the engine refuses to construct and reports "Invalid change journal; automatic actions are held." The checks are cheap and they catch the classic hand-edit and partial-merge mistakes — an owner removed from active but left on a receipt, a receipt duplicated by a copy-paste.

Preserved Means Untouched

The file itself is never rewritten while it cannot be read. CwkKit's document store refuses to save over a present-but-unreadable file unless the caller explicitly asks to replace it, and Tinker never asks. The journal is read with defaults materialisation turned off, so even a successful read does not trigger a rewrite. The bad file sits there for a person to open, fix or move aside. "Disables actions" in the house sign means commands: a held journal or held settings stop presets from running, but — as the next lesson shows — an unreadable settings file never takes emergency release away.

Forget: the One Way to Drop a Claim

Uncertain records are cleared only by a deliberate decision in the Recovery tab. forgetUncertain(key:) refuses while an action is executing — a receipt that is prepared because its hide is in progress right now must not be erased under it, and Tinker's tests freeze a hide mid-flight to prove exactly that. It refuses for applied receipts, which belong to normal restore. When it succeeds it removes the receipt and drops from active any owner that no longer has a receipt. In the UI, Forget is an armed two-click control, like Delete Preset: the first click arms it, the second acts.

Why Not a Timeout?

One could argue that an uncertain record older than a day is surely stale and could be dropped automatically. Tinker does not, because age is not evidence. The record exists precisely because Tinker could not tell what happened; waiting does not make it able to tell. Only a person looking at the screen can.

Code

Forget: refuse in-flight and applied receipts, then drop the claim·swift
/// Only a deliberate Recovery UI decision may relinquish an uncertain intent.
public func forgetUncertain(key: String) throws {
  guard !executing else { throw TinkerError("An in-progress action cannot be forgotten.") }
  guard let receipt = journal.changes.first(where: { $0.key == key }),
        receipt.phase == .prepared else {
    throw TinkerError("Only an uncertain prepared change can be forgotten.")
  }
  var next = journal
  next.changes.removeAll { $0.key == key }
  for owner in receipt.owners where !next.changes.contains(where: { $0.owners.contains(owner) }) {
    next.active.remove(owner)
  }
  try persist(next)
}
An armed two-click control in SwiftUI (pattern, simplified)·swift
import SwiftUI

struct ArmedButton: View {
  let title: String
  let action: () -> Void
  @State private var armedUntil: Date?

  var body: some View {
    let armed = armedUntil.map { $0 > Date() } ?? false
    Button(armed ? "Click again to \(title.lowercased())" : title, role: .destructive) {
      if armed { armedUntil = nil; action() }
      else { armedUntil = Date().addingTimeInterval(4) }
    }
  }
}

External links

Exercise

Someone hand-edits journal.json to 'clean it up' and deletes "preset/edit" from active while leaving an applied receipt whose owners include preset/edit. What does Tinker do at the next launch, and what must the person do to recover? Then write the one-line check you would add to catch a receipt whose capability ID is not registered.
Hint
The owners-subset-of-active check fails, the engine refuses to construct, and actions are held with 'Invalid change journal'. Recovery is to restore the file from a copy or fix it consistently (put the owner back in active), then relaunch. A capability check would be something like journal.changes.allSatisfy { capabilities[$0.capability] != nil } — though Tinker deliberately keeps such receipts and reports 'Missing capability …; change retained' at restore time instead, so that removing a capability never silently disowns its changes.

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.