Skip to main content

perspt_sdk/
ledger.rs

1//! Event-sourced replay ledger and durable-execution obligations (PSP-8
2//! System 11).
3//!
4//! The ledger is an append-only, Merkle-chained event stream. Replay is
5//! deterministic over recorded observations: any nondeterministic observation a
6//! transition depends on is recorded *before* it is used, and the kernel refuses
7//! to commit a transition that references an observation lacking a ledger
8//! record. This structurally discharges the recording obligation at the SDK
9//! effect boundary rather than relying on convention.
10//!
11//! The six durable-execution obligations (PSP-8 Def 9.1):
12//!
13//! * R1 durable single-assignment outcomes ([`IdempotencyLog`]);
14//! * R2 recorded nondeterminism ([`Ledger::record_observation`]);
15//! * R3 deterministic transition ([`replay_accepted_trajectory`]);
16//! * R4 unforgeable capability transport ([`crate::capability`]);
17//! * R5 write-ahead external effects ([`ExternalEffectLog`]);
18//! * R6 ordered non-commuting durable turns ([`crate::scheduler`]).
19
20use std::collections::HashMap;
21
22use serde::{Deserialize, Serialize};
23use sha2::{Digest, Sha256};
24
25use crate::error::{Result, SdkError};
26
27/// A ledgered event (PSP-8 System 11). Representative of the full event family;
28/// `Custom` carries any additional structured event.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(tag = "event", rename_all = "snake_case")]
31pub enum LedgerEvent {
32    ProposalObserved {
33        proposal_id: String,
34        actor: String,
35    },
36    AdmissibilityChecked {
37        proposal_id: String,
38        allowed: bool,
39    },
40    EffectApplied {
41        proposal_id: String,
42        idempotency_key: String,
43    },
44    EffectDenied {
45        proposal_id: String,
46        reason: String,
47    },
48    VerifierCompleted {
49        node_id: String,
50        generation: u32,
51    },
52    ResidualEmitted {
53        residual_id: String,
54        node_id: String,
55    },
56    EnergyScored {
57        node_id: String,
58        generation: u32,
59        energy: f64,
60    },
61    GateDecisionRecorded {
62        node_id: String,
63        accepted: bool,
64    },
65    CandidateAccepted {
66        node_id: String,
67        generation: u32,
68        energy: f64,
69    },
70    CandidateRejected {
71        node_id: String,
72        generation: u32,
73    },
74    GraphRevisionAccepted {
75        revision_id: String,
76        sequence: u32,
77    },
78    NodeGenerationRetired {
79        node_id: String,
80        generation: u32,
81    },
82    ResidualCertificateIssued {
83        certificate_id: String,
84        node_id: String,
85    },
86    RollbackApplied {
87        target_event: String,
88    },
89    CapabilityGranted {
90        capability_id: String,
91        holder: String,
92    },
93    CapabilityRevoked {
94        capability_id: String,
95    },
96    /// An observation of nondeterministic data, recorded before use (R2).
97    ObservationRecorded {
98        handle: String,
99        content_hash: String,
100    },
101    // --- PSP-9 model-plane events (system 14) ---
102    RouteResolved {
103        phase: String,
104        model: String,
105        reason: String,
106    },
107    ProviderDegraded {
108        provider: String,
109        missing: String,
110        fallback: String,
111    },
112    RouteFailover {
113        from_model: String,
114        to_model: String,
115        cause: String,
116    },
117    VerdictRecorded {
118        validator: String,
119        candidate_id: String,
120        passed: bool,
121    },
122    Custom {
123        kind: String,
124        payload: serde_json::Value,
125    },
126}
127
128/// One record in the Merkle-chained ledger.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct LedgerRecord {
131    pub sequence: u64,
132    pub event: LedgerEvent,
133    /// Hash of the previous record (`"GENESIS"` for the first).
134    pub prev_hash: String,
135    /// `sha256(prev_hash || sequence || canonical(event))`.
136    pub hash: String,
137}
138
139/// Compute the chained hash for a record.
140/// The chain hash of one ledger entry — `sha256(prev_hash || sequence ||
141/// canonical(event))`. Public so external tooling and tests appending rows
142/// produce chain-valid entries; the ledger's integrity comes from
143/// verification on read, never from this function being private.
144pub fn entry_chain_hash(prev_hash: &str, sequence: u64, event: &LedgerEvent) -> Result<String> {
145    chain_hash(prev_hash, sequence, event)
146}
147
148fn chain_hash(prev_hash: &str, sequence: u64, event: &LedgerEvent) -> Result<String> {
149    let canonical = serde_json::to_vec(event)
150        .map_err(|e| SdkError::Domain(format!("event serialization failed: {e}")))?;
151    let mut hasher = Sha256::new();
152    hasher.update(prev_hash.as_bytes());
153    hasher.update(sequence.to_le_bytes());
154    hasher.update(&canonical);
155    Ok(hex(&hasher.finalize()))
156}
157
158/// Hash arbitrary content (for observations and state witnesses).
159pub fn content_hash(bytes: &[u8]) -> String {
160    let mut hasher = Sha256::new();
161    hasher.update(bytes);
162    hex(&hasher.finalize())
163}
164
165fn hex(bytes: &[u8]) -> String {
166    let mut s = String::with_capacity(bytes.len() * 2);
167    for b in bytes {
168        s.push_str(&format!("{b:02x}"));
169    }
170    s
171}
172
173/// The append-only, Merkle-chained event ledger.
174#[derive(Debug, Clone, Default)]
175pub struct Ledger {
176    records: Vec<LedgerRecord>,
177    /// Recorded observation handles -> content hash (R2).
178    observations: HashMap<String, String>,
179}
180
181impl Ledger {
182    pub fn new() -> Self {
183        Self::default()
184    }
185
186    /// The current ledger head (Merkle root), or `"GENESIS"` when empty.
187    pub fn head(&self) -> String {
188        self.records
189            .last()
190            .map(|r| r.hash.clone())
191            .unwrap_or_else(|| "GENESIS".to_string())
192    }
193
194    pub fn len(&self) -> usize {
195        self.records.len()
196    }
197
198    pub fn is_empty(&self) -> bool {
199        self.records.is_empty()
200    }
201
202    pub fn records(&self) -> &[LedgerRecord] {
203        &self.records
204    }
205
206    /// Append an event, extending the Merkle chain. Returns the new head.
207    pub fn append(&mut self, event: LedgerEvent) -> Result<String> {
208        let sequence = self.records.len() as u64;
209        let prev_hash = self.head();
210        let hash = chain_hash(&prev_hash, sequence, &event)?;
211        // Track observation records as they are appended (R2).
212        if let LedgerEvent::ObservationRecorded {
213            handle,
214            content_hash,
215        } = &event
216        {
217            self.observations
218                .insert(handle.clone(), content_hash.clone());
219        }
220        self.records.push(LedgerRecord {
221            sequence,
222            event,
223            prev_hash,
224            hash: hash.clone(),
225        });
226        Ok(hash)
227    }
228
229    /// Compute the record `append` would produce, without mutating the chain.
230    ///
231    /// A write-ahead sink persists the staged record first and then commits
232    /// it with [`commit_staged`](Self::commit_staged); a durable-write failure
233    /// leaves the in-memory chain untouched, with no chain clone required.
234    pub fn stage(&self, event: LedgerEvent) -> Result<LedgerRecord> {
235        let sequence = self.records.len() as u64;
236        let prev_hash = self.head();
237        let hash = chain_hash(&prev_hash, sequence, &event)?;
238        Ok(LedgerRecord {
239            sequence,
240            event,
241            prev_hash,
242            hash,
243        })
244    }
245
246    /// Commit a record produced by [`stage`](Self::stage). Rejects a record
247    /// that does not extend the current head exactly.
248    pub fn commit_staged(&mut self, record: LedgerRecord) -> Result<()> {
249        if record.sequence != self.records.len() as u64 || record.prev_hash != self.head() {
250            return Err(SdkError::Domain(
251                "staged record does not extend the current ledger head".into(),
252            ));
253        }
254        if let LedgerEvent::ObservationRecorded {
255            handle,
256            content_hash,
257        } = &record.event
258        {
259            self.observations
260                .insert(handle.clone(), content_hash.clone());
261        }
262        self.records.push(record);
263        Ok(())
264    }
265
266    /// Record a nondeterministic observation before it is used (R2). Returns the
267    /// observation handle (content address).
268    pub fn record_observation(&mut self, content: &[u8]) -> Result<String> {
269        let content_hash = content_hash(content);
270        let handle = content_hash.clone();
271        self.append(LedgerEvent::ObservationRecorded {
272            handle: handle.clone(),
273            content_hash,
274        })?;
275        Ok(handle)
276    }
277
278    /// Whether an observation handle has a ledger record.
279    pub fn has_observation(&self, handle: &str) -> bool {
280        self.observations.contains_key(handle)
281    }
282
283    /// The recorded content hash for an observation handle, if any.
284    pub fn observation_hash(&self, handle: &str) -> Option<&str> {
285        self.observations.get(handle).map(String::as_str)
286    }
287
288    /// The kernel-refusal rule: refuse to commit a transition that references an
289    /// observation lacking a ledger record (PSP-8 System 11). Returns an error
290    /// naming the first unrecorded handle.
291    pub fn commit_transition(
292        &mut self,
293        event: LedgerEvent,
294        referenced_observations: &[String],
295    ) -> Result<String> {
296        for handle in referenced_observations {
297            if !self.has_observation(handle) {
298                return Err(SdkError::Domain(format!(
299                    "kernel-refusal: transition references unrecorded observation `{handle}`"
300                )));
301            }
302        }
303        self.append(event)
304    }
305
306    /// Verify the Merkle chain end to end (tamper detection).
307    pub fn verify_chain(&self) -> Result<()> {
308        let mut prev = "GENESIS".to_string();
309        for (i, rec) in self.records.iter().enumerate() {
310            if rec.sequence != i as u64 {
311                return Err(SdkError::Domain(format!("sequence gap at index {i}")));
312            }
313            if rec.prev_hash != prev {
314                return Err(SdkError::Domain(format!(
315                    "broken chain at sequence {}",
316                    rec.sequence
317                )));
318            }
319            let expected = chain_hash(&rec.prev_hash, rec.sequence, &rec.event)?;
320            if expected != rec.hash {
321                return Err(SdkError::Domain(format!(
322                    "hash mismatch at sequence {}",
323                    rec.sequence
324                )));
325            }
326            prev = rec.hash.clone();
327        }
328        Ok(())
329    }
330}
331
332/// The shape of one `tool_loop` payload (PSP-10 Gate AD).
333///
334/// Version 1 rows wrap the runtime event in
335/// `{"schema_version": 1, "body": {...}}`; rows without `schema_version`
336/// are pre-PSP-10 legacy payloads. Any other version fails authoritative
337/// folds closed — a forensic display may still show the raw payload, but
338/// replay and resume never guess.
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum ToolLoopBody<'a> {
341    Legacy(&'a serde_json::Value),
342    V1(&'a serde_json::Value),
343}
344
345/// Classify one `tool_loop` payload by its envelope version.
346pub fn tool_loop_body(payload: &serde_json::Value) -> Result<ToolLoopBody<'_>> {
347    match payload.get("schema_version") {
348        None => Ok(ToolLoopBody::Legacy(payload)),
349        Some(version) if version.as_u64() == Some(1) => payload
350            .get("body")
351            .map(ToolLoopBody::V1)
352            .ok_or_else(|| SdkError::Domain("versioned tool_loop row has no body".into())),
353        Some(version) => Err(SdkError::Domain(format!(
354            "unknown tool_loop schema_version {version}; authoritative replay fails closed"
355        ))),
356    }
357}
358
359/// Reconstruct the accepted trajectory deterministically from the recorded
360/// events (R3). Replay reads recorded observations rather than re-running
361/// nondeterministic sources.
362pub fn replay_accepted_trajectory(ledger: &Ledger) -> Result<Vec<(String, u32, f64)>> {
363    // A ledger carries acceptances either as typed `CandidateAccepted` events
364    // or as the tool loop's custom stream. Folding both would double-count a
365    // ledger that records both forms, so typed events win and the custom
366    // stream is consulted only when no typed acceptance exists.
367    let typed: Vec<(String, u32, f64)> = ledger
368        .records()
369        .iter()
370        .filter_map(|record| match &record.event {
371            LedgerEvent::CandidateAccepted {
372                node_id,
373                generation,
374                energy,
375            } => Some((node_id.clone(), *generation, *energy)),
376            _ => None,
377        })
378        .collect();
379    if !typed.is_empty() {
380        return Ok(typed);
381    }
382
383    let mut accepted = Vec::new();
384    // Keyed by (node, generation, candidate_id) — PSP-10 Phase 2. A
385    // pre-PSP-10 row has no candidate id; its empty-string key reproduces
386    // the legacy latest-measurement correlation exactly, so old ledgers
387    // fold byte-identically. Concurrent candidates at one (node, gen) no
388    // longer overwrite one another.
389    let mut latest = HashMap::<(String, u32, String), f64>::new();
390    for record in ledger.records() {
391        if let LedgerEvent::Custom { kind, payload } = &record.event {
392            if kind == "tool_loop" {
393                // Unknown envelope versions fail the fold closed (Gate AD).
394                let (ToolLoopBody::Legacy(body) | ToolLoopBody::V1(body)) =
395                    tool_loop_body(payload)?;
396                fold_tool_loop_row(body, &mut latest, &mut accepted);
397            }
398        }
399    }
400    Ok(accepted)
401}
402
403/// Fold one `tool_loop` payload into the accepted projection.
404fn fold_tool_loop_row(
405    payload: &serde_json::Value,
406    latest: &mut HashMap<(String, u32, String), f64>,
407    accepted: &mut Vec<(String, u32, f64)>,
408) {
409    let event = payload.get("event").and_then(|value| value.as_str());
410    let node = payload.get("node_id").and_then(|value| value.as_str());
411    let generation = payload
412        .get("generation")
413        .and_then(|value| value.as_u64())
414        .and_then(|value| u32::try_from(value).ok());
415    let candidate = payload
416        .get("candidate_id")
417        .and_then(|value| value.as_str())
418        .unwrap_or("");
419    if let (Some("candidate_measured"), Some(node), Some(generation), Some(energy)) = (
420        event,
421        node,
422        generation,
423        payload.get("energy").and_then(|value| value.as_f64()),
424    ) {
425        latest.insert(
426            (node.to_string(), generation, candidate.to_string()),
427            energy,
428        );
429    }
430    if let (Some("gate_decision_recorded"), Some(node), Some(generation)) =
431        (event, node, generation)
432    {
433        // Deserialize into the typed decision so acceptance has one
434        // definition (`GateDecision::is_accepted`) shared with the
435        // producer, instead of a string list that drifts on rename.
436        let accepted_decision = payload
437            .get("decision")
438            .cloned()
439            .and_then(|value| serde_json::from_value::<crate::gate::GateDecision>(value).ok())
440            .is_some_and(|decision| decision.is_accepted());
441        if accepted_decision {
442            // The event's own recorded energy is authoritative; the
443            // measurement correlation is the legacy fallback.
444            let energy = payload
445                .get("observed_energy")
446                .and_then(|value| value.as_f64())
447                .or_else(|| {
448                    latest
449                        .get(&(node.to_string(), generation, candidate.to_string()))
450                        .copied()
451                });
452            if let Some(energy) = energy {
453                accepted.push((node.to_string(), generation, energy));
454            }
455        }
456    }
457}
458
459/// R1 durable single-assignment outcomes. Every proposal/commit outcome is
460/// written once and never reassigned; redelivery of the same idempotency key
461/// with equivalent content returns the prior result, and key reuse for
462/// different content is invalid.
463#[derive(Debug, Clone, Default)]
464pub struct IdempotencyLog {
465    entries: HashMap<String, (String, String)>, // key -> (content_hash, outcome)
466}
467
468impl IdempotencyLog {
469    pub fn new() -> Self {
470        Self::default()
471    }
472
473    /// Record an outcome under an idempotency key. On first write, stores and
474    /// returns the outcome. On redelivery with equivalent content, returns the
475    /// prior outcome. On key reuse with different content, returns an error.
476    pub fn record(&mut self, key: &str, content: &[u8], outcome: &str) -> Result<String> {
477        let ch = content_hash(content);
478        match self.entries.get(key) {
479            Some((existing_hash, existing_outcome)) => {
480                if existing_hash == &ch {
481                    Ok(existing_outcome.clone())
482                } else {
483                    Err(SdkError::Domain(format!(
484                        "idempotency key `{key}` reused for different content"
485                    )))
486                }
487            }
488            None => {
489                self.entries
490                    .insert(key.to_string(), (ch, outcome.to_string()));
491                Ok(outcome.to_string())
492            }
493        }
494    }
495}
496
497/// Theorem 7's recording obligation, adopted literally (PSP-9 system 14):
498/// the kernel refuses to commit any transition that depends on an unrecorded
499/// observation. Returns the observation hashes on success so the transition
500/// record can content-address them.
501pub fn require_recorded(ledger: &Ledger, handles: &[String]) -> Result<Vec<String>> {
502    let mut hashes = Vec::with_capacity(handles.len());
503    for handle in handles {
504        match ledger.observation_hash(handle) {
505            Some(hash) => hashes.push(hash.to_string()),
506            None => {
507                return Err(SdkError::Domain(format!(
508                    "transition depends on unrecorded observation {handle:?}; \
509                     record it before use (R2)"
510                )))
511            }
512        }
513    }
514    Ok(hashes)
515}
516
517/// The result of a provider-free audit replay (PSP-9 system 14).
518///
519/// Audit replay does not re-run tools: it folds recorded records and
520/// verifies the event chain, head, and accepted trajectory. Verification
521/// replay (re-running deterministic verifiers) is a separate mode whose
522/// result is a *new* observation, never a substitute for the original.
523#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
524pub struct AuditReport {
525    pub chain_ok: bool,
526    pub head: String,
527    pub records: u64,
528    /// The accepted trajectory folded from the chain.
529    pub accepted: Vec<(String, u32, f64)>,
530}
531
532/// Fold the ledger deterministically with credentials absent by construction
533/// (nothing in the chain stores one), verifying every link.
534pub fn audit_replay(ledger: &Ledger) -> AuditReport {
535    let chain_ok = ledger.verify_chain().is_ok();
536    // An unfoldable stream (unknown envelope version) is a failed audit,
537    // not a silently truncated projection (Gate AD).
538    let accepted = replay_accepted_trajectory(ledger);
539    AuditReport {
540        chain_ok: chain_ok && accepted.is_ok(),
541        head: ledger.head(),
542        records: ledger.records().len() as u64,
543        accepted: accepted.unwrap_or_default(),
544    }
545}
546
547/// R5 write-ahead external effects: each irreversible external effect is
548/// bracketed by intent, result, and (where defined) compensation records under
549/// a stable idempotency key.
550#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
551pub enum ExternalEffectPhase {
552    Intent,
553    Result,
554    Compensation,
555}
556
557#[derive(Debug, Clone, Default)]
558pub struct ExternalEffectLog {
559    phases: HashMap<String, Vec<ExternalEffectPhase>>, // idempotency_key -> phases
560}
561
562impl ExternalEffectLog {
563    pub fn new() -> Self {
564        Self::default()
565    }
566
567    /// Record intent before the external effect executes.
568    pub fn intent(&mut self, key: &str) {
569        self.phases
570            .entry(key.to_string())
571            .or_default()
572            .push(ExternalEffectPhase::Intent);
573    }
574
575    /// Record the result after the external effect executes.
576    pub fn result(&mut self, key: &str) -> Result<()> {
577        let phases = self.phases.get(key).cloned().unwrap_or_default();
578        if !phases.contains(&ExternalEffectPhase::Intent) {
579            return Err(SdkError::Domain(format!(
580                "R5 violation: result recorded for `{key}` without prior intent"
581            )));
582        }
583        self.phases
584            .get_mut(key)
585            .unwrap()
586            .push(ExternalEffectPhase::Result);
587        Ok(())
588    }
589
590    pub fn compensation(&mut self, key: &str) {
591        self.phases
592            .entry(key.to_string())
593            .or_default()
594            .push(ExternalEffectPhase::Compensation);
595    }
596
597    /// Whether an effect was properly bracketed (intent precedes result).
598    pub fn is_bracketed(&self, key: &str) -> bool {
599        match self.phases.get(key) {
600            Some(p) => {
601                let i = p.iter().position(|x| *x == ExternalEffectPhase::Intent);
602                let r = p.iter().position(|x| *x == ExternalEffectPhase::Result);
603                matches!((i, r), (Some(i), Some(r)) if i < r)
604            }
605            None => false,
606        }
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn chain_is_verifiable() {
616        let mut ledger = Ledger::new();
617        ledger
618            .append(LedgerEvent::CandidateAccepted {
619                node_id: "a".into(),
620                generation: 0,
621                energy: 5.0,
622            })
623            .unwrap();
624        ledger
625            .append(LedgerEvent::CandidateAccepted {
626                node_id: "b".into(),
627                generation: 0,
628                energy: 0.0,
629            })
630            .unwrap();
631        assert_eq!(ledger.len(), 2);
632        assert!(ledger.verify_chain().is_ok());
633    }
634
635    #[test]
636    fn tampering_breaks_the_chain() {
637        let mut ledger = Ledger::new();
638        ledger
639            .append(LedgerEvent::CandidateAccepted {
640                node_id: "a".into(),
641                generation: 0,
642                energy: 5.0,
643            })
644            .unwrap();
645        ledger
646            .append(LedgerEvent::CandidateAccepted {
647                node_id: "b".into(),
648                generation: 0,
649                energy: 0.0,
650            })
651            .unwrap();
652        // Tamper with a recorded energy.
653        if let LedgerEvent::CandidateAccepted { energy, .. } = &mut ledger.records[0].event {
654            *energy = 999.0;
655        }
656        assert!(ledger.verify_chain().is_err());
657    }
658
659    #[test]
660    fn replay_reconstructs_accepted_trajectory() {
661        let mut ledger = Ledger::new();
662        ledger
663            .append(LedgerEvent::CandidateRejected {
664                node_id: "a".into(),
665                generation: 0,
666            })
667            .unwrap();
668        ledger
669            .append(LedgerEvent::CandidateAccepted {
670                node_id: "a".into(),
671                generation: 1,
672                energy: 8.0,
673            })
674            .unwrap();
675        ledger
676            .append(LedgerEvent::CandidateAccepted {
677                node_id: "b".into(),
678                generation: 0,
679                energy: 0.0,
680            })
681            .unwrap();
682        let traj = replay_accepted_trajectory(&ledger);
683        assert_eq!(
684            traj.unwrap(),
685            vec![("a".into(), 1, 8.0), ("b".into(), 0, 0.0)]
686        );
687    }
688
689    #[test]
690    fn kernel_refuses_unrecorded_observation() {
691        let mut ledger = Ledger::new();
692        let event = LedgerEvent::EffectApplied {
693            proposal_id: "p1".into(),
694            idempotency_key: "k1".into(),
695        };
696        // Referencing an observation that was never recorded is refused.
697        let err = ledger.commit_transition(event.clone(), &["never-recorded".into()]);
698        assert!(err.is_err());
699
700        // After recording the observation, the same commit succeeds.
701        let handle = ledger.record_observation(b"llm output bytes").unwrap();
702        assert!(ledger.has_observation(&handle));
703        assert!(ledger.commit_transition(event, &[handle]).is_ok());
704    }
705
706    #[test]
707    fn idempotency_redelivery_returns_prior_outcome() {
708        let mut log = IdempotencyLog::new();
709        let first = log.record("k1", b"patch-content", "applied").unwrap();
710        assert_eq!(first, "applied");
711        // Redelivery with same content returns the prior result.
712        let again = log.record("k1", b"patch-content", "applied-again").unwrap();
713        assert_eq!(again, "applied");
714    }
715
716    #[test]
717    fn idempotency_key_reuse_for_different_content_is_invalid() {
718        let mut log = IdempotencyLog::new();
719        log.record("k1", b"content-a", "applied").unwrap();
720        assert!(log.record("k1", b"content-b", "applied").is_err());
721    }
722
723    #[test]
724    fn external_effect_must_be_bracketed() {
725        let mut log = ExternalEffectLog::new();
726        // Result without intent is an R5 violation.
727        assert!(log.result("k1").is_err());
728        log.intent("k1");
729        assert!(log.result("k1").is_ok());
730        assert!(log.is_bracketed("k1"));
731    }
732}