pingone-aic-manager TUI design note · v2 implementation plan

Undo Design v2

v1 (undo-design.html) settled the principles — record reversible writes through an abstraction, tag entries with sensitivity, be honest with users about irreversibility. v2 is the build plan: concrete Rust types, the on-disk format, the storage-policy matrix, and a phased rollout starting with ESV variables.

Non-negotiables (inherited from v1)

  • Domain code calls a service, not a backend. Persistence is the service's secret.
  • Every entry declares its sensitivity. The service decides what to do with it.
  • If the previous value is unknowable, the operation is Irreversible; confirm prompts say so before the user commits.
  • Undo is one more forward write — it conflict-checks against current tenant state.
  • Undo entries outlive the screen state that produced them; they render from the log.

Scope of v2 (the first cut)

Ship ESV variable undo end-to-end: record on save/delete/create, persist to .aic/undo.log, expose ^Z in the TUI plus an undo history panel. Defer secret variables, scripts, OAuth, SAML, journeys, and CLI undo to follow-up cuts — but lock in the data shape and trait surface now so those slot in without schema migration.

In scope

  • UndoLog trait + in-memory impl + disk-backed impl (NDJSON, sensitive payloads sealed with the project DEK).
  • Per (tenant, realm) log files under .aic/undo/<tenant>/ so switching tenant or realm shows the right history without filtering noise.
  • ESV variable operations: Update, Delete, Create (all three are reversible).
  • Navigation on undo: applying an entry routes the user back to the (tenant, realm, tab, element) where the change happened. Each UndoOp exposes a locate() target the app uses to switch context before / after running the inverse.
  • Per-element undo in the list view. A deleted ESV shows up as a tombstone row marked with a red !; cursoring onto any row (live or tombstone) with a pending undo and pressing u reverses that specific entry.
  • Global TUI bindings: ^Z for "undo the most recent for the current (tenant, realm)", ^Y for the history panel, plus the per-row u above.
  • Conflict detection on apply using the existing content-equality helpers (aic::esv::content_equal).

Out of scope (designed for, not built)

  • Secret value operations beyond Create — we don't have the old value to restore.
  • CLI undo (aic undo). The log is process-shared on disk, so it's mechanical, but no UX yet.
  • Cross-tenant undo (each entry is tenant-scoped; "undo the last write" is tenant-local).
  • Branching / redo. We only stack reversals one-deep; redoing a reversal is a new entry in its own right.

Core types

Lives in src/undo/. The trait stays small so we can swap implementations (in-memory for tests; disk for production; an aic undo CLI helper later). Operations are an enum, not a trait — serializable, exhaustively-matchable, and aligned with how the rest of the codebase shapes domain enums (e.g. screens::prod_confirm::PendingProdAction).

// src/undo/mod.rs

pub struct UndoId(pub uuid::Uuid);

/// The "where does this live and how do I get the user back to it" scope.
/// Every entry has one. Tenant is mandatory; realm is Some(_) for resources
/// that are realm-scoped (scripts, OAuth2 clients, journeys) and None for
/// tenant-level resources (ESVs, IDM managed config).
pub struct UndoScope {
    pub tenant: String,           // matches Tenant::name
    pub realm:  Option<Realm>,    // None ⇒ tenant-level
}

pub struct UndoEntry {
    pub id:            UndoId,
    pub created_at:    chrono::DateTime<chrono::Utc>,
    pub scope:         UndoScope,
    pub actor:         &'static str,      // "esv", "script", … (the screen)
    pub description:   String,            // "Delete ESV variable esv-foo"
    pub sensitivity:   Sensitivity,
    pub capability:    Capability,
    pub op:            Option<UndoOp>,    // None ⇒ audit-only (irreversible)
    pub conflict_check: ConflictCheck,
    pub status:        EntryStatus,
}

pub enum Sensitivity {
    PublicMetadata,   // tenant config, ids, ESV variable values (non-secret)
    TenantConfig,     // base URLs, service-account scopes
    SensitiveValue,   // values we knew at creation time but treat as sensitive
                      //   (e.g., a secret we just created and recorded for undo)
    SecretValue,      // strictly in-memory; never persisted, never re-rendered
}

pub enum Capability {
    Undoable,      // forward known, inverse exists, conflict check is feasible
    BestEffort,    // inverse exists but conflict resolution may fail
    Irreversible,  // recorded for audit only; UI must show "cannot undo"
    Expired,       // payload compacted out of the log; metadata kept for history
}

pub enum EntryStatus { Pending, AppliedSuccess, AppliedConflict, AppliedFailure, Expired }

pub enum ConflictCheck {
    /// PUT only if the current tenant value equals this body.
    ContentEqualsBefore { body: serde_json::Value },
    /// CREATE only if the resource is currently absent.
    ResourceAbsent,
    /// DELETE only if the current tenant value equals this body.
    ContentEqualsAfter  { body: serde_json::Value },
    /// Allow regardless of current state — used by audit-only entries.
    None,
}

pub enum UndoOp {
    EsvVariableRestore  { id: String, body: serde_json::Value },
    EsvVariableDelete   { id: String, recorded_body: serde_json::Value },
    EsvVariableUpdateTo { id: String, body: serde_json::Value },
    // Future: ScriptRestore, OauthClientRestore, etc.
    //   (tenant + realm come from UndoEntry::scope, not the op variant.)
}

impl UndoOp {
    /// "Where does this entry live in the app?" Used by the undo applier
    /// and the per-element `u` handler to route focus before / after the
    /// inverse runs.
    pub fn locate(&self, scope: &UndoScope) -> LocateTarget {
        match self {
            UndoOp::EsvVariableRestore  { id, .. } |
            UndoOp::EsvVariableDelete   { id, .. } |
            UndoOp::EsvVariableUpdateTo { id, .. } => LocateTarget {
                tenant: scope.tenant.clone(),
                realm:  scope.realm,
                tab:    Tab::Esvs,
                element: Some(ElementRef::EsvVariable(id.clone())),
            },
        }
    }
}

/// Routing target for navigation-on-undo. The app calls
/// `focus_locate_target(target)` which (a) switches active tenant +
/// realm if needed, (b) switches the tab, and (c) selects the element
/// if specified.
pub struct LocateTarget {
    pub tenant:  String,
    pub realm:   Option<Realm>,
    pub tab:     Tab,
    pub element: Option<ElementRef>,
}

pub enum ElementRef {
    EsvVariable(String),   // by _id
    Script(String),        // future
    OauthClient(String),   // future
}

pub trait UndoLog: Send + Sync {
    fn record(&mut self, entry: UndoEntry) -> crate::Result<UndoId>;

    /// Most-recent-first, filtered by scope. The scope is mandatory:
    /// callers ask for "what's undoable for the current (tenant, realm)?"
    /// rather than getting the global stream.
    fn list(&self, scope: &UndoScope, limit: usize) -> Vec<UndoSummary>;

    /// Pending entries that touch a specific element. Drives the
    /// in-list `!` marker and the per-row `u` keybind.
    fn pending_for(&self, scope: &UndoScope, element: &ElementRef) -> Vec<UndoSummary>;

    fn load(&self, id: UndoId) -> crate::Result<UndoEntry>;

    /// Mark a previous record as having been undone (any outcome).
    /// Idempotent: a second mark with the same id is a no-op.
    fn mark_applied(&mut self, id: UndoId, status: EntryStatus) -> crate::Result<()>;
}

/// Cheap summary for the history panel / list-row markers. Mirrors
/// `UndoEntry` minus the (possibly sealed) payload, plus the element
/// reference so the list view can match rows without re-loading.
pub struct UndoSummary {
    pub id: UndoId,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub scope: UndoScope,
    pub actor: &'static str,
    pub description: String,
    pub sensitivity: Sensitivity,
    pub capability: Capability,
    pub status: EntryStatus,
    pub element: Option<ElementRef>,
}

Sensitivity → storage policy

The matrix is fixed in code, not surfaced as configuration — surprises about where sensitive material went are worse than a missing knob.

SensitivityPersistenceOn-disk shape
PublicMetadata Always persist Plain JSON
TenantConfig Always persist Plain JSON
SensitiveValue Persist only when the project has a DEK (encrypted mode) Sealed envelope { "sealed": "<b64 AES-GCM>" }
SecretValue In-memory only n/a — entry not appended to the log file

Plain mode (settings.encrypt_keys = false) demotes SensitiveValue entries to in-memory-only too: it'd be a foot-gun to write them next to keys.plain at mode 0600 and call that equivalent protection.

Scoping: tenant + realm

Every entry has an UndoScope { tenant, realm }. The tenant is mandatory and the realm is optional — set only for resources that AIC realms apply to.

Resource familyRealm scopeLog file
ESV variables, ESV secrets, IDM managed config None — tenant-level <tenant>/_tenant.log
Scripts, OAuth2 clients, journeys, SAML entities Some(Realm::Alpha) or Some(Realm::Bravo) <tenant>/alpha.log or <tenant>/bravo.log
Tenant config itself (rename, scope change) None <tenant>/_tenant.log

Switching the active tenant or realm in the TUI changes which log the history panel + per-row marker reads from. Switching back later re-shows the entries that were already there — they don't follow the user around.

On-disk layout

Directory .aic/undo/, mode 0700, gitignored. One NDJSON file per (tenant, realm). The directory keeps the active set small (the history panel never reads other tenants' logs) and the file names are stable so a git diff on a checked-in summary would be deterministic if we ever wanted that.

.aic/undo/
├── sandbox/
│   ├── _tenant.log     # ESVs, secrets, IDM config
│   ├── alpha.log       # scripts, oauth2, journeys in the alpha realm
│   └── bravo.log
├── prod/
│   ├── _tenant.log
│   ├── alpha.log
│   └── bravo.log
└── _meta.json          # schema version, last compaction timestamp

Each .log is append-only NDJSON. Metadata is always plain so the history panel can render without unlocking, even though the payload may be sealed. The scope is implicit in the file path so we don't repeat it on every line:

{"v":1,"id":"a1f…","ts":"2026-05-26T11:02:14Z","actor":"esv",
 "desc":"Delete esv-foo","sensitivity":"public_metadata",
 "capability":"undoable","status":"pending",
 "element":{"kind":"esv_variable","id":"esv-foo"},
 "conflict":{"kind":"absent"},
 "op":{"kind":"esv_variable_restore","id":"esv-foo","body":{…}}}
{"v":1,"id":"7c2…","ts":"…","actor":"esv",
 "desc":"Reset secret foo","sensitivity":"secret_value",
 "capability":"irreversible","status":"pending",
 "element":{"kind":"esv_secret","id":"esv-foo"},
 "conflict":{"kind":"none"},"op":null}
{"v":1,"id":"e91…","ts":"…","actor":"esv",
 "desc":"Rotate api-key","sensitivity":"sensitive_value",
 "capability":"undoable","status":"pending",
 "element":{"kind":"esv_variable","id":"api-key"},
 "conflict":{"kind":"content_equals_after","body":{…}},
 "op":{"sealed":"<base64 AES-GCM ciphertext>"}}

The sealed envelope uses the same scheme as keys.enc (crypto::encrypt_data with the project DEK), so unlocking the tenant store also unlocks the undo log. Failed decryption marks the entry Expired instead of crashing the load.

A compaction pass (size cap, age cap — defaults: 1000 entries or 30 days per file, whichever first) rewrites each file atomically: write foo.log.tmp, then rename(2). Compaction runs on app shutdown and on first load if the file's over the cap; there is no background timer.

Lifecycle: record → undo → mark

  1. Build the plan. The screen prepares the forward write (e.g. SavePlan in screens::esv) and, alongside, an UndoEntry describing how to reverse it. Capability + conflict check are picked here.
  2. Prod-confirm (if applicable). Existing flow via screens::prod_confirm. The undo entry rides with the PendingProdAction so it's recorded if confirmed and dropped if cancelled.
  3. Record. Call app.undo.record(entry) before spawning the background write. Reasoning: an undo for a crash mid-write is more useful than a guaranteed undo for a successful write. The conflict check on apply protects us when the forward write actually failed.
  4. Execute the forward write. Unchanged — the background save_variable task in screens::esv, etc.
  5. Surface affordance. Success toast carries a key hint (u while the toast is alive). The undo history panel (^Y) shows entries with status=pending at the top.
  6. Undo. Load entry → run its conflict check → if it passes, spawn the inverse write (an ordinary aic::api::* call) → toast success or conflict → call mark_applied.
  7. Conflict path. Conflict-check fails: do not write. Surface "remote state changed; refresh and try again." Keep the entry as Pending so the user can re-attempt after refreshing.

Navigation: undo brings you back to the scene

When the user undoes something — whether via ^Z, the history panel, or the per-row u — the app routes focus to the place where the change happened before running the inverse. That way the user sees what's about to be reverted, and after the inverse lands they're already on the element they care about.

What "route focus" means concretely

  1. Read entry.scope → switch active_tenant_idx and the realm chip if either differs from the current state. (Tenant switching already calls screens::esv::refresh(app, true), so we get the latest list as part of the move.)
  2. Read entry.op.locate(&entry.scope) → switch current_tab to the target (today always Tab::Esvs).
  3. If locate.element is set, set the selection cursor in that screen's state struct. For ESVs that's app.esv.selected pointed at the row whose _id matches; the list scrolls itself to keep the selection visible.
  4. Render once. The user sees the about-to-change row (or its tombstone, see below) under the cursor.
  5. Run the inverse, conflict-check it, toast the outcome.

One implementation, two entry points

The router lives at app::focus_locate_target(&mut self, target: LocateTarget) on App. Both screens::undo_history::apply_selected and the per-row u handler in screens::esv::handle_normal_key call into it. Adding a new screen later is a single match arm extension on Tab.

What about cross-realm jumps?

Realm-scoped resources (scripts, OAuth2) live in different log files per realm. Undoing one from a different realm is rare but legal — the router flips the realm chip first, then opens the tab. Cross-tenant undo works the same way but is less common; the history panel filter defaults to the current tenant for that reason.

ESV operations — concrete table

Forward action UndoOp ConflictCheck on apply Sensitivity
Update ESV variable EsvVariableUpdateTo { body: <previous> } ContentEqualsAfter { body: <saved> } PublicMetadata
Delete ESV variable EsvVariableRestore { body: <deleted> } ResourceAbsent PublicMetadata
Create ESV variable EsvVariableDelete { id, recorded_body: <created> } ContentEqualsAfter { body: <created> } PublicMetadata
Update ESV variable type EsvVariableUpdateTo { body: <previous> }
(delete+put under the hood; undo is a single PUT of the captured old body)
ContentEqualsAfter { body: <saved> } PublicMetadata
Create secret (future) SecretDelete { id } ResourceAbsent SensitiveValue
payload is the id only; persistable encrypted
Reset secret value (future) SecretValue
Irreversible — old value was never readable.
Delete secret (future) SecretValue
Irreversible.

Honesty rule

Confirm prompts for irreversible actions must contain the word cannot be undone. The TUI defaults the confirm cursor to the cancel side for irreversible writes against production-themed tenants.

Per-element undo (tombstones & u)

Global undo (^Z) is "reverse the most recent change." Per-element undo is "reverse the change to this thing." The user cursors onto a row in the ESV list, presses u, and the change to that specific variable rewinds. The two modes share the same log and the same conflict-check semantics; only the row resolution differs.

Tombstone rows for pending deletes

A delete that has a Pending undo entry on it is shown in the list as a tombstone row: the variable's _id with a red ! in the gutter and the body greyed out. The tombstone disappears once mark_applied is called (either because the user undid it, or because they discarded the entry from the history panel).

Source of truth is the undo log, not a parallel "deleted things" cache. At render time, apply_refresh joins the live variable list with app.undo.pending_for(scope, ElementRef::EsvVariable(_)):

// pseudo-Rust, in render_esv_row's caller
let mut rows: Vec<Row> = live_variables.iter().map(Row::live).collect();

for summary in app.undo.pending_for(&scope, ElementRef::any_esv()) {
    match summary.element {
        Some(ElementRef::EsvVariable(ref id))
            if !rows.iter().any(|r| r.id() == id) =>
        {
            // Pending delete or pending-create-rollback for an id that
            // isn't in the live list. Render as a tombstone.
            rows.push(Row::tombstone(id.clone(), summary.id));
        }
        Some(ElementRef::EsvVariable(ref id)) => {
            // Live row exists but has a pending undo (e.g. update). Mark it.
            rows.iter_mut().find(|r| r.id() == id).map(|r| r.mark_pending(summary.id));
        }
        _ => {}
    }
}
rows.sort_by(|a, b| a.id().cmp(b.id()));

Live rows that have a pending undo (e.g. recent update, recent create) get the same red ! glyph, but their body stays full-colour — only the deleted ones grey out. The gutter glyph is the affordance: "there's something here you can undo."

The u handler

In screens::esv::handle_normal_key, u on a selected row looks up the most-recent Pending entry whose element matches the row, runs focus_locate_target on it (a no-op when we're already on the right (tenant, realm, tab, element)), then applies the inverse the same way ^Z would. If the selected row has no pending undo, u falls through to the normal-mode default (currently a no-op).

Interplay with existing in-flight / failed states

ESV already shows in-flight writes in green and failed writes in red via recent_writes / failed_writes. The tombstone gutter glyph reuses the same red ! slot:

  • Live + failed save: red !, full-colour text. u doesn't apply here yet — failed saves are reattempted via r, not undone. The undo entry for the failed save is the one recorded before the write spawned; it stays Pending until the user either re-tries or explicitly undoes.
  • Live + pending undo: red !, full-colour text. u applies the inverse.
  • Tombstone (deleted + pending undo): red !, greyed body. u restores the variable.
  • Live + in-flight save: green ! (existing behaviour). Undo entries don't show until the save settles, so the ! stays single-coloured per row.

How a screen wires it up

Concretely, in screens::esv::execute_save_plan:

// 1. Build the entry from data already on hand. `plan.original` is
//    the variable snapshot captured at edit-open time; for a fresh
//    create it's `None` and we record a delete-by-id instead.
let undo = match (&plan.original, plan.was_creating) {
    (Some(prev), false) => UndoEntry {
        actor: "esv",
        tenant: plan.tenant_name.clone(),
        description: format!("Revert {} to prior value", plan.id),
        sensitivity: Sensitivity::PublicMetadata,
        capability: Capability::Undoable,
        conflict_check: ConflictCheck::ContentEqualsAfter { body: plan.optimistic.clone() },
        op: Some(UndoOp::EsvVariableUpdateTo {
            tenant: plan.tenant_name.clone(),
            id: plan.id.clone(),
            body: prev.clone(),
        }),
        .. UndoEntry::pending()
    },
    (None, true) => UndoEntry {
        actor: "esv",
        tenant: plan.tenant_name.clone(),
        description: format!("Delete created variable {}", plan.id),
        sensitivity: Sensitivity::PublicMetadata,
        capability: Capability::Undoable,
        conflict_check: ConflictCheck::ContentEqualsAfter { body: plan.optimistic.clone() },
        op: Some(UndoOp::EsvVariableDelete {
            tenant: plan.tenant_name.clone(),
            id: plan.id.clone(),
            recorded_body: plan.optimistic.clone(),
        }),
        .. UndoEntry::pending()
    },
    _ => { /* shouldn't happen with a valid plan */ }
};

// 2. Record before kicking off the background write.
let _id = app.undo.record(undo)?;

// 3. Spawn the existing save task — unchanged.
tokio::spawn(async move { … });

Where state lives

  • App.undo: Box<dyn UndoLog> — initialised at startup. Disk impl for normal runs; in-memory impl for unit tests.
  • UndoLog::list backs the history panel. The panel widget owns a selection cursor; the log is read-only from its perspective.
  • No screen-side cache. If the file grows, the panel renders only the most recent N entries (default 100) and offers pagination.

Cross-process — agent & CLI

  • The TUI is the sole writer to undo.log. The agent never writes — it doesn't know about undo at all.
  • The CLI could read the log for an aic undo list command (read-only, no state risk). Out of scope for v2 but the format supports it.
  • An aic undo apply <id> would need write access to the same file; deferred until we've shaken out the TUI flow first.
  • Concurrent TUI + CLI undo apply would race. When CLI joins, we'll add a simple lockfile (.aic/undo.lock).

Conflict handling — the three checks

The conflict check is the difference between best-effort undo and "we clobbered something the user didn't intend." Each check uses the existing content-equality helpers from aic::esv so we don't fork the equality rules between save and undo.

CheckPre-conditionOn failure
ContentEqualsAfter Fetch the current value. content_equal(current, recorded_after) must be true — i.e. nothing else changed it since we wrote. Surface "remote value diverged; refresh and re-attempt." Keep the entry Pending; do not write.
ResourceAbsent GET returns 404. A new resource with the same id exists. Surface "esv-foo already exists, can't restore." Keep Pending.
ContentEqualsBefore Symmetric to After but used for redo-style flows. Not needed in v2.

UI integration

Keybindings

  • ^Z — undo the most-recent Pending entry for the current (tenant, realm). Routes focus to the entry's locator (see Navigation), then runs the inverse. No prompt if it's marked Undoable and not on a prod-themed tenant; prod-themed tenants flow through screens::prod_confirm with a "you are about to undo X" prompt.
  • ^Y — open the undo history panel for the current (tenant, realm). a while the panel is open toggles to "all scopes" so the user can find an entry that lives elsewhere.
  • u on a focused list row — apply the most recent Pending entry for that specific element. See the per-element panel above. Works on live rows (revert-update / undo-create) and on tombstone rows (restore-deleted).
  • u while a "saved ESV …" success toast is showing — same as ^Z, but the toast acts as the undo affordance for the most recent change.

History panel

A modal sharing ui::modal_chrome::Modal with the rest of the dialogs. Scoped to the current (tenant, realm) by default. One row per entry; columns: time · scope · element · sensitivity-pill · capability-pill · description. Enter attempts undo (which triggers focus routing as above); d deletes the entry (with confirm) for housekeeping; a toggles the all-scopes filter.

Tombstone markers in lists

Per the Per-element panel: the ESV list view joins the live variable list with pending undo entries at render time. Tombstones show the deleted _id with a red ! in the gutter; live rows with pending undos get the same marker but full-colour body text. Cursoring onto either and pressing u applies that entry's inverse.

Toasts vs panels

Toasts go through the existing app.push_toast. Panel rendering stays in src/ui/undo_history.rs; key handlers in src/screens/undo_history.rs. The pattern matches auth_settings exactly.

Build order

  1. Skeleton. Add src/undo/mod.rs with the trait, types (UndoScope, UndoEntry, UndoOp, LocateTarget, ElementRef), and an in-memory VecDequeLog keyed by scope. Hang it off App.undo as a trait object. Unit tests on record / list-by-scope / pending-for-element / load.
  2. ESV variable hooks. Wire execute_save_plan + the delete path to call app.undo.record with a scope of { tenant, realm: None }. Add a ^Z handler that pops the top entry for the current scope, conflict-checks, and runs aic::api::*. Toast-based affordance.
  3. Navigation routing. Implement app::focus_locate_target: tenant switch + realm chip flip + tab switch + element selection. ^Z and the toast affordance route through it before running the inverse.
  4. Per-element tombstones. Merge pending-undo summaries into the ESV list at render time; gutter glyph, tombstone row styling, u key on the focused row.
  5. Disk persistence. DiskLog wrapping the in-memory impl with append-only NDJSON. One file per (tenant, realm) under .aic/undo/. Sealed-payload envelope for SensitiveValue entries. Atomic compaction. Load on App::new (lazy-load per scope on first access).
  6. History panel. ^Y opens screens::undo_history. Defaults to current scope; a toggles all-scopes. Enter routes via focus_locate_target, then applies.
  7. Settings. Optional cap-on-entries + retention-days knobs in Settings. Defaults baked in; only surface if a user asks.
  8. Followups (separate PRs). Secret-create undo; aic undo list in the CLI; scripts & OAuth2 ops when those screens land.

Tests

Unit (in-memory log)

  • record + list newest-first
  • load + mark_applied; idempotency
  • capacity cap drops oldest first
  • SecretValue entries never reach the persistence callback (use a fake disk impl that asserts on any write).

Integration (disk impl)

  • Round-trip plain + sealed entries through a temp file.
  • Corrupted line is skipped, log still loads.
  • Compaction rewrites atomically (kill mid-write doesn't lose entries — the original is intact until rename(2)).
  • Plain-mode project refuses to persist SensitiveValue.

End-to-end (TUI smoke)

  • Create ESV → ^Z → variable gone.
  • Update ESV → ^Z → original restored.
  • Delete ESV → ^Z → variable back.
  • Conflict: edit out-of-band between save & undo → undo refuses, entry stays Pending.

Risks & mitigations

  • Undo log growing unbounded. Default cap at 1000 entries / 30 days, configurable; compaction runs on a Lock-and-quit or on startup, not in a background timer.
  • Sealed payload after a key change. Encrypted entries become undecryptable; flag them Expired. Don't crash the load.
  • Stale undo against a real change someone else made. That's exactly what ConflictCheck is for; if it fails we keep the entry and surface the conflict instead of overwriting.
  • Misleading capability label. Capability is set by the screen at record time, not load time. If we ever upgrade an op from Irreversible to Undoable (because we learn how to read a value back), old entries stay Irreversible — that's accurate to when they were taken.

Open questions

  • Do we want redo? Today: no. A reversal that the user wants to revert is just another forward action, recorded as its own undo entry. Avoids a stack-of-stacks data model.
  • Should the history panel show applied entries too, or only pending? Probably both with a filter, defaulting to Pending.
  • Should we record audit-only entries (capability = Irreversible) when the secret APIs land? Yes — useful for "what did we do to this tenant?" even when we can't reverse it. The log doubles as a change journal.
  • Where does the undo cursor sit when a forward write succeeds while the history panel is open? Probably refresh the list and keep the cursor on the same entry id if it still exists, else on row 0.
  • Multi-entry undo (undo the last 3 writes)? Not in v2. The history panel supports selecting one entry; bulk undo can come later if it's actually wanted.