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.
| Sensitivity | Persistence | On-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 family | Realm scope | Log 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
-
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.
-
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.
-
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.
-
Execute the forward write. Unchanged — the background
save_variable task in screens::esv, etc.
-
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.
-
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.
-
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
- 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.)
- Read
entry.op.locate(&entry.scope) → switch
current_tab to the target (today always
Tab::Esvs).
- 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.
- Render once. The user sees the about-to-change row (or its
tombstone, see below) under the cursor.
- 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.
| Check | Pre-condition | On 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
-
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.
-
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.
-
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.
-
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.
-
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).
-
History panel.
^Y opens
screens::undo_history. Defaults to current scope;
a toggles all-scopes. Enter routes via
focus_locate_target, then applies.
-
Settings. Optional cap-on-entries +
retention-days knobs in
Settings. Defaults baked in;
only surface if a user asks.
-
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.