Skip to content

Commit bb76812

Browse files
Zacclaude
andcommitted
feat: add witness query subcommand (bd-ogo)
Add `rvl witness query/last/count` subcommands for reading the append-only witness ledger. Uses clap's subcommand_negates_reqs so `rvl <old.csv> <new.csv>` remains the default behavior. New modules: witness/reader.rs (LedgerReader), witness/query.rs (QueryFilter + human/JSON formatters). Filters: --tool, --since, --until, --outcome, --input-hash, --limit. Bump version to 0.2.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d40a882 commit bb76812

File tree

17 files changed

+3100
-32
lines changed

17 files changed

+3100
-32
lines changed

.beads/issues.jsonl

Lines changed: 6 additions & 0 deletions
Large diffs are not rendered by default.

Cargo.lock

Lines changed: 468 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rvl"
3-
version = "0.1.1"
3+
version = "0.2.0"
44
edition = "2024"
55
authors = ["CMD+RVL <engineering@cmdrvl.com>"]
66
description = "Reveal the smallest set of numeric changes that explain what actually changed."
@@ -19,6 +19,7 @@ clap = { version = "4", features = ["derive"] }
1919
csv = "1"
2020
serde = { version = "1", features = ["derive"] }
2121
serde_json = "1"
22+
blake3 = "1"
2223
simd-csv = { version = "0.10.3", optional = true }
2324

2425
[features]
@@ -27,6 +28,8 @@ simd_csv = ["simd-csv"]
2728
[dev-dependencies]
2829
arrow-csv = "57.2.0"
2930
arrow-schema = "57.2.0"
31+
blake3 = "1"
32+
jsonschema = "0.42"
3033
polars = { version = "0.52.0", default-features = false, features = ["csv"] }
3134
simd-csv = "0.10.3"
3235

benches/runtime.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,15 @@ fn main() {
7575

7676
fn run_case(case: &Case, iterations: u64, warmup: u64) -> f64 {
7777
let args = Args {
78-
old: case.old.clone(),
79-
new: case.new.clone(),
78+
old: Some(case.old.clone()),
79+
new: Some(case.new.clone()),
8080
key: case.key.clone(),
8181
threshold: 0.95,
8282
tolerance: 1e-9,
8383
delimiter: None,
8484
json: false,
85+
no_witness: true,
86+
command: None,
8587
};
8688

8789
for _ in 0..warmup {

docs/witness.v0.schema.json

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://cmdrvl.com/schemas/witness.v0.schema.json",
4+
"title": "Witness Record v0",
5+
"description": "Append-only audit record for a tool invocation, content-addressed via BLAKE3.",
6+
"type": "object",
7+
"required": [
8+
"id",
9+
"tool",
10+
"version",
11+
"binary_hash",
12+
"inputs",
13+
"params",
14+
"outcome",
15+
"exit_code",
16+
"output_hash",
17+
"prev",
18+
"ts"
19+
],
20+
"additionalProperties": false,
21+
"properties": {
22+
"id": {
23+
"type": "string",
24+
"pattern": "^blake3:[0-9a-f]{64}$",
25+
"description": "Content-addressed record ID: blake3 hash of the canonical JSON with id blanked."
26+
},
27+
"tool": {
28+
"type": "string",
29+
"minLength": 1,
30+
"description": "Name of the tool that produced this record."
31+
},
32+
"version": {
33+
"type": "string",
34+
"pattern": "^\\d+\\.\\d+\\.\\d+",
35+
"description": "Semantic version of the tool."
36+
},
37+
"binary_hash": {
38+
"type": "string",
39+
"pattern": "^blake3:[0-9a-f]{64}$",
40+
"description": "BLAKE3 hash of the tool binary at invocation time."
41+
},
42+
"inputs": {
43+
"type": "array",
44+
"minItems": 1,
45+
"items": {
46+
"type": "object",
47+
"required": ["path", "hash", "bytes"],
48+
"additionalProperties": false,
49+
"properties": {
50+
"path": {
51+
"type": "string",
52+
"minLength": 1,
53+
"description": "File path as provided to the tool."
54+
},
55+
"hash": {
56+
"type": "string",
57+
"pattern": "^blake3:[0-9a-f]{64}$",
58+
"description": "BLAKE3 hash of the file contents."
59+
},
60+
"bytes": {
61+
"type": "integer",
62+
"minimum": 0,
63+
"description": "File size in bytes."
64+
}
65+
}
66+
},
67+
"description": "Input files consumed by the tool invocation."
68+
},
69+
"params": {
70+
"type": "object",
71+
"description": "Tool-specific parameters (additionalProperties allowed)."
72+
},
73+
"outcome": {
74+
"type": "string",
75+
"minLength": 1,
76+
"description": "Tool-specific outcome label (e.g. REAL_CHANGE, NO_REAL_CHANGE, REFUSAL)."
77+
},
78+
"exit_code": {
79+
"type": "integer",
80+
"minimum": 0,
81+
"maximum": 255,
82+
"description": "Process exit code."
83+
},
84+
"output_hash": {
85+
"type": "string",
86+
"pattern": "^blake3:[0-9a-f]{64}$",
87+
"description": "BLAKE3 hash of the tool's output text."
88+
},
89+
"prev": {
90+
"oneOf": [
91+
{
92+
"type": "string",
93+
"pattern": "^blake3:[0-9a-f]{64}$"
94+
},
95+
{
96+
"type": "null"
97+
}
98+
],
99+
"description": "ID of the previous record in the ledger chain, or null for the first record."
100+
},
101+
"ts": {
102+
"type": "string",
103+
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$",
104+
"description": "ISO 8601 UTC timestamp of the invocation."
105+
}
106+
}
107+
}

src/cli/args.rs

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::path::PathBuf;
22

3-
use clap::Parser;
3+
use clap::{Parser, Subcommand};
44

55
use super::delimiter::parse_delimiter_arg;
66

@@ -12,16 +12,17 @@ const DEFAULT_TOLERANCE: f64 = 1e-9;
1212
#[command(
1313
name = "rvl",
1414
about = "Reveal the smallest set of numeric changes that explain what actually changed.",
15-
override_usage = "rvl <old.csv> <new.csv> [--key <column>] [--threshold <float>] [--tolerance <float>] [--delimiter <delim>] [--json]"
15+
override_usage = "rvl <old.csv> <new.csv> [OPTIONS]\n rvl witness <query|last|count> [OPTIONS]",
16+
subcommand_negates_reqs = true
1617
)]
1718
pub struct Args {
1819
/// Old CSV path.
1920
#[arg(value_name = "OLD_CSV")]
20-
pub old: PathBuf,
21+
pub old: Option<PathBuf>,
2122

2223
/// New CSV path.
2324
#[arg(value_name = "NEW_CSV")]
24-
pub new: PathBuf,
25+
pub new: Option<PathBuf>,
2526

2627
/// Align rows by this key column (otherwise align by row order).
2728
#[arg(long, value_name = "COLUMN")]
@@ -52,6 +53,70 @@ pub struct Args {
5253
/// Emit JSON output (single object).
5354
#[arg(long)]
5455
pub json: bool,
56+
57+
/// Suppress witness ledger recording.
58+
#[arg(long)]
59+
pub no_witness: bool,
60+
61+
#[command(subcommand)]
62+
pub command: Option<RvlCommand>,
63+
}
64+
65+
#[derive(Debug, Clone, Subcommand)]
66+
pub enum RvlCommand {
67+
/// Query the witness ledger.
68+
Witness {
69+
#[command(subcommand)]
70+
action: WitnessAction,
71+
},
72+
}
73+
74+
#[derive(Debug, Clone, Subcommand)]
75+
pub enum WitnessAction {
76+
/// Search witness records with filters.
77+
Query(WitnessQueryArgs),
78+
/// Show the most recent witness record.
79+
Last(WitnessLastArgs),
80+
/// Count matching witness records.
81+
Count(WitnessQueryArgs),
82+
}
83+
84+
#[derive(Debug, Clone, clap::Args)]
85+
pub struct WitnessQueryArgs {
86+
/// Filter by tool name.
87+
#[arg(long)]
88+
pub tool: Option<String>,
89+
90+
/// Filter records on or after this ISO 8601 timestamp.
91+
#[arg(long)]
92+
pub since: Option<String>,
93+
94+
/// Filter records on or before this ISO 8601 timestamp.
95+
#[arg(long)]
96+
pub until: Option<String>,
97+
98+
/// Filter by outcome (REAL_CHANGE, NO_REAL_CHANGE, REFUSAL).
99+
#[arg(long)]
100+
pub outcome: Option<String>,
101+
102+
/// Filter by input file hash (substring match).
103+
#[arg(long)]
104+
pub input_hash: Option<String>,
105+
106+
/// Maximum number of records to return (default: 20).
107+
#[arg(long, default_value_t = 20)]
108+
pub limit: usize,
109+
110+
/// Emit JSON output.
111+
#[arg(long)]
112+
pub json: bool,
113+
}
114+
115+
#[derive(Debug, Clone, clap::Args)]
116+
pub struct WitnessLastArgs {
117+
/// Emit JSON output.
118+
#[arg(long)]
119+
pub json: bool,
55120
}
56121

57122
impl Args {
@@ -70,15 +135,27 @@ impl Args {
70135
json: bool,
71136
) -> Self {
72137
Self {
73-
old,
74-
new,
138+
old: Some(old),
139+
new: Some(new),
75140
key,
76141
threshold,
77142
tolerance,
78143
delimiter,
79144
json,
145+
no_witness: false,
146+
command: None,
80147
}
81148
}
149+
150+
/// Get the old path, panics if not set (only valid in comparison mode).
151+
pub fn old_path(&self) -> &PathBuf {
152+
self.old.as_ref().expect("old path required for comparison")
153+
}
154+
155+
/// Get the new path, panics if not set (only valid in comparison mode).
156+
pub fn new_path(&self) -> &PathBuf {
157+
self.new.as_ref().expect("new path required for comparison")
158+
}
82159
}
83160

84161
fn parse_threshold(raw: &str) -> Result<f64, String> {

0 commit comments

Comments
 (0)