Skip to content

Commit d4f689a

Browse files
committed
feat: content-hash invalidation of proven (stale detection)
Implements the `proven -> inbox` auto-invalidation from arghda-spec.adoc's open question ("record a content hash so upstream changes can invalidate proven"). Answers the recurring echo-types pain: "is this proven thing still valid, or did something underneath it change?" - `hash` module: dependency-free SHA-256 (FIPS 180-4), pinned against the NIST vectors (empty / "abc" / "hello world" / multi-block). Kept hermetic (no sha2 dependency tree) in keeping with the crate's lightweight ethos; this is change-detection, not a security boundary. - `proven` module: the `.arghda/hashes.json` manifest (basename -> {sha256, promoted_at}). - `Workspace::transition` records a file's hash on entry to `proven` and drops it on exit; `Workspace::stale_proven` recomputes and flags proven files whose content changed since promotion (or were never recorded — the "how did this get into proven/?" case). - CLI `stale <ws> [--invalidate]`: lists stale proven files; `--invalidate` moves each back to inbox (proven -> inbox) with an Invalidate event. Scope: v1 hashes file content only. The spec's "file + transitive imports" form needs the workspace to track the source tree's include root (the flat triage layout doesn't carry it) — a documented follow-on. Verification - cargo fmt/clippy(-D warnings)/test clean; 35 tests (+4 SHA-256 vectors, +3 proven lifecycle). `just check` + `just validate-rsr` green. - End-to-end CLI dogfood: promote records the hash; editing the proven file is flagged "content changed since promotion"; `stale --invalidate` moves it back to inbox and logs the Invalidate event. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GiSiEfgZCte35dyykgBHs
1 parent cec15c5 commit d4f689a

8 files changed

Lines changed: 368 additions & 4 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ milestones = [
2626
{ name = "State machine (claim/promote/reject/requeue/invalidate) + events.jsonl", completion = 100 },
2727
{ name = "`check` command (Agda typecheck + lint verdict)", completion = 100 },
2828
{ name = "Multi-root reachability (union of discovered CI roots)", completion = 100 },
29+
{ name = "Content-hash invalidation of proven (file-level; +imports deferred)", completion = 75 },
2930
{ name = "Remaining lint rules (unpinned-headline, unused-import)", completion = 0 },
30-
{ name = "Content-hash invalidation of proven", completion = 0 },
3131
{ name = "Groove service manifest (/.well-known/groove)", completion = 0 },
3232
{ name = "RSR retrofit (.machine_readable, Justfile, .well-known)", completion = 60 },
3333
]
@@ -38,8 +38,8 @@ milestones = [
3838

3939
[critical-next-actions]
4040
actions = [
41-
"Add unpinned-headline + unused-import lint rules",
42-
"Content-hash proven files (SHA-256 of file + imports) for auto-invalidation",
41+
"Add unpinned-headline (needs config + headline regex) + unused-import (agda-unused) lint rules",
42+
"Extend content-hash to cover transitive imports (needs include-root tracking)",
4343
"Serve /.well-known/groove for PanLL discovery",
4444
"Scaffold arghda-studio (AffineScript visual layer consuming the dag JSON)",
4545
]

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ All notable changes to arghda-core are documented here. The format follows
2626
(warn).
2727
- RSR scaffolding: `.machine_readable/6a2/` artefacts, `0-AI-MANIFEST.a2ml`,
2828
`Justfile`, `.well-known/`, and community-health files.
29+
- Content-hash invalidation of `proven`: promotion records a SHA-256 of the
30+
file in `.arghda/hashes.json`; the `stale` command reports proven files
31+
edited since promotion, and `stale --invalidate` moves them back to inbox
32+
(the `proven -> inbox` invalidation). Dependency-free SHA-256, pinned
33+
against the NIST test vectors.
2934

3035
### Notes
3136

src/hash.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
//! Dependency-free SHA-256 (FIPS 180-4).
2+
//!
3+
//! Used for `proven`-state content hashing — detecting when a file changes
4+
//! after it was promoted, so the `proven -> inbox` invalidation in
5+
//! `arghda-spec.adoc` can fire. This is change-detection, not a security
6+
//! boundary, and the crate's ethos is lightweight, so we keep it hermetic
7+
//! (no `sha2` dependency tree) and pin correctness against the canonical
8+
//! NIST vectors in the tests below.
9+
10+
const H0: [u32; 8] = [
11+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
12+
];
13+
14+
const K: [u32; 64] = [
15+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
16+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
17+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
18+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
19+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
20+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
21+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
22+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
23+
];
24+
25+
/// Lowercase hex SHA-256 digest of `data`.
26+
pub fn sha256_hex(data: &[u8]) -> String {
27+
// Pre-processing: append 0x80, pad with zeros to 56 mod 64, append the
28+
// 64-bit big-endian bit length.
29+
let mut msg = data.to_vec();
30+
let bit_len = (data.len() as u64).wrapping_mul(8);
31+
msg.push(0x80);
32+
while msg.len() % 64 != 56 {
33+
msg.push(0);
34+
}
35+
msg.extend_from_slice(&bit_len.to_be_bytes());
36+
37+
let mut h = H0;
38+
for chunk in msg.chunks_exact(64) {
39+
let mut w = [0u32; 64];
40+
for (i, word) in chunk.chunks_exact(4).take(16).enumerate() {
41+
w[i] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]);
42+
}
43+
for i in 16..64 {
44+
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
45+
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
46+
w[i] = w[i - 16]
47+
.wrapping_add(s0)
48+
.wrapping_add(w[i - 7])
49+
.wrapping_add(s1);
50+
}
51+
52+
let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
53+
for (&ki, &wi) in K.iter().zip(w.iter()) {
54+
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
55+
let ch = (e & f) ^ ((!e) & g);
56+
let t1 = hh
57+
.wrapping_add(s1)
58+
.wrapping_add(ch)
59+
.wrapping_add(ki)
60+
.wrapping_add(wi);
61+
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
62+
let maj = (a & b) ^ (a & c) ^ (b & c);
63+
let t2 = s0.wrapping_add(maj);
64+
hh = g;
65+
g = f;
66+
f = e;
67+
e = d.wrapping_add(t1);
68+
d = c;
69+
c = b;
70+
b = a;
71+
a = t1.wrapping_add(t2);
72+
}
73+
for (slot, v) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
74+
*slot = slot.wrapping_add(v);
75+
}
76+
}
77+
78+
let mut out = String::with_capacity(64);
79+
for word in h {
80+
out.push_str(&format!("{word:08x}"));
81+
}
82+
out
83+
}
84+
85+
#[cfg(test)]
86+
mod tests {
87+
use super::*;
88+
89+
// Canonical FIPS 180-4 / NIST test vectors.
90+
#[test]
91+
fn empty_string() {
92+
assert_eq!(
93+
sha256_hex(b""),
94+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
95+
);
96+
}
97+
98+
#[test]
99+
fn abc() {
100+
assert_eq!(
101+
sha256_hex(b"abc"),
102+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
103+
);
104+
}
105+
106+
#[test]
107+
fn hello_world() {
108+
assert_eq!(
109+
sha256_hex(b"hello world"),
110+
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
111+
);
112+
}
113+
114+
#[test]
115+
fn multi_block() {
116+
// 56 bytes forces a second padding block (length straddles the boundary).
117+
let input = "a".repeat(1000);
118+
assert_eq!(
119+
sha256_hex(input.as_bytes()),
120+
"41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3"
121+
);
122+
}
123+
}

src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ pub mod dag;
88
pub mod diagnostic;
99
pub mod event;
1010
pub mod graph;
11+
pub mod hash;
1112
pub mod lint;
13+
pub mod proven;
1214
pub mod timestamp;
1315
pub mod watcher;
1416
pub mod workspace;
@@ -19,4 +21,4 @@ pub use diagnostic::{Diagnostic, LintReport, Severity};
1921
pub use event::{Event, EventKind};
2022
pub use graph::{build as build_graph, ImportGraph};
2123
pub use lint::{default_rules, run_lints, LintRule};
22-
pub use workspace::{State, Workspace};
24+
pub use workspace::{StaleEntry, State, Workspace};

src/main.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ enum Cmd {
7171
Invalidate { workspace: PathBuf, file: String },
7272
/// Print the workspace event log.
7373
Events { workspace: PathBuf },
74+
/// List proven files whose content changed since promotion (stale).
75+
Stale {
76+
workspace: PathBuf,
77+
/// Move stale files back to inbox (proven -> inbox).
78+
#[arg(long)]
79+
invalidate: bool,
80+
},
7481
/// Watch `inbox/` and `working/` in a workspace; print events.
7582
Watch { workspace: PathBuf },
7683
}
@@ -105,6 +112,10 @@ fn main() -> Result<()> {
105112
transition(&workspace, &file, State::Proven, State::Inbox)?
106113
}
107114
Cmd::Events { workspace } => events(&workspace)?,
115+
Cmd::Stale {
116+
workspace,
117+
invalidate,
118+
} => stale(&workspace, invalidate)?,
108119
Cmd::Watch { workspace } => watch(&workspace)?,
109120
}
110121
Ok(())
@@ -290,6 +301,30 @@ fn events(workspace: &Path) -> Result<()> {
290301
Ok(())
291302
}
292303

304+
fn stale(workspace: &Path, invalidate: bool) -> Result<()> {
305+
let ws = Workspace::open(workspace)?;
306+
let stale = ws.stale_proven()?;
307+
if stale.is_empty() {
308+
println!("(no stale proven files)");
309+
return Ok(());
310+
}
311+
for s in &stale {
312+
println!("stale: {} ({})", s.file, s.reason);
313+
}
314+
if invalidate {
315+
for s in &stale {
316+
ws.transition(
317+
&s.file,
318+
State::Proven,
319+
State::Inbox,
320+
Some(format!("auto-invalidated: {}", s.reason)),
321+
)?;
322+
println!("invalidated: {} (proven -> inbox)", s.file);
323+
}
324+
}
325+
Ok(())
326+
}
327+
293328
fn watch(workspace_path: &Path) -> Result<()> {
294329
let ws = Workspace::open(workspace_path)?;
295330
let inbox = ws.state_dir(State::Inbox);

src/proven.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//! The `proven`-state content-hash manifest (`.arghda/hashes.json`).
2+
//!
3+
//! When a file is promoted to `proven`, its content hash is recorded here.
4+
//! [`crate::Workspace::stale_proven`] recomputes and compares, so a `proven`
5+
//! file that was edited (or never recorded) after promotion is surfaced and
6+
//! can be sent back to `inbox` — the `proven -> inbox` invalidation from
7+
//! `docs/arghda-spec.adoc`.
8+
//!
9+
//! v1 hashes the file content only. Hashing a file *plus its transitive
10+
//! imports* (the spec's full form) needs the workspace to know the source
11+
//! tree's include root, which the flat triage layout does not yet carry;
12+
//! that is a documented follow-on.
13+
14+
use crate::hash::sha256_hex;
15+
use anyhow::{Context, Result};
16+
use serde::{Deserialize, Serialize};
17+
use std::collections::BTreeMap;
18+
use std::fs;
19+
use std::path::{Path, PathBuf};
20+
21+
pub const HASHES_FILE: &str = "hashes.json";
22+
23+
/// What was recorded when a file entered `proven`.
24+
#[derive(Clone, Debug, Serialize, Deserialize)]
25+
pub struct ProvenRecord {
26+
pub sha256: String,
27+
pub promoted_at: String,
28+
}
29+
30+
/// The whole manifest: basename -> record.
31+
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
32+
pub struct ProvenManifest {
33+
#[serde(default)]
34+
pub entries: BTreeMap<String, ProvenRecord>,
35+
}
36+
37+
fn manifest_path(ws_root: &Path) -> PathBuf {
38+
ws_root.join(".arghda").join(HASHES_FILE)
39+
}
40+
41+
/// Load the manifest, or an empty one if it does not exist yet.
42+
pub fn load(ws_root: &Path) -> Result<ProvenManifest> {
43+
let path = manifest_path(ws_root);
44+
if !path.is_file() {
45+
return Ok(ProvenManifest::default());
46+
}
47+
let contents =
48+
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
49+
serde_json::from_str(&contents).with_context(|| format!("parsing {}", path.display()))
50+
}
51+
52+
/// Write the manifest, creating `.arghda/` if needed.
53+
pub fn save(ws_root: &Path, manifest: &ProvenManifest) -> Result<()> {
54+
let meta = ws_root.join(".arghda");
55+
fs::create_dir_all(&meta).with_context(|| format!("creating {}", meta.display()))?;
56+
let path = manifest_path(ws_root);
57+
let mut json = serde_json::to_string_pretty(manifest).context("serialising manifest")?;
58+
json.push('\n');
59+
fs::write(&path, json).with_context(|| format!("writing {}", path.display()))
60+
}
61+
62+
/// SHA-256 of a file's bytes, lowercase hex.
63+
pub fn hash_file(path: &Path) -> Result<String> {
64+
let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
65+
Ok(sha256_hex(&bytes))
66+
}

src/workspace.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::event::{self, Event, EventKind};
2+
use crate::proven::{self, ProvenRecord};
23
use anyhow::{Context, Result};
34
use serde::{Deserialize, Serialize};
45
use std::fs;
@@ -121,10 +122,65 @@ impl Workspace {
121122
.with_from(from)
122123
.with_to(to)
123124
.with_note(note);
125+
126+
// Maintain the proven content-hash manifest: record on entry to
127+
// `proven`, drop on exit. This is what lets `stale_proven` detect a
128+
// file edited after promotion.
129+
if to == State::Proven {
130+
let mut manifest = proven::load(&self.root)?;
131+
manifest.entries.insert(
132+
file_name.to_string(),
133+
ProvenRecord {
134+
sha256: proven::hash_file(&dst)?,
135+
promoted_at: ev.ts.clone(),
136+
},
137+
);
138+
proven::save(&self.root, &manifest)?;
139+
} else if from == State::Proven {
140+
let mut manifest = proven::load(&self.root)?;
141+
if manifest.entries.remove(file_name).is_some() {
142+
proven::save(&self.root, &manifest)?;
143+
}
144+
}
145+
124146
event::append(&self.root, &ev)
125147
.with_context(|| format!("logging {:?} for {}", kind, file_name))?;
126148
Ok(dst)
127149
}
150+
151+
/// Proven files whose current content no longer matches the hash recorded
152+
/// at promotion (or that were never recorded). These are the candidates
153+
/// for `proven -> inbox` invalidation.
154+
pub fn stale_proven(&self) -> Result<Vec<StaleEntry>> {
155+
let manifest = proven::load(&self.root)?;
156+
let mut out = Vec::new();
157+
for path in self.list(State::Proven)? {
158+
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
159+
continue;
160+
};
161+
let current = proven::hash_file(&path)?;
162+
let reason = match manifest.entries.get(name) {
163+
None => Some("no recorded hash"),
164+
Some(rec) if rec.sha256 != current => Some("content changed since promotion"),
165+
Some(_) => None,
166+
};
167+
if let Some(reason) = reason {
168+
out.push(StaleEntry {
169+
file: name.to_string(),
170+
reason,
171+
});
172+
}
173+
}
174+
out.sort_by(|a, b| a.file.cmp(&b.file));
175+
Ok(out)
176+
}
177+
}
178+
179+
/// A `proven` file flagged stale by [`Workspace::stale_proven`].
180+
#[derive(Clone, Debug)]
181+
pub struct StaleEntry {
182+
pub file: String,
183+
pub reason: &'static str,
128184
}
129185

130186
/// The event kind for a transition, or `None` if the pair is not a legal

0 commit comments

Comments
 (0)