Skip to content

Commit b11ed0a

Browse files
Zacclaude
andcommitted
Add full operator.v0 contract and --describe flag
Creates operator.json with all 18 refusal codes, CLI surface, witness subcommands, and pipeline position. Wires --describe to emit it via include_str!. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b028670 commit b11ed0a

File tree

6 files changed

+128
-0
lines changed

6 files changed

+128
-0
lines changed

operator.json

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
{
2+
"schema_version": "operator.v0",
3+
"name": "rvl",
4+
"version": "0.4.0",
5+
"description": "Numeric change explainer — finds the smallest set of cell-level changes that account for the difference between two CSV files",
6+
"repository": "https://github.com/cmdrvl/rvl",
7+
"license": "MIT",
8+
"agent_guide": "https://github.com/cmdrvl/.github/blob/main/profile/AGENT_PROMPT.md",
9+
10+
"invocation": {
11+
"binary": "rvl",
12+
"usage": [
13+
"rvl <old.csv> <new.csv> [OPTIONS]",
14+
"rvl witness <query|last|count> [OPTIONS]"
15+
],
16+
"output_mode": "report",
17+
"output_schema": "rvl.v0",
18+
"json_flag": "--json"
19+
},
20+
21+
"arguments": [
22+
{ "name": "old", "type": "file_path", "required": true, "position": 0, "description": "Old CSV file" },
23+
{ "name": "new", "type": "file_path", "required": true, "position": 1, "description": "New CSV file" }
24+
],
25+
26+
"options": [
27+
{ "name": "key", "flag": "--key", "type": "string", "description": "Align rows by this key column (otherwise align by row order)" },
28+
{ "name": "threshold", "flag": "--threshold", "type": "float", "default": 0.95, "description": "Coverage target: 0 < x <= 1" },
29+
{ "name": "tolerance", "flag": "--tolerance", "type": "float", "default": 1e-9, "description": "Per-cell noise floor: x >= 0" },
30+
{ "name": "delimiter", "flag": "--delimiter", "type": "string", "description": "Force CSV delimiter (comma/tab/semicolon/pipe/caret, 0xNN, or single ASCII byte)" },
31+
{ "name": "profile", "flag": "--profile", "type": "file_path", "description": "Use profile YAML at this path for key derivation and column scoping" },
32+
{ "name": "profile_id", "flag": "--profile-id", "type": "string", "description": "Resolve profile by ID from ~/.epistemic/profiles/*.yaml (or direct path)" },
33+
{ "name": "capsule_out", "flag": "--capsule-out", "type": "directory_path", "description": "Write deterministic repro capsule artifacts to this directory (default: disabled)" },
34+
{ "name": "json", "flag": "--json", "type": "flag", "description": "Emit JSON output (single object)" },
35+
{ "name": "no_witness", "flag": "--no-witness", "type": "flag", "description": "Suppress witness ledger recording" },
36+
{ "name": "describe", "flag": "--describe", "type": "flag", "description": "Print compiled operator.json and exit 0 without positional args" }
37+
],
38+
39+
"subcommands": [
40+
{
41+
"name": "witness",
42+
"description": "Witness ledger query commands",
43+
"status": "available",
44+
"actions": [
45+
{
46+
"name": "query",
47+
"usage": "rvl witness query [--tool <name>] [--since <iso8601>] [--until <iso8601>] [--outcome <REAL_CHANGE|NO_REAL_CHANGE|REFUSAL>] [--input-hash <substring>] [--limit <n>] [--json]"
48+
},
49+
{
50+
"name": "last",
51+
"usage": "rvl witness last [--json]"
52+
},
53+
{
54+
"name": "count",
55+
"usage": "rvl witness count [--tool <name>] [--since <iso8601>] [--until <iso8601>] [--outcome <REAL_CHANGE|NO_REAL_CHANGE|REFUSAL>] [--input-hash <substring>] [--json]"
56+
}
57+
],
58+
"current_runtime_behavior": {
59+
"success_exit_code": 0,
60+
"no_match_exit_code": 1,
61+
"error_exit_code": 2,
62+
"ledger_path_resolution": [
63+
"EPISTEMIC_WITNESS",
64+
"~/.epistemic/witness.jsonl"
65+
]
66+
}
67+
}
68+
],
69+
70+
"exit_codes": {
71+
"0": { "meaning": "NO_REAL_CHANGE", "domain": "positive" },
72+
"1": { "meaning": "REAL_CHANGE", "domain": "negative" },
73+
"2": { "meaning": "REFUSAL / CLI error", "domain": "error" }
74+
},
75+
76+
"refusals": [
77+
{ "code": "E_IO", "message": "Cannot read input file", "action": "escalate" },
78+
{ "code": "E_ENCODING", "message": "Unsupported text encoding (UTF-16/32 BOM or NUL bytes)", "action": "escalate" },
79+
{ "code": "E_CSV_PARSE", "message": "CSV parse failure", "action": "escalate" },
80+
{ "code": "E_HEADERS", "message": "Invalid or duplicate headers", "action": "escalate" },
81+
{ "code": "E_NO_KEY", "message": "Key column missing from file", "action": "retry_with_flag", "flag": "--key" },
82+
{ "code": "E_KEY_EMPTY", "message": "Empty key value in row", "action": "escalate" },
83+
{ "code": "E_KEY_DUP", "message": "Duplicate key values (non-unique)", "action": "escalate" },
84+
{ "code": "E_KEY_MISMATCH", "message": "Key sets differ between files", "action": "escalate" },
85+
{ "code": "E_ROWCOUNT", "message": "Row count mismatch and no key provided", "action": "retry_with_flag", "flag": "--key" },
86+
{ "code": "E_NEED_KEY", "message": "Cannot deterministically align without a key", "action": "retry_with_flag", "flag": "--key" },
87+
{ "code": "E_DIALECT", "message": "Delimiter ambiguous or undetectable", "action": "retry_with_flag", "flag": "--delimiter" },
88+
{ "code": "E_AMBIGUOUS_PROFILE", "message": "Both --profile and --profile-id were provided", "action": "adjust_input" },
89+
{ "code": "E_PROFILE_NOT_FOUND", "message": "Profile could not be resolved from ID", "action": "escalate" },
90+
{ "code": "E_KEY_CONFLICT", "message": "--key flag conflicts with profile-defined key", "action": "adjust_input" },
91+
{ "code": "E_MIXED_TYPES", "message": "Mixed numeric and non-numeric values in column", "action": "escalate" },
92+
{ "code": "E_NO_NUMERIC", "message": "No numeric columns in common", "action": "escalate" },
93+
{ "code": "E_MISSINGNESS", "message": "Numeric-vs-missing mismatch", "action": "escalate" },
94+
{ "code": "E_DIFFUSE", "message": "Diffuse change below coverage threshold", "action": "retry_with_flag", "flag": "--threshold" }
95+
],
96+
97+
"capabilities": {
98+
"formats": ["csv"],
99+
"profile_aware": true,
100+
"streaming": false,
101+
"witness": {
102+
"ambient_recording": "enabled_by_default",
103+
"query_subcommands": "available",
104+
"no_witness_flag": "suppresses_recording"
105+
}
106+
},
107+
108+
"pipeline": {
109+
"upstream": ["shape", "profile"],
110+
"downstream": ["pack"]
111+
}
112+
}

src/cli/args.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ pub struct Args {
7070
#[arg(long)]
7171
pub no_witness: bool,
7272

73+
/// Print compiled operator.json and exit 0.
74+
#[arg(long)]
75+
pub describe: bool,
76+
7377
#[command(subcommand)]
7478
pub command: Option<RvlCommand>,
7579
}
@@ -166,6 +170,7 @@ impl Args {
166170
capsule_out: None,
167171
json,
168172
no_witness: false,
173+
describe: false,
169174
command: None,
170175
}
171176
}

src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ pub mod refusal;
1414
pub mod repro;
1515
pub mod witness;
1616

17+
const OPERATOR_JSON: &str = include_str!("../operator.json");
18+
1719
/// Run the rvl pipeline. Returns exit code (0, 1, or 2).
1820
pub fn run() -> Result<u8, Box<dyn std::error::Error>> {
1921
let args = match cli::args::Args::parse() {
@@ -24,6 +26,11 @@ pub fn run() -> Result<u8, Box<dyn std::error::Error>> {
2426
}
2527
};
2628

29+
if args.describe {
30+
println!("{OPERATOR_JSON}");
31+
return Ok(0);
32+
}
33+
2734
if let Some(ref cmd) = args.command {
2835
return run_witness(cmd);
2936
}

tests/capsule_replay.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ fn run_with_capsule(
4747
capsule_out: Some(capsule_root.to_path_buf()),
4848
json: true,
4949
no_witness: true,
50+
describe: false,
5051
command: None,
5152
};
5253

@@ -115,6 +116,7 @@ fn replay_from_manifest(manifest: &Value, capsule_dir: &Path) -> Value {
115116
.and_then(Value::as_bool)
116117
.expect("manifest.args.json"),
117118
no_witness: true,
119+
describe: false,
118120
command: None,
119121
};
120122

tests/profile_integration.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ fn make_args(old: &PathBuf, new: &PathBuf) -> Args {
4545
capsule_out: None,
4646
json: true,
4747
no_witness: true,
48+
describe: false,
4849
command: None,
4950
}
5051
}

tests/regression.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ fn run_case(old: &str, new: &str, key: Option<&str>, json: bool) -> String {
2121
capsule_out: None,
2222
json,
2323
no_witness: true,
24+
describe: false,
2425
command: None,
2526
};
2627
orchestrator::run(&args)

0 commit comments

Comments
 (0)