Argus · append-only action record

A record of what an agent did that shows when it was changed.

Logs can be edited after the fact. Argus links every entry to the one before it, so a reader can check that nothing in the past was changed and, if something was, see exactly where.

Try it

Edit the past and watch the check.

Six agent actions are appended by the crate. Edit one and the walk stops at it. Then edit it and re-hash everything after it: the plain check passes, and only the anchor catches it.

Running argus-ledger 0.1.0-alpha.4 in your browser (WebAssembly). Loads 139 KB when you start it; nothing is sent anywhere.

Linear SHA-256 hash chaining

Each event's hash covers its sequence number, event ID, timestamp, type, actor, the previous event's hash and a digest of its payload. The first event links to a fixed all-zero hash. Changing any stored event breaks the link at that event, and verification reports its position, walking from the first event forward.

Inputs are checked before anything is recorded: payloads must be canonical JSON objects (sorted keys, no extra whitespace), and type and actor names are restricted to plain printable characters. Timestamps are nanoseconds and strictly increase along the chain. Events can optionally be signed with Ed25519.

Head anchoring

A chain checks only itself. Someone who can rewrite the storage can change an event and recompute every later hash, and the chain will look intact. The fix is an anchor: the latest sequence number and hash, kept somewhere the writer cannot change, such as another system, a ticket or a signed message. Verifying against the anchor fails if the anchored event is missing or different, which catches rewritten, rolled-back and truncated history.

MeasurementMedianRangeRaw data
Fully recomputed history passes a check without an anchor
An attacker who can rewrite storage recomputes every hash after an edit; internal consistency alone cannot see it
yessame in every runrun1 run2 run3 run4 run5 · source · details
Same recomputed history caught by the anchor
Verification against the anchor recorded before the rewrite
yessame in every runrun1 run2 run3 run4 run5 · source · details
Events in the anchoring test
One in-memory ledger; head recorded as an anchor
10,000 eventssame in every runrun1 run2 run3 run4 run5 · source · details
Verification speed with an anchor
10,000 events verified against a recorded head (sequence number and hash)
2,660,694 events/s2,480,960–2,697,836run1 run2 run3 run4 run5 · source · details

Install

Rust: Cargo.toml
[dependencies]
argus-ledger = "=0.1.0-alpha.4"
Python
pip install "argus-ledger==0.1.0a4"

Version 0.1.0-alpha.4 is on crates.io and PyPI.

Rust (tested)
use argus_ledger::{AuditLedgerReader, AuditLedgerWriter, InMemoryLedger, VerificationPolicy};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let ledger = InMemoryLedger::new();
    ledger.append("tool.exec", "agent-7", r#"{"cmd":"cargo test","exit":0}"#)?;
    let head = ledger.append("deploy.request", "agent-7", r#"{"env":"staging"}"#)?;
    let anchor = head.anchor(); // keep (sequence, hash) outside the agent's reach

    // Someone edits the stored record of event 1.
    let mut events = ledger.all_events()?;
    events[0].payload = r#"{"cmd":"cargo test","exit":1}"#.to_string();
    let report = VerificationPolicy { anchor: Some(anchor), ..Default::default() }.verify(&events)?;
    println!("valid: {}, first broken event: {:?}", report.valid, report.first_broken_sequence);
    Ok(())
}
Python (tested)
from argus_ledger import AppendOnlyLedger

ledger = AppendOnlyLedger("audit.jsonl")
ledger.append("tool.exec", "agent-7", {"cmd": "cargo test", "exit": 0})
head = ledger.append("deploy.request", "agent-7", {"env": "staging"})
report = ledger.verify_chain(anchor=head.anchor())  # anchor kept outside the agent's reach
print("valid:", report.valid, "events:", report.total_events)

Scale measurements

MeasurementMedianRangeRaw data
Events in the ledger scale test
One in-memory ledger
1,000,000 eventssame in every runrun1 run2 run3 run4 run5 · source · details
Appends per second
1,000,000 appends through the public API, in memory; each validated, timestamped and SHA-256 chained
893,679 events/s886,397–897,344run1 run2 run3 run4 run5 · source · details
Average time per append
1,000,000 appends, in memory
1.12 µs1.11–1.13run1 run2 run3 run4 run5 · source · details
Verification speed
Full re-verification of all 1,000,000 events from the first event
2,605,457 events/s2,576,361–2,673,097run1 run2 run3 run4 run5 · source · details
Position reported for the edited event
Event 500,000 of 1,000,000 edited in place; the verifier reports the first break
500,000same in every runrun1 run2 run3 run4 run5 · source · details
Time to reach and report the edit
Verification walks from event 1 to the edit at event 500,000, in memory
183.9 ms182.4–185.7run1 run2 run3 run4 run5 · source · details

How these numbers were measured. Each figure is the median of 5 runs of the telarch-benchmarks programs at release tag v0.1.0-alpha.4, with the range of all runs beside it. 13th Gen Intel Core i5-13500, 20 logical CPUs, Linux 6.18, rustc 1.97.1, pinned to the performance-core threads (CPUs 0-11). Shared server, not isolated; each run started only after the 1-minute load average was below 4 on two checks 20 s apart. Synthetic data; real workloads will differ. Raw output of every run.

Limits, stated plainly

  • Detection, not prevention. Argus cannot stop someone with write access from editing storage. It makes the edit visible.
  • First break only. Verification stops at the first broken link; later edits are not listed separately.
  • Anchors need a safe place. An anchor helps only if the writer cannot change where it is kept.
  • Local clock. Timestamps come from the machine's clock and are checked for order, not against a trusted time source.
  • Storage. The Rust ledger is in memory; the Python AppendOnlyLedger writes a JSON Lines file.