agentplane 0.46.0

Durable, replayable agent runtime — the journal is the plan of record
Documentation
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
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
//! Sealing the payload fields a record carries, without hiding the record.
//!
//! # Why a field and not the whole record
//!
//! Wrapping a whole [`RecordKind`](super::RecordKind) in a sealed variant is the obvious design
//! and it is wrong here, for a reason that compiles and passes tests: both
//! store backends match on the concrete variant. redb keys the **exactly-once**
//! index off `EffectStarted`, and both backends key the outcome index off
//! `RunConcluded`. A record whose variant became a sealed wrapper would still
//! build, still pass every test that writes unsealed records, and silently stop
//! enforcing exactly-once — the guarantee whose failure is a payment taken
//! twice.
//!
//! So the variant stays exactly what it was and only the *payload* is sealed —
//! every field that carries the caller's data, enumerated in `payloads`
//! (crate-private: the list is a rule this crate applies, not a surface a
//! caller selects from).
//! Everything the runtime routes on (`seq`, `run`, `case`, `step`, `phase`,
//! `epoch`, `effect_key`, and the variant itself) stays in the clear, so
//! exactly-once, the case scan, the outcome index and the chain all keep
//! working with no key at all.
//!
//! # The chain commits to ciphertext
//!
//! A sealed payload is an ordinary JSON value, so the record serialises and
//! hashes exactly as it always did — over the sealed bytes. That is the
//! decision worth stating, because the alternative is tempting and worse:
//! hashing the plaintext would tie tamper evidence to the key, and destroying
//! the key would erase both the data *and* the ability to prove nothing had
//! been altered. Committing to ciphertext means an auditor with **no keys**
//! still verifies the chain of a run whose payloads are gone — the same shape
//! blobs already have, where the chain commits to a digest and the bytes stay
//! erasable.

use serde_json::Value;

/// The reserved key marking a sealed payload.
///
/// A payload that legitimately contained this key as its *only* key would be
/// indistinguishable from a sealed one, so the name is deliberately not
/// something a business document would carry, and the shape is checked
/// exactly: one key, whose value is a string.
pub(crate) const SEALED: &str = "$sealed";

/// Whether this value is a sealed payload rather than a readable one.
#[must_use]
pub fn is_sealed(value: &Value) -> bool {
    value
        .as_object()
        .is_some_and(|o| o.len() == 1 && o.get(SEALED).is_some_and(serde_json::Value::is_string))
}

/// Whether this string field is a sealed payload rather than readable text.
///
/// The string counterpart of [`is_sealed`], for the fields whose schema is a
/// string rather than a value — a note's text, a failure's message. The same
/// caveat applies in the same shape: text that legitimately began with the
/// marker and decoded as base64 to its end would be indistinguishable, so the
/// marker is deliberately not something prose would open with.
#[must_use]
pub fn is_sealed_text(text: &str) -> bool {
    text.strip_prefix(SEALED)
        .and_then(|rest| rest.strip_prefix(':'))
        .is_some_and(|encoded| crate::core::b64::decode(encoded).is_some())
}

/// Wrap an envelope as the JSON a record carries in place of its payload.
#[cfg(feature = "keyring")]
pub(crate) fn wrap(envelope: &[u8]) -> Value {
    serde_json::json!({ SEALED: crate::core::b64::encode(envelope) })
}

/// The envelope inside a sealed payload, if this is one.
pub(crate) fn unwrap(value: &Value) -> Option<Vec<u8>> {
    if !is_sealed(value) {
        return None;
    }
    let encoded = value.as_object()?.get(SEALED)?.as_str()?;
    crate::core::b64::decode(encoded)
}

/// Wrap an envelope as the string a record carries in place of a text field.
#[cfg(feature = "keyring")]
pub(crate) fn wrap_text(envelope: &[u8]) -> String {
    format!("{SEALED}:{}", crate::core::b64::encode(envelope))
}

/// The envelope inside a sealed text field, if this is one.
pub(crate) fn unwrap_text(text: &str) -> Option<Vec<u8>> {
    let encoded = text.strip_prefix(SEALED)?.strip_prefix(':')?;
    crate::core::b64::decode(encoded)
}

/// The tenant an envelope's wrapped key names, read from its header without
/// opening anything.
///
/// Builds without the `keyring` feature need it too: a restore must refuse a
/// sealed history put back under a tenant its ciphertext does not name, and
/// the binary that restores carries no key ring. The layout is the one
/// `keyring::envelope` writes: a version byte, a big-endian `u32` length, then
/// the wrapped key as JSON whose `scope` is `{tenant}/{unit}`.
pub(crate) fn sealed_tenant(envelope: &[u8]) -> Option<String> {
    let (_, rest) = envelope.split_first()?;
    let (len, rest) = rest.split_first_chunk::<4>()?;
    let wrapped = rest.get(..usize::try_from(u32::from_be_bytes(*len)).ok()?)?;
    let key: Value = serde_json::from_slice(wrapped).ok()?;
    let (tenant, _) = key.get("scope")?.as_str()?.split_once('/')?;
    Some(tenant.to_owned())
}

/// One sealable field of a record, by the shape its schema gives it.
///
/// Two arms rather than coercing text into a JSON value, because the record's
/// wire format is the field's declared type: a `Note`'s `text` is a string on
/// the wire, and sealing must replace it with a string or every reader of the
/// serialized record changes shape with the key configuration.
pub(crate) enum SealedField<'a> {
    /// A JSON payload — input, output, arguments, a frozen plan.
    Value(&'a mut Value),
    /// A free-text payload — a note, a failure message.
    Text(&'a mut String),
}

/// The payload fields of a record kind, for sealing and opening in place.
///
/// One list, consulted by both directions, because a field sealed on the way
/// in and forgotten on the way out is a record nobody can read — and the
/// reverse is a payload that was never sealed at all.
///
/// The dividing rule: *what a store is asked questions about stays readable;
/// what it merely holds is sealed.* Neither backend matches or
/// indexes on any field below — routing lives in `seq`, `run`, `case`,
/// `effect_key`, the variant, and `RunConcluded.outcome`, all of which stay
/// clear.
///
/// **No arm uses `..`, including the ones that do seal something.** An
/// exhaustive match over *variants* asks the question when a record kind is
/// added and stays silent when a **field** is added to a kind that already
/// exists — which compiles, passes every test, and seals nothing. The sealing
/// arms are the ones where that silence costs the most, because a new payload
/// field on `EffectDone` or `EffectReconciled` is by construction the caller's
/// data. Naming every field is what makes the compiler ask the second
/// question.
// One match, and it may not be split. `too_many_lines` asks for the arms to be
// moved into helpers, and every way of doing that puts a `_ =>` or an `other =>`
// in front of them — after which a record kind added later takes the wildcard
// and is written in the clear. The length is the enumeration; the enumeration is
// the control.
#[allow(clippy::too_many_lines)]
pub(crate) fn payloads(kind: &mut super::RecordKind) -> Vec<SealedField<'_>> {
    use super::RecordKind as K;
    match kind {
        K::RunAdmitted {
            input,
            capability: _,
            governed_by: _,
            input_label: _,
            policy_bundle: _,
            canon: _,
            idempotency_key: _,
            // Clear, like `EffectDone.by`: who asked is control-plane, and the
            // four-eyes exclusion reads it on every task the run opens.
            admitted_by: _,
            // Clear: which holder a run draws as is control-plane.
            served_unchained: _,
            plane_chain: _,
        } => vec![SealedField::Value(input)],
        // The frozen plan is sealed because it can *embed* the caller's data,
        // not merely reference it: a `planned` agent's planner reads the
        // (trusted) input to write the plan, and any constant it binds —
        // `ArgSource::Const` — is a value derived from that input, frozen into
        // the graph. Trusted is not non-sensitive. Everything that routes on
        // the plan (the step list, the digest) is recomputed from the opened
        // value by a runtime that holds the key; with the key erased the run's
        // data is gone and its replay legitimately goes with it, exactly as it
        // does for `RunAdmitted.input`.
        K::PlanFrozen { plan, steps: _ } => vec![SealedField::Value(plan)],
        K::EffectStarted {
            descriptor,
            recovery: _,
            mutates: _,
            attempt: _,
            backoff_ms: _,
            outbound_label: _,
            // **Clear on purpose, and it is the one field here whose whole
            // value is surviving erasure.** A byte count is not caller data —
            // nothing about it identifies a person — and *how much left* has to
            // stay answerable after *what left* is destroyed. Sealing it would
            // make the volume signal vanish with the payload it measures.
            outbound_bytes: _,
            // Clear: rule ids are the deployment's declaration, not caller
            // data.
            content_rules: _,
            // Clear: an audience and a principal id, both already clear on
            // the run's chain — never the credential.
            credential: _,
        } => {
            // Named field by field, like the record around it: a field added
            // to the descriptor must be decided here, not pass as clear.
            let crate::core::EffectDescriptor { kind: _, args } = descriptor;
            vec![SealedField::Value(args)]
        }
        K::EffectDone {
            output,
            source: _,
            // Clear, and it is the one field on this record whose whole value
            // is surviving the erasure of the payload beside it: an approval's
            // decider rides in `output`, so a sealed-only record answers *who
            // let this run carry on* with nothing. An operator's name is
            // control-plane, exactly as `EffectReconciled.asserted_by` is.
            by: _,
            spend: _,
            declared: _,
            // Clear: rule ids, a level and a pointer whose matched keys are
            // masked — the deployment's declaration, not caller data.
            content: _,
            // Clear: a duration, which identifies nobody.
            elapsed_ms: _,
        } => vec![SealedField::Value(output)],
        // A reconciled effect's recovered result is the same object an
        // `EffectDone.output` is — caller data a probe happened to fetch —
        // and its `detail` is the same free text an `EffectFailed.error` is:
        // a probe's failure message from a provider, which echoes the request
        // it was asked about. `disposition` stays clear, and so does the
        // detail's *presence* — recovery routes on whether the probe spoke,
        // never on what it said, so the Option survives while the words seal.
        K::EffectReconciled {
            output,
            detail,
            disposition: _,
            spend: _,
            declared: _,
            // An operator's name is control-plane, exactly as a canceller's is:
            // recovery routes on *whether* a person answered, and a name that
            // needed a key to read would make an unopenable journal unable to
            // say who decided a run could carry on.
            asserted_by: _,
            // And their account with it. `detail` above is a provider's words
            // over the caller's request; this is a person's words about what
            // they checked, which is the same class as a cancellation's
            // reason and stays readable for the same reason.
            note: _,
        } => output
            .as_mut()
            .map(SealedField::Value)
            .into_iter()
            .chain(detail.as_mut().map(SealedField::Text))
            .collect(),
        // A settlement's `detail` names the failing invariant or the abort
        // reason in the skill author's words over the caller's values — "hold
        // h-73 does not cover order for alice@…" — while `outcome` is the
        // routing fact and stays clear.
        K::GroupSettled {
            detail,
            group: _,
            outcome: _,
        } => {
            detail.as_mut().map(SealedField::Text).into_iter().collect()
        }
        // A compensation's outcome is its error text when it failed — a refund
        // provider's refusal quoting the charge it was asked to reverse — and
        // the fixed word "compensated" otherwise, sealed alike so the ciphertext
        // does not say which. `compensation` is the declared class and stays
        // clear: nothing about the caller is in it.
        K::StepCompensated {
            outcome,
            compensation: _,
        } => vec![SealedField::Text(outcome)],
        // The message is free text a provider or tool wrote — it quotes the
        // request it refused, which is the caller's data. `disposition` and
        // `permanent` MUST stay clear: retry and reconciliation route on them,
        // and a recovery that needed a key to decide whether a call reached
        // the world would fail closed into an outage.
        K::EffectFailed {
            error,
            spend: _,
            disposition: _,
            permanent: _,
            elapsed_ms: _,
        } => vec![SealedField::Text(error)],
        // Reasoning recorded beside the effects it explains — model output
        // over the caller's data, and nothing routes on it.
        K::Note { text } => vec![SealedField::Text(text)],
        // An observed step's own words: the instruction a user typed, a tool's
        // title, the sentence a person was shown before they allowed a call.
        // It is the *observed party's* data — this plane neither produced it
        // nor governs the agent that did — so it is sealed like any caller's.
        // `session` and `step` stay clear: they are what an operator searches
        // by, and a session nobody can find is evidence nobody has.
        K::Observed {
            detail,
            session: _,
            reported: _,
        } => detail.as_mut().map(SealedField::Text).into_iter().collect(),

        // A data subject identifies a person, and erasing the run's data must
        // take it with it. Where it was read from and whether that was trusted
        // name a binding, not a person, and stay clear.
        K::DataSubjectBound { bindings } => bindings
            .iter_mut()
            .map(|bound| {
                let super::BoundSubject {
                    index: _,
                    binding: _,
                    trusted: _,
                    subject,
                } = bound;
                SealedField::Text(subject)
            })
            .collect(),

        // A conclusion's reason is the same free text `EffectFailed.error` is —
        // a provider or tool's refusal, quoting the request it refused — lifted
        // to the run. `outcome` and `chain_head` route and stay clear.
        K::RunConcluded {
            reason,
            outcome: _,
            exhaustion: _,
            live_spend: _,
            chain_head: _,
        } => reason.as_mut().map(SealedField::Text).into_iter().collect(),

        // Control-plane: names, states, digests, counts. Sealing them would
        // cost the readability that makes an unopenable journal still useful,
        // and buy nothing — none of them carries the caller's data. One arm,
        // because the answer is one answer.
        K::QuotaPassStarted {
            period: _,
            release_slot: _,
        }
        | K::StepStarted { skill: _ }
        | K::StepFinished { outcome: _ }
        | K::CaseBound {
            case_kind: _,
            opened: _,
            correlation: _,
        }
        | K::DeadlineRegistered {
            name: _,
            resolved_at: _,
            calendar_digest: _,
        }
        | K::DeadlineTransition {
            name: _,
            from: _,
            to: _,
        }
        // What a run waits for: a kind and a correlation key, both of which the
        // buffer is asked questions about.
        | K::RunSuspended { reason: _ }
        | K::BudgetRefused { limit: _, used: _ }
        | K::BudgetReadmitted { limit: _ }
        // A subject is an identifier an operator typed into a halt and a reason
        // they wrote for the next person; neither is the run's data. Sealing
        // them would put the *reason a run stopped* behind a key an erasure
        // destroys, which is the one sentence somebody reading a withheld run
        // two years later has to be able to read.
        | K::AuthorityWithheld {
            subject: _,
            reason: _,
            // The operator who threw the halt, on the same footing as the
            // reason: a withheld run two years on has to say who withdrew the
            // authority, and a name behind a destroyed key says nobody did.
            by: _,
        }
        | K::AuthorityRestored { subject: _ }
        | K::IdentityBound { chain: _ }
        // The rule's own words, written by the operator who wrote the rule —
        // never the request. Naming a reason to a caller is what this crate
        // refuses; recording it for the operator is why the record exists.
        | K::PolicyDenied {
            reason: _,
            action: _,
            resource: _,
        }
        | K::GroupOpened {
            group: _,
            resources: _,
        }
        // `value` is a **digest**, not the value: the record binds a release
        // decision to bytes it does not hold, and a digest is not the bytes.
        | K::Released {
            releaser: _,
            release: _,
            label: _,
            field_labels: _,
            value: _,
        }
        | K::RunCancelled {
            actor: _,
            reason: _,
        }
        // An operator's own words about a run, like a cancellation's — the
        // record of a judgement, not the caller's data it was made about.
        | K::QuarantineDecided {
            decider: _,
            reason: _,
            decision: _,
        }
        | K::BreakGlass {
            actor: _,
            roles: _,
            reason: _,
        }
        // An operator's own instruction about a control: the scope and the
        // ended stop's reason are the plane's, not a caller's data.
        | K::HaltLifted {
            scope: _,
            by: _,
            at: _,
            reason: _,
            thrown_by: _,
            thrown_at: _,
        }
        // Names and instants only, so the erasure a hold's release permits leaves
        // its authorization readable.
        | K::HoldReleased {
            by: _,
            at: _,
            placed_by: _,
            placed_at: _,
        }
        | K::Swept {
            subject: _,
            action: _,
            detail: _,
        } => Vec::new(),
    }
}