1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
//! Reversibility — a typed answer to *can this be undone?*
//!
//! CAR already types **who may authorize an action**:
//! `car_policy::PermissionTier`, ordered `ReadOnly < SandboxEdit <
//! FullAccess`. It has never typed the orthogonal question of what happens
//! *after* the action runs, and the two got conflated. `PermissionTier`'s own
//! doc comments give it away — `SandboxEdit` is described as "reversible local
//! mutation" and `FullAccess` as "externally-consequential **or**
//! irreversible". That `or` is the problem: it collapses
//!
//! | Action | Authority required | Rollback contract |
//! |---|---|---|
//! | write a scratch file in the sandbox | `SandboxEdit` | reversible |
//! | `INSERT` into a production table | `FullAccess` | compensable (delete the row) |
//! | `git push` | `FullAccess` | compensable (force-push the prior ref) |
//! | send an email / charge a card | `FullAccess` | **irreversible** |
//! | `rm -rf` outside a snapshotted tree | `FullAccess` | **irreversible** |
//!
//! into a single ladder rung. With one lever for both questions the runtime
//! has two available failure modes and no third option: gate every
//! `FullAccess` action identically (approval fatigue, and the predictable
//! response is that someone turns the gate off), or relax the tier and lose
//! the genuinely permanent cases along with the recoverable ones. Splitting
//! the axis is what lets a later gate treat "needs approval and can be undone"
//! differently from "needs approval and is permanent".
//!
//! Background: `docs/proposals/shepherd-substrate-adoption.md`, section "The
//! finding worth acting on first: two axes, one enum", derived from *Shepherd:
//! A Runtime Substrate Empowering Meta-Agents with a Formalized Execution
//! Trace* (arXiv 2605.10913) Appendix A.2, which argues the deployment case for
//! a reversibility tier on every effect.
//!
//! # What this module deliberately does not do
//!
//! It does not enforce anything. Deferring the materialization of an
//! irreversible effect until a gate releases it needs scope machinery CAR does
//! not have yet (the proposal's item 3/4: a branchable oplog and a checkpoint
//! coupled to the filesystem — today `car_engine::Checkpoint` restores the KV
//! store and leaves whatever a tool wrote to disk exactly where it is). This
//! slice makes the property *typed, classified, and audited*, which is the
//! prerequisite for the rest. Nothing in the runtime reads
//! [`Action::reversibility`](crate::Action::reversibility) to decide whether to
//! run an action, and no claim here should be read as saying otherwise.
use ;
use Value;
use HashMap;
/// The rollback contract for an action's effects — orthogonal to
/// `car_policy::PermissionTier`, which answers *who may authorize this*.
///
/// # Ordering
///
/// The `Ord` derive follows declaration order, so `Reversible < Compensable <
/// Irreversible` — ascending in **severity**, the same convention
/// `PermissionTier` uses. That makes "what is the rollback contract of this
/// whole batch?" a `max()` over its actions (see
/// [`ActionProposal::rollback_contract`](crate::ActionProposal::rollback_contract)):
/// a plan is only as recoverable as its least recoverable step.
///
/// # Why the default is `Irreversible`
///
/// `#[serde(default)]` on [`Action::reversibility`](crate::Action::reversibility)
/// fires whenever an author omitted the field — that is, for every proposal
/// written before this axis existed and every proposal from a model that has
/// not been taught about it. The default is therefore not a neutral technical
/// choice; it decides what the runtime believes about an *unclassified* action,
/// and the two directions fail very differently:
///
/// - Default `Reversible`, get it wrong: the runtime silently believes an
/// email that has already been sent can be unsent. A future gate waves it
/// through, an audit record says it is recoverable, and nothing anywhere
/// surfaces the mistake. This is a safety property, and it fails quietly.
/// - Default `Irreversible`, get it wrong: an action that was in fact
/// perfectly recoverable is treated as permanent. A future gate over-asks.
/// That is annoying, it is visible, and the fix is local — annotate the
/// action, or teach the classifier about the tool.
///
/// The proposal notes the honest cost of the conservative choice: against the
/// existing corpus, *everything* comes back `Irreversible`, which is noisy. Two
/// things make that acceptable rather than merely tolerable. First, the noise
/// is only paid once something enforces on the field, and this slice enforces
/// nothing — so the cost arrives on the same change that adds a gate, when
/// someone is looking at it, rather than now. Second, by that point
/// `car-policy`'s classifier is expected to supply a *classified* value for
/// every action whose tool it recognizes, so the default is what an
/// unrecognized tool falls back to — precisely the case where assuming the
/// worst is right.
///
/// The rule of thumb this encodes: when a default is a safety property, pick
/// the direction whose failure mode is loud.
/// How a [`Reversibility::Compensable`] action is undone — the action-level
/// analogue of the saga-pattern handler CAR already has one layer up.
///
/// `car_workflow::CompensationHandler` is `Proposal(ProposalStep) | StageRef {
/// stage_id }`: an inline handler, or a reference to something named elsewhere
/// in the same document. This enum keeps that split and restates it at action
/// granularity. It does **not** reuse the workflow type: `car-ir` sits at the
/// bottom of the stack (serde, serde_json, uuid, chrono, thiserror — nothing
/// else), and `car-workflow` already depends on `car-ir`, so importing it here
/// would invert the layering and cycle.
///
/// # Why the inline arm is a tool call and not a nested `Action`
///
/// A `Compensation::Action(Box<Action>)` arm is the obvious mirror of
/// `CompensationHandler::Proposal`, and it was rejected for two reasons.
///
/// 1. It makes `Action` recursive. That round-trips fine through serde, but
/// every FFI surface has to mirror the IR (project convention #2:
/// `index.d.ts`, `car_runtime.pyi`, both binding crates, the JSON-RPC
/// dispatcher), and a self-referential node is materially harder to state
/// honestly in a hand-maintained type stub than a flat tagged union.
/// 2. It is not needed. Undoing a *state* write is a scope restore, which is
/// [`Reversibility::Reversible`] by definition — so the inline arm only ever
/// has to express an external undo, and every external undo CAR can perform
/// is a tool call. Anything that genuinely needs several steps declares them
/// as actions and points at the entry point with
/// [`Compensation::ActionRef`].