Skip to main content

daemon/grpc_local_impl/
state_review.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local-mode implementation of the [`StateReviewService`] gRPC contract.
3//!
4//! Reads and writes review-signature attachments. Verifies the client-supplied
5//! signature against the deterministic [`signing_payload`] before persisting.
6
7// `::state_review` disambiguates from this module's own name
8// (`grpc_local_impl::state_review`), the same way the hosted impl
9// disambiguates by being in a sibling module.
10use ::state_review::{
11    PathSymbol, ReadingOrderPartition, SymbolKind, build_review_payload_partition,
12};
13use api::heddle::api::v1alpha1::{
14    AnchoredDiscussion, GetRepoSignalHealthRequest, GetReviewPayloadRequest,
15    GetReviewProgressRequest, GetReviewProgressResponse, ListSignaturesRequest,
16    ListSignaturesResponse, MergeRequirement, PathSymbolRef as ProtoPathSymbolRef,
17    ReadingOrderPartition as ProtoReadingOrderPartition, RecordCheckAckRequest,
18    RecordCheckAckResponse, RecordVerdictRequest, RecordVerdictResponse, RepoSignalHealthReport,
19    ReviewPayload, ReviewScope as ProtoReviewScope, ReviewSignature as ProtoReviewSignature,
20    ReviewSummary, RiskSignal as ProtoRiskSignal, SignStateRequest, SignStateResponse,
21    SignalAnchor as ProtoSignalAnchor, SignalHealthEntry as ProtoSignalHealthEntry, SigningFooter,
22    Verdict as ProtoVerdict, state_review_service_server::StateReviewService,
23};
24use crypto::verify_payload_signature;
25use objects::{
26    lock::RepositoryLockExt,
27    object::{
28        Blob, DiffKind, Discussion, DiscussionResolution, DiscussionsBlob, ReviewKind, ReviewScope,
29        ReviewSignature, ReviewSignaturesBlob, RiskSignalBlob, State, StateAttachment,
30        StateAttachmentBody, StateId, SymbolAnchor, signing_payload,
31    },
32    store::ObjectStore,
33    worktree::diff_blobs,
34};
35use prost::Message;
36use repo::{Repository, StateAttachmentKind};
37use tonic::{Request, Response, Status};
38
39use super::{GrpcLocalService, to_status, with_idempotency};
40
41/// Maximum drift (seconds) between the client's `signed_at_unix` and the
42/// server's wall clock. Generous enough to absorb NTP skew, narrow enough
43/// to bound the window for replay-style attacks.
44const SIGN_TIMESTAMP_SKEW_SECS: i64 = 5 * 60;
45
46/// Verdicts use a distinct canonical payload so they cannot be replayed as
47/// ordinary positive review signatures.
48const VERDICT_SIGNING_PAYLOAD_TAG: &[u8] = b"hd-rev-sig-v2\x00";
49
50/// Collision-safe marker for verdict metadata stored in the existing review
51/// signature justification field.
52const VERDICT_ENVELOPE_TAG: &str = "\u{1}hd-verdict-v1\u{1}";
53
54/// Local-mode `StateReviewService` implementation.
55#[derive(Clone)]
56pub struct LocalStateReviewService {
57    inner: GrpcLocalService,
58}
59
60impl LocalStateReviewService {
61    pub fn new(inner: GrpcLocalService) -> Self {
62        Self { inner }
63    }
64}
65
66#[tonic::async_trait]
67impl StateReviewService for LocalStateReviewService {
68    async fn get_repo_signal_health(
69        &self,
70        request: Request<GetRepoSignalHealthRequest>,
71    ) -> Result<Response<RepoSignalHealthReport>, Status> {
72        let report =
73            super::get_repo_signal_health(self.inner.repo(), request.into_inner().window_states)
74                .map_err(to_status)?;
75        Ok(Response::new(RepoSignalHealthReport {
76            entries: report
77                .entries
78                .into_iter()
79                .map(|entry| ProtoSignalHealthEntry {
80                    module_id: entry.module_id,
81                    fire_rate: entry.fire_rate,
82                    warn: entry.warn,
83                })
84                .collect(),
85            window_states: report.window_states,
86        }))
87    }
88
89    async fn get_review_payload(
90        &self,
91        request: Request<GetReviewPayloadRequest>,
92    ) -> Result<Response<ReviewPayload>, Status> {
93        let req = request.into_inner();
94        let state_id = parse_state_id(&req.state_id)?;
95        let repo = self.inner.repo();
96        let state = repo
97            .store()
98            .get_state(&state_id)
99            .map_err(to_status)?
100            .ok_or_else(|| {
101                Status::not_found(format!("state {} not found", state_id.to_string_full()))
102            })?;
103
104        // Diff the state's tree against its first parent so the summary
105        // counts reflect what actually changed in this state. The
106        // signal registry / budgeter will eventually layer on top of
107        // this; until then `files_changed` is the most useful single
108        // number an agent can use for self-review.
109        let diff_summary = compute_state_diff_summary(repo, &state).map_err(to_status)?;
110
111        let summary = ReviewSummary {
112            headline: state.intent.clone().unwrap_or_default(),
113            files_changed: diff_summary.files_changed,
114            added_lines: diff_summary.added_lines,
115            removed_lines: diff_summary.removed_lines,
116            in_budget_signal_count: 0,
117            hidden_signal_count: 0,
118        };
119
120        let agent_narrative = if state.attribution.agent.is_some() {
121            state.intent.clone().unwrap_or_default()
122        } else {
123            String::new()
124        };
125
126        // Surface fired risk signals if requested. The signal registry will
127        // replace this with a proper budget split; until then everything we
128        // read is "visible".
129        let mut all_signals: Vec<ProtoRiskSignal> = Vec::new();
130        if req.include_all_signals
131            && let Some(hash) =
132                attachment_hash(repo, &state.state_id, StateAttachmentKind::RiskSignals)?
133            && let Some(blob) = repo.store().get_blob(&hash).map_err(to_status)?
134        {
135            let decoded = RiskSignalBlob::decode(blob.content())
136                .map_err(|err| Status::internal(format!("decode risk signals: {err}")))?;
137            all_signals = decoded
138                .signals
139                .into_iter()
140                .map(|s| risk_signal_to_proto(s, "visible"))
141                .collect();
142        }
143
144        // Synthesize a structured `diff_summary` signal so the
145        // `in_budget_signals` array is non-empty even before the real
146        // signal registry is wired up. Anchored on each modified
147        // file (capped) so consumers can iterate without losing the
148        // summary aggregate. This is a deliberate stable shape: agents
149        // already iterating signals get a usable record per file
150        // change, and the registry-driven path will simply layer real
151        // signals alongside it.
152        let mut in_budget_signals: Vec<ProtoRiskSignal> = Vec::new();
153        let summary_kind = match (
154            diff_summary.added_files,
155            diff_summary.modified_files,
156            diff_summary.deleted_files,
157        ) {
158            (a, 0, 0) if a > 0 => "diff_summary.added_only",
159            (0, m, 0) if m > 0 => "diff_summary.modified_only",
160            (0, 0, d) if d > 0 => "diff_summary.deleted_only",
161            (0, 0, 0) => "diff_summary.empty",
162            _ => "diff_summary.mixed",
163        };
164        let summary_reason = format!(
165            "{} files changed (+{}/-{}, {} added, {} modified, {} deleted)",
166            diff_summary.files_changed,
167            diff_summary.added_lines,
168            diff_summary.removed_lines,
169            diff_summary.added_files,
170            diff_summary.modified_files,
171            diff_summary.deleted_files,
172        );
173        // Per-file anchors keep the array reasoning-friendly when
174        // many files change, but cap so very large diffs don't bloat
175        // the payload. The aggregate summary always rides on the
176        // first entry's reason field; the rest carry per-file deltas.
177        const MAX_DIFF_SIGNAL_ANCHORS: usize = 32;
178        if diff_summary.changed_paths.is_empty() {
179            in_budget_signals.push(ProtoRiskSignal {
180                kind: summary_kind.to_string(),
181                anchor: Some(ProtoSignalAnchor {
182                    file: String::new(),
183                    symbol: String::new(),
184                    start_line: 0,
185                    end_line: 0,
186                }),
187                reason: summary_reason.clone(),
188                producer_module: "review_show.diff_summary".to_string(),
189                producer_version: 1,
190                computed_at: None,
191                visibility: "visible".to_string(),
192            });
193        } else {
194            for (idx, path_kind) in diff_summary
195                .changed_paths
196                .iter()
197                .take(MAX_DIFF_SIGNAL_ANCHORS)
198                .enumerate()
199            {
200                let reason = if idx == 0 {
201                    summary_reason.clone()
202                } else {
203                    format!("{} ({})", path_kind.path, path_kind.kind_str())
204                };
205                in_budget_signals.push(ProtoRiskSignal {
206                    kind: summary_kind.to_string(),
207                    anchor: Some(ProtoSignalAnchor {
208                        file: path_kind.path.clone(),
209                        symbol: String::new(),
210                        start_line: 0,
211                        end_line: 0,
212                    }),
213                    reason,
214                    producer_module: "review_show.diff_summary".to_string(),
215                    producer_version: 1,
216                    computed_at: None,
217                    visibility: "visible".to_string(),
218                });
219            }
220        }
221
222        // Server-side reading-order partition. Same per-symbol
223        // extraction logic as the hosted handler: tree-sitter when the
224        // `semantic` feature is enabled, path-only fallback otherwise.
225        let symbols = changed_files_as_symbols(repo, &state, &diff_summary.changed_paths)
226            .map_err(to_status)?;
227        let partition = build_review_payload_partition(&symbols);
228
229        // Project the state's `DiscussionsBlob` (when present)
230        // into the wire-shape `AnchoredDiscussion` list.
231        let discussions =
232            match attachment_hash(repo, &state.state_id, StateAttachmentKind::Discussions)? {
233                Some(hash) => {
234                    let blob = repo
235                        .store()
236                        .get_blob(&hash)
237                        .map_err(to_status)?
238                        .ok_or_else(|| {
239                            Status::internal(format!(
240                                "discussions blob {} referenced by state {} is missing",
241                                hash,
242                                state.state_id.to_string_full()
243                            ))
244                        })?;
245                    let decoded = DiscussionsBlob::decode(blob.content())
246                        .map_err(|err| Status::internal(format!("decode discussions: {err}")))?;
247                    decoded
248                        .discussions
249                        .iter()
250                        .map(discussion_to_anchored_proto)
251                        .collect()
252                }
253                None => Vec::<AnchoredDiscussion>::new(),
254            };
255
256        let mut summary = summary;
257        summary.in_budget_signal_count = in_budget_signals.len() as u32;
258        summary.hidden_signal_count =
259            all_signals.len().saturating_sub(in_budget_signals.len()) as u32;
260
261        let payload = ReviewPayload {
262            state_id: req.state_id.clone(),
263            summary: Some(summary),
264            agent_narrative,
265            partition: Some(partition_to_proto(partition)),
266            in_budget_signals,
267            all_signals,
268            tick_budget: 3,
269            discussions,
270            // Local mode has no policy registry — merge requirements
271            // are surfaced only by the hosted handler.
272            merge_requirements: Vec::<MergeRequirement>::new(),
273            signing_footer: Some(SigningFooter {
274                available_kinds: vec![
275                    api::heddle::api::v1alpha1::ReviewKind::Read as i32,
276                    api::heddle::api::v1alpha1::ReviewKind::AgentPreview as i32,
277                    api::heddle::api::v1alpha1::ReviewKind::AgentCoReview as i32,
278                ],
279            }),
280        };
281
282        Ok(Response::new(payload))
283    }
284
285    async fn sign_state(
286        &self,
287        request: Request<SignStateRequest>,
288    ) -> Result<Response<SignStateResponse>, Status> {
289        let req = request.into_inner();
290        let req_bytes = req.encode_to_vec();
291        let client_operation_id = req.client_operation_id.clone();
292        let inner = self.inner.clone();
293
294        let response = with_idempotency(
295            &self.inner,
296            &client_operation_id,
297            "state_review.sign_state",
298            &req_bytes,
299            move || {
300                let inner = inner.clone();
301                async move { execute_sign_state(&inner, req).await }
302            },
303        )
304        .await?;
305
306        Ok(Response::new(response))
307    }
308
309    async fn list_signatures(
310        &self,
311        request: Request<ListSignaturesRequest>,
312    ) -> Result<Response<ListSignaturesResponse>, Status> {
313        let req = request.into_inner();
314        let state_id = parse_state_id(&req.state_id)?;
315        let repo = self.inner.repo();
316        let state = repo
317            .store()
318            .get_state(&state_id)
319            .map_err(to_status)?
320            .ok_or_else(|| {
321                Status::not_found(format!("state {} not found", state_id.to_string_full()))
322            })?;
323
324        let signatures =
325            match attachment_hash(repo, &state.state_id, StateAttachmentKind::ReviewSignatures)? {
326                Some(hash) => {
327                    let blob = repo
328                        .store()
329                        .get_blob(&hash)
330                        .map_err(to_status)?
331                        .ok_or_else(|| {
332                            Status::internal(format!(
333                                "review signatures blob {} missing from object store",
334                                hash
335                            ))
336                        })?;
337                    let decoded = ReviewSignaturesBlob::decode(blob.content()).map_err(|err| {
338                        Status::internal(format!("decode review signatures: {err}"))
339                    })?;
340                    decoded
341                        .signatures
342                        .into_iter()
343                        .enumerate()
344                        .map(|(idx, sig)| {
345                            review_signature_to_proto(sig, synthetic_signature_id(idx))
346                        })
347                        .collect()
348                }
349                None => Vec::new(),
350            };
351
352        Ok(Response::new(ListSignaturesResponse { signatures }))
353    }
354
355    async fn record_verdict(
356        &self,
357        request: Request<RecordVerdictRequest>,
358    ) -> Result<Response<RecordVerdictResponse>, Status> {
359        let req = request.into_inner();
360        let req_bytes = req.encode_to_vec();
361        let client_operation_id = req.client_operation_id.clone();
362        let inner = self.inner.clone();
363
364        let response = with_idempotency(
365            &self.inner,
366            &client_operation_id,
367            "state_review.record_verdict",
368            &req_bytes,
369            move || {
370                let inner = inner.clone();
371                async move { execute_record_verdict(&inner, req).await }
372            },
373        )
374        .await?;
375
376        Ok(Response::new(response))
377    }
378
379    async fn record_check_ack(
380        &self,
381        _request: Request<RecordCheckAckRequest>,
382    ) -> Result<Response<RecordCheckAckResponse>, Status> {
383        Err(Status::unimplemented(
384            "record_check_ack is not available in local mode: local repositories do not persist review check acknowledgements",
385        ))
386    }
387
388    async fn get_review_progress(
389        &self,
390        _request: Request<GetReviewProgressRequest>,
391    ) -> Result<Response<GetReviewProgressResponse>, Status> {
392        Err(Status::unimplemented(
393            "get_review_progress is not available in local mode: local repositories do not persist review progress",
394        ))
395    }
396}
397
398/// Body of [`LocalStateReviewService::sign_state`]. Lifted out of the trait
399/// method so [`with_idempotency`] can re-execute it inside its closure.
400async fn execute_sign_state(
401    inner: &GrpcLocalService,
402    req: SignStateRequest,
403) -> Result<SignStateResponse, Status> {
404    // 1. Validate the kind.
405    let kind = match api::heddle::api::v1alpha1::ReviewKind::try_from(req.kind)
406        .map_err(|_| Status::invalid_argument(format!("unknown review kind tag {}", req.kind)))?
407    {
408        api::heddle::api::v1alpha1::ReviewKind::Read => ReviewKind::Read,
409        api::heddle::api::v1alpha1::ReviewKind::AgentPreview => ReviewKind::AgentPreview,
410        api::heddle::api::v1alpha1::ReviewKind::AgentCoReview => ReviewKind::AgentCoReview,
411        api::heddle::api::v1alpha1::ReviewKind::Unspecified => {
412            return Err(Status::invalid_argument("review kind is required"));
413        }
414    };
415
416    // 2. Locate the state.
417    let state_id = parse_state_id(&req.state_id)?;
418    let repo = inner.repo();
419
420    // 3. Translate the scope.
421    let scope = match req.scope.as_ref() {
422        Some(s) => proto_scope_to_object(s)?,
423        None => ReviewScope::WholeChange,
424    };
425
426    // 4. Build the ReviewSignature, then verify the client-supplied
427    // signature is well-formed and matches the deterministic signing
428    // payload. A malformed or forged signature must never reach the
429    // persisted blob. Attribute the signature to the local-mode
430    // caller (`Repository::get_principal` resolves env vars then
431    // `[principal]` in `.heddle/config.toml`), not the state's author
432    // — Bob signing Alice's state should record Bob.
433    let actor = repo
434        .get_principal()
435        .map_err(|err| Status::internal(format!("resolve caller principal: {err}")))?;
436    if req.justification.starts_with(VERDICT_ENVELOPE_TAG) {
437        return Err(Status::invalid_argument(
438            "justification must not begin with the reserved verdict-envelope prefix",
439        ));
440    }
441    let justification = if req.justification.is_empty() {
442        None
443    } else {
444        Some(req.justification.clone())
445    };
446
447    let now = chrono::Utc::now().timestamp();
448    let signed_at = req.signed_at.as_ref().map(|t| t.seconds).unwrap_or(0);
449    if signed_at == 0 {
450        return Err(Status::invalid_argument(
451            "signed_at is required and must match the timestamp the client signed over",
452        ));
453    }
454    if (signed_at - now).abs() > SIGN_TIMESTAMP_SKEW_SECS {
455        return Err(Status::invalid_argument(format!(
456            "signed_at={signed_at} is too far from server time={now} (max skew {SIGN_TIMESTAMP_SKEW_SECS}s)"
457        )));
458    }
459
460    let new_sig = ReviewSignature {
461        actor,
462        kind,
463        scope: scope.clone(),
464        justification: justification.clone(),
465        signed_at,
466        algorithm: req.algorithm.clone(),
467        public_key: hex::encode(&req.public_key),
468        signature: hex::encode(&req.signature),
469    };
470    new_sig
471        .validate()
472        .map_err(|err| Status::invalid_argument(format!("invalid review signature: {err}")))?;
473
474    let public_key_bytes = req.public_key.clone();
475    let signature_bytes = req.signature.clone();
476    let payload = signing_payload(state_id, kind, &scope, signed_at, justification.as_deref());
477    verify_payload_signature(
478        &payload,
479        &req.algorithm,
480        &public_key_bytes,
481        &signature_bytes,
482    )
483    .map_err(|err| {
484        Status::invalid_argument(format!(
485            "review signature failed verification ({}): {err}",
486            req.algorithm
487        ))
488    })?;
489
490    let new_index = append_review_signature(repo, state_id, new_sig)?;
491
492    Ok(SignStateResponse {
493        signature_id: synthetic_signature_id(new_index),
494        state_id: req.state_id,
495    })
496}
497
498async fn execute_record_verdict(
499    inner: &GrpcLocalService,
500    req: RecordVerdictRequest,
501) -> Result<RecordVerdictResponse, Status> {
502    let verdict = verdict_str_from_proto(req.verdict)?;
503    let state_id = parse_state_id(&req.state_id)?;
504    if matches!(verdict, "hold" | "reject") && req.reason.trim().is_empty() {
505        return Err(Status::invalid_argument(
506            "reason is required for HOLD and REJECT verdicts",
507        ));
508    }
509    let scope = match req.scope.as_ref() {
510        Some(scope) => proto_scope_to_object(scope)?,
511        None => ReviewScope::WholeChange,
512    };
513    let signed_at = req.signed_at.as_ref().map(|time| time.seconds).unwrap_or(0);
514    if signed_at == 0 {
515        return Err(Status::invalid_argument(
516            "signed_at is required and must match the timestamp the client signed over",
517        ));
518    }
519    let now = chrono::Utc::now().timestamp();
520    if (signed_at - now).abs() > SIGN_TIMESTAMP_SKEW_SECS {
521        return Err(Status::invalid_argument(format!(
522            "signed_at={signed_at} is too far from server time={now} (max skew {SIGN_TIMESTAMP_SKEW_SECS}s)"
523        )));
524    }
525
526    let payload = verdict_signing_payload(state_id, verdict, &scope, signed_at, &req.reason);
527    verify_payload_signature(&payload, &req.algorithm, &req.public_key, &req.signature).map_err(
528        |err| {
529            Status::invalid_argument(format!(
530                "verdict signature failed verification ({}): {err}",
531                req.algorithm
532            ))
533        },
534    )?;
535
536    let repo = inner.repo();
537    let signature = ReviewSignature {
538        actor: repo
539            .get_principal()
540            .map_err(|err| Status::internal(format!("resolve caller principal: {err}")))?,
541        kind: ReviewKind::Read,
542        scope,
543        justification: Some(encode_verdict_envelope(verdict, &req.reason)),
544        signed_at,
545        algorithm: req.algorithm.clone(),
546        public_key: hex::encode(&req.public_key),
547        signature: hex::encode(&req.signature),
548    };
549    signature
550        .validate()
551        .map_err(|err| Status::invalid_argument(format!("invalid review signature: {err}")))?;
552
553    let new_index = append_review_signature(repo, state_id, signature)?;
554    Ok(RecordVerdictResponse {
555        signature_id: synthetic_signature_id(new_index),
556        state_id: req.state_id,
557    })
558}
559
560/// Append one signed review record while holding the repository write lock.
561/// SignState and RecordVerdict share this tail so concurrent local writes do
562/// not lose one another's attachment updates.
563fn append_review_signature(
564    repo: &Repository,
565    state_id: StateId,
566    signature: ReviewSignature,
567) -> Result<usize, Status> {
568    let _lock = repo
569        .locker()
570        .write()
571        .map_err(|err| Status::internal(err.to_string()))?;
572    repo.store()
573        .get_state(&state_id)
574        .map_err(to_status)?
575        .ok_or_else(|| {
576            Status::not_found(format!("state {} not found", state_id.to_string_full()))
577        })?;
578    let prior = repo
579        .latest_state_attachment(&state_id, StateAttachmentKind::ReviewSignatures)
580        .map_err(to_status)?;
581    let mut blob = match prior.as_ref().map(|attachment| {
582        let StateAttachmentBody::ReviewSignatures(hash) = &attachment.body else {
583            unreachable!()
584        };
585        *hash
586    }) {
587        Some(hash) => {
588            let raw = repo
589                .store()
590                .get_blob(&hash)
591                .map_err(to_status)?
592                .ok_or_else(|| {
593                    Status::internal(format!(
594                        "existing review signatures blob {} missing from object store",
595                        hash
596                    ))
597                })?;
598            ReviewSignaturesBlob::decode(raw.content())
599                .map_err(|err| Status::internal(format!("decode review signatures: {err}")))?
600        }
601        None => ReviewSignaturesBlob::new(Vec::new()),
602    };
603    blob.signatures.push(signature);
604    let new_index = blob.signatures.len() - 1;
605
606    let bytes = blob
607        .encode()
608        .map_err(|err| Status::internal(format!("encode review signatures: {err}")))?;
609    let content_hash = repo
610        .store()
611        .put_blob(&Blob::new(bytes))
612        .map_err(to_status)?;
613
614    let attachment = StateAttachment {
615        state_id,
616        body: StateAttachmentBody::ReviewSignatures(content_hash),
617        attribution: repo.get_attribution().map_err(to_status)?,
618        created_at: chrono::Utc::now(),
619        supersedes: prior.map(|attachment| attachment.id()),
620    };
621    repo.put_state_attachment(&attachment).map_err(to_status)?;
622    Ok(new_index)
623}
624
625/// `ReviewSignature` doesn't carry an explicit id; we synthesise one from
626/// the per-state index so the wire surface has stable signature ids within a
627/// single state. (A future schema bump may add an explicit id.)
628fn synthetic_signature_id(index: usize) -> String {
629    format!("rs-{index}")
630}
631
632fn attachment_hash(
633    repo: &Repository,
634    state_id: &StateId,
635    kind: StateAttachmentKind,
636) -> Result<Option<objects::object::ContentHash>, Status> {
637    let Some(attachment) = repo
638        .latest_state_attachment(state_id, kind)
639        .map_err(to_status)?
640    else {
641        return Ok(None);
642    };
643    let hash = match attachment.body {
644        StateAttachmentBody::RiskSignals(hash)
645        | StateAttachmentBody::ReviewSignatures(hash)
646        | StateAttachmentBody::Discussions(hash) => hash,
647        _ => unreachable!(),
648    };
649    Ok(Some(hash))
650}
651
652fn parse_state_id(
653    state_id: &Option<api::heddle::api::v1alpha1::StateId>,
654) -> Result<StateId, Status> {
655    let bytes = state_id
656        .as_ref()
657        .ok_or_else(|| Status::invalid_argument("state_id is required"))?;
658    StateId::try_from_slice(&bytes.value)
659        .map_err(|err| Status::invalid_argument(format!("invalid state_id: {err}")))
660}
661
662fn api_state_id(state_id: &StateId) -> api::heddle::api::v1alpha1::StateId {
663    api::heddle::api::v1alpha1::StateId {
664        value: state_id.as_bytes().to_vec(),
665    }
666}
667
668fn verdict_signing_payload(
669    state_id: StateId,
670    verdict: &str,
671    scope: &ReviewScope,
672    signed_at: i64,
673    reason: &str,
674) -> Vec<u8> {
675    let mut payload = Vec::with_capacity(VERDICT_SIGNING_PAYLOAD_TAG.len() + 256);
676    payload.extend_from_slice(VERDICT_SIGNING_PAYLOAD_TAG);
677    payload.extend_from_slice(state_id.to_string_full().as_bytes());
678    payload.push(0);
679    payload.extend_from_slice(verdict.as_bytes());
680    payload.push(0);
681    match scope {
682        ReviewScope::WholeChange => {
683            payload.extend_from_slice(b"whole_change");
684            payload.push(0);
685        }
686        ReviewScope::Symbols(symbols) => {
687            payload.extend_from_slice(b"symbols");
688            payload.push(0);
689            payload.extend_from_slice(&(symbols.len() as u32).to_le_bytes());
690            for symbol in symbols {
691                payload.extend_from_slice(symbol.file.as_bytes());
692                payload.push(0);
693                payload.extend_from_slice(symbol.symbol.as_bytes());
694                payload.push(0);
695            }
696        }
697    }
698    payload.extend_from_slice(&signed_at.to_le_bytes());
699    payload.extend_from_slice(reason.as_bytes());
700    payload.push(0);
701    payload
702}
703
704fn verdict_str_from_proto(tag: i32) -> Result<&'static str, Status> {
705    match ProtoVerdict::try_from(tag)
706        .map_err(|_| Status::invalid_argument(format!("unknown verdict tag {tag}")))?
707    {
708        ProtoVerdict::Sign => Ok("sign"),
709        ProtoVerdict::Hold => Ok("hold"),
710        ProtoVerdict::Reject => Ok("reject"),
711        ProtoVerdict::Unspecified => Err(Status::invalid_argument("verdict is required")),
712    }
713}
714
715fn encode_verdict_envelope(verdict: &str, reason: &str) -> String {
716    let body = serde_json::json!({ "verdict": verdict, "reason": reason });
717    format!("{VERDICT_ENVELOPE_TAG}{body}")
718}
719
720fn decode_verdict_envelope(justification: &str) -> Option<(String, String)> {
721    let body = justification.strip_prefix(VERDICT_ENVELOPE_TAG)?;
722    let value: serde_json::Value = serde_json::from_str(body).ok()?;
723    let verdict = value.get("verdict")?.as_str()?.to_string();
724    let reason = value
725        .get("reason")
726        .and_then(|reason| reason.as_str())
727        .unwrap_or_default()
728        .to_string();
729    Some((verdict, reason))
730}
731
732fn verdict_proto_from_str(verdict: &str) -> ProtoVerdict {
733    match verdict {
734        "sign" => ProtoVerdict::Sign,
735        "hold" => ProtoVerdict::Hold,
736        "reject" => ProtoVerdict::Reject,
737        _ => ProtoVerdict::Unspecified,
738    }
739}
740
741fn proto_scope_to_object(scope: &ProtoReviewScope) -> Result<ReviewScope, Status> {
742    use api::heddle::api::v1alpha1::review_scope::Scope;
743    match scope.scope.as_ref() {
744        None | Some(Scope::WholeChange(_)) => Ok(ReviewScope::WholeChange),
745        Some(Scope::Symbols(list)) => {
746            if list.symbols.is_empty() {
747                return Err(Status::invalid_argument(
748                    "symbols scope requires at least one symbol anchor",
749                ));
750            }
751            let symbols = list
752                .symbols
753                .iter()
754                .map(|s| SymbolAnchor::new(s.file.clone(), s.symbol.clone()))
755                .collect();
756            Ok(ReviewScope::Symbols(symbols))
757        }
758    }
759}
760
761fn object_scope_to_proto(scope: &ReviewScope) -> ProtoReviewScope {
762    use api::heddle::api::v1alpha1::review_scope::{Scope, SymbolList, WholeChange};
763    let inner = match scope {
764        ReviewScope::WholeChange => Scope::WholeChange(WholeChange {}),
765        ReviewScope::Symbols(symbols) => Scope::Symbols(SymbolList {
766            symbols: symbols
767                .iter()
768                .map(|s| ProtoPathSymbolRef {
769                    file: s.file.clone(),
770                    symbol: s.symbol.clone(),
771                })
772                .collect(),
773        }),
774    };
775    ProtoReviewScope { scope: Some(inner) }
776}
777
778fn review_signature_to_proto(sig: ReviewSignature, signature_id: String) -> ProtoReviewSignature {
779    let (verdict, reason, justification) = match sig
780        .justification
781        .as_deref()
782        .and_then(decode_verdict_envelope)
783    {
784        Some((verdict, reason)) => (
785            verdict_proto_from_str(&verdict) as i32,
786            reason,
787            String::new(),
788        ),
789        None => (
790            ProtoVerdict::Unspecified as i32,
791            String::new(),
792            sig.justification.unwrap_or_default(),
793        ),
794    };
795    ProtoReviewSignature {
796        signature_id,
797        actor_name: sig.actor.name.clone(),
798        actor_email: sig.actor.email.clone(),
799        kind: review_kind_to_proto(sig.kind) as i32,
800        scope: Some(object_scope_to_proto(&sig.scope)),
801        justification,
802        signed_at: Some(prost_types::Timestamp {
803            seconds: sig.signed_at,
804            nanos: 0,
805        }),
806        algorithm: sig.algorithm,
807        public_key: hex::decode(&sig.public_key).unwrap_or_default(),
808        signature: hex::decode(&sig.signature).unwrap_or_default(),
809        verdict,
810        reason,
811    }
812}
813
814fn review_kind_to_proto(kind: ReviewKind) -> api::heddle::api::v1alpha1::ReviewKind {
815    match kind {
816        ReviewKind::Read => api::heddle::api::v1alpha1::ReviewKind::Read,
817        ReviewKind::AgentPreview => api::heddle::api::v1alpha1::ReviewKind::AgentPreview,
818        ReviewKind::AgentCoReview => api::heddle::api::v1alpha1::ReviewKind::AgentCoReview,
819    }
820}
821
822fn risk_signal_to_proto(sig: objects::object::RiskSignal, visibility: &str) -> ProtoRiskSignal {
823    let (start_line, end_line) = sig.anchor.line_range.unwrap_or((0, 0));
824    ProtoRiskSignal {
825        kind: sig.kind.as_str().to_string(),
826        anchor: Some(ProtoSignalAnchor {
827            file: sig.anchor.file,
828            symbol: sig.anchor.symbol.unwrap_or_default(),
829            start_line,
830            end_line,
831        }),
832        reason: sig.reason,
833        producer_module: sig.producer.module,
834        producer_version: sig.producer.version,
835        computed_at: Some(prost_types::Timestamp {
836            seconds: sig.computed_at,
837            nanos: 0,
838        }),
839        visibility: visibility.to_string(),
840    }
841}
842
843// ---------------------------------------------------------------------------
844// Symbol extraction + discussion projection (mirrors hosted impl).
845// ---------------------------------------------------------------------------
846
847/// Symbol projection for the reading-order partition. Mirrors the hosted-handler
848/// implementation: when the `semantic` feature is enabled and the
849/// changed path has a tree-sitter parser and a readable new-side blob, emits
850/// one [`PathSymbol`] per definition. Otherwise falls back to a single path-only
851/// entry (kind = `Other`), which keeps deletes and gitlink pointer changes
852/// visible even though they do not carry Heddle blob content.
853fn changed_files_as_symbols(
854    repo: &Repository,
855    state: &State,
856    changed_paths: &[ChangedPath],
857) -> objects::error::Result<Vec<PathSymbol>> {
858    let new_tree = match repo.store().get_tree(&state.tree)? {
859        Some(t) => t,
860        None => return Ok(Vec::new()),
861    };
862    let new_files = collect_files(repo, &new_tree, "")?;
863
864    let mut out: Vec<PathSymbol> = Vec::new();
865    for path_kind in changed_paths {
866        let path = &path_kind.path;
867        #[cfg_attr(not(feature = "semantic"), allow(unused_mut))]
868        let mut emitted_any = false;
869        if let Some(hash) = new_files.get(path) {
870            #[cfg(feature = "semantic")]
871            {
872                if let Some(blob) = repo.store().get_blob(hash)? {
873                    emitted_any = extract_file_symbols(path, blob.content(), &mut out);
874                }
875            }
876            #[cfg(not(feature = "semantic"))]
877            {
878                let _ = hash;
879            }
880        }
881        if !emitted_any {
882            out.push(PathSymbol {
883                file: path.clone(),
884                symbol: path.clone(),
885                kind: SymbolKind::Other,
886            });
887        }
888    }
889    Ok(out)
890}
891
892#[cfg(feature = "semantic")]
893fn extract_file_symbols(path: &str, source: &[u8], out: &mut Vec<PathSymbol>) -> bool {
894    use ::semantic::symbol_resolver::{Definition, DefinitionKind, extract_definitions};
895    let definitions: Vec<Definition> = match extract_definitions(source, std::path::Path::new(path))
896    {
897        Ok(defs) => defs,
898        Err(_) => return false,
899    };
900    if definitions.is_empty() {
901        return false;
902    }
903    for d in definitions {
904        let kind = match d.kind {
905            DefinitionKind::Type => SymbolKind::Type,
906            DefinitionKind::Trait => SymbolKind::Trait,
907            DefinitionKind::Class => SymbolKind::Class,
908            DefinitionKind::Interface => SymbolKind::Interface,
909            DefinitionKind::TypeAlias => SymbolKind::TypeAlias,
910            DefinitionKind::EnumDef => SymbolKind::EnumDef,
911            DefinitionKind::ConstDecl => SymbolKind::ConstDecl,
912            DefinitionKind::Module => SymbolKind::Module,
913            DefinitionKind::Function => SymbolKind::Function,
914            DefinitionKind::Other => SymbolKind::Other,
915        };
916        let symbol = match d.parent_name.as_deref() {
917            Some(parent) if !parent.is_empty() => format!("{parent}::{}", d.name),
918            _ => d.name,
919        };
920        out.push(PathSymbol {
921            file: path.to_string(),
922            symbol,
923            kind,
924        });
925    }
926    true
927}
928
929fn collect_files(
930    repo: &Repository,
931    tree: &objects::object::Tree,
932    prefix: &str,
933) -> objects::error::Result<std::collections::HashMap<String, objects::object::ContentHash>> {
934    let mut out = std::collections::HashMap::new();
935    for entry in tree.entries() {
936        let path = if prefix.is_empty() {
937            entry.name().to_string()
938        } else {
939            format!("{prefix}/{}", entry.name())
940        };
941        if entry.is_tree() {
942            if let Some(hash) = entry.tree_hash()
943                && let Some(subtree) = repo.store().get_tree(&hash)?
944            {
945                let sub = collect_files(repo, &subtree, &path)?;
946                out.extend(sub);
947            }
948        } else if let Some(hash) = entry.content_hash() {
949            out.insert(path, hash);
950        }
951    }
952    Ok(out)
953}
954
955fn partition_to_proto(p: ReadingOrderPartition) -> ProtoReadingOrderPartition {
956    ProtoReadingOrderPartition {
957        structural: p.structural.iter().map(path_symbol_to_proto).collect(),
958        consequence: p.consequence.iter().map(path_symbol_to_proto).collect(),
959        tests_and_docs: p.tests_and_docs.iter().map(path_symbol_to_proto).collect(),
960    }
961}
962
963fn path_symbol_to_proto(p: &PathSymbol) -> ProtoPathSymbolRef {
964    ProtoPathSymbolRef {
965        file: p.file.clone(),
966        symbol: p.symbol.clone(),
967    }
968}
969
970fn discussion_to_anchored_proto(d: &Discussion) -> AnchoredDiscussion {
971    AnchoredDiscussion {
972        id: d.id.clone(),
973        anchor: Some(ProtoPathSymbolRef {
974            file: d.anchor.file.clone(),
975            symbol: d.anchor.symbol.clone(),
976        }),
977        opened_against_state: Some(api_state_id(&d.opened_against_state)),
978        opened_at: Some(prost_types::Timestamp {
979            seconds: d.opened_at,
980            nanos: 0,
981        }),
982        turns: d
983            .turns
984            .iter()
985            .map(|t| api::heddle::api::v1alpha1::DiscussionTurn {
986                author_name: t.author.name.clone(),
987                author_email: t.author.email.clone(),
988                body: t.body.clone(),
989                posted_at: Some(prost_types::Timestamp {
990                    seconds: t.posted_at,
991                    nanos: 0,
992                }),
993            })
994            .collect(),
995        resolution: Some(discussion_resolution_to_proto(&d.resolution)),
996        body_changed_since_open: d.body_changed_since_open,
997        orphaned: d.orphaned,
998        visibility: d.visibility.as_str().to_string(),
999    }
1000}
1001
1002fn discussion_resolution_to_proto(
1003    resolution: &DiscussionResolution,
1004) -> api::heddle::api::v1alpha1::DiscussionResolution {
1005    use api::heddle::api::v1alpha1::discussion_resolution::{
1006        Dismissed, Open, ResolvedByEdit, ResolvedIntoAnnotation, State,
1007    };
1008    let state = match resolution {
1009        DiscussionResolution::Open => State::Open(Open {}),
1010        DiscussionResolution::ResolvedIntoAnnotation { annotation_id } => {
1011            State::IntoAnnotation(ResolvedIntoAnnotation {
1012                annotation_id: annotation_id.clone(),
1013            })
1014        }
1015        DiscussionResolution::ResolvedByEdit { state_id } => State::ByEdit(ResolvedByEdit {
1016            state_id: Some(api_state_id(state_id)),
1017        }),
1018        DiscussionResolution::Dismissed { reason } => State::Dismissed(Dismissed {
1019            reason: reason.clone(),
1020        }),
1021    };
1022    api::heddle::api::v1alpha1::DiscussionResolution { state: Some(state) }
1023}
1024
1025// ---------------------------------------------------------------------------
1026// Diff summary helpers (state.tree vs first parent's tree).
1027// ---------------------------------------------------------------------------
1028
1029/// File-change kinds we surface in the diff summary signal anchors.
1030/// Mirrors `objects::object::DiffKind` minus the `Unchanged` variant
1031/// (we filter those out before constructing this).
1032#[derive(Debug, Clone)]
1033struct ChangedPath {
1034    path: String,
1035    kind: DiffKind,
1036}
1037
1038impl ChangedPath {
1039    fn kind_str(&self) -> &'static str {
1040        match self.kind {
1041            DiffKind::Added => "added",
1042            DiffKind::Modified => "modified",
1043            DiffKind::Deleted => "deleted",
1044            DiffKind::Unchanged => "unchanged",
1045        }
1046    }
1047}
1048
1049/// Aggregated counts plus a path list, computed by diffing
1050/// `state.tree` against the first parent's tree (or empty when the
1051/// state is a root). When `state.parents` is empty every file in the
1052/// state's tree counts as added, which makes "first capture" reviews
1053/// non-empty too. The `_state` prefix on `_state` is intentional: the
1054/// helper currently only reads `state.tree` and `state.parents`.
1055struct DiffSummary {
1056    files_changed: u32,
1057    added_files: u32,
1058    modified_files: u32,
1059    deleted_files: u32,
1060    added_lines: u32,
1061    removed_lines: u32,
1062    changed_paths: Vec<ChangedPath>,
1063}
1064
1065/// Compute a summary diff for `state` vs its first parent. Errors
1066/// from the object store propagate; missing trees / blobs are skipped
1067/// silently (treated as zero-change for that path) so a partially
1068/// pruned object store never blocks the review surface. The
1069/// distinction matters: missing-object errors must become zero (the
1070/// summary is best-effort, callers want a payload they can render),
1071/// but genuine I/O errors must still propagate so a corrupt store
1072/// surfaces loudly instead of silently truncating the review.
1073fn compute_state_diff_summary(
1074    repo: &Repository,
1075    state: &State,
1076) -> objects::error::Result<DiffSummary> {
1077    use objects::object::Tree;
1078    let parent_tree_hash = if let Some(parent_id) = state.parents.first() {
1079        match repo.store().get_state(parent_id)? {
1080            Some(parent_state) => parent_state.tree,
1081            None => Tree::new().hash(),
1082        }
1083    } else {
1084        Tree::new().hash()
1085    };
1086
1087    // Resolve both tree objects up front so the missing-tree case
1088    // becomes a synthesized empty changeset rather than an error from
1089    // the recursive diff. `get_tree` returns `Ok(None)` for missing
1090    // (not an error), and propagates only on genuine I/O — matching
1091    // the policy the doc-comment claims.
1092    let parent_tree_obj = repo.store().get_tree(&parent_tree_hash)?;
1093    let new_tree_obj = repo.store().get_tree(&state.tree)?;
1094
1095    // If either tree is missing from the local store the diff is not
1096    // meaningful — return an empty summary instead of erroring out.
1097    // This mirrors the "Modified branch tolerates missing blobs" stance
1098    // for the *tree* level: a partially pruned store should never block
1099    // review payload retrieval, only render an empty summary.
1100    let changes = if parent_tree_obj.is_some() && new_tree_obj.is_some() {
1101        repo.diff_trees(&parent_tree_hash, &state.tree)?
1102    } else {
1103        objects::object::FileChangeSet::new()
1104    };
1105
1106    // Compute per-file line deltas. We only count `Modified` here for
1107    // the symmetric add/remove totals; `Added` files contribute every
1108    // line as an add, and `Deleted` files contribute every line as a
1109    // remove. Files with non-utf8 contents (e.g. binaries) silently
1110    // contribute zero — `diff_blobs` already returns an empty vec in
1111    // that case, and we mirror the same behavior for raw line counts.
1112    let mut added_lines: u32 = 0;
1113    let mut removed_lines: u32 = 0;
1114    let mut changed_paths: Vec<ChangedPath> = Vec::with_capacity(changes.len());
1115
1116    let parent_files = match parent_tree_obj.as_ref() {
1117        Some(t) => collect_files(repo, t, "")?,
1118        None => std::collections::HashMap::new(),
1119    };
1120    let new_files = match new_tree_obj.as_ref() {
1121        Some(t) => collect_files(repo, t, "")?,
1122        None => std::collections::HashMap::new(),
1123    };
1124
1125    let mut added_files: u32 = 0;
1126    let mut modified_files: u32 = 0;
1127    let mut deleted_files: u32 = 0;
1128
1129    for change in changes.iter() {
1130        match change.kind {
1131            DiffKind::Added => {
1132                added_files += 1;
1133                // Missing blob (`get_blob` returns `Ok(None)`) → file
1134                // counts but contributes zero lines. Genuine I/O
1135                // errors still propagate via `?` — same shape as the
1136                // Modified branch's intent, but here we keep the
1137                // distinction explicit so a corrupt store surfaces
1138                // rather than getting silently swallowed.
1139                if let Some(hash) = new_files.get(&change.path)
1140                    && let Some(blob) = repo.store().get_blob(hash)?
1141                {
1142                    added_lines = added_lines.saturating_add(line_count(blob.content()));
1143                }
1144            }
1145            DiffKind::Deleted => {
1146                deleted_files += 1;
1147                if let Some(hash) = parent_files.get(&change.path)
1148                    && let Some(blob) = repo.store().get_blob(hash)?
1149                {
1150                    removed_lines = removed_lines.saturating_add(line_count(blob.content()));
1151                }
1152            }
1153            DiffKind::Modified => {
1154                modified_files += 1;
1155                // `get_blob` already returns `Ok(None)` for a missing
1156                // blob, so `?` here only fires on genuine I/O. Match
1157                // the Added/Deleted branches' propagation policy
1158                // explicitly instead of the older `.ok().flatten()`
1159                // form, which silently swallowed IO errors and
1160                // conflated them with "missing".
1161                let old_blob = match parent_files.get(&change.path) {
1162                    Some(h) => repo.store().get_blob(h)?,
1163                    None => None,
1164                };
1165                let new_blob = match new_files.get(&change.path) {
1166                    Some(h) => repo.store().get_blob(h)?,
1167                    None => None,
1168                };
1169                if let (Some(old), Some(new)) = (old_blob, new_blob) {
1170                    for line in diff_blobs(&old, &new) {
1171                        match line {
1172                            objects::worktree::DiffLine::Added(_) => {
1173                                added_lines = added_lines.saturating_add(1);
1174                            }
1175                            objects::worktree::DiffLine::Removed(_) => {
1176                                removed_lines = removed_lines.saturating_add(1);
1177                            }
1178                            objects::worktree::DiffLine::Context(_) => {}
1179                        }
1180                    }
1181                }
1182            }
1183            DiffKind::Unchanged => continue,
1184        }
1185        changed_paths.push(ChangedPath {
1186            path: change.path.clone(),
1187            kind: change.kind,
1188        });
1189    }
1190
1191    Ok(DiffSummary {
1192        files_changed: changed_paths.len() as u32,
1193        added_files,
1194        modified_files,
1195        deleted_files,
1196        added_lines,
1197        removed_lines,
1198        changed_paths,
1199    })
1200}
1201
1202/// Count the number of newline-separated lines in a file blob. Binary
1203/// blobs (non-utf8) count as zero — we deliberately don't byte-count
1204/// them, since "lines" is meaningless for binary content. A trailing
1205/// newline does not introduce a phantom empty line.
1206fn line_count(content: &[u8]) -> u32 {
1207    let Ok(s) = std::str::from_utf8(content) else {
1208        return 0;
1209    };
1210    if s.is_empty() {
1211        return 0;
1212    }
1213    let trimmed = s.strip_suffix('\n').unwrap_or(s);
1214    if trimmed.is_empty() {
1215        return 1;
1216    }
1217    (trimmed.matches('\n').count() as u32).saturating_add(1)
1218}
1219
1220// ---------------------------------------------------------------------------
1221// Tests
1222// ---------------------------------------------------------------------------
1223
1224#[cfg(test)]
1225mod tests {
1226    use std::sync::Arc;
1227
1228    use api::heddle::api::v1alpha1::ReviewScope as ProtoReviewScope;
1229    use crypto::Signer as _;
1230    use repo::{Repository, operation_dedup::OperationDedupStore};
1231    use tempfile::TempDir;
1232
1233    use super::*;
1234
1235    fn fresh_service() -> (LocalStateReviewService, Arc<Repository>, TempDir) {
1236        let temp = TempDir::new().expect("create tempdir");
1237        // SAFETY: tests run with a controlled environment; setting these
1238        // env vars steers the default attribution into a predictable place.
1239        // SAFETY: setting env vars in tests; rust 2024 marks these unsafe.
1240        unsafe {
1241            std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Alice Tester");
1242            std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "alice@example.com");
1243        }
1244        let repo = Repository::init_default(temp.path()).expect("init repo");
1245        let dedup = OperationDedupStore::open(repo.heddle_dir()).expect("open dedup");
1246        let repo = Arc::new(repo);
1247        let svc =
1248            LocalStateReviewService::new(GrpcLocalService::new(repo.clone(), Arc::new(dedup)));
1249        (svc, repo, temp)
1250    }
1251
1252    fn capture_state(repo: &Repository) -> StateId {
1253        // Write a tiny file so snapshot has something to capture.
1254        std::fs::write(repo.root().join("hello.txt"), b"hi").expect("write file");
1255        let state = repo
1256            .snapshot(Some("seed".to_string()), None)
1257            .expect("snapshot");
1258        state.state_id
1259    }
1260
1261    fn sign_request(state_id: &StateId, op_id: &str) -> SignStateRequest {
1262        let signer = crypto::Ed25519Signer::generate().expect("generate ed25519 key");
1263        let scope = ReviewScope::WholeChange;
1264        let signed_at = chrono::Utc::now().timestamp();
1265        let payload = signing_payload(*state_id, ReviewKind::Read, &scope, signed_at, None);
1266        let signature = signer.sign(&payload).expect("sign payload");
1267        use api::heddle::api::v1alpha1::review_scope::{Scope, WholeChange};
1268        SignStateRequest {
1269            repo_path: None,
1270            state_id: Some(api_state_id(state_id)),
1271            kind: api::heddle::api::v1alpha1::ReviewKind::Read as i32,
1272            scope: Some(ProtoReviewScope {
1273                scope: Some(Scope::WholeChange(WholeChange {})),
1274            }),
1275            justification: String::new(),
1276            algorithm: "ed25519".to_string(),
1277            public_key: signer.public_key().to_vec(),
1278            signature: signature.clone(),
1279            signed_at: Some(prost_types::Timestamp {
1280                seconds: signed_at,
1281                nanos: 0,
1282            }),
1283            client_operation_id: op_id.to_string(),
1284        }
1285    }
1286
1287    fn verdict_request(
1288        state_id: &StateId,
1289        verdict: ProtoVerdict,
1290        reason: &str,
1291        op_id: &str,
1292    ) -> RecordVerdictRequest {
1293        let signer = crypto::Ed25519Signer::generate().expect("generate ed25519 key");
1294        let scope = ReviewScope::WholeChange;
1295        let signed_at = chrono::Utc::now().timestamp();
1296        let verdict_str = verdict_str_from_proto(verdict as i32).expect("explicit verdict");
1297        let payload = verdict_signing_payload(*state_id, verdict_str, &scope, signed_at, reason);
1298        let signature = signer.sign(&payload).expect("sign verdict payload");
1299        use api::heddle::api::v1alpha1::review_scope::{Scope, WholeChange};
1300        RecordVerdictRequest {
1301            repo_path: None,
1302            state_id: Some(api_state_id(state_id)),
1303            verdict: verdict as i32,
1304            scope: Some(ProtoReviewScope {
1305                scope: Some(Scope::WholeChange(WholeChange {})),
1306            }),
1307            reason: reason.to_string(),
1308            algorithm: "ed25519".to_string(),
1309            public_key: signer.public_key().to_vec(),
1310            signature,
1311            signed_at: Some(prost_types::Timestamp {
1312                seconds: signed_at,
1313                nanos: 0,
1314            }),
1315            client_operation_id: op_id.to_string(),
1316        }
1317    }
1318
1319    #[tokio::test]
1320    #[serial_test::serial(process_global)]
1321    async fn sign_state_persists_to_review_signatures_blob() {
1322        let (svc, repo, _tmp) = fresh_service();
1323        let state_id = capture_state(&repo);
1324
1325        let resp = svc
1326            .sign_state(Request::new(sign_request(&state_id, "")))
1327            .await
1328            .expect("sign_state");
1329        assert!(!resp.get_ref().signature_id.is_empty());
1330        assert_eq!(resp.get_ref().state_id, Some(api_state_id(&state_id)));
1331
1332        let listing = svc
1333            .list_signatures(Request::new(ListSignaturesRequest {
1334                repo_path: None,
1335                state_id: Some(api_state_id(&state_id)),
1336            }))
1337            .await
1338            .expect("list_signatures");
1339        let sigs = &listing.get_ref().signatures;
1340        assert_eq!(sigs.len(), 1, "expected one signature, got {sigs:?}");
1341        assert_eq!(
1342            sigs[0].kind,
1343            api::heddle::api::v1alpha1::ReviewKind::Read as i32
1344        );
1345        assert_eq!(sigs[0].algorithm, "ed25519");
1346        assert_eq!(sigs[0].actor_name, "Alice Tester");
1347        assert_eq!(sigs[0].actor_email, "alice@example.com");
1348        let scope_case = sigs[0].scope.as_ref().and_then(|s| s.scope.as_ref());
1349        assert!(matches!(
1350            scope_case,
1351            Some(api::heddle::api::v1alpha1::review_scope::Scope::WholeChange(_))
1352        ));
1353    }
1354
1355    #[tokio::test]
1356    #[serial_test::serial(process_global)]
1357    async fn record_verdict_persists_signed_opinion_without_changing_state() {
1358        let (svc, repo, _tmp) = fresh_service();
1359        let state_id = capture_state(&repo);
1360        let op_id = objects::object::OperationId::new().to_string();
1361        let request = verdict_request(&state_id, ProtoVerdict::Hold, "needs another pass", &op_id);
1362
1363        let first = svc
1364            .record_verdict(Request::new(request.clone()))
1365            .await
1366            .expect("record verdict");
1367        let replay = svc
1368            .record_verdict(Request::new(request))
1369            .await
1370            .expect("replay verdict");
1371        assert_eq!(first.get_ref(), replay.get_ref());
1372        assert_eq!(first.get_ref().state_id, Some(api_state_id(&state_id)));
1373        assert_eq!(repo.head().expect("read head"), Some(state_id));
1374
1375        let listing = svc
1376            .list_signatures(Request::new(ListSignaturesRequest {
1377                repo_path: None,
1378                state_id: Some(api_state_id(&state_id)),
1379            }))
1380            .await
1381            .expect("list signatures");
1382        let signatures = &listing.get_ref().signatures;
1383        assert_eq!(signatures.len(), 1, "idempotent replay must append once");
1384        assert_eq!(signatures[0].verdict, ProtoVerdict::Hold as i32);
1385        assert_eq!(signatures[0].reason, "needs another pass");
1386        assert!(signatures[0].justification.is_empty());
1387        assert_eq!(
1388            signatures[0].kind,
1389            api::heddle::api::v1alpha1::ReviewKind::Read as i32
1390        );
1391    }
1392
1393    #[tokio::test]
1394    #[serial_test::serial(process_global)]
1395    async fn record_verdict_rejects_reason_changed_after_signing() {
1396        let (svc, repo, _tmp) = fresh_service();
1397        let state_id = capture_state(&repo);
1398        let mut request = verdict_request(&state_id, ProtoVerdict::Reject, "unsafe", "");
1399        request.reason = "different reason".to_string();
1400
1401        let error = svc
1402            .record_verdict(Request::new(request))
1403            .await
1404            .expect_err("changed signed reason must fail verification");
1405        assert_eq!(error.code(), tonic::Code::InvalidArgument);
1406        assert!(error.message().contains("failed verification"));
1407    }
1408
1409    #[tokio::test]
1410    #[serial_test::serial(process_global)]
1411    async fn full_contract_routes_signal_health_and_stubs_review_progress() {
1412        let (svc, _repo, _tmp) = fresh_service();
1413        let health = svc
1414            .get_repo_signal_health(Request::new(GetRepoSignalHealthRequest {
1415                repo_path: None,
1416                window_states: 1,
1417            }))
1418            .await
1419            .expect("local signal health");
1420        assert_eq!(health.get_ref().window_states, 1);
1421
1422        let ack_error = svc
1423            .record_check_ack(Request::new(RecordCheckAckRequest::default()))
1424            .await
1425            .expect_err("local check acks are unsupported");
1426        assert_eq!(ack_error.code(), tonic::Code::Unimplemented);
1427        assert!(ack_error.message().contains("not available in local mode"));
1428
1429        let progress_error = svc
1430            .get_review_progress(Request::new(GetReviewProgressRequest::default()))
1431            .await
1432            .expect_err("local review progress is unsupported");
1433        assert_eq!(progress_error.code(), tonic::Code::Unimplemented);
1434        assert!(
1435            progress_error
1436                .message()
1437                .contains("not available in local mode")
1438        );
1439    }
1440
1441    #[tokio::test]
1442    #[serial_test::serial(process_global)]
1443    async fn sign_state_idempotent() {
1444        let (svc, repo, _tmp) = fresh_service();
1445        let state_id = capture_state(&repo);
1446        let op_id = objects::object::OperationId::new().to_string();
1447        // The second call must replay the *same* request body — fresh
1448        // signatures hash differently, so we build once and clone.
1449        let req = sign_request(&state_id, &op_id);
1450
1451        let first = svc
1452            .sign_state(Request::new(req.clone()))
1453            .await
1454            .expect("first sign_state");
1455        let second = svc
1456            .sign_state(Request::new(req))
1457            .await
1458            .expect("second sign_state");
1459        assert_eq!(
1460            first.get_ref().signature_id,
1461            second.get_ref().signature_id,
1462            "replayed call must return the same signature_id"
1463        );
1464
1465        let listing = svc
1466            .list_signatures(Request::new(ListSignaturesRequest {
1467                repo_path: None,
1468                state_id: Some(api_state_id(&state_id)),
1469            }))
1470            .await
1471            .expect("list_signatures");
1472        assert_eq!(
1473            listing.get_ref().signatures.len(),
1474            1,
1475            "idempotent replay must not append a duplicate signature"
1476        );
1477    }
1478
1479    #[tokio::test]
1480    #[serial_test::serial(process_global)]
1481    async fn sign_state_rejects_forged_signature() {
1482        let (svc, repo, _tmp) = fresh_service();
1483        let state_id = capture_state(&repo);
1484        let mut req = sign_request(&state_id, "");
1485        // Flip the last byte of the signature so verification fails.
1486        let last = req.signature.len() - 1;
1487        req.signature[last] ^= 0xff;
1488
1489        let err = svc
1490            .sign_state(Request::new(req))
1491            .await
1492            .expect_err("forged signature must be rejected");
1493        assert_eq!(err.code(), tonic::Code::InvalidArgument, "{err:?}");
1494        assert!(
1495            err.message().contains("failed verification"),
1496            "unexpected error message: {}",
1497            err.message()
1498        );
1499    }
1500
1501    #[tokio::test]
1502    #[serial_test::serial(process_global)]
1503    async fn sign_state_rejects_skewed_timestamp() {
1504        let (svc, repo, _tmp) = fresh_service();
1505        let state_id = capture_state(&repo);
1506        let mut req = sign_request(&state_id, "");
1507        // Timestamp 1 hour in the future is well outside the skew window.
1508        if let Some(ts) = req.signed_at.as_mut() {
1509            ts.seconds += 60 * 60;
1510        }
1511
1512        let err = svc
1513            .sign_state(Request::new(req))
1514            .await
1515            .expect_err("skewed timestamp must be rejected");
1516        assert_eq!(err.code(), tonic::Code::InvalidArgument);
1517        assert!(err.message().contains("too far from server time"));
1518    }
1519
1520    #[tokio::test]
1521    #[serial_test::serial(process_global)]
1522    async fn sign_state_attributes_to_caller_not_state_author() {
1523        // Regression for the codex-flagged bug: sign_state used to
1524        // attribute the signature to the state's author. Bob signing
1525        // Alice's state would record Alice. The signature must record
1526        // the *caller*. In local mode the caller is resolved via
1527        // `Repository::get_principal()` (env vars then config).
1528        let (svc, repo, _tmp) = fresh_service();
1529        // `fresh_service` already set the env to Alice; capture the
1530        // state under Alice's identity so `state.attribution.principal`
1531        // is Alice.
1532        let state_id = capture_state(&repo);
1533
1534        // Now switch the local user to Bob and sign Alice's state.
1535        // SAFETY: tests run with a controlled environment.
1536        unsafe {
1537            std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Bob Signer");
1538            std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "bob@example.com");
1539        }
1540
1541        svc.sign_state(Request::new(sign_request(&state_id, "")))
1542            .await
1543            .expect("sign_state");
1544
1545        let listing = svc
1546            .list_signatures(Request::new(ListSignaturesRequest {
1547                repo_path: None,
1548                state_id: Some(api_state_id(&state_id)),
1549            }))
1550            .await
1551            .expect("list_signatures");
1552        let sigs = &listing.get_ref().signatures;
1553        assert_eq!(sigs.len(), 1);
1554        assert_eq!(
1555            sigs[0].actor_name, "Bob Signer",
1556            "signature must attribute to the caller (Bob), not the state author (Alice)"
1557        );
1558        assert_eq!(sigs[0].actor_email, "bob@example.com");
1559
1560        // Restore the env so other serial tests see the expected
1561        // Alice baseline.
1562        unsafe {
1563            std::env::set_var("HEDDLE_PRINCIPAL_NAME", "Alice Tester");
1564            std::env::set_var("HEDDLE_PRINCIPAL_EMAIL", "alice@example.com");
1565        }
1566    }
1567
1568    #[tokio::test]
1569    #[serial_test::serial(process_global)]
1570    async fn sign_state_serializes_concurrent_appends() {
1571        let (svc, repo, _tmp) = fresh_service();
1572        let state_id = capture_state(&repo);
1573
1574        // Two distinct ops so neither replays the other through dedup.
1575        let op_a = objects::object::OperationId::new().to_string();
1576        let op_b = objects::object::OperationId::new().to_string();
1577        let req_a = sign_request(&state_id, &op_a);
1578        let req_b = sign_request(&state_id, &op_b);
1579
1580        let svc_a = svc.clone();
1581        let svc_b = svc.clone();
1582        let (a, b) = tokio::join!(
1583            svc_a.sign_state(Request::new(req_a)),
1584            svc_b.sign_state(Request::new(req_b)),
1585        );
1586        a.expect("first sign_state");
1587        b.expect("second sign_state");
1588
1589        let listing = svc
1590            .list_signatures(Request::new(ListSignaturesRequest {
1591                repo_path: None,
1592                state_id: Some(api_state_id(&state_id)),
1593            }))
1594            .await
1595            .expect("list_signatures");
1596        assert_eq!(
1597            listing.get_ref().signatures.len(),
1598            2,
1599            "both concurrent signatures must land — neither should be lost \
1600             to a stale-blob clobber"
1601        );
1602    }
1603
1604    /// Regression: `get_review_payload` previously returned
1605    /// `summary.files_changed = 0` and empty `in_budget_signals` for
1606    /// every state, even when the diff against the parent had real
1607    /// content. This test snapshots once (root state — every file is
1608    /// "added"), then snapshots again with a modification, and asserts
1609    /// both states report a non-empty diff summary plus a populated
1610    /// `diff_summary` signal anchored on the changed file.
1611    #[tokio::test]
1612    #[serial_test::serial(process_global)]
1613    async fn get_review_payload_populates_diff_summary_and_signals() {
1614        let (svc, repo, _tmp) = fresh_service();
1615
1616        // First capture: root state, one file added. Every line of
1617        // `hello.txt` should count as added.
1618        std::fs::write(repo.root().join("hello.txt"), b"first\nsecond\nthird\n")
1619            .expect("write hello.txt");
1620        let first = repo
1621            .snapshot(Some("first capture".to_string()), None)
1622            .expect("first snapshot");
1623
1624        let resp_first = svc
1625            .get_review_payload(Request::new(GetReviewPayloadRequest {
1626                repo_path: None,
1627                state_id: Some(api_state_id(&first.state_id)),
1628                include_all_signals: false,
1629            }))
1630            .await
1631            .expect("get_review_payload first");
1632        let payload_first = resp_first.into_inner();
1633        let summary_first = payload_first.summary.as_ref().expect("summary present");
1634        assert!(
1635            summary_first.files_changed >= 1,
1636            "first state should report at least one file changed (vs empty parent), got {}",
1637            summary_first.files_changed
1638        );
1639        assert!(
1640            summary_first.added_lines >= 3,
1641            "first state should report 3+ added lines, got {}",
1642            summary_first.added_lines
1643        );
1644        assert!(
1645            !payload_first.in_budget_signals.is_empty(),
1646            "in_budget_signals must include a diff_summary entry"
1647        );
1648        let first_signal = &payload_first.in_budget_signals[0];
1649        assert!(
1650            first_signal.kind.starts_with("diff_summary."),
1651            "expected synthetic diff_summary signal kind, got {}",
1652            first_signal.kind
1653        );
1654        assert_eq!(first_signal.producer_module, "review_show.diff_summary");
1655        assert_eq!(first_signal.visibility, "visible");
1656
1657        // Second capture: modify the file. Diff vs the first state's
1658        // tree should report a single modified file with at least one
1659        // added and one removed line.
1660        std::fs::write(
1661            repo.root().join("hello.txt"),
1662            b"first\nsecond\nthird\nfourth\n",
1663        )
1664        .expect("modify hello.txt");
1665        let second = repo
1666            .snapshot(Some("second capture".to_string()), None)
1667            .expect("second snapshot");
1668
1669        let resp_second = svc
1670            .get_review_payload(Request::new(GetReviewPayloadRequest {
1671                repo_path: None,
1672                state_id: Some(api_state_id(&second.state_id)),
1673                include_all_signals: false,
1674            }))
1675            .await
1676            .expect("get_review_payload second");
1677        let payload_second = resp_second.into_inner();
1678        let summary_second = payload_second.summary.as_ref().expect("summary present");
1679        assert_eq!(
1680            summary_second.files_changed, 1,
1681            "second state should report exactly one modified file"
1682        );
1683        assert!(
1684            summary_second.added_lines >= 1,
1685            "second state should report at least one added line, got {}",
1686            summary_second.added_lines
1687        );
1688        assert!(
1689            !payload_second.in_budget_signals.is_empty(),
1690            "in_budget_signals must include a diff_summary entry"
1691        );
1692        let signal = &payload_second.in_budget_signals[0];
1693        assert_eq!(
1694            signal
1695                .anchor
1696                .as_ref()
1697                .map(|a| a.file.as_str())
1698                .unwrap_or(""),
1699            "hello.txt",
1700            "diff_summary signal should anchor on the modified file"
1701        );
1702        assert!(
1703            signal.reason.contains("files changed"),
1704            "first signal reason should carry the aggregate summary, got {}",
1705            signal.reason
1706        );
1707        // The summary's signal-count fields should reflect the visible
1708        // budget so consumers can short-circuit on empty-vs-populated
1709        // without re-counting the array.
1710        assert_eq!(
1711            summary_second.in_budget_signal_count,
1712            payload_second.in_budget_signals.len() as u32,
1713            "in_budget_signal_count must match the array length"
1714        );
1715    }
1716
1717    #[tokio::test]
1718    #[serial_test::serial(process_global)]
1719    async fn get_review_payload_surfaces_gitlink_target_changes() {
1720        let (svc, repo, _tmp) = fresh_service();
1721
1722        let old_target = "0303030303030303030303030303030303030303"
1723            .parse()
1724            .expect("old git oid");
1725        let new_target = "0404040404040404040404040404040404040404"
1726            .parse()
1727            .expect("new git oid");
1728        let old_tree = objects::object::Tree::from_entries(vec![
1729            objects::object::TreeEntry::gitlink("vendor", old_target).expect("old gitlink"),
1730        ]);
1731        let new_tree = objects::object::Tree::from_entries(vec![
1732            objects::object::TreeEntry::gitlink("vendor", new_target).expect("new gitlink"),
1733        ]);
1734        let old_tree_hash = repo.store().put_tree(&old_tree).expect("put old tree");
1735        let new_tree_hash = repo.store().put_tree(&new_tree).expect("put new tree");
1736        let base = State::new_snapshot(
1737            old_tree_hash,
1738            Vec::new(),
1739            objects::object::Attribution::human(objects::object::Principal::new(
1740                "Gitlink Reviewer",
1741                "gitlink@example.test",
1742            )),
1743        );
1744        let base_id = base.state_id;
1745        repo.store().put_state(&base).expect("put base state");
1746        let changed = State::new_snapshot(
1747            new_tree_hash,
1748            vec![base_id],
1749            objects::object::Attribution::human(objects::object::Principal::new(
1750                "Gitlink Reviewer",
1751                "gitlink@example.test",
1752            )),
1753        );
1754        let changed_id = changed.state_id;
1755        repo.store().put_state(&changed).expect("put changed state");
1756
1757        let resp = svc
1758            .get_review_payload(Request::new(GetReviewPayloadRequest {
1759                repo_path: None,
1760                state_id: Some(api_state_id(&changed_id)),
1761                include_all_signals: false,
1762            }))
1763            .await
1764            .expect("get_review_payload gitlink change");
1765        let payload = resp.into_inner();
1766        let summary = payload.summary.as_ref().expect("summary present");
1767        assert_eq!(
1768            summary.files_changed, 1,
1769            "gitlink pointer change should count as one changed path"
1770        );
1771        assert_eq!(summary.added_lines, 0);
1772        assert_eq!(summary.removed_lines, 0);
1773        assert_eq!(
1774            payload
1775                .in_budget_signals
1776                .first()
1777                .and_then(|signal| signal.anchor.as_ref())
1778                .map(|anchor| anchor.file.as_str()),
1779            Some("vendor"),
1780            "diff_summary signal should be anchored on the gitlink path"
1781        );
1782        let partition = payload.partition.expect("partition present");
1783        let surfaced = partition
1784            .structural
1785            .iter()
1786            .chain(partition.consequence.iter())
1787            .chain(partition.tests_and_docs.iter())
1788            .any(|symbol| symbol.file == "vendor" && symbol.symbol == "vendor");
1789        assert!(surfaced, "gitlink change should be path-visible in review");
1790    }
1791
1792    /// Regression for codex feedback on PRs #52 (tree fallback) and
1793    /// #56 (blob fallback): `compute_state_diff_summary` must tolerate
1794    /// missing trees and blobs by returning an empty/partial summary
1795    /// rather than blocking the entire review payload. Construct a
1796    /// state whose `tree` points to a content hash that was never
1797    /// stored — `get_tree` returns `Ok(None)`, and the function must
1798    /// fall back to an empty changeset instead of erroring out of
1799    /// `diff_trees`. (Pre-fix, the Modified branch already tolerated
1800    /// missing blobs via `.ok().flatten()`, but the Added/Deleted
1801    /// branches and the top-level diff_trees call did not — surfaces
1802    /// of pruned object stores all errored out inconsistently.)
1803    #[tokio::test]
1804    #[serial_test::serial(process_global)]
1805    async fn get_review_payload_tolerates_missing_tree() {
1806        let (svc, repo, _tmp) = fresh_service();
1807        let state_id = capture_state(&repo);
1808
1809        // Load the state, swap its tree pointer to a synthetic hash
1810        // that nothing references, and re-persist. The state object
1811        // itself survives, but `repo.store().get_tree(state.tree)`
1812        // will return `Ok(None)` — the missing-tree case.
1813        let state = repo
1814            .store()
1815            .get_state(&state_id)
1816            .expect("get state")
1817            .expect("state present");
1818        let bogus_tree = objects::object::ContentHash::compute(b"definitely-not-in-store-bytes");
1819        let mut mutated = state.clone();
1820        mutated.tree = bogus_tree;
1821        let missing_tree_state_id = mutated.id();
1822        repo.store().put_state(&mutated).expect("put mutated state");
1823
1824        // The review payload must still come back — empty summary,
1825        // but no Status::Internal error from the gRPC layer.
1826        let resp = svc
1827            .get_review_payload(Request::new(GetReviewPayloadRequest {
1828                repo_path: None,
1829                state_id: Some(api_state_id(&missing_tree_state_id)),
1830                include_all_signals: false,
1831            }))
1832            .await
1833            .expect("missing tree must not block review payload");
1834        let payload = resp.into_inner();
1835        let summary = payload.summary.as_ref().expect("summary present");
1836        assert_eq!(
1837            summary.files_changed, 0,
1838            "missing tree must produce a zero-change summary, got {} files",
1839            summary.files_changed
1840        );
1841        // Synthetic diff_summary signal should still be present (with
1842        // the `empty` kind) so consumers always see at least one
1843        // signal — keeps the wire shape stable.
1844        assert!(
1845            !payload.in_budget_signals.is_empty(),
1846            "in_budget_signals should always contain at least the synthetic diff_summary entry"
1847        );
1848        let kind = &payload.in_budget_signals[0].kind;
1849        assert!(
1850            kind.starts_with("diff_summary."),
1851            "expected synthetic diff_summary signal, got {kind}"
1852        );
1853    }
1854
1855    /// `line_count` should match git-style line counts — trailing
1856    /// newline never produces a phantom empty line, but an unterminated
1857    /// final line still counts.
1858    #[test]
1859    fn line_count_matches_git_semantics() {
1860        assert_eq!(line_count(b""), 0);
1861        assert_eq!(line_count(b"\n"), 1);
1862        assert_eq!(line_count(b"hello"), 1);
1863        assert_eq!(line_count(b"hello\n"), 1);
1864        assert_eq!(line_count(b"hello\nworld"), 2);
1865        assert_eq!(line_count(b"hello\nworld\n"), 2);
1866        assert_eq!(line_count(b"a\nb\nc\n"), 3);
1867        // Non-utf8 bytes count as zero (treated as binary).
1868        assert_eq!(line_count(&[0xff, 0xfe, 0xfd]), 0);
1869    }
1870}