agentplane 0.45.0

Durable, replayable agent runtime — the journal is the plan of record
Documentation
//! 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.
#[cfg(feature = "keyring")]
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.
#[cfg(feature = "keyring")]
pub(crate) fn unwrap_text(text: &str) -> Option<Vec<u8>> {
    let encoded = text.strip_prefix(SEALED)?.strip_prefix(':')?;
    crate::core::b64::decode(encoded)
}

/// 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: _,
        } => 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: _,
        } => {
            // 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: _,
        } => 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: _,
        } => 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 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: _,
        }
        | K::Swept {
            subject: _,
            action: _,
            detail: _,
        } => Vec::new(),
    }
}