Skip to main content

verbs/review/
state_review.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Heddle-owned local state-review operations.
3//!
4//! Reads and writes review-signature attachments. Verifies the client-supplied
5//! signature against the deterministic [`signing_payload`] before persisting.
6
7use crypto::verify_payload_signature;
8use objects::{
9    lock::RepositoryLockExt,
10    object::{
11        Blob, DiffKind, Discussion, DiscussionsBlob, ProducerId, ReviewKind, ReviewScope,
12        ReviewSignature, ReviewSignaturesBlob, RiskSignalBlob, RiskSignalKind, SignalAnchor, State,
13        StateAttachment, StateAttachmentBody, StateId, signing_payload,
14    },
15    store::ObjectStore,
16    worktree::diff_blobs,
17};
18use repo::{Repository, StateAttachmentKind};
19use serde::{Deserialize, Serialize};
20use state_review::{
21    PathSymbol, ReadingOrderPartition, SymbolKind, payload::build_review_payload_partition_owned,
22};
23
24use super::{LocalReviewContext, LocalReviewError, map_repository_error, with_idempotency};
25
26/// Maximum drift (seconds) between the client's `signed_at_unix` and the
27/// local wall clock. Generous enough to absorb NTP skew, narrow enough
28/// to bound the window for replay-style attacks.
29const SIGN_TIMESTAMP_SKEW_SECS: i64 = 5 * 60;
30
31/// Collision-safe marker reserved for verdict metadata persisted in the
32/// review-signature justification field by the hosted review contract.
33const VERDICT_ENVELOPE_TAG: &str = "\u{1}hd-verdict-v1\u{1}";
34
35/// Idempotency namespace for the repository-local JSON response codec.
36///
37/// The earlier implementation reused the hosted RPC verb while caching
38/// Prost bytes. Keeping the codec generation in the verb makes that old data
39/// a cross-verb conflict instead of attempting to decode it as JSON. Dedup
40/// entries expire after seven days, so a new operation id is the clean-cut
41/// migration path.
42const LOCAL_SIGN_REPLAY_VERB: &str = "local.state_review.sign/json-v1";
43
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct ReviewSummary {
46    pub headline: String,
47    pub files_changed: u32,
48    pub added_lines: u32,
49    pub removed_lines: u32,
50    pub in_budget_signal_count: u32,
51    pub hidden_signal_count: u32,
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum ReviewSignalKind {
56    DiffSummary,
57    Risk(RiskSignalKind),
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum ReviewSignalVisibility {
62    Visible,
63    Hidden,
64}
65
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct ReviewSignal {
68    pub kind: ReviewSignalKind,
69    pub anchor: SignalAnchor,
70    pub reason: String,
71    pub producer: ProducerId,
72    pub computed_at: Option<i64>,
73    pub visibility: ReviewSignalVisibility,
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct ReviewPayload {
78    pub state_id: StateId,
79    pub summary: ReviewSummary,
80    pub agent_narrative: Option<String>,
81    pub partition: ReadingOrderPartition,
82    pub in_budget_signals: Vec<ReviewSignal>,
83    pub all_signals: Vec<ReviewSignal>,
84    pub tick_budget: u32,
85    pub discussions: Vec<Discussion>,
86    pub signing_kinds: Vec<ReviewKind>,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct StoredReviewSignature {
91    pub id: String,
92    pub signature: ReviewSignature,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96pub struct SignReviewRequest {
97    pub state_id: StateId,
98    pub kind: ReviewKind,
99    pub scope: ReviewScope,
100    pub justification: Option<String>,
101    pub algorithm: String,
102    pub public_key: Vec<u8>,
103    pub signature: Vec<u8>,
104    pub signed_at: i64,
105    pub client_operation_id: String,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109pub struct SignReviewResult {
110    pub signature_id: String,
111    pub state_id: StateId,
112}
113
114/// Deep local review module used in-process by native CLI commands.
115#[derive(Clone)]
116pub struct LocalStateReview {
117    inner: LocalReviewContext,
118}
119
120impl LocalStateReview {
121    pub fn new(inner: LocalReviewContext) -> Self {
122        Self { inner }
123    }
124
125    pub fn get_review_payload(
126        &self,
127        state_id: StateId,
128        include_all_signals: bool,
129    ) -> Result<ReviewPayload, LocalReviewError> {
130        self.get_review_payload_from(state_id, include_all_signals, None)
131    }
132
133    /// Build the existing review payload with an optional named change-list
134    /// base. `None` preserves state-vs-first-parent review behavior.
135    pub fn get_review_payload_from(
136        &self,
137        state_id: StateId,
138        include_all_signals: bool,
139        base_state_id: Option<StateId>,
140    ) -> Result<ReviewPayload, LocalReviewError> {
141        let repo = self.inner.repo();
142        let state = repo
143            .store()
144            .get_state(&state_id)
145            .map_err(map_repository_error)?
146            .ok_or_else(|| {
147                LocalReviewError::not_found(format!(
148                    "state {} not found",
149                    state_id.to_string_full()
150                ))
151            })?;
152
153        // Diff the state's tree against its first parent so the summary
154        // counts reflect what actually changed in this state. The
155        // signal registry / budgeter will eventually layer on top of
156        // this; until then `files_changed` is the most useful single
157        // number an agent can use for self-review.
158        let diff_summary = compute_state_diff_summary(repo, &state, base_state_id)
159            .map_err(map_repository_error)?;
160
161        let summary = ReviewSummary {
162            headline: state.intent.clone().unwrap_or_default(),
163            files_changed: diff_summary.files_changed,
164            added_lines: diff_summary.added_lines,
165            removed_lines: diff_summary.removed_lines,
166            in_budget_signal_count: 0,
167            hidden_signal_count: 0,
168        };
169
170        let agent_narrative = if state.attribution.agent.is_some() {
171            state.intent.clone()
172        } else {
173            None
174        };
175
176        // Persist-time risk signals are always in-budget. `--all-signals`
177        // also copies them onto `all_signals` (no hidden partition yet;
178        // HEDDLE-DR-10 removed the unused ranked budgeter).
179        let risk_signals = load_persisted_risk_signals(repo, &state.state_id)?;
180        let all_signals = if include_all_signals {
181            risk_signals.clone()
182        } else {
183            Vec::new()
184        };
185
186        // Synthesize a structured `diff_summary` signal so the
187        // `in_budget_signals` array is non-empty even before the real
188        // signal registry is wired up. Anchored on each modified
189        // file (capped) so consumers can iterate without losing the
190        // summary aggregate. This is a deliberate stable shape: agents
191        // already iterating signals get a usable record per file
192        // change, and the registry-driven path will simply layer real
193        // signals alongside it.
194        let mut in_budget_signals = Vec::new();
195        let summary_reason = format!(
196            "{} files changed (+{}/-{}, {} added, {} modified, {} deleted)",
197            diff_summary.files_changed,
198            diff_summary.added_lines,
199            diff_summary.removed_lines,
200            diff_summary.added_files,
201            diff_summary.modified_files,
202            diff_summary.deleted_files,
203        );
204        // Per-file anchors keep the array reasoning-friendly when
205        // many files change, but cap so very large diffs don't bloat
206        // the payload. The aggregate summary always rides on the
207        // first entry's reason field; the rest carry per-file deltas.
208        const MAX_DIFF_SIGNAL_ANCHORS: usize = 32;
209        if diff_summary.changed_paths.is_empty() {
210            in_budget_signals.push(ReviewSignal {
211                kind: ReviewSignalKind::DiffSummary,
212                anchor: SignalAnchor {
213                    file: String::new(),
214                    symbol: None,
215                    line_range: None,
216                },
217                reason: summary_reason.clone(),
218                producer: ProducerId::new("review_show.diff_summary", 1),
219                computed_at: None,
220                visibility: ReviewSignalVisibility::Visible,
221            });
222        } else {
223            for (idx, path_kind) in diff_summary
224                .changed_paths
225                .iter()
226                .take(MAX_DIFF_SIGNAL_ANCHORS)
227                .enumerate()
228            {
229                let reason = if idx == 0 {
230                    summary_reason.clone()
231                } else {
232                    format!("{} ({})", path_kind.path, path_kind.kind_str())
233                };
234                in_budget_signals.push(ReviewSignal {
235                    kind: ReviewSignalKind::DiffSummary,
236                    anchor: SignalAnchor {
237                        file: path_kind.path.clone(),
238                        symbol: None,
239                        line_range: None,
240                    },
241                    reason,
242                    producer: ProducerId::new("review_show.diff_summary", 1),
243                    computed_at: None,
244                    visibility: ReviewSignalVisibility::Visible,
245                });
246            }
247        }
248        in_budget_signals.extend(risk_signals);
249
250        // Build the reading-order partition from the same domain symbols
251        // used at the hosted boundary: tree-sitter when the
252        // `semantic` feature is enabled, path-only fallback otherwise.
253        let symbols = changed_files_as_symbols(repo, &state, &diff_summary.changed_paths)
254            .map_err(map_repository_error)?;
255        let partition = build_review_payload_partition_owned(symbols);
256
257        // Decode the state's durable discussions attachment when present.
258        let discussions =
259            match attachment_hash(repo, &state.state_id, StateAttachmentKind::Discussions)? {
260                Some(hash) => {
261                    let blob = repo
262                        .store()
263                        .get_blob(&hash)
264                        .map_err(map_repository_error)?
265                        .ok_or_else(|| {
266                            LocalReviewError::internal(format!(
267                                "discussions blob {} referenced by state {} is missing",
268                                hash,
269                                state.state_id.to_string_full()
270                            ))
271                        })?;
272                    let decoded = DiscussionsBlob::decode(blob.content()).map_err(|err| {
273                        LocalReviewError::internal(format!("decode discussions: {err}"))
274                    })?;
275                    decoded.discussions
276                }
277                None => Vec::new(),
278            };
279
280        let mut summary = summary;
281        summary.in_budget_signal_count = in_budget_signals.len() as u32;
282        summary.hidden_signal_count =
283            all_signals.len().saturating_sub(in_budget_signals.len()) as u32;
284
285        let payload = ReviewPayload {
286            state_id,
287            summary,
288            agent_narrative,
289            partition,
290            in_budget_signals,
291            all_signals,
292            tick_budget: 3,
293            discussions,
294            signing_kinds: vec![
295                ReviewKind::Read,
296                ReviewKind::AgentPreview,
297                ReviewKind::AgentCoReview,
298            ],
299        };
300
301        Ok(payload)
302    }
303
304    pub async fn sign_state(
305        &self,
306        req: SignReviewRequest,
307    ) -> Result<SignReviewResult, LocalReviewError> {
308        let req_bytes = serde_json::to_vec(&req)
309            .map_err(|error| LocalReviewError::internal(format!("encode sign request: {error}")))?;
310        let client_operation_id = req.client_operation_id.clone();
311        let inner = self.inner.clone();
312
313        let response = with_idempotency(
314            &self.inner,
315            &client_operation_id,
316            LOCAL_SIGN_REPLAY_VERB,
317            &req_bytes,
318            move || {
319                let inner = inner.clone();
320                async move { execute_sign_state(&inner, req).await }
321            },
322        )
323        .await?;
324
325        Ok(response)
326    }
327
328    pub fn list_signatures(
329        &self,
330        state_id: StateId,
331    ) -> Result<Vec<StoredReviewSignature>, LocalReviewError> {
332        let repo = self.inner.repo();
333        let state = repo
334            .store()
335            .get_state(&state_id)
336            .map_err(map_repository_error)?
337            .ok_or_else(|| {
338                LocalReviewError::not_found(format!(
339                    "state {} not found",
340                    state_id.to_string_full()
341                ))
342            })?;
343
344        let signatures =
345            match attachment_hash(repo, &state.state_id, StateAttachmentKind::ReviewSignatures)? {
346                Some(hash) => {
347                    let blob = repo
348                        .store()
349                        .get_blob(&hash)
350                        .map_err(map_repository_error)?
351                        .ok_or_else(|| {
352                            LocalReviewError::internal(format!(
353                                "review signatures blob {} missing from object store",
354                                hash
355                            ))
356                        })?;
357                    let decoded = ReviewSignaturesBlob::decode(blob.content()).map_err(|err| {
358                        LocalReviewError::internal(format!("decode review signatures: {err}"))
359                    })?;
360                    decoded
361                        .signatures
362                        .into_iter()
363                        .enumerate()
364                        .map(|(idx, sig)| StoredReviewSignature {
365                            id: synthetic_signature_id(idx),
366                            signature: sig,
367                        })
368                        .collect()
369                }
370                None => Vec::new(),
371            };
372
373        Ok(signatures)
374    }
375}
376
377/// Body of [`LocalStateReview::sign_state`]. Lifted out of the public method
378/// method so [`with_idempotency`] can re-execute it inside its closure.
379async fn execute_sign_state(
380    inner: &LocalReviewContext,
381    req: SignReviewRequest,
382) -> Result<SignReviewResult, LocalReviewError> {
383    let state_id = req.state_id;
384    let repo = inner.repo();
385
386    // Build the ReviewSignature, then verify the client-supplied
387    // signature is well-formed and matches the deterministic signing
388    // payload. A malformed or forged signature must never reach the
389    // persisted blob. Attribute the signature to the local-mode
390    // caller (`Repository::get_principal` resolves env vars then
391    // `[principal]` in `.heddle/config.toml`), not the state's author
392    // — Bob signing Alice's state should record Bob.
393    let actor = repo
394        .get_principal()
395        .map_err(|err| LocalReviewError::internal(format!("resolve caller principal: {err}")))?;
396    if req
397        .justification
398        .as_deref()
399        .is_some_and(|text| text.starts_with(VERDICT_ENVELOPE_TAG))
400    {
401        return Err(LocalReviewError::invalid_argument(
402            "justification must not begin with the reserved verdict-envelope prefix",
403        ));
404    }
405    let justification = req.justification.clone().filter(|text| !text.is_empty());
406
407    let now = chrono::Utc::now().timestamp();
408    let signed_at = req.signed_at;
409    if signed_at == 0 {
410        return Err(LocalReviewError::invalid_argument(
411            "signed_at is required and must match the timestamp the client signed over",
412        ));
413    }
414    if (signed_at - now).abs() > SIGN_TIMESTAMP_SKEW_SECS {
415        return Err(LocalReviewError::invalid_argument(format!(
416            "signed_at={signed_at} is too far from server time={now} (max skew {SIGN_TIMESTAMP_SKEW_SECS}s)"
417        )));
418    }
419
420    let new_sig = ReviewSignature {
421        actor,
422        kind: req.kind,
423        scope: req.scope.clone(),
424        justification: justification.clone(),
425        signed_at,
426        algorithm: req.algorithm.clone(),
427        public_key: hex::encode(&req.public_key),
428        signature: hex::encode(&req.signature),
429    };
430    new_sig.validate().map_err(|err| {
431        LocalReviewError::invalid_argument(format!("invalid review signature: {err}"))
432    })?;
433
434    let public_key_bytes = req.public_key.clone();
435    let signature_bytes = req.signature.clone();
436    let payload = signing_payload(
437        state_id,
438        req.kind,
439        &req.scope,
440        signed_at,
441        justification.as_deref(),
442    );
443    verify_payload_signature(
444        &payload,
445        &req.algorithm,
446        &public_key_bytes,
447        &signature_bytes,
448    )
449    .map_err(|err| {
450        LocalReviewError::invalid_argument(format!(
451            "review signature failed verification ({}): {err}",
452            req.algorithm
453        ))
454    })?;
455
456    let new_index = append_review_signature(repo, state_id, new_sig)?;
457
458    Ok(SignReviewResult {
459        signature_id: synthetic_signature_id(new_index),
460        state_id,
461    })
462}
463
464/// Append one signed review record while holding the repository write lock.
465fn append_review_signature(
466    repo: &Repository,
467    state_id: StateId,
468    signature: ReviewSignature,
469) -> Result<usize, LocalReviewError> {
470    let _lock = repo
471        .locker()
472        .write()
473        .map_err(|err| LocalReviewError::internal(err.to_string()))?;
474    repo.store()
475        .get_state(&state_id)
476        .map_err(map_repository_error)?
477        .ok_or_else(|| {
478            LocalReviewError::not_found(format!("state {} not found", state_id.to_string_full()))
479        })?;
480    let prior = repo
481        .latest_state_attachment(&state_id, StateAttachmentKind::ReviewSignatures)
482        .map_err(map_repository_error)?;
483    let mut blob = match prior.as_ref().map(|attachment| {
484        let StateAttachmentBody::ReviewSignatures(hash) = &attachment.body else {
485            unreachable!()
486        };
487        *hash
488    }) {
489        Some(hash) => {
490            let raw = repo
491                .store()
492                .get_blob(&hash)
493                .map_err(map_repository_error)?
494                .ok_or_else(|| {
495                    LocalReviewError::internal(format!(
496                        "existing review signatures blob {} missing from object store",
497                        hash
498                    ))
499                })?;
500            ReviewSignaturesBlob::decode(raw.content()).map_err(|err| {
501                LocalReviewError::internal(format!("decode review signatures: {err}"))
502            })?
503        }
504        None => ReviewSignaturesBlob::new(Vec::new()),
505    };
506    blob.signatures.push(signature);
507    let new_index = blob.signatures.len() - 1;
508
509    let bytes = blob
510        .encode()
511        .map_err(|err| LocalReviewError::internal(format!("encode review signatures: {err}")))?;
512    let content_hash = repo
513        .store()
514        .put_blob(&Blob::new(bytes))
515        .map_err(map_repository_error)?;
516
517    let attachment = StateAttachment {
518        state_id,
519        body: StateAttachmentBody::ReviewSignatures(content_hash),
520        attribution: repo.get_attribution().map_err(map_repository_error)?,
521        created_at: chrono::Utc::now(),
522        supersedes: prior.map(|attachment| attachment.id()),
523    };
524    repo.put_state_attachment(&attachment)
525        .map_err(map_repository_error)?;
526    Ok(new_index)
527}
528
529/// `ReviewSignature` doesn't carry an explicit id; we synthesise one from
530/// the per-state index so local output has stable signature ids within a
531/// single state. (A future schema bump may add an explicit id.)
532fn synthetic_signature_id(index: usize) -> String {
533    format!("rs-{index}")
534}
535
536fn load_persisted_risk_signals(
537    repo: &Repository,
538    state_id: &StateId,
539) -> Result<Vec<ReviewSignal>, LocalReviewError> {
540    let Some(hash) = attachment_hash(repo, state_id, StateAttachmentKind::RiskSignals)? else {
541        return Ok(Vec::new());
542    };
543    let Some(blob) = repo.store().get_blob(&hash).map_err(map_repository_error)? else {
544        return Ok(Vec::new());
545    };
546    let decoded = RiskSignalBlob::decode(blob.content())
547        .map_err(|err| LocalReviewError::internal(format!("decode risk signals: {err}")))?;
548    Ok(decoded
549        .signals
550        .into_iter()
551        .map(|signal| review_signal(signal, ReviewSignalVisibility::Visible))
552        .collect())
553}
554
555fn attachment_hash(
556    repo: &Repository,
557    state_id: &StateId,
558    kind: StateAttachmentKind,
559) -> Result<Option<objects::object::ContentHash>, LocalReviewError> {
560    let Some(attachment) = repo
561        .latest_state_attachment(state_id, kind)
562        .map_err(map_repository_error)?
563    else {
564        return Ok(None);
565    };
566    let hash = match attachment.body {
567        StateAttachmentBody::RiskSignals(hash)
568        | StateAttachmentBody::ReviewSignatures(hash)
569        | StateAttachmentBody::Discussions(hash) => hash,
570        _ => unreachable!(),
571    };
572    Ok(Some(hash))
573}
574
575fn review_signal(
576    signal: objects::object::RiskSignal,
577    visibility: ReviewSignalVisibility,
578) -> ReviewSignal {
579    ReviewSignal {
580        kind: ReviewSignalKind::Risk(signal.kind),
581        anchor: signal.anchor,
582        reason: signal.reason,
583        producer: signal.producer,
584        computed_at: Some(signal.computed_at),
585        visibility,
586    }
587}
588
589// ---------------------------------------------------------------------------
590// Symbol extraction for the shared review-payload domain model.
591// ---------------------------------------------------------------------------
592
593/// Symbol projection for the reading-order partition. When the `semantic`
594/// feature is enabled and the
595/// changed path has a tree-sitter parser and a readable new-side blob, emits
596/// one [`PathSymbol`] per definition. Otherwise falls back to a single path-only
597/// entry (kind = `Other`), which keeps deletes and gitlink pointer changes
598/// visible even though they do not carry Heddle blob content.
599fn changed_files_as_symbols(
600    repo: &Repository,
601    state: &State,
602    changed_paths: &[ChangedPath],
603) -> objects::error::Result<Vec<PathSymbol>> {
604    let new_tree = match repo.store().get_tree(&state.tree)? {
605        Some(t) => t,
606        None => return Ok(Vec::new()),
607    };
608    let new_files = collect_files(repo, &new_tree, "")?;
609
610    let mut out: Vec<PathSymbol> = Vec::new();
611    for path_kind in changed_paths {
612        let path = &path_kind.path;
613        #[cfg_attr(not(feature = "semantic"), allow(unused_mut))]
614        let mut emitted_any = false;
615        if let Some(hash) = new_files.get(path) {
616            #[cfg(feature = "semantic")]
617            {
618                if let Some(blob) = repo.store().get_blob(hash)? {
619                    emitted_any = extract_file_symbols(path, blob.content(), &mut out);
620                }
621            }
622            #[cfg(not(feature = "semantic"))]
623            {
624                let _ = hash;
625            }
626        }
627        if !emitted_any {
628            out.push(PathSymbol {
629                file: path.clone(),
630                symbol: path.clone(),
631                kind: SymbolKind::Other,
632            });
633        }
634    }
635    Ok(out)
636}
637
638#[cfg(feature = "semantic")]
639fn extract_file_symbols(path: &str, source: &[u8], out: &mut Vec<PathSymbol>) -> bool {
640    use ::semantic::symbol_resolver::{Definition, extract_definitions};
641    let definitions: Vec<Definition> = match extract_definitions(source, std::path::Path::new(path))
642    {
643        Ok(defs) => defs,
644        Err(_) => return false,
645    };
646    if definitions.is_empty() {
647        return false;
648    }
649    for d in definitions {
650        let symbol = match d.parent_name.as_deref() {
651            Some(parent) if !parent.is_empty() => format!("{parent}::{}", d.name),
652            _ => d.name,
653        };
654        out.push(PathSymbol {
655            file: path.to_string(),
656            symbol,
657            kind: d.kind,
658        });
659    }
660    true
661}
662
663fn collect_files(
664    repo: &Repository,
665    tree: &objects::object::Tree,
666    prefix: &str,
667) -> objects::error::Result<std::collections::HashMap<String, objects::object::ContentHash>> {
668    let mut out = std::collections::HashMap::new();
669    for entry in tree.entries() {
670        let path = if prefix.is_empty() {
671            entry.name().to_string()
672        } else {
673            format!("{prefix}/{}", entry.name())
674        };
675        if entry.is_tree() {
676            if let Some(hash) = entry.tree_hash()
677                && let Some(subtree) = repo.store().get_tree(&hash)?
678            {
679                let sub = collect_files(repo, &subtree, &path)?;
680                out.extend(sub);
681            }
682        } else if let Some(hash) = entry.content_hash() {
683            out.insert(path, hash);
684        }
685    }
686    Ok(out)
687}
688
689// ---------------------------------------------------------------------------
690// Diff summary helpers (state.tree vs first parent's tree).
691// ---------------------------------------------------------------------------
692
693/// File-change kinds we surface in the diff summary signal anchors.
694/// Mirrors `objects::object::DiffKind` minus the `Unchanged` variant
695/// (we filter those out before constructing this).
696#[derive(Debug, Clone)]
697struct ChangedPath {
698    path: String,
699    kind: DiffKind,
700}
701
702impl ChangedPath {
703    fn kind_str(&self) -> &'static str {
704        match self.kind {
705            DiffKind::Added => "added",
706            DiffKind::Modified => "modified",
707            DiffKind::Deleted => "deleted",
708            DiffKind::Unchanged => "unchanged",
709        }
710    }
711}
712
713/// Aggregated counts plus a path list, computed by diffing
714/// `state.tree` against the first parent's tree (or empty when the
715/// state is a root). When `state.parents` is empty every file in the
716/// state's tree counts as added, which makes "first capture" reviews
717/// non-empty too. The `_state` prefix on `_state` is intentional: the
718/// helper currently only reads `state.tree` and `state.parents`.
719struct DiffSummary {
720    files_changed: u32,
721    added_files: u32,
722    modified_files: u32,
723    deleted_files: u32,
724    added_lines: u32,
725    removed_lines: u32,
726    changed_paths: Vec<ChangedPath>,
727}
728
729/// Compute a summary diff for `state` vs its first parent. Errors
730/// from the object store propagate; missing trees / blobs are skipped
731/// silently (treated as zero-change for that path) so a partially
732/// pruned object store never blocks the review surface. The
733/// distinction matters: missing-object errors must become zero (the
734/// summary is best-effort, callers want a payload they can render),
735/// but genuine I/O errors must still propagate so a corrupt store
736/// surfaces loudly instead of silently truncating the review.
737fn compute_state_diff_summary(
738    repo: &Repository,
739    state: &State,
740    base_state_id: Option<StateId>,
741) -> objects::error::Result<DiffSummary> {
742    use objects::object::Tree;
743    let parent_id = base_state_id.as_ref().or_else(|| state.parents.first());
744    let parent_tree_hash = if let Some(parent_id) = parent_id {
745        match repo.store().get_state(parent_id)? {
746            Some(parent_state) => parent_state.tree,
747            None => Tree::new().hash(),
748        }
749    } else {
750        Tree::new().hash()
751    };
752
753    // Resolve both tree objects up front so the missing-tree case
754    // becomes a synthesized empty changeset rather than an error from
755    // the recursive diff. `get_tree` returns `Ok(None)` for missing
756    // (not an error), and propagates only on genuine I/O — matching
757    // the policy the doc-comment claims.
758    let parent_tree_obj = repo.store().get_tree(&parent_tree_hash)?;
759    let new_tree_obj = repo.store().get_tree(&state.tree)?;
760
761    // If either tree is missing from the local store the diff is not
762    // meaningful — return an empty summary instead of erroring out.
763    // This mirrors the "Modified branch tolerates missing blobs" stance
764    // for the *tree* level: a partially pruned store should never block
765    // review payload retrieval, only render an empty summary.
766    let changes = if parent_tree_obj.is_some() && new_tree_obj.is_some() {
767        repo.diff_trees(&parent_tree_hash, &state.tree)?
768    } else {
769        objects::object::FileChangeSet::new()
770    };
771
772    // Compute per-file line deltas. We only count `Modified` here for
773    // the symmetric add/remove totals; `Added` files contribute every
774    // line as an add, and `Deleted` files contribute every line as a
775    // remove. Files with non-utf8 contents (e.g. binaries) silently
776    // contribute zero — `diff_blobs` already returns an empty vec in
777    // that case, and we mirror the same behavior for raw line counts.
778    let mut added_lines: u32 = 0;
779    let mut removed_lines: u32 = 0;
780    let mut changed_paths: Vec<ChangedPath> = Vec::with_capacity(changes.len());
781
782    let parent_files = match parent_tree_obj.as_ref() {
783        Some(t) => collect_files(repo, t, "")?,
784        None => std::collections::HashMap::new(),
785    };
786    let new_files = match new_tree_obj.as_ref() {
787        Some(t) => collect_files(repo, t, "")?,
788        None => std::collections::HashMap::new(),
789    };
790
791    let mut added_files: u32 = 0;
792    let mut modified_files: u32 = 0;
793    let mut deleted_files: u32 = 0;
794
795    for change in changes.iter() {
796        match change.kind {
797            DiffKind::Added => {
798                added_files += 1;
799                // Missing blob (`get_blob` returns `Ok(None)`) → file
800                // counts but contributes zero lines. Genuine I/O
801                // errors still propagate via `?` — same shape as the
802                // Modified branch's intent, but here we keep the
803                // distinction explicit so a corrupt store surfaces
804                // rather than getting silently swallowed.
805                if let Some(hash) = new_files.get(&change.path)
806                    && let Some(blob) = repo.store().get_blob(hash)?
807                {
808                    added_lines = added_lines.saturating_add(line_count(blob.content()));
809                }
810            }
811            DiffKind::Deleted => {
812                deleted_files += 1;
813                if let Some(hash) = parent_files.get(&change.path)
814                    && let Some(blob) = repo.store().get_blob(hash)?
815                {
816                    removed_lines = removed_lines.saturating_add(line_count(blob.content()));
817                }
818            }
819            DiffKind::Modified => {
820                modified_files += 1;
821                // `get_blob` already returns `Ok(None)` for a missing
822                // blob, so `?` here only fires on genuine I/O. Match
823                // the Added/Deleted branches' propagation policy
824                // explicitly instead of the older `.ok().flatten()`
825                // form, which silently swallowed IO errors and
826                // conflated them with "missing".
827                let old_blob = match parent_files.get(&change.path) {
828                    Some(h) => repo.store().get_blob(h)?,
829                    None => None,
830                };
831                let new_blob = match new_files.get(&change.path) {
832                    Some(h) => repo.store().get_blob(h)?,
833                    None => None,
834                };
835                if let (Some(old), Some(new)) = (old_blob, new_blob) {
836                    for line in diff_blobs(&old, &new) {
837                        match line {
838                            objects::worktree::DiffLine::Added(_) => {
839                                added_lines = added_lines.saturating_add(1);
840                            }
841                            objects::worktree::DiffLine::Removed(_) => {
842                                removed_lines = removed_lines.saturating_add(1);
843                            }
844                            objects::worktree::DiffLine::Context(_) => {}
845                        }
846                    }
847                }
848            }
849            DiffKind::Unchanged => continue,
850        }
851        changed_paths.push(ChangedPath {
852            path: change.path.clone(),
853            kind: change.kind,
854        });
855    }
856
857    Ok(DiffSummary {
858        files_changed: changed_paths.len() as u32,
859        added_files,
860        modified_files,
861        deleted_files,
862        added_lines,
863        removed_lines,
864        changed_paths,
865    })
866}
867
868/// Count the number of newline-separated lines in a file blob. Binary
869/// blobs (non-utf8) count as zero — we deliberately don't byte-count
870/// them, since "lines" is meaningless for binary content. A trailing
871/// newline does not introduce a phantom empty line.
872fn line_count(content: &[u8]) -> u32 {
873    let Ok(s) = std::str::from_utf8(content) else {
874        return 0;
875    };
876    if s.is_empty() {
877        return 0;
878    }
879    let trimmed = s.strip_suffix('\n').unwrap_or(s);
880    if trimmed.is_empty() {
881        return 1;
882    }
883    (trimmed.matches('\n').count() as u32).saturating_add(1)
884}
885
886// ---------------------------------------------------------------------------
887// Tests
888// ---------------------------------------------------------------------------
889
890#[cfg(test)]
891mod tests {
892    use std::sync::Arc;
893
894    use crypto::Signer as _;
895    use repo::{Repository, operation_dedup::OperationDedupStore};
896    use tempfile::TempDir;
897
898    use super::*;
899
900    fn fresh_review() -> (LocalStateReview, Arc<Repository>, TempDir) {
901        let temp = TempDir::new().expect("create tempdir");
902        // SAFETY: these serial tests own the process-global attribution.
903        unsafe {
904            std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Alice Tester");
905            std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "alice@example.com");
906        }
907        let repo = Repository::init_default(temp.path()).expect("init repo");
908        let dedup = OperationDedupStore::open(repo.heddle_dir()).expect("open dedup");
909        let repo = Arc::new(repo);
910        let review =
911            LocalStateReview::new(LocalReviewContext::new(Arc::clone(&repo), Arc::new(dedup)));
912        (review, repo, temp)
913    }
914
915    fn capture_state(repo: &Repository, content: &[u8]) -> StateId {
916        std::fs::write(repo.root().join("hello.txt"), content).expect("write file");
917        repo.snapshot(Some("seed".to_string()), None)
918            .expect("snapshot")
919            .state_id
920    }
921
922    fn sign_request(state_id: StateId, operation_id: impl Into<String>) -> SignReviewRequest {
923        let signer = crypto::Ed25519Signer::generate().expect("generate ed25519 key");
924        let scope = ReviewScope::WholeChange;
925        let signed_at = chrono::Utc::now().timestamp();
926        let payload = signing_payload(state_id, ReviewKind::Read, &scope, signed_at, None);
927        SignReviewRequest {
928            state_id,
929            kind: ReviewKind::Read,
930            scope,
931            justification: None,
932            algorithm: "ed25519".to_string(),
933            public_key: signer.public_key().to_vec(),
934            signature: signer.sign(&payload).expect("sign payload"),
935            signed_at,
936            client_operation_id: operation_id.into(),
937        }
938    }
939
940    #[tokio::test]
941    #[serial_test::serial(process_global)]
942    async fn local_interface_signs_lists_and_replays_without_protocol_types() {
943        let (review, repo, _temp) = fresh_review();
944        let state_id = capture_state(&repo, b"hello\n");
945        let operation_id = objects::object::OperationId::new().to_string();
946        let request = sign_request(state_id, operation_id);
947
948        let first = review.sign_state(request.clone()).await.expect("sign");
949        let replay = review.sign_state(request).await.expect("replay");
950
951        assert_eq!(first, replay);
952        assert_eq!(first.state_id, state_id);
953        let signatures = review.list_signatures(state_id).expect("signatures");
954        assert_eq!(signatures.len(), 1, "replay must not append");
955        assert_eq!(signatures[0].id, "rs-0");
956        assert_eq!(signatures[0].signature.kind, ReviewKind::Read);
957        assert_eq!(signatures[0].signature.scope, ReviewScope::WholeChange);
958        assert_eq!(signatures[0].signature.actor.name, b"Alice Tester");
959        assert_eq!(signatures[0].signature.actor.email, b"alice@example.com");
960    }
961
962    #[tokio::test]
963    #[serial_test::serial(process_global)]
964    async fn legacy_prost_replay_is_a_controlled_operation_id_conflict() {
965        use repo::operation_dedup::hash_request_body;
966
967        let (review, repo, _temp) = fresh_review();
968        let state_id = capture_state(&repo, b"hello\n");
969        let operation_id = objects::object::OperationId::new();
970        let request = sign_request(state_id, operation_id.to_string());
971        let request_bytes = serde_json::to_vec(&request).expect("encode request");
972
973        // Simulate a response cached by the retired hosted/Prost-backed
974        // implementation. The bytes intentionally are not valid JSON.
975        review
976            .inner
977            .dedup
978            .record(
979                operation_id,
980                "state_review.sign_state",
981                hash_request_body(&request_bytes),
982                vec![0x0a, 0x03, b'o', b'l', b'd'],
983            )
984            .expect("record legacy replay");
985
986        let error = review
987            .sign_state(request)
988            .await
989            .expect_err("legacy replay must not be decoded as local JSON");
990
991        assert_eq!(
992            error.code(),
993            crate::review::LocalReviewCode::FailedPrecondition
994        );
995        assert!(
996            error
997                .message()
998                .contains("different operation or replay encoding")
999        );
1000        assert!(
1001            review
1002                .list_signatures(state_id)
1003                .expect("signatures")
1004                .is_empty()
1005        );
1006    }
1007
1008    #[tokio::test]
1009    #[serial_test::serial(process_global)]
1010    async fn local_interface_rejects_a_forged_signature() {
1011        let (review, repo, _temp) = fresh_review();
1012        let state_id = capture_state(&repo, b"hello\n");
1013        let mut request = sign_request(state_id, "");
1014        let last = request.signature.len() - 1;
1015        request.signature[last] ^= 0xff;
1016
1017        let error = review
1018            .sign_state(request)
1019            .await
1020            .expect_err("forgery must fail");
1021
1022        assert_eq!(
1023            error.code(),
1024            crate::review::LocalReviewCode::InvalidArgument
1025        );
1026        assert!(error.message().contains("failed verification"));
1027        assert!(
1028            review
1029                .list_signatures(state_id)
1030                .expect("signatures")
1031                .is_empty()
1032        );
1033    }
1034
1035    #[tokio::test]
1036    #[serial_test::serial(process_global)]
1037    async fn local_interface_rejects_the_reserved_verdict_envelope_prefix() {
1038        let (review, repo, _temp) = fresh_review();
1039        let state_id = capture_state(&repo, b"hello\n");
1040        let mut request = sign_request(state_id, "");
1041        request.justification = Some(format!("{VERDICT_ENVELOPE_TAG}{{\"verdict\":\"hold\"}}"));
1042
1043        let error = review
1044            .sign_state(request)
1045            .await
1046            .expect_err("reserved verdict envelope must fail");
1047
1048        assert_eq!(
1049            error.code(),
1050            crate::review::LocalReviewCode::InvalidArgument
1051        );
1052        assert!(error.message().contains("reserved verdict-envelope prefix"));
1053        assert!(
1054            review
1055                .list_signatures(state_id)
1056                .expect("signatures")
1057                .is_empty()
1058        );
1059    }
1060
1061    #[tokio::test]
1062    #[serial_test::serial(process_global)]
1063    async fn local_interface_rejects_a_skewed_timestamp() {
1064        let (review, repo, _temp) = fresh_review();
1065        let state_id = capture_state(&repo, b"hello\n");
1066        let mut request = sign_request(state_id, "");
1067        request.signed_at += 60 * 60;
1068
1069        let error = review
1070            .sign_state(request)
1071            .await
1072            .expect_err("skewed timestamp must fail");
1073
1074        assert_eq!(
1075            error.code(),
1076            crate::review::LocalReviewCode::InvalidArgument
1077        );
1078        assert!(error.message().contains("too far from server time"));
1079        assert!(
1080            review
1081                .list_signatures(state_id)
1082                .expect("signatures")
1083                .is_empty()
1084        );
1085    }
1086
1087    #[tokio::test]
1088    #[serial_test::serial(process_global)]
1089    async fn local_interface_attributes_the_signature_to_the_current_principal() {
1090        let (review, repo, _temp) = fresh_review();
1091        let state_id = capture_state(&repo, b"hello\n");
1092
1093        // SAFETY: this serial test owns the process-global attribution.
1094        unsafe {
1095            std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Bob Signer");
1096            std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "bob@example.com");
1097        }
1098        review
1099            .sign_state(sign_request(state_id, ""))
1100            .await
1101            .expect("sign as Bob");
1102
1103        let signature = review
1104            .list_signatures(state_id)
1105            .expect("signatures")
1106            .pop()
1107            .expect("signature")
1108            .signature;
1109        assert_eq!(signature.actor.name, b"Bob Signer");
1110        assert_eq!(signature.actor.email, b"bob@example.com");
1111    }
1112
1113    #[tokio::test]
1114    #[serial_test::serial(process_global)]
1115    async fn local_interface_serializes_concurrent_signature_appends() {
1116        let (review, repo, _temp) = fresh_review();
1117        let state_id = capture_state(&repo, b"hello\n");
1118        let request_a = sign_request(state_id, objects::object::OperationId::new().to_string());
1119        let request_b = sign_request(state_id, objects::object::OperationId::new().to_string());
1120
1121        let first_review = review.clone();
1122        let second_review = review.clone();
1123        let (a, b) = tokio::join!(
1124            first_review.sign_state(request_a),
1125            second_review.sign_state(request_b)
1126        );
1127        a.expect("first sign");
1128        b.expect("second sign");
1129
1130        assert_eq!(
1131            review.list_signatures(state_id).expect("signatures").len(),
1132            2
1133        );
1134    }
1135
1136    #[test]
1137    #[serial_test::serial(process_global)]
1138    fn local_payload_exposes_domain_summary_signals_and_reading_order() {
1139        let (review, repo, _temp) = fresh_review();
1140        let state_id = capture_state(&repo, b"first\nsecond\nthird\n");
1141
1142        let payload = review.get_review_payload(state_id, false).expect("payload");
1143
1144        assert_eq!(payload.state_id, state_id);
1145        assert!(payload.summary.files_changed >= 1);
1146        assert!(payload.summary.added_lines >= 3);
1147        assert_eq!(
1148            payload.summary.in_budget_signal_count,
1149            payload.in_budget_signals.len() as u32
1150        );
1151        let signal = payload.in_budget_signals.first().expect("diff signal");
1152        assert_eq!(signal.kind, ReviewSignalKind::DiffSummary);
1153        assert_eq!(signal.producer.module, "review_show.diff_summary");
1154        assert_eq!(signal.visibility, ReviewSignalVisibility::Visible);
1155        assert_eq!(signal.anchor.file, "hello.txt");
1156        let surfaced = payload
1157            .partition
1158            .structural
1159            .iter()
1160            .chain(payload.partition.consequence.iter())
1161            .chain(payload.partition.tests_and_docs.iter())
1162            .any(|symbol| symbol.file == "hello.txt");
1163        assert!(surfaced, "changed path must appear in reading order");
1164
1165        std::fs::write(
1166            repo.root().join("hello.txt"),
1167            b"first\nsecond changed\nthird\nfourth\n",
1168        )
1169        .expect("modify file");
1170        let modified_state = repo
1171            .snapshot(Some("modify".to_string()), None)
1172            .expect("snapshot modification")
1173            .state_id;
1174        let modified = review
1175            .get_review_payload(modified_state, false)
1176            .expect("payload");
1177        assert_eq!(modified.summary.files_changed, 1);
1178        assert!(modified.summary.added_lines >= 1);
1179        assert!(modified.summary.removed_lines >= 1);
1180        assert_eq!(
1181            modified.in_budget_signals[0].anchor.file, "hello.txt",
1182            "the aggregate signal must anchor on the changed file"
1183        );
1184        assert!(
1185            modified.in_budget_signals[0]
1186                .reason
1187                .contains("files changed")
1188        );
1189    }
1190
1191    #[test]
1192    #[serial_test::serial(process_global)]
1193    fn local_payload_surfaces_gitlink_target_changes() {
1194        let (review, repo, _temp) = fresh_review();
1195        let old_target = "0303030303030303030303030303030303030303"
1196            .parse()
1197            .expect("old git oid");
1198        let new_target = "0404040404040404040404040404040404040404"
1199            .parse()
1200            .expect("new git oid");
1201        let old_tree = objects::object::Tree::from_entries(vec![
1202            objects::object::TreeEntry::gitlink("vendor", old_target).expect("old gitlink"),
1203        ]);
1204        let new_tree = objects::object::Tree::from_entries(vec![
1205            objects::object::TreeEntry::gitlink("vendor", new_target).expect("new gitlink"),
1206        ]);
1207        let old_tree_hash = repo.store().put_tree(&old_tree).expect("put old tree");
1208        let new_tree_hash = repo.store().put_tree(&new_tree).expect("put new tree");
1209        let attribution = objects::object::Attribution::human(objects::object::Principal::new(
1210            "Gitlink Reviewer",
1211            "gitlink@example.test",
1212        ));
1213        let base = State::new_snapshot(old_tree_hash, Vec::new(), attribution.clone());
1214        repo.store().put_state(&base).expect("put base state");
1215        let changed = State::new_snapshot(new_tree_hash, vec![base.state_id], attribution);
1216        repo.store().put_state(&changed).expect("put changed state");
1217
1218        let payload = review
1219            .get_review_payload(changed.state_id, false)
1220            .expect("payload");
1221
1222        assert_eq!(payload.summary.files_changed, 1);
1223        assert_eq!(payload.summary.added_lines, 0);
1224        assert_eq!(payload.summary.removed_lines, 0);
1225        assert_eq!(payload.in_budget_signals[0].anchor.file, "vendor");
1226        let surfaced = payload
1227            .partition
1228            .structural
1229            .iter()
1230            .chain(payload.partition.consequence.iter())
1231            .chain(payload.partition.tests_and_docs.iter())
1232            .any(|symbol| symbol.file == "vendor" && symbol.symbol == "vendor");
1233        assert!(surfaced, "gitlink change must remain path-visible");
1234    }
1235
1236    #[test]
1237    #[serial_test::serial(process_global)]
1238    fn local_payload_tolerates_a_missing_tree() {
1239        let (review, repo, _temp) = fresh_review();
1240        let state_id = capture_state(&repo, b"hello\n");
1241        let mut state = repo
1242            .store()
1243            .get_state(&state_id)
1244            .expect("get state")
1245            .expect("state");
1246        state.tree = objects::object::ContentHash::compute(b"missing-tree");
1247        let missing_tree_state_id = state.id();
1248        repo.store().put_state(&state).expect("put mutated state");
1249
1250        let payload = review
1251            .get_review_payload(missing_tree_state_id, false)
1252            .expect("missing tree must not block the review payload");
1253
1254        assert_eq!(payload.summary.files_changed, 0);
1255        assert_eq!(payload.in_budget_signals.len(), 1);
1256        assert_eq!(
1257            payload.in_budget_signals[0].kind,
1258            ReviewSignalKind::DiffSummary
1259        );
1260        assert_eq!(
1261            payload.in_budget_signals[0].producer.module,
1262            "review_show.diff_summary"
1263        );
1264    }
1265
1266    #[test]
1267    #[serial_test::serial(process_global)]
1268    fn local_payload_surfaces_persisted_risk_signals_without_all_signals_flag() {
1269        let (review, repo, _temp) = fresh_review();
1270        let state_id = capture_state(&repo, b"hello\n");
1271        let signal = objects::object::RiskSignal {
1272            kind: objects::object::RiskSignalKind::Novelty,
1273            anchor: objects::object::SignalAnchor::symbol("changed.rs", "delta"),
1274            reason: "function shape unique in repo".to_string(),
1275            producer: objects::object::ProducerId::new("novelty.tree_sitter", 1),
1276            computed_at: 1,
1277            computed_against: Some(state_id),
1278        };
1279        let bytes = objects::object::RiskSignalBlob::new(vec![signal])
1280            .encode()
1281            .expect("encode risk signals");
1282        let hash = repo
1283            .store()
1284            .put_blob(&objects::object::Blob::new(bytes))
1285            .expect("put risk blob");
1286        repo.put_state_attachment(&objects::object::StateAttachment {
1287            state_id,
1288            body: objects::object::StateAttachmentBody::RiskSignals(hash),
1289            attribution: repo.get_attribution().expect("attribution"),
1290            created_at: chrono::Utc::now(),
1291            supersedes: None,
1292        })
1293        .expect("attach risk signals");
1294
1295        let payload = review.get_review_payload(state_id, false).expect("payload");
1296        assert!(
1297            payload.in_budget_signals.iter().any(|signal| {
1298                signal.kind == ReviewSignalKind::Risk(objects::object::RiskSignalKind::Novelty)
1299                    && signal.producer.module == "novelty.tree_sitter"
1300            }),
1301            "review show must emit persisted risk signals, got {:?}",
1302            payload.in_budget_signals
1303        );
1304        assert!(
1305            payload.all_signals.is_empty(),
1306            "all_signals stays empty unless --all-signals"
1307        );
1308
1309        let health = crate::review::get_repo_signal_health(&repo, 10).expect("health");
1310        assert!(
1311            health
1312                .entries
1313                .iter()
1314                .any(|entry| entry.module_id == "novelty.tree_sitter"),
1315            "review health must see persisted signals: {:?}",
1316            health.entries
1317        );
1318    }
1319
1320    #[test]
1321    fn line_count_matches_git_semantics() {
1322        assert_eq!(line_count(b""), 0);
1323        assert_eq!(line_count(b"\n"), 1);
1324        assert_eq!(line_count(b"hello"), 1);
1325        assert_eq!(line_count(b"hello\n"), 1);
1326        assert_eq!(line_count(b"hello\nworld"), 2);
1327        assert_eq!(line_count(b"hello\nworld\n"), 2);
1328        assert_eq!(line_count(&[0xff, 0xfe, 0xfd]), 0);
1329    }
1330}