Skip to content

Commit 4809505

Browse files
authored
fix: batch agent recorder import integrity state (#32)
Summary - Add a flush hook to record adapters and let integrity wrapping choose immediate or on-flush state persistence. - Keep live run integrity state crash-durable after each record, but persist import integrity state once after the replayable batch completes. - Document the import/run durability distinction and add coverage for on-flush state persistence. Testing - node scripts/sync-platform-package-versions.js - cd tools/agent-recorder && cargo fmt --check - cd tools/agent-recorder && cargo test --quiet - cd tools/agent-recorder && cargo test --examples --quiet Notes/Risks - Interrupted integrity-backed imports may leave partial output/state alignment that should be discarded or rerun from a clean/aligned backend and state file. - This preserves per-record durability for run.
1 parent 6686c26 commit 4809505

9 files changed

Lines changed: 104 additions & 15 deletions

File tree

docs/info/src/content/docs/tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Tool calls and tool results are message annotations. Edges such as `follows-mess
6262

6363
### Integrity
6464

65-
`agent-recorder` can add optional forward-integrity metadata to each record. For the 0.1.0 tool, treat this as initial tamper-evidence and forward-integrity plumbing for review and experimentation, not as a cryptographically audited production guarantee. The public metadata contains the algorithm, key id, absolute index, payload hash, and authenticator. Local key-evolution state stays private and is not stored in backend records.
65+
`agent-recorder` can add optional forward-integrity metadata to each record. For the 0.1.x tool, treat this as initial tamper-evidence and forward-integrity plumbing for review and experimentation, not as a cryptographically audited production guarantee. The public metadata contains the algorithm, key id, absolute index, payload hash, and authenticator. Local key-evolution state stays private and is not stored in backend records.
6666

6767
The integrity layer authenticates each indexed record independently. Backend storage, including Sync Web, remains responsible for ordering and history. The recorder provides `read --integrity-key`, `verify`, `status`, and `rekey` commands for verification and emergency key cutover workflows.
6868

tools/agent-recorder/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/agent-recorder/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "agent-recorder"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
edition = "2021"
55
license = "MIT"
66
publish = false

tools/agent-recorder/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ The stable public JSON uses kebab-case where field names are controlled by `agen
154154

155155
## Integrity
156156

157-
Integrity is optional and backend-independent. In the 0.1.0 tool, treat this as initial tamper-evidence and forward-integrity plumbing for review and experimentation, not as a cryptographically audited production guarantee. Enable it on writes with `--integrity agent-recorder-integrity-v1`, an integrity state path, and an initial key.
157+
Integrity is optional and backend-independent. In the 0.1.x tool, treat this as initial tamper-evidence and forward-integrity plumbing for review and experimentation, not as a cryptographically audited production guarantee. Enable it on writes with `--integrity agent-recorder-integrity-v1`, an integrity state path, and an initial key.
158158

159159
```sh
160160
AGENT_RECORDER_INTEGRITY_KEY='example secret' \
@@ -197,6 +197,8 @@ Local integrity state stores private future keys and is not written into records
197197

198198
At one-based event `i`, compute `h = v2(i)` and generate future edge keys for levels `0..h`, each targeting `i + 2^d`. At event `j`, consume pending incoming keys whose target is `j`; after the backend write succeeds, consumed keys are deleted and the local state advances. A verifier with the root key derives `K_i` in logarithmic time and verifies the selected indexed record independently. Cryptographic review is still recommended before relying on this for high-assurance audit workflows.
199199

200+
`run` persists integrity state after each new record because it is live capture. `import` treats the input as a replayable batch and persists integrity state after the batch completes, avoiding one durable state-file rewrite per imported record. If an integrity-backed import is interrupted, discard the partial output or rerun from a clean/aligned backend and state file.
201+
200202
Read with verification:
201203

202204
```sh
@@ -271,7 +273,7 @@ A real signed JSON-LD example is checked in at `examples/integrity-agent-record.
271273

272274
The `Agent Recorder Binaries` GitHub Actions workflow builds downloadable `agent-recorder-*` binaries for Linux, Linux musl/Alpine, macOS, and Windows targets. Branch workflow artifacts can be downloaded for testing before a tagged release.
273275

274-
Tagged releases use `agent-recorder-v*` tags, for example `agent-recorder-v0.1.0`. Ledger binary releases use separate `ledger-v*` tags so agent-recorder artifacts do not mix with ledger/journal release assets.
276+
Tagged releases use `agent-recorder-v*` tags, for example `agent-recorder-v0.1.1`. Ledger binary releases use separate `ledger-v*` tags so agent-recorder artifacts do not mix with ledger/journal release assets.
275277

276278
## Sync Web Backend
277279

tools/agent-recorder/src/cli.rs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use serde::Deserialize;
1313
use crate::adapters::AdapterRegistry;
1414
use crate::integrity::{
1515
integrity_status, load_state, rekey_state, verify_indexed_records, IntegrityKey,
16-
IntegrityRecordAdapter, VerificationStatus, ALGORITHM,
16+
IntegrityRecordAdapter, IntegrityStatePersistence, VerificationStatus, ALGORITHM,
1717
};
1818
use crate::records::{
1919
sync_web::{
@@ -445,6 +445,7 @@ fn cmd_run(
445445
args.recorder_data.as_deref(),
446446
&args.sync_web,
447447
&args.integrity,
448+
IntegrityStatePersistence::Immediate,
448449
)?;
449450
let roots = vec![PathBuf::from(args.agent_data)];
450451
let mut seen = HashSet::new();
@@ -499,10 +500,13 @@ fn cmd_import(
499500
args.recorder_data.as_deref(),
500501
&args.sync_web,
501502
&args.integrity,
503+
IntegrityStatePersistence::OnFlush,
502504
)?;
503505
let roots = vec![PathBuf::from(args.agent_data)];
504506
let report = if let Some(sink) = sink.as_mut() {
505-
import_records(adapter.as_ref(), &roots, sink.as_mut())?
507+
let report = import_records(adapter.as_ref(), &roots, sink.as_mut())?;
508+
sink.flush()?;
509+
report
506510
} else {
507511
let stdout = io::stdout();
508512
let mut stdout = stdout.lock();
@@ -775,6 +779,7 @@ fn create_record_adapter(
775779
recorder_data: Option<&str>,
776780
sync_web: &SyncWebArgs,
777781
integrity: &IntegrityArgs,
782+
integrity_persistence: IntegrityStatePersistence,
778783
) -> Result<Option<Box<dyn RecordAdapter>>> {
779784
let Some(recorder) = recorder else {
780785
if integrity.algorithm.is_some() {
@@ -815,12 +820,15 @@ fn create_record_adapter(
815820
.ok_or_else(|| anyhow!("integrity logging requires --integrity-state PATH"))?;
816821
let init_key = resolve_optional_integrity_key(integrity)?;
817822
let reader = create_record_reader(registry, recorder, recorder_data, sync_web)?;
818-
Ok(Some(Box::new(IntegrityRecordAdapter::create_checked(
819-
sink,
820-
state,
821-
init_key,
822-
Some(reader.as_ref()),
823-
)?)))
823+
Ok(Some(Box::new(
824+
IntegrityRecordAdapter::create_checked_with_persistence(
825+
sink,
826+
state,
827+
init_key,
828+
Some(reader.as_ref()),
829+
integrity_persistence,
830+
)?,
831+
)))
824832
}
825833

826834
fn resolve_optional_integrity_key(args: &IntegrityArgs) -> Result<Option<IntegrityKey>> {

tools/agent-recorder/src/integrity/mod.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ pub struct IntegrityRecordAdapter {
7575
inner: Box<dyn RecordAdapter>,
7676
state_path: PathBuf,
7777
state: IntegrityState,
78+
persistence: IntegrityStatePersistence,
79+
dirty: bool,
80+
}
81+
82+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83+
pub enum IntegrityStatePersistence {
84+
Immediate,
85+
OnFlush,
7886
}
7987

8088
/// Root verifier/signing secret for one integrity stream.
@@ -154,6 +162,22 @@ impl IntegrityRecordAdapter {
154162
state_path: impl AsRef<Path>,
155163
init_key: Option<IntegrityKey>,
156164
reader: Option<&dyn RecordReader>,
165+
) -> Result<Self> {
166+
Self::create_checked_with_persistence(
167+
inner,
168+
state_path,
169+
init_key,
170+
reader,
171+
IntegrityStatePersistence::Immediate,
172+
)
173+
}
174+
175+
pub fn create_checked_with_persistence(
176+
inner: Box<dyn RecordAdapter>,
177+
state_path: impl AsRef<Path>,
178+
init_key: Option<IntegrityKey>,
179+
reader: Option<&dyn RecordReader>,
180+
persistence: IntegrityStatePersistence,
157181
) -> Result<Self> {
158182
let state_path = state_path.as_ref().to_path_buf();
159183
let mut state = load_or_create_state(&state_path, init_key.as_ref())?;
@@ -166,6 +190,8 @@ impl IntegrityRecordAdapter {
166190
inner,
167191
state_path,
168192
state,
193+
persistence,
194+
dirty: false,
169195
})
170196
}
171197
}
@@ -184,7 +210,19 @@ impl RecordAdapter for IntegrityRecordAdapter {
184210
self.inner.log(&signed)?;
185211

186212
advance_state(&mut self.state, index, generated)?;
187-
store_state(&self.state_path, &self.state)?;
213+
match self.persistence {
214+
IntegrityStatePersistence::Immediate => store_state(&self.state_path, &self.state)?,
215+
IntegrityStatePersistence::OnFlush => self.dirty = true,
216+
}
217+
Ok(())
218+
}
219+
220+
fn flush(&mut self) -> Result<()> {
221+
self.inner.flush()?;
222+
if self.dirty {
223+
store_state(&self.state_path, &self.state)?;
224+
self.dirty = false;
225+
}
188226
Ok(())
189227
}
190228
}

tools/agent-recorder/src/records.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ pub trait RecordAdapter {
5858
fn name(&self) -> &'static str;
5959
/// Append or otherwise persist one normalized record.
6060
fn log(&mut self, record: &GraphRecord) -> Result<()>;
61+
/// Flush any buffered adapter state after a batch of records.
62+
fn flush(&mut self) -> Result<()> {
63+
Ok(())
64+
}
6165
}
6266

6367
/// Readable backend used by `read`, `verify`, `status`, and integrity recovery.

tools/agent-recorder/src/records/jsonl.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ impl RecordAdapter for JsonlRecordAdapter {
4646
self.writer.flush()?;
4747
Ok(())
4848
}
49+
50+
fn flush(&mut self) -> Result<()> {
51+
self.writer.flush()?;
52+
Ok(())
53+
}
4954
}
5055

5156
impl RecordReader for JsonlRecordReader {

tools/agent-recorder/tests/import_fixtures.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use agent_recorder::{
77
integrity::{
88
integrity_status, rekey_state, verify_indexed_record, verify_indexed_records,
99
verify_record, IntegrityAlignment, IntegrityKey, IntegrityRecordAdapter,
10-
VerificationStatus,
10+
IntegrityStatePersistence, VerificationStatus,
1111
},
1212
records,
1313
records::{RecordAdapter, RecordReader, RecordSelector},
@@ -526,6 +526,38 @@ fn integrity_wrapper_signs_jsonl_records_and_verifies_range() -> Result<()> {
526526
Ok(())
527527
}
528528

529+
#[test]
530+
fn integrity_on_flush_persists_state_after_batch() -> Result<()> {
531+
let state = std::env::temp_dir().join(format!(
532+
"agent-recorder-integrity-on-flush-{}.json",
533+
std::process::id()
534+
));
535+
let _ = fs::remove_file(&state);
536+
let key = IntegrityKey::from_secret("batch-secret");
537+
let records = import_fixture("pi")?;
538+
539+
let mut adapter = IntegrityRecordAdapter::create_checked_with_persistence(
540+
Box::new(MemoryRecordAdapter::default()),
541+
&state,
542+
Some(key),
543+
None,
544+
IntegrityStatePersistence::OnFlush,
545+
)?;
546+
adapter.log(&records[0])?;
547+
adapter.log(&records[1])?;
548+
549+
let before_flush = agent_recorder::integrity::load_state(&state)?;
550+
assert_eq!(before_flush.next_index, 0);
551+
552+
adapter.flush()?;
553+
let after_flush = agent_recorder::integrity::load_state(&state)?;
554+
assert_eq!(after_flush.next_index, 2);
555+
assert!(after_flush.future_keys.iter().all(|key| key.target >= 2));
556+
557+
let _ = fs::remove_file(state);
558+
Ok(())
559+
}
560+
529561
#[test]
530562
fn integrity_status_reports_alignment() -> Result<()> {
531563
let (out, state, _key) = write_integrity_fixture("status")?;

0 commit comments

Comments
 (0)