Skip to content

Commit fbf74cd

Browse files
committed
feat(federation): verify materialised parent corpora
Signed-off-by: Tom Ballard <tom@armytage.co>
1 parent 0f52adc commit fbf74cd

9 files changed

Lines changed: 1863 additions & 16 deletions

File tree

docs/cli.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,44 @@ These apply across every command.
3434

3535
---
3636

37+
## corpus digest
38+
39+
Calculate the canonical pin for a parent corpus that is already materialised
40+
on disk. The command is read-only: it does not clone, fetch, update, write, or
41+
repin the parent.
42+
43+
```bash
44+
decided corpus digest --root vendor/standards --corpus decisions
45+
```
46+
47+
`--root` is the parent repository root and bounds configuration discovery to
48+
exactly `<root>/.decided/config.yaml`; the command never inherits a config from
49+
an ancestor. `--corpus` is a relative directory below that root. The config
50+
must declare an explicit valid `corpus.source`. On success stdout is exactly a
51+
full lowercase pin followed by a newline:
52+
53+
```text
54+
sha256:899d5cdfa52b90a157b018dceb20f4f2901e0d56c91b089c12286c0b8b7b3325
55+
```
56+
57+
Digest version 1 hashes the fixed domain bytes
58+
`asdecided-corpus-digest-v1\0`, then length-framed records. Each record is a
59+
one-byte tag, an unsigned 64-bit big-endian byte length, and the raw payload:
60+
61+
1. tag `0x01`: parent `corpus.source` UTF-8 bytes;
62+
2. tag `0x02`: exact governing `.decided/config.yaml` bytes; then
63+
3. for every discovered Markdown file in corpus-relative POSIX UTF-8 path
64+
order, tag `0x03` for the path bytes and tag `0x04` for its exact content
65+
bytes.
66+
67+
Checkout location, timestamps, filesystem iteration order, hidden paths, and
68+
non-`.md` files do not enter the digest. Absolute or `..` corpus paths, path
69+
escape, and traversed symlinks are rejected with stable `parent-corpus-*`
70+
errors. Exit `0` means the digest was calculated; exit `1` means the bounded
71+
materialisation could not be safely snapshotted.
72+
73+
---
74+
3775
## validate
3876

3977
Validate an artifact — or every artifact in a directory — for structural and

rust/decided-mcp/tests/docs_contract.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const SUPPORTED_DECIDED_COMMANDS: &[&str] = &[
5454
"retrieve",
5555
"sentry",
5656
"herald",
57+
"corpus",
5758
];
5859

5960
fn documented_decided_command(line: &str) -> Option<&str> {

rust/decided/tests/cli.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,87 @@ fn export_rejects_an_invalid_configured_corpus_source() {
171171

172172
fs::remove_dir_all(root).expect("remove CLI smoke corpus");
173173
}
174+
175+
#[test]
176+
fn corpus_digest_prints_the_canonical_read_only_pin() {
177+
let root = scratch_root();
178+
fs::create_dir_all(root.join(".decided")).unwrap();
179+
fs::create_dir_all(root.join("decisions/sub")).unwrap();
180+
fs::write(
181+
root.join(".decided/config.yaml"),
182+
b"repository_key: STD\ncorpus:\n source: acme/standards\n",
183+
)
184+
.unwrap();
185+
fs::write(root.join("decisions/a.md"), b"alpha\n").unwrap();
186+
fs::write(root.join("decisions/sub/b.md"), b"beta\r\n").unwrap();
187+
fs::write(root.join("decisions/ignored.MD"), b"ignored\n").unwrap();
188+
let before_config = fs::read(root.join(".decided/config.yaml")).unwrap();
189+
let before_a = fs::read(root.join("decisions/a.md")).unwrap();
190+
let root_text = root.to_string_lossy().into_owned();
191+
192+
let output = run(&[
193+
"corpus",
194+
"digest",
195+
"--root",
196+
&root_text,
197+
"--corpus",
198+
"decisions",
199+
]);
200+
assert!(
201+
output.status.success(),
202+
"stdout={}, stderr={}",
203+
String::from_utf8_lossy(&output.stdout),
204+
String::from_utf8_lossy(&output.stderr)
205+
);
206+
assert_eq!(
207+
output.stdout,
208+
b"sha256:899d5cdfa52b90a157b018dceb20f4f2901e0d56c91b089c12286c0b8b7b3325\n"
209+
);
210+
assert!(output.stderr.is_empty());
211+
assert_eq!(fs::read(root.join(".decided/config.yaml")).unwrap(), before_config);
212+
assert_eq!(fs::read(root.join("decisions/a.md")).unwrap(), before_a);
213+
214+
fs::remove_dir_all(root).expect("remove CLI digest corpus");
215+
}
216+
217+
#[test]
218+
fn corpus_digest_bounds_config_and_rejects_escaping_corpus_paths() {
219+
let root = scratch_root();
220+
fs::create_dir_all(root.join("parent/decisions")).unwrap();
221+
fs::write(root.join("parent/decisions/a.md"), b"alpha\n").unwrap();
222+
fs::create_dir_all(root.join(".decided")).unwrap();
223+
fs::write(
224+
root.join(".decided/config.yaml"),
225+
b"repository_key: CHILD\ncorpus:\n source: acme/child\n",
226+
)
227+
.unwrap();
228+
let parent = root.join("parent").to_string_lossy().into_owned();
229+
230+
let missing = run(&[
231+
"corpus",
232+
"digest",
233+
"--root",
234+
&parent,
235+
"--corpus",
236+
"decisions",
237+
]);
238+
assert_eq!(missing.status.code(), Some(1));
239+
assert!(
240+
String::from_utf8_lossy(&missing.stderr).contains("parent-corpus-config-missing")
241+
);
242+
243+
let escaping = run(&[
244+
"corpus",
245+
"digest",
246+
"--root",
247+
&parent,
248+
"--corpus",
249+
"../decisions",
250+
]);
251+
assert_eq!(escaping.status.code(), Some(1));
252+
assert!(
253+
String::from_utf8_lossy(&escaping.stderr).contains("parent-corpus-path-escape")
254+
);
255+
256+
fs::remove_dir_all(root).expect("remove CLI digest corpus");
257+
}

rust/rac-engine/src/cli.rs

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@
55
//! (decision 9) — stdout stays byte-identical (empty on errors).
66
77
use crate::commands::{
8-
cmd_coverage, cmd_decisions_for, cmd_diagnose, cmd_diff, cmd_doctor, cmd_eval, cmd_export,
9-
cmd_find, cmd_gate, cmd_herald, cmd_hook, cmd_improve, cmd_index, cmd_init, cmd_inspect,
10-
cmd_mcp_stats, cmd_migrate, cmd_new, cmd_portfolio, cmd_quickstart, cmd_relationships,
11-
cmd_rename, cmd_resolve, cmd_retrieve, cmd_review, cmd_schema, cmd_sentry, cmd_skill,
12-
cmd_stats, cmd_telemetry, cmd_templates, cmd_usage, cmd_validate, CoverageArgs,
13-
DecisionsForArgs, DiagnoseArgs, DiffArgs, DoctorArgs, EvalArgs, ExportArgs, FindArgs,
14-
GateArgs, HeraldArgs, HookArgs, ImproveArgs, IndexArgs, InitArgs, InspectArgs, McpStatsArgs,
15-
MigrateArgs, NewArgs, PortfolioArgs, QuickstartArgs, RelationshipsArgs, RenameArgs,
16-
ResolveArgs, RetrieveArgs, ReviewArgs, SchemaArgs, SentryArgs, SkillArgs, StatsArgs,
17-
TelemetryArgs, TemplatesArgs, UsageArgs, ValidateArgs, WatchkeeperArgs,
8+
cmd_corpus_digest, cmd_coverage, cmd_decisions_for, cmd_diagnose, cmd_diff, cmd_doctor,
9+
cmd_eval, cmd_export, cmd_find, cmd_gate, cmd_herald, cmd_hook, cmd_improve, cmd_index,
10+
cmd_init, cmd_inspect, cmd_mcp_stats, cmd_migrate, cmd_new, cmd_portfolio, cmd_quickstart,
11+
cmd_relationships, cmd_rename, cmd_resolve, cmd_retrieve, cmd_review, cmd_schema, cmd_sentry,
12+
cmd_skill, cmd_stats, cmd_telemetry, cmd_templates, cmd_usage, cmd_validate,
13+
CorpusDigestArgs, CoverageArgs, DecisionsForArgs, DiagnoseArgs, DiffArgs, DoctorArgs, EvalArgs,
14+
ExportArgs, FindArgs, GateArgs, HeraldArgs, HookArgs, ImproveArgs, IndexArgs, InitArgs,
15+
InspectArgs, McpStatsArgs, MigrateArgs, NewArgs, PortfolioArgs, QuickstartArgs,
16+
RelationshipsArgs, RenameArgs, ResolveArgs, RetrieveArgs, ReviewArgs, SchemaArgs, SentryArgs,
17+
SkillArgs, StatsArgs, TelemetryArgs, TemplatesArgs, UsageArgs, ValidateArgs, WatchkeeperArgs,
1818
};
1919
use crate::commands::cmd_watchkeeper;
2020
use crate::output::rac_version;
@@ -156,7 +156,10 @@ fn run_dispatch(args: &[String]) -> u8 {
156156
// Native-only additions dispatch but are deliberately NOT in SUBCOMMANDS:
157157
// the retired Python oracle's `invalid choice` bytes remain pinned by the
158158
// bounded compatibility suite.
159-
if !matches!(first.as_str(), "retrieve" | "sentry" | "herald" | "diagnose")
159+
if !matches!(
160+
first.as_str(),
161+
"retrieve" | "sentry" | "herald" | "diagnose" | "corpus"
162+
)
160163
&& !SUBCOMMANDS.contains(&first.as_str())
161164
{
162165
return argparse_error("decided", &invalid_choice_message(first));
@@ -232,6 +235,7 @@ fn run_dispatch(args: &[String]) -> u8 {
232235
"quickstart" => run_quickstart(&rest),
233236
"rename" => run_rename(&rest),
234237
"migrate" => run_migrate(&rest),
238+
"corpus" => run_corpus(&rest),
235239
other => {
236240
eprintln!("decided-rs: subcommand '{other}' is not yet implemented");
237241
2
@@ -278,6 +282,78 @@ fn take_opt_value(
278282
}
279283
}
280284

285+
fn run_corpus(rest: &[&String]) -> u8 {
286+
let prog = "decided corpus";
287+
let mut action: Option<String> = None;
288+
let mut root: Option<String> = None;
289+
let mut corpus: Option<String> = None;
290+
let mut extras: Vec<String> = Vec::new();
291+
let mut positional_only = false;
292+
293+
let mut i = 0;
294+
while i < rest.len() {
295+
let arg = rest[i].as_str();
296+
if positional_only || arg == "-" || !arg.starts_with('-') {
297+
if action.is_none() {
298+
if arg != "digest" {
299+
return argparse_error(
300+
prog,
301+
&format!(
302+
"argument action: invalid choice: '{arg}' (choose from 'digest')"
303+
),
304+
);
305+
}
306+
action = Some(arg.to_string());
307+
} else {
308+
extras.push(arg.to_string());
309+
}
310+
i += 1;
311+
continue;
312+
}
313+
match arg {
314+
"--" => positional_only = true,
315+
other if other == "--root" || other.starts_with("--root=") => {
316+
match take_opt_value(prog, "--root", other, rest, &mut i) {
317+
Ok(value) => root = Some(value),
318+
Err(code) => return code,
319+
}
320+
}
321+
other if other == "--corpus" || other.starts_with("--corpus=") => {
322+
match take_opt_value(prog, "--corpus", other, rest, &mut i) {
323+
Ok(value) => corpus = Some(value),
324+
Err(code) => return code,
325+
}
326+
}
327+
other => extras.push(other.to_string()),
328+
}
329+
i += 1;
330+
}
331+
332+
if action.is_none() {
333+
return argparse_error(prog, "the following arguments are required: action");
334+
}
335+
if root.is_none() || corpus.is_none() {
336+
let missing = match (root.is_none(), corpus.is_none()) {
337+
(true, true) => "--root, --corpus",
338+
(true, false) => "--root",
339+
(false, true) => "--corpus",
340+
(false, false) => unreachable!(),
341+
};
342+
return argparse_error(
343+
prog,
344+
&format!("the following arguments are required: {missing}"),
345+
);
346+
}
347+
if !extras.is_empty() {
348+
return unrecognized(&extras);
349+
}
350+
351+
cmd_corpus_digest(&CorpusDigestArgs {
352+
root: root.expect("checked above"),
353+
corpus: corpus.expect("checked above"),
354+
}) as u8
355+
}
356+
281357
fn run_validate(rest: &[&String]) -> u8 {
282358
let prog = "decided validate";
283359
let mut file: Option<String> = None;

rust/rac-engine/src/commands.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2244,6 +2244,27 @@ pub fn cmd_rename(args: &RenameArgs) -> i32 {
22442244
EXIT_OK
22452245
}
22462246

2247+
pub struct CorpusDigestArgs {
2248+
pub root: String,
2249+
pub corpus: String,
2250+
}
2251+
2252+
/// Read-only operator calculation for the canonical parent corpus pin. The
2253+
/// implementation consumes only local bytes below `root` and cannot write,
2254+
/// fetch, refresh, or repin anything.
2255+
pub fn cmd_corpus_digest(args: &CorpusDigestArgs) -> i32 {
2256+
match crate::federation::calculate_parent_digest(&args.root, &args.corpus) {
2257+
Ok(result) => {
2258+
emit(result.digest);
2259+
EXIT_OK
2260+
}
2261+
Err(error) => {
2262+
eprintln!("decided: {error}");
2263+
EXIT_VALIDATION_FAILED
2264+
}
2265+
}
2266+
}
2267+
22472268
pub struct TelemetryArgs {
22482269
/// Validated positional choice; argparse default is `status`.
22492270
pub action: String,

0 commit comments

Comments
 (0)