Skip to content

Recover leader preferences with retained rule transactions - #1291

Merged
malpern merged 1 commit into
masterfrom
codex/catalog-durable-preferences
Sep 6, 2026
Merged

malpern merged 1 commit into
masterfrom
codex/catalog-durable-preferences

Conversation

@malpern

@malpern malpern commented Sep 6, 2026 •

Copy link
Copy Markdown
Owner

Leader-key edits previously persisted UserDefaults before the retained rule journal existed, so a process exit or rejected reload could leave the next launch generating from a preference that did not match the recovered rule files.

This change keeps the leader candidate local until ConfigurationService stages a versioned journal containing the fixed leader preference role and the config/source files. It verifies the canonical app defaults domain before reload, commits applied and pending runtime outcomes, restores rejected/interrupted revisions together, and fails closed when either a tracked file or the leader preference has a third external revision. Bootstrap and normal manager mutations refresh the durable preference before snapshotting. Installed CLI reconciliation now generates from a local candidate and retains the canonical preference with the raw generated config; failed applies and dry runs do not advance GUI defaults. Version 1 journals remain readable, and rule saves with no preference entry avoid the defaults synchronization barrier.

Scope is intentionally leader-only. Logical keymap selection, Context HUD config inputs, device selection, and the global handwritten-file ownership guard remain documented follow-ups.

Validation:

  • swift build --jobs 4 with pinned Xcode 26.6: passed
  • DurableConfigPreferenceRecoveryTests: 11/11 passed
  • CollectionLeaderRecoveryTests: 8/8 passed
  • RecoverableRuleWriteTests: 11/11 passed
  • ConfigFacadeTests: 12/12 passed
  • ConfigFacadeAdmissionTests: 4/4 passed
  • RawConfigurationRecoveryTests: 12/12 passed
  • PackRuleTransactionTests: 27/27 passed
  • ConfigurationServiceSavePipelineTests: 41/41 passed
  • AppKeymapSaveTests: 19/19 passed
  • SwiftFormat 0.61.1 lint on changed Swift files: passed
  • python3 Scripts/check-accessibility.py: passed, 379 files checked
  • Full safe gate: 5,303 passed; four pre-existing host snapshot drifts remained (hrm-fast-typing, hrm-per-finger-sliders, hrm-typing-feel-slider, settings-repair-tab). One stale leader conflict assertion exposed by the gate was updated to the required fail-closed contract and its full focused class then passed.
  • Review gate (normal environment): configured local Claude returned Unknown command: /thermo-nuclear-swift-review; GitHub claude-review passed on the current commit.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-06T16:46:59.447392Z 12c93cc Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review note: diff was truncated after 60000 bytes in what I was given, so this covers everything up through DurableConfigPreferenceRecoveryTests.swift's stage(...) helper; anything after that (docs files) was not reviewed.

Likely data-loss bug: leader-key preference migration

PreferencesService.canonicalDefaults (Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift) changes the storage location for KeyPath.LeaderKey.Preference from UserDefaults.standard to UserDefaults(suiteName: "com.keypath.KeyPath")!:

static var canonicalDefaults: UserDefaults {
    TestEnvironment.isRunningTests
        ? .standard
        : UserDefaults(suiteName: "com.keypath.KeyPath")!
}

PreferencesService.init now reads the leader-key preference from leaderDefaults (this new suite) instead of .standard:

if let data = leaderDefaults.data(forKey: Keys.leaderKeyPreference),
   let stored = try? JSONDecoder().decode(LeaderKeyPreference.self, from: data)

Unless "com.keypath.KeyPath" is guaranteed by the platform to alias to the same backing store as .standard for this app's actual bundle identifier, every existing install that already has a leader key configured (stored under .standard) will silently read back nothing on first launch after this update and fall back to LeaderKeyPreference.default — effectively resetting a user-configured leader key with no error, no migration path, and no test covering upgrade-from-.standard. Worth confirming this suite name exactly matches Bundle.main.bundleIdentifier (in which case it's safe), and if not, adding a one-time migration that copies the existing value from .standard before falling back to defaults.

Also a minor nit: UserDefaults(suiteName:)! force-unwraps; if that ever returns nil (e.g. sandboxing/entitlement issue) this crashes at PreferencesService.shared construction, which is on the hot path for the whole app.

Everything else

The new preference-journal machinery in RecoverableRuleWrite.swift (property-list round-tripping via preferenceData/preferenceValue, before/after tracking, fail-closed conflict detection, rollback via recoverLocked) looks internally consistent and is well covered by the new DurableConfigPreferenceRecoveryTests. The pendingLeaderKeyPreference staging pattern in RuleCollectionsManager correctly avoids mutating the live PreferencesService.leaderKeyPreference until the durable write commits, closing the crash window described in docs/bugs/leader-preference-crash-recovery.md. No other correctness issues found in the reviewed portion.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 458eacd75c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Set(preferenceChanges.map(\.role)).count == preferenceChanges.count
else { throw Failure.invalidJournal }
let preferenceEntries = try preferenceChanges.map { change in
let before = try readPreference(change.role, from: preferences)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the snapshotted preference before journaling

If another writer changes the leader preference after the manager captures leaderPreferenceBefore but before this asynchronous staging step, this line records that newer value as the transaction's before revision and then overwrites it with the stale candidate. A successful reload therefore silently loses the intervening preference update rather than triggering the intended third-revision conflict; pass the manager's expected preimage into staging and compare it before creating the journal.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 65f1c34. RuleCollectionsManager now captures the exact durable leader preference Data with its admitted in-memory value, and RecoverableRuleWrite requires that expected preimage before creating the journal. The existing second comparison still catches changes during file I/O. Added a deterministic manager-level race test that mutates UserDefaults in onBeforeSave, after the manager snapshot and before stage; it verifies zero reloads, the intervening preference survives, files remain unchanged, and no journal is created. Focused suites pass: DurableConfigPreferenceRecoveryTests 10/10, CollectionLeaderRecoveryTests 8/8, RecoverableRuleWriteTests 11/11.

@malpern

malpern commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Confirmed: the canonical suite is the GUI's existing app domain, not a new migration domain. Sources/KeyPathApp/Info.plist declares CFBundleIdentifier = com.keypath.KeyPath, KeyPathConstants.App.bundleID carries the same stable identifier, and the installed preference plist/launcher contracts already use com.keypath.KeyPath.plist. The explicit suite is required so the bundled CLI (signed as com.keypath.KeyPath.CLI) reads and recovers the GUI's domain rather than its own .standard domain. Existing GUI values therefore remain in the same persistent domain.

The force unwrap is for this fixed, valid Foundation suite identifier; tests inject temporary suites and route the default test path to the test process's .standard domain.

@malpern
malpern force-pushed the codex/catalog-durable-preferences branch from 458eacd to 65f1c34 Compare September 6, 2026 16:06
@malpern

malpern commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown

Reviewed the diff (durable leader-key-preference journaling alongside rule-file recovery). The new RecoverableRuleWrite preference journal + fail-closed changedPreference handling is well covered by DurableConfigPreferenceRecoveryTests, and the before/after canonicalization via PropertyListSerialization is applied symmetrically on read/write so equality checks stay consistent. Two things worth a second look:

  1. UserDefaults.synchronize() is treated as a durability barrier it may not actually provide. RecoverableRuleWrite.write gates the preference commit on synchronizePreferences(...) returning true (default { $0.value.synchronize() }), and the whole feature is framed around crash-safety (docs/bugs/leader-preference-crash-recovery.md). On modern macOS, synchronize() is documented as unnecessary/deprecated and its return value doesn't reliably reflect whether the value was actually flushed to the on-disk plist (writes go through cfprefsd asynchronously). The follow-up requireAfterPreferences check only re-reads the in-memory cache via defaults.object(forKey:), which will reflect the write immediately regardless of real disk persistence. So a crash between synchronizePreferences returning true and the actual disk flush wouldn't be caught by either check. Tests only exercise this by injecting a fake synchronizePreferences that returns false; there's no verification that a real durability failure is detectable. Worth confirming this matches the intended guarantee, or documenting the actual (weaker) guarantee.

  2. A stuck journal after a genuine 3-way preference conflict has no visible recovery path. testTrackedLeaderConflictLeavesFilesAndJournalForDiagnosis confirms that when the persisted leader preference differs from both the journaled before and after, recover()/recoverLocked throws changedPreference and intentionally leaves the journal + files in place "for diagnosis." But RuleCollectionsManager.recoverBootstrap (Bootstrap.swift) just calls onError?(...) and returns on that throw — every subsequent launch will hit recoverPendingRuleWrite again, get the same changedPreference, and report the same error, with no code path shown here that clears the journal or otherwise breaks the loop. If this is intentional (manual/support-only recovery), fine, but it's worth confirming there's an actual out for a user who hits this, since as written it looks like a permanent soft-lock on rule-state recovery until someone manually deletes the journal file.

Nothing else in the diff looked incorrect — the snapshot/rollback plumbing through RuleCollectionsManager+Mutation/+PublicAPI (staged pendingLeaderKeyPreference instead of immediate didSet persistence) is internally consistent with the new journal-based commit/rollback flow.

@malpern

malpern commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Thanks. Both points match the intentionally bounded contract documented in this PR:

  1. The guarantee is process-interruption recovery, not power-loss atomicity. synchronize() is used because the stable macOS SDK header documents it as blocking until in-progress writes complete; the subsequent readback detects API-level failure. docs/bugs/leader-preference-crash-recovery.md explicitly says this is not a claim of atomic persistence across sudden power loss. A stronger fsync-level guarantee would require moving this preference into the file transaction rather than overstating what UserDefaults provides.

  2. A genuine third-revision conflict intentionally stops all restoration and leaves the journal because choosing either revision automatically could destroy an external edit. Recovery is no longer permanently blocked once support restores the tracked leader preference to either journaled revision: the retained journal can then complete deterministically on the next admission. An automated user-facing conflict resolver is outside this leader-only persistence slice; the retained artifact and explicit error are the diagnostic/recovery boundary.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65f1c34e50

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

static var canonicalDefaults: UserDefaults {
TestEnvironment.isRunningTests
? .standard
: UserDefaults(suiteName: "com.keypath.KeyPath")!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include CLI reconciliation in the retained transaction

When the installed keypath-cli config apply reconciles a Leader collection, ConfigFacade.applyReconciledLeaderKeyPreference persists PreferencesService.shared.leaderKeyPreference before saveConfiguration validates and writes the config, and its non-dry-run error path never restores the prior value. By redirecting that shared service to the app's defaults domain here, a validation or file-write failure now leaves the app's durable leader preference advanced while the rule files remain unchanged—the split revision this transaction is intended to prevent. Route CLI reconciliation through the retained preference/file transaction or restore the preference on every precommit failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a99d7d0. ConfigFacade no longer mutates PreferencesService during reconciliation. It captures the exact canonical defaults preimage, generates from a local candidate, and for a real apply stages the generated config plus fixed leader preference role in the retained .rawConfig journal. Raw startup recovery now receives canonical defaults. Dry runs use the candidate without any defaults write. Added a failed non-dry CLI apply regression test and an interrupted CLI raw journal recovery test through another defaults instance. Focused passes: ConfigFacadeTests 10/10, DurableConfigPreferenceRecoveryTests 11/11, ConfigFacadeAdmissionTests 4/4, RawConfigurationRecoveryTests 12/12.

@malpern
malpern force-pushed the codex/catalog-durable-preferences branch from 65f1c34 to a99d7d0 Compare September 6, 2026 16:16
@malpern

malpern commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review finding: staged leader-key preference can be silently dropped

RuleCollectionsManager+PublicAPI.swift: syncLeaderKeyPreference(key:enabled:persistImmediately:) now defaults to persistImmediately: false, which stores the candidate in pendingLeaderKeyPreference instead of writing preferencesService.leaderKeyPreference immediately. That's fine if every call site subsequently threads the matching leaderPreferenceBefore snapshot into commitRuleMutation/commitRuleMutationResult.

But in RuleCollectionsManager+Mutation.swift, commitRuleMutationResult unconditionally clears the pending value at the end:

let preparedLeaderPreference = pendingLeaderKeyPreference ?? preferencesService.leaderKeyPreference
...
if leaderPreferenceBefore != nil {
    try preferenceChanges.append(.leader(...))
}
let result = await SaveCoordinator(...).saveRuleState(
    ...,
    leaderKeyPreference: leaderPreferenceBefore == nil ? nil : preparedLeaderPreference,
    ...
)
pendingLeaderKeyPreference = nil   // <-- always reset, regardless of whether it was consumed

If a caller sets pendingLeaderKeyPreference via syncLeaderKeyPreference(...) (e.g. the Leader Key collection enable/disable path in RuleCollectionsManager+PublicAPI.swift, around the if id == RuleCollectionIdentifier.leaderKey { ... syncLeaderKeyPreference(key: key, enabled: true) } block) but the code path that follows calls commitRuleMutation/regenerateConfigFromCollections without passing leaderPreferenceBefore, then:

  • leaderKeyPreference: nil is passed to saveRuleState, so config generation falls back to the old persisted PreferencesService.shared.leaderKeyPreference instead of the staged candidate.
  • The staged change is never added to preferenceChanges, so it's never journaled/persisted either.
  • pendingLeaderKeyPreference is reset to nil at the end of the call regardless, so the change is lost with no error surfaced to the user.

This diff was truncated before I could see the tail of the toggle/enable-disable function to confirm whether it passes leaderPreferenceBefore through in all cases (I count only one code path in the visible diff — the bootstrap reconcileLeaderKeyFromCollection — that reliably wires this up). Please double check every syncLeaderKeyPreference(..., persistImmediately: false) call site (collection enable/disable, updateSelectedOutput, etc.) actually forwards the corresponding leader snapshot into the commit call it feeds, otherwise toggling/reassigning the Leader Key collection could silently stop updating the effective leader key.

Minor: PreferencesService.canonicalDefaults force-unwraps UserDefaults(suiteName: "com.keypath.KeyPath")! — low risk in practice, but a nil suite would crash rather than fail closed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a99d7d0b6b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// `selectedOutput` were silently ignored by `keypath apply`. Reconcile both, mirroring
// the in-process reconcile the app does on load (RuleCollectionsManager). See #889.
let reconcile = await applyReconciledLeaderKeyPreference(from: collections)
let reconcile = await reconciledLeaderKeyPreference(from: collections)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recover the raw journal before deriving the CLI candidate

If the CLI previously crashed after staging a leader/config revision but before committing its raw journal, this reads the staged preference before recovery. When the collection already matches that value, reconcile is nil and the non-transactional saveConfiguration branch leaves the old journal in place, so a later startup recovery can undo this apparently successful apply; when it differs, the transactional overload recovers to the older preference and then rejects the now-stale preimage. Recover pending raw writes before loading the preference and deriving reconciliation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in da11ea7. ConfigFacade now calls retained-journal recovery with canonical defaults immediately after gate admission, reloads the shared preference cache, and only then loads sources and derives the leader candidate. Added a deterministic interrupted raw-journal apply test that verifies the recovered revision is reconciled and recommitted, the journal is removed, and the final config/preference both use tab.

Comment on lines +167 to +169
guard let decoded = try? JSONDecoder().decode(LeaderKeyPreference.self, from: previousData) else {
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail or reconcile when the stored leader data is malformed

When KeyPath.LeaderKey.Preference contains Data that no longer decodes, returning nil treats reconciliation as unnecessary. The subsequent ordinary save generates from PreferencesService.shared's default fallback, so an enabled Leader collection with an explicit non-default output is silently ignored while the malformed preference remains durable. Preserve the exact malformed preimage for the journal and reconcile from the default fallback, or fail the apply explicitly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in da11ea7. Malformed stored Data now supplies the exact journal preimage while reconciliation derives from the same default fallback PreferencesService uses; it no longer returns nil. Added a regression test that starts with malformed canonical Data and verifies CLI apply commits the collection-selected leader.

@malpern
malpern force-pushed the codex/catalog-durable-preferences branch from a99d7d0 to da11ea7 Compare September 6, 2026 16:23
@malpern

malpern commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown

Correctness: pending leader-key preference can be silently dropped

RuleCollectionsManager+Mutation.swift — commitRuleMutationResult(...):

let preparedLeaderPreference = pendingLeaderKeyPreference ?? preferencesService.leaderKeyPreference
...
if leaderPreferenceBefore != nil {
    try preferenceChanges.append(.leader(before: leaderPreferenceBefore?.data, after: JSONEncoder().encode(preparedLeaderPreference)))
}
...
let result = await SaveCoordinator(...).saveRuleState(
    ...
    preferenceChanges: preferenceChanges,
    leaderKeyPreference: leaderPreferenceBefore == nil ? nil : preparedLeaderPreference,
    ...
)
pendingLeaderKeyPreference = nil

Whether a pending leader-key change is journaled/persisted and whether it's fed into config generation for this save is gated on the caller-supplied leaderPreferenceBefore snapshot, not on pendingLeaderKeyPreference itself. If any call path sets pendingLeaderKeyPreference via syncLeaderKeyPreference(...) (default persistImmediately: false, e.g. in RuleCollectionsManager+PublicAPI.swift's leader-key toggle-on/toggle-off branches) but invokes commitRuleMutation/commitRuleMutationResult without threading a matching leaderPreferenceBefore snapshot, the change is silently lost: it's excluded from preferenceChanges (never persisted to UserDefaults or journaled for crash recovery), excluded from the leaderKeyPreference passed into this save's config generation, and then unconditionally wiped by pendingLeaderKeyPreference = nil regardless of the save's outcome. The Bootstrap.swift path threads leaderSnapshot through correctly, but the coupling is easy to break at any future/other call site since nothing asserts (pendingLeaderKeyPreference != nil) == (leaderPreferenceBefore != nil).

Suggest deriving the "should this save include a leader-preference change" decision directly from pendingLeaderKeyPreference != nil (with the snapshot only supplying the before value), or adding an assertion that fails loudly if the two are inconsistent, so a future call site that forgets to pass leaderPreferenceBefore fails a test instead of silently dropping the user's leader-key change.


Minor/lower-confidence notes, not blocking:

  • PreferencesService.canonicalDefaults force-unwraps UserDefaults(suiteName: "com.keypath.KeyPath")! on every access (no caching) — if that suite ever fails to initialize (sandboxing/entitlement issues) this crashes rather than falling back. Worth confirming "com.keypath.KeyPath" is exactly the app's bundle identifier so this suite is truly equivalent to .standard (if it differs at all, this PR silently migrates the persisted leader-key preference to a new, initially-empty defaults domain with no migration step).
  • RecoverableRuleWrite's new preference-durability guard relies on UserDefaults.synchronize()'s return value (guard synchronizePreferences(...) else { throw ... }), but synchronize() is a documented no-op returning true on modern OS versions, so this check can't actually detect a failed flush to disk — acceptable given API constraints, just worth knowing it's not a real durability guarantee.

Note: the diff supplied for review was truncated (after ~60000 bytes, partway through ConfigFacadeTests.swift), so docs/, DurableConfigPreferenceRecoveryTests.swift, and the remainder of the test files were not reviewed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: da11ea7346

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +77 to +80
try await service.recoverPendingRuleWrite(
mutationPermit: permit,
preferenceDefaults: PreferencesService.canonicalDefaults
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh the runtime after recovering the retained revision

When a previous app save crashed after Kanata accepted the staged rules but before the journal was committed, this call restores the files/preferences and sets needsRecoveredRuntimeRefresh, but the CLI never calls applyRecoveredRuntimeIfNeeded. A dry run returns without reloading, and generation or validation can also throw before the final reload, leaving Kanata on the attempted revision after the journal has been removed; because the marker is only in this service instance, it is lost when the CLI exits. Apply the recovered runtime before proceeding, or retain durable recovery evidence until it is reloaded.

AGENTS.md reference: AGENTS.md:L344-L344

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 547a231. Immediately after retained-journal recovery, ConfigFacade now calls applyRecoveredRuntimeIfNeeded through the same injected/TCP reload path before refreshing preferences, loading sources, or generating/validating a new candidate. A failed recovery reload aborts while the service still owns the recovery requirement. The interrupted CLI test now asserts exactly two reloads: restored revision first, newly committed apply second. ConfigFacadeTests 12/12 and ConfigFacadeAdmissionTests 4/4 pass.

@malpern
malpern force-pushed the codex/catalog-durable-preferences branch from da11ea7 to 547a231 Compare September 6, 2026 16:32
@malpern

malpern commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 547a2311c2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +495 to +498
let rawRecovered = try RecoverableRuleWrite.recover(
files: rawFiles, directory: directory, scope: .rawConfig,
preferences: sendablePreferences?.value
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh the cached leader after pack-triggered recovery

When an interrupted leader transaction is first encountered by an installed CLI pack operation, PacksFacade.withPackOperation constructs a manager—initializing PreferencesService.shared from the staged preference—before PackInstaller.recoverAndValidateState reaches this recovery call. Recovery restores the old preference directly in UserDefaults, but that pack path never reloads the manager's cached preference; the subsequent SaveCoordinator.stageRuleState therefore generates and commits the pack revision using the stale attempted leader while defaults contain the recovered leader, recreating the split revision. Return preference-recovery information or explicitly reload manager.preferencesService before the pack mutation proceeds.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 12c93cc. Pack recovery now uses the manager’s persistence defaults and immediately reloads that manager’s cached leader before recovered-source refresh or any pack mutation. Added a regression that constructs a fresh manager while the attempted preference is staged, runs pack recovery, and verifies its cache returns to the recovered prior leader. I also audited every recovery entry and downstream leader consumer: rule/bootstrap already reload injected stores; CLI recovers/reloads before candidate derivation; SaveCoordinator raw/app editors now reload canonical shared preferences; ConfigurationService rule/app/ordinary generation decodes the durable post-recovery leader directly. Backup/restore-only paths do not generate a leader-dependent candidate. PackRuleTransactionTests 27/27, ConfigurationServiceSavePipelineTests 41/41, AppKeymapSaveTests 19/19, RawConfigurationRecoveryTests 12/12 pass.

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review notes

Preference durability relies on deprecated UserDefaults.synchronize(), which weakens the crash-safety guarantee this PR is trying to add (RecoverableRuleWrite.swift, write()/stage() and recoverLocked).

The whole point of this change is to journal the leader-key preference write alongside the config file so a crash mid-write can't leave them out of sync (see docs/bugs/leader-preference-crash-recovery.md). But the preference side of that guarantee is built on UserDefaults.set(...) + synchronize():

try writePreference(change.after, role: change.role, to: preferences)
guard synchronizePreferences(PreferenceDefaults(preferences)) else {
    throw Failure.systemCall("preference synchronize", EIO)
}
try requireAfterPreferences(preferenceEntries, in: preferences)

synchronize() has been deprecated since ~2012 precisely because it isn't a real disk-flush barrier — cfprefsd persists writes asynchronously in the background, and a true return doesn't mean the on-disk plist has been updated yet. requireAfterPreferences re-reads via defaults.object(forKey:), which is served from the in-process cache, not disk, so it will report success even if nothing has hit disk yet.

Contrast that with the config file write, which goes through durableWrite (presumably an actual fsync). So after a "committed" journal, the file is durably on disk but the preference may not be. If the process is killed right after commit() returns success, a relaunch's fresh UserDefaults instance could still read the old (pre-write) value from disk while the config file reflects the new leader key — reproducing the exact file/preference desync this feature set out to fix, just shifted one layer down.

Worth either accepting this as a known residual risk (much narrower than before, since it's now scoped to the sync-flush window rather than the whole save), or replacing the UserDefaults-based preference store with something that has an explicit fsync-equivalent guarantee for this one durability-critical key.

Minor: force-unwrap in PreferencesService.canonicalDefaults

static var canonicalDefaults: UserDefaults {
    TestEnvironment.isRunningTests
        ? .standard
        : UserDefaults(suiteName: "com.keypath.KeyPath")!
}

UserDefaults(suiteName:) is documented as failable and this is now called from several hot paths (ConfigFacade.applyConfiguration, recoverPendingRuleWrite, bootstrap/recovery). A nil here (e.g. sandbox/suite-creation failure) crashes every apply/recovery call site instead of degrading to .standard.

No other correctness issues found in the reviewed portion of the diff (note: diff was truncated after the ConfigFacadeTests.swift additions, so later test files weren't reviewed).

@malpern
malpern force-pushed the codex/catalog-durable-preferences branch from 547a231 to 12c93cc Compare September 6, 2026 16:41
@malpern

malpern commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown

Correctness: RecoverableRuleWrite.recoverLocked rolls back a committed preference change on ordinary crash recovery

In Sources/KeyPathAppKit/Infrastructure/Config/RecoverableRuleWrite.swift, the new preference-recovery block in recoverLocked is:

if let preferences, !journal.preferences.isEmpty {
    for entry in journal.preferences where try readPreference(entry.role, from: preferences) == entry.after {
        if let before = entry.before {
            try writePreference(before, role: entry.role, to: preferences)
        } else {
            preferences.removeObject(forKey: entry.role.key)
        }
    }
    ...
}

This condition — "if the on-disk value currently equals after, roll it back to before" — fires unconditionally, regardless of journal.committed. The adjacent file-entry recovery loop (unchanged in this diff) branches on journal.committed to distinguish "forward-complete a committed write" from "roll back an aborted one." The preference block has no such branch.

Concretely: stage() writes both the config file and the preference eagerly (see the write() do-block), then commit() only flips committed = true in the journal and removes it (ConfigurationService.saveConfiguration's two-phase stage → commit in ConfigurationService.swift). If the process crashes after commit() durably marks the journal committed = true but before removeJournal finishes, the preference on disk is already (correctly) after. On the next launch, recover() → recoverLocked() sees actual == after and — since the loop doesn't check journal.committed — reverts the leader-key preference back to before, even though the journal says the write was committed and the config file (which is checked against committed) is correctly left at after. Result: the generated config and the persisted PreferencesService leader-key preference silently desync after an ordinary crash between commit and journal cleanup — precisely the scenario docs/bugs/leader-preference-crash-recovery.md (added in this PR) is meant to address.

Suggested fix: mirror the file-entry logic — only restore to before when !journal.committed; when journal.committed and actual == before, forward-apply after instead (and no-op when already after).

(Note: reviewed only the supplied diff, which was truncated after ~60000 bytes near RuleCollectionsManager+PublicAPI.swift; I did not see the remainder of that file or RuleCollectionsManager.swift/tests.)

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 12c93cc660

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@malpern

malpern commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Resolution of the remaining top-level review notes:

  • Committed journal cleanup does not enter rollback. RecoverableRuleWrite.recoverLocked decodes and validates the journal, then at lines 307–310 immediately removes a committed journal and returns. File rollback begins at line 311 and preference rollback at line 333, so neither is reachable for a committed journal. DurableConfigPreferenceRecoveryTests.testCommittedJournalCleanupDoesNotRejectNewerLeader covers cleanup while preserving a legitimate newer leader value and removing the journal. The suggested forward-apply behavior would overwrite a newer preference and would violate this PR's preservation contract.

  • The synchronization contract follows Apple's documented persistence API. Apple's UserDefaults.synchronize() documentation says it returns true when the data was saved successfully to disk: https://developer.apple.com/documentation/foundation/userdefaults/synchronize%28%29. The pinned macOS SDK header (NSUserDefaults.h, lines 166–172) says the call blocks until in-progress asynchronous sets complete and recommends CFPreferencesAppSynchronize for an exiting CLI; CFPreferences.h, lines 78–82, describes that operation as writing all changes. This implementation intentionally uses the existing UserDefaults object's domain-aware synchronize() contract, checks its result, and reads back the value before journal settlement. The docs explicitly avoid claiming atomic survival of sudden machine power loss. Moving this key to an fsync-owned file would be a different storage migration. Falling back to CLI .standard if the canonical suite cannot be created would silently write the wrong domain, so the fixed GUI suite remains fail-closed.

  • Pending leader candidates are consumed by every current caller. The four non-immediate syncLeaderKeyPreference sites were audited: leader toggle captures leaderPreferenceSnapshot and passes it when the toggled ID is Leader; selected-output editing captures and conditionally passes its snapshot; direct updateLeaderKey passes its snapshot; collection reconciliation is called only by replace/bootstrap, both of which pass their snapshot when reconciliation changed the leader. Focused applied/pending/rejected tests cover these paths, including exact reload counts and preference/file restoration. No caller leaves a pending candidate for a non-leader commit.

  • Canonical suite and conflict behavior remain intentional. com.keypath.KeyPath is the GUI bundle's existing defaults domain; the explicit suite lets the separately identified installed CLI access that domain. A third preference revision stops before any restoration and retains the journal rather than choosing an external edit to destroy. Recovery can proceed once support restores either recorded revision; a user-facing conflict resolver is a separate workflow.

@malpern
malpern merged commit 04643dd into master Sep 6, 2026
8 checks passed
@malpern
malpern deleted the codex/catalog-durable-preferences branch September 6, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant