Skip to main content

bcode_code_review_models/
lib.rs

1#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
2#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
3#![allow(clippy::multiple_crate_versions)]
4
5//! Shared code review models for Bcode.
6
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10use std::path::{Component, Path, PathBuf};
11
12/// Code review plugin service interface.
13pub const CODE_REVIEW_SERVICE_INTERFACE_ID: &str = "bcode.code_review/v1";
14/// Review publisher service interface.
15pub const REVIEW_PUBLISHER_INTERFACE_ID: &str = "bcode.review_publisher/v1";
16/// External review importer service interface.
17pub const REVIEW_IMPORTER_INTERFACE_ID: &str = "bcode.review_importer/v1";
18
19/// Operation that returns a provider-neutral review bundle.
20pub const OP_REVIEW_BUNDLE_GET: &str = "review.bundle.get";
21/// Operation that lists review publishers.
22pub const OP_REVIEW_PUBLISHERS_LIST: &str = "review.publishers.list";
23/// Operation that previews a review publish operation.
24pub const OP_REVIEW_PUBLISH_PREVIEW: &str = "review.publish.preview";
25/// Operation that submits a review publish operation.
26pub const OP_REVIEW_PUBLISH_SUBMIT: &str = "review.publish.submit";
27/// Operation that saves publish history for an externally handled publisher.
28pub const OP_REVIEW_PUBLISH_RECORD_SAVE: &str = "review.publish.record.save";
29/// Operation that returns publish records for a durable workspace.
30pub const OP_REVIEW_PUBLISH_RECORDS_LIST: &str = "review.publish.records.list";
31/// Operation that lists durable review workspaces.
32pub const OP_REVIEW_WORKSPACE_LIST: &str = "review.workspace.list";
33/// Operation that creates a durable review workspace.
34pub const OP_REVIEW_WORKSPACE_CREATE: &str = "review.workspace.create";
35/// Operation that fetches a durable review workspace.
36pub const OP_REVIEW_WORKSPACE_GET: &str = "review.workspace.get";
37/// Operation that updates a durable review workspace.
38pub const OP_REVIEW_WORKSPACE_UPDATE: &str = "review.workspace.update";
39/// Operation that archives a durable review workspace.
40pub const OP_REVIEW_WORKSPACE_ARCHIVE: &str = "review.workspace.archive";
41/// Operation that materializes review workspace sources into reviewable surfaces.
42pub const OP_REVIEW_WORKSPACE_MATERIALIZE: &str = "review.workspace.materialize";
43/// Operation that returns repository file content for review browsing.
44pub const OP_REVIEW_REPO_FILE_GET: &str = "review.repo.file.get";
45/// Operation that lists durable AI-suggested review comments.
46pub const OP_REVIEW_SUGGESTIONS_LIST: &str = "review.suggestions.list";
47/// Operation that creates or updates a durable AI-suggested review comment.
48pub const OP_REVIEW_SUGGESTION_SAVE: &str = "review.suggestion.save";
49/// Operation that lists durable, non-publishable review AI exchanges.
50pub const OP_REVIEW_AI_EXCHANGES_LIST: &str = "review.ai_exchanges.list";
51/// Operation that creates or updates a durable, non-publishable review AI exchange.
52pub const OP_REVIEW_AI_EXCHANGE_SAVE: &str = "review.ai_exchange.save";
53/// Operation that explicitly deletes a durable review AI exchange.
54pub const OP_REVIEW_AI_EXCHANGE_DELETE: &str = "review.ai_exchange.delete";
55/// Current persisted schema version for review AI exchanges.
56pub const REVIEW_AI_EXCHANGE_SCHEMA_VERSION: u16 = 1;
57/// Operation that saves a provider-neutral external review import snapshot.
58pub const OP_REVIEW_EXTERNAL_IMPORT_SAVE: &str = "review.external_import.save";
59/// Operation that lists saved external review import snapshots.
60pub const OP_REVIEW_EXTERNAL_IMPORTS_LIST: &str = "review.external_imports.list";
61/// Operation that returns an external publisher manifest.
62pub const OP_REVIEW_PUBLISHER_MANIFEST: &str = "review.publisher.manifest";
63/// Operation that previews an external publisher request.
64pub const OP_REVIEW_PUBLISHER_PREVIEW: &str = "review.publisher.preview";
65/// Operation that submits an external publisher request.
66pub const OP_REVIEW_PUBLISHER_SUBMIT: &str = "review.publisher.submit";
67/// Operation that returns an external review importer manifest.
68pub const OP_REVIEW_IMPORTER_MANIFEST: &str = "review.importer.manifest";
69/// Operation that imports external review state without mutating it.
70pub const OP_REVIEW_IMPORTER_IMPORT: &str = "review.importer.import";
71
72/// Supported local Git review target.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "kind", rename_all = "snake_case")]
75pub enum ReviewTarget {
76    /// Review unstaged working-tree changes.
77    WorkingTreeUnstaged,
78    /// Review staged index changes.
79    IndexStaged,
80    /// Review both staged and unstaged changes.
81    WorkingTreeAndIndex,
82    /// Review the last commit.
83    LastCommit,
84    /// Review an explicit commit range.
85    CommitRange {
86        /// Base revision.
87        base: String,
88        /// Head revision.
89        head: String,
90        /// Whether to use merge-base `...` semantics.
91        #[serde(default)]
92        merge_base: bool,
93    },
94    /// Review a branch comparison.
95    BranchCompare {
96        /// Base branch.
97        base_branch: String,
98        /// Head branch.
99        head_branch: String,
100        /// Whether to use merge-base `...` semantics.
101        #[serde(default = "default_true")]
102        merge_base: bool,
103    },
104    /// Review the repository as browsable read-only files.
105    Repository,
106}
107
108pub const REVIEW_WORKSPACE_PRESENTATION_SCHEMA_VERSION: u16 = 1;
109
110/// Durable Code Review-owned semantic presentation state.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct ReviewWorkspacePresentationState {
113    /// Presentation schema version.
114    pub schema_version: u16,
115    /// Stable selected source or file path, when known.
116    #[serde(default)]
117    pub selected_path: Option<String>,
118    /// Stable selected thread identity, when known.
119    #[serde(default)]
120    pub selected_thread_key: Option<String>,
121    /// Semantic selected line within the source, when known.
122    #[serde(default)]
123    pub selected_line: Option<u32>,
124    /// Plugin-owned sidebar mode identifier.
125    #[serde(default)]
126    pub sidebar_mode: String,
127    /// Whether the sidebar is visible.
128    #[serde(default = "default_true")]
129    pub sidebar_visible: bool,
130    /// Plugin-owned thread filter identifier.
131    #[serde(default)]
132    pub thread_filter: String,
133    /// Whether resolved threads are shown.
134    #[serde(default = "default_true")]
135    pub show_resolved_threads: bool,
136    /// Whether viewed files are hidden.
137    #[serde(default)]
138    pub hide_viewed_files: bool,
139    /// Stable collapsed thread identities.
140    #[serde(default)]
141    pub collapsed_thread_keys: BTreeSet<String>,
142    /// Stable expanded linked-session answer identities.
143    #[serde(default)]
144    pub expanded_agent_answer_keys: BTreeSet<String>,
145}
146
147/// Durable review workspace assembled from one or more review sources.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct ReviewWorkspace {
150    /// Stable workspace id.
151    pub id: String,
152    /// Human-readable review title.
153    pub title: String,
154    /// Repository path for the workspace.
155    pub repo_root: PathBuf,
156    /// Sources intentionally included in the review.
157    pub sources: Vec<ReviewSource>,
158    /// Created timestamp in milliseconds since Unix epoch, when known.
159    #[serde(default)]
160    pub created_at_ms: Option<u64>,
161    /// Updated timestamp in milliseconds since Unix epoch, when known.
162    #[serde(default)]
163    pub updated_at_ms: Option<u64>,
164    /// Review file display paths marked viewed.
165    #[serde(default)]
166    pub viewed_files: BTreeSet<String>,
167    /// Archived timestamp in milliseconds since Unix epoch, when archived.
168    #[serde(default)]
169    pub archived_at_ms: Option<u64>,
170    /// Stable semantic presentation state for useful workspace reopen.
171    #[serde(default)]
172    pub presentation_state: Option<ReviewWorkspacePresentationState>,
173}
174
175impl ReviewWorkspace {
176    /// Create a transient workspace from an entry-point review target.
177    #[must_use]
178    pub fn from_target(repo_root: PathBuf, target: ReviewTarget) -> Self {
179        let source_kind = ReviewSourceKind::from(target);
180        let title = source_kind.label();
181        Self {
182            id: "transient-review-workspace".to_string(),
183            title: title.clone(),
184            repo_root,
185            sources: vec![ReviewSource {
186                id: "source-1".to_string(),
187                kind: source_kind,
188                label: title,
189                included: true,
190            }],
191            created_at_ms: None,
192            updated_at_ms: None,
193            viewed_files: BTreeSet::new(),
194            archived_at_ms: None,
195            presentation_state: None,
196        }
197    }
198}
199
200/// One source of reviewable content in a workspace.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct ReviewSource {
203    /// Stable source id within the workspace.
204    pub id: String,
205    /// Source kind.
206    pub kind: ReviewSourceKind,
207    /// Human-readable source label.
208    pub label: String,
209    /// Whether this source is included in the review output.
210    pub included: bool,
211}
212
213/// Supported source kinds for building a review workspace.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(tag = "kind", rename_all = "snake_case")]
216pub enum ReviewSourceKind {
217    /// Unstaged working-tree changes.
218    WorkingTreeUnstaged,
219    /// Staged index changes.
220    IndexStaged,
221    /// Staged and unstaged working-tree changes together.
222    WorkingTreeAndIndex,
223    /// Last commit reachable from `HEAD`.
224    LastCommit,
225    /// Specific commit.
226    Commit {
227        /// Commit revision.
228        rev: String,
229    },
230    /// Explicit commit range.
231    CommitRange {
232        /// Base revision.
233        base: String,
234        /// Head revision.
235        head: String,
236        /// Whether to use merge-base semantics.
237        #[serde(default)]
238        merge_base: bool,
239    },
240    /// Branch comparison.
241    BranchCompare {
242        /// Base branch.
243        base_branch: String,
244        /// Head branch.
245        head_branch: String,
246        /// Whether to use merge-base semantics.
247        #[serde(default = "default_true")]
248        merge_base: bool,
249    },
250    /// Specific repository file.
251    File {
252        /// Repository-relative file path.
253        path: String,
254    },
255    /// Specific repository file range.
256    FileRange {
257        /// Repository-relative file path.
258        path: String,
259        /// Start line.
260        start: u32,
261        /// End line.
262        end: u32,
263    },
264    /// Full repository browser context.
265    Repository,
266}
267
268impl ReviewSourceKind {
269    /// Return a human-readable source label.
270    #[must_use]
271    pub fn label(&self) -> String {
272        match self {
273            Self::WorkingTreeUnstaged => "Unstaged changes".to_string(),
274            Self::IndexStaged => "Staged changes".to_string(),
275            Self::WorkingTreeAndIndex => "Working tree and index".to_string(),
276            Self::LastCommit => "Last commit".to_string(),
277            Self::Commit { rev } => format!("Commit {rev}"),
278            Self::CommitRange {
279                base,
280                head,
281                merge_base,
282            } => {
283                let separator = if *merge_base { "..." } else { ".." };
284                format!("Range {base}{separator}{head}")
285            }
286            Self::BranchCompare {
287                base_branch,
288                head_branch,
289                merge_base,
290            } => {
291                let separator = if *merge_base { "..." } else { ".." };
292                format!("Compare {base_branch}{separator}{head_branch}")
293            }
294            Self::File { path } => format!("File {path}"),
295            Self::FileRange { path, start, end } => format!("File {path}:{start}-{end}"),
296            Self::Repository => "Repository browser".to_string(),
297        }
298    }
299}
300
301impl From<ReviewTarget> for ReviewSourceKind {
302    fn from(target: ReviewTarget) -> Self {
303        match target {
304            ReviewTarget::WorkingTreeUnstaged => Self::WorkingTreeUnstaged,
305            ReviewTarget::IndexStaged => Self::IndexStaged,
306            ReviewTarget::WorkingTreeAndIndex => Self::WorkingTreeAndIndex,
307            ReviewTarget::LastCommit => Self::LastCommit,
308            ReviewTarget::CommitRange {
309                base,
310                head,
311                merge_base,
312            } => Self::CommitRange {
313                base,
314                head,
315                merge_base,
316            },
317            ReviewTarget::BranchCompare {
318                base_branch,
319                head_branch,
320                merge_base,
321            } => Self::BranchCompare {
322                base_branch,
323                head_branch,
324                merge_base,
325            },
326            ReviewTarget::Repository => Self::Repository,
327        }
328    }
329}
330
331/// Normalized review surface kind.
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
333#[serde(rename_all = "snake_case")]
334pub enum ReviewSurfaceKind {
335    /// Diff surface.
336    Diff,
337    /// Full-file surface.
338    File,
339}
340
341/// Normalized reviewable surface produced by workspace sources.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub struct ReviewSurface {
344    /// Stable surface id.
345    pub id: String,
346    /// Source id that produced this surface.
347    pub source_id: String,
348    /// Repository-relative path.
349    pub path: String,
350    /// Surface kind.
351    pub kind: ReviewSurfaceKind,
352    /// Materialized review file, when this is a diff surface.
353    #[serde(default)]
354    pub file: Option<ReviewFile>,
355}
356
357/// Severity for a source materialization diagnostic.
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360pub enum ReviewSourceDiagnosticSeverity {
361    /// Informational diagnostic.
362    Info,
363    /// Warning diagnostic.
364    Warning,
365    /// Error diagnostic.
366    Error,
367}
368
369/// Diagnostic produced while materializing one review source.
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub struct ReviewSourceDiagnostic {
372    /// Source id that produced this diagnostic.
373    pub source_id: String,
374    /// Diagnostic severity.
375    pub severity: ReviewSourceDiagnosticSeverity,
376    /// Stable diagnostic code.
377    pub code: String,
378    /// Human-readable diagnostic message.
379    pub message: String,
380}
381
382/// Repository commit available for source pickers.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384pub struct ReviewRepositoryCommit {
385    /// Full commit revision.
386    pub rev: String,
387    /// Short commit revision.
388    pub short_rev: String,
389    /// Commit subject.
390    pub subject: String,
391}
392
393/// Materialized workspace review data.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct ReviewWorkspaceMaterialization {
396    /// Source workspace.
397    pub workspace: ReviewWorkspace,
398    /// Reviewable surfaces produced from included sources.
399    pub surfaces: Vec<ReviewSurface>,
400    /// Diagnostics produced while materializing sources.
401    #[serde(default)]
402    pub diagnostics: Vec<ReviewSourceDiagnostic>,
403    /// Repository file paths available for source/file pickers.
404    #[serde(default)]
405    pub repository_files: Vec<String>,
406    /// Repository branch names available for source pickers.
407    #[serde(default)]
408    pub repository_branches: Vec<String>,
409    /// Recent repository commits available for source pickers.
410    #[serde(default)]
411    pub repository_commits: Vec<ReviewRepositoryCommit>,
412    /// Total added lines across diff surfaces.
413    pub additions: u32,
414    /// Total removed lines across diff surfaces.
415    pub deletions: u32,
416}
417
418/// Request payload for `review.workspace.materialize`.
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420pub struct MaterializeReviewWorkspaceRequest {
421    /// Repository path where the workspace lives.
422    pub repo_path: PathBuf,
423    /// Workspace to materialize.
424    pub workspace: ReviewWorkspace,
425}
426
427/// Response payload for `review.workspace.materialize`.
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct MaterializeReviewWorkspaceResponse {
430    /// Materialized workspace review data.
431    pub materialization: ReviewWorkspaceMaterialization,
432}
433
434/// Request payload for listing publish records for a workspace.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct ListPublishRecordsRequest {
437    /// Repository path where the workspace lives.
438    pub repo_path: PathBuf,
439    /// Durable workspace id.
440    pub workspace_id: String,
441}
442
443/// Response payload for listing publish records.
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445pub struct ListPublishRecordsResponse {
446    /// Publish records in creation order.
447    pub records: Vec<ReviewPublishRecord>,
448}
449
450/// Request payload for `review.workspace.list`.
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452pub struct ListReviewWorkspacesRequest {
453    /// Repository path whose review workspaces should be listed.
454    pub repo_path: PathBuf,
455    /// Whether archived workspaces should be included.
456    #[serde(default)]
457    pub include_archived: bool,
458}
459
460/// Publish history metadata for a review workspace.
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
462pub struct ReviewPublishRecord {
463    /// Publish record id.
464    pub id: String,
465    /// Workspace id, when the publish was tied to a durable workspace.
466    #[serde(default)]
467    pub workspace_id: Option<String>,
468    /// Review bundle id/key that was published.
469    pub review_id: String,
470    /// Publisher id.
471    pub publisher_id: String,
472    /// Whether submission happened.
473    pub submitted: bool,
474    /// Provider-neutral review thread anchors included in this publish.
475    #[serde(default)]
476    pub published_thread_anchors: Vec<DraftAnchor>,
477    /// Output location, when available.
478    #[serde(default)]
479    pub output: Option<String>,
480    /// Human-readable result message.
481    pub message: String,
482    /// Record creation timestamp.
483    pub created_at_ms: u64,
484}
485
486/// Summary metadata for a review workspace picker row.
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488pub struct ReviewWorkspaceListItem {
489    /// Review workspace.
490    pub workspace: ReviewWorkspace,
491    /// Number of draft threads associated with this workspace.
492    #[serde(default)]
493    pub thread_count: usize,
494    /// Number of draft comments associated with this workspace.
495    #[serde(default)]
496    pub draft_count: usize,
497    /// Most recent publish record associated with this workspace.
498    #[serde(default)]
499    pub last_publish: Option<ReviewPublishRecord>,
500}
501
502/// Response payload for `review.workspace.list`.
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
504pub struct ListReviewWorkspacesResponse {
505    /// Matching review workspaces.
506    pub workspaces: Vec<ReviewWorkspace>,
507    /// Matching review workspaces with picker metadata.
508    #[serde(default)]
509    pub items: Vec<ReviewWorkspaceListItem>,
510}
511
512/// Request payload for `review.workspace.create`.
513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct CreateReviewWorkspaceRequest {
515    /// Repository path where the review workspace should be created.
516    pub repo_path: PathBuf,
517    /// Optional workspace title.
518    #[serde(default)]
519    pub title: Option<String>,
520    /// Initial workspace sources.
521    #[serde(default)]
522    pub sources: Vec<ReviewSource>,
523}
524
525/// Response payload for `review.workspace.create`.
526#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
527pub struct CreateReviewWorkspaceResponse {
528    /// Created review workspace.
529    pub workspace: ReviewWorkspace,
530}
531
532/// Request payload for `review.workspace.get`.
533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
534pub struct GetReviewWorkspaceRequest {
535    /// Repository path where the workspace lives.
536    pub repo_path: PathBuf,
537    /// Workspace id.
538    pub workspace_id: String,
539}
540
541/// Response payload for `review.workspace.get`.
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
543pub struct GetReviewWorkspaceResponse {
544    /// Requested workspace, when found.
545    pub workspace: Option<ReviewWorkspace>,
546}
547
548/// Request payload for `review.workspace.update`.
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
550pub struct UpdateReviewWorkspaceRequest {
551    /// Repository path where the workspace lives.
552    pub repo_path: PathBuf,
553    /// Updated workspace.
554    pub workspace: ReviewWorkspace,
555}
556
557/// Response payload for `review.workspace.update`.
558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
559pub struct UpdateReviewWorkspaceResponse {
560    /// Updated workspace.
561    pub workspace: ReviewWorkspace,
562}
563
564/// Request payload for `review.workspace.archive`.
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
566pub struct ArchiveReviewWorkspaceRequest {
567    /// Repository path where the workspace lives.
568    pub repo_path: PathBuf,
569    /// Workspace id.
570    pub workspace_id: String,
571}
572
573/// Response payload for `review.workspace.archive`.
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575pub struct ArchiveReviewWorkspaceResponse {
576    /// Whether a workspace was archived.
577    pub archived: bool,
578}
579
580/// Stable review scope used for draft persistence and publishing.
581#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
582#[serde(tag = "kind", rename_all = "snake_case")]
583pub enum ReviewScope {
584    /// Legacy target-scoped review.
585    Target {
586        /// Review target.
587        target: ReviewTarget,
588    },
589    /// Durable workspace-scoped review.
590    Workspace {
591        /// Workspace id.
592        workspace_id: String,
593        /// Fallback target for provider operations.
594        target: ReviewTarget,
595    },
596}
597
598impl ReviewScope {
599    /// Return the target associated with this scope.
600    #[must_use]
601    pub const fn target(&self) -> &ReviewTarget {
602        match self {
603            Self::Target { target } | Self::Workspace { target, .. } => target,
604        }
605    }
606}
607
608/// Request payload for `draft.list`.
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
610pub struct ListDraftsRequest {
611    /// Repository path where Git commands should run.
612    pub repo_path: PathBuf,
613    /// Local Git target whose drafts should be listed.
614    pub target: ReviewTarget,
615    /// Review scope, when using durable workspace-scoped drafts.
616    #[serde(default)]
617    pub scope: Option<ReviewScope>,
618}
619
620/// Request payload for `draft.save`.
621#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
622pub struct SaveDraftRequest {
623    /// Repository path where Git commands should run.
624    pub repo_path: PathBuf,
625    /// Review target.
626    pub target: ReviewTarget,
627    /// Review scope, when using durable workspace-scoped drafts.
628    #[serde(default)]
629    pub scope: Option<ReviewScope>,
630    /// Draft anchor.
631    pub anchor: DraftAnchor,
632    /// Markdown body.
633    pub body: String,
634}
635
636/// Request payload for `draft.delete`.
637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
638pub struct DeleteDraftRequest {
639    /// Repository path where Git commands should run.
640    pub repo_path: PathBuf,
641    /// Review target.
642    pub target: ReviewTarget,
643    /// Review scope, when using durable workspace-scoped drafts.
644    #[serde(default)]
645    pub scope: Option<ReviewScope>,
646    /// Comment id to delete.
647    pub comment_id: String,
648}
649
650/// Request payload for `draft.update`.
651#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
652pub struct UpdateDraftRequest {
653    /// Repository path where Git commands should run.
654    pub repo_path: PathBuf,
655    /// Review target.
656    pub target: ReviewTarget,
657    /// Review scope, when using durable workspace-scoped drafts.
658    #[serde(default)]
659    pub scope: Option<ReviewScope>,
660    /// Comment id to update.
661    pub comment_id: String,
662    /// Markdown body.
663    pub body: String,
664}
665
666/// Request payload for `thread.link_session`.
667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
668pub struct LinkThreadSessionRequest {
669    /// Repository path where Git commands should run.
670    pub repo_path: PathBuf,
671    /// Review target for the thread.
672    pub target: ReviewTarget,
673    /// Review scope, when using durable workspace-scoped drafts.
674    #[serde(default)]
675    pub scope: Option<ReviewScope>,
676    /// Thread anchor.
677    pub anchor: DraftAnchor,
678    /// Bcode session id.
679    pub session_id: String,
680}
681
682/// Request payload for `review.thread.get`.
683#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
684pub struct GetReviewThreadRequest {
685    /// Repository path where Git commands should run.
686    pub repo_path: PathBuf,
687    /// Review target.
688    pub target: ReviewTarget,
689    /// Review scope, when using durable workspace-scoped drafts.
690    #[serde(default)]
691    pub scope: Option<ReviewScope>,
692    /// Thread id to fetch, if known.
693    pub thread_id: Option<String>,
694    /// Thread anchor to fetch, if thread id is not known.
695    pub anchor: Option<DraftAnchor>,
696}
697
698/// Request payload for `review.ai_exchanges.list`.
699#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
700pub struct ListReviewAiExchangesRequest {
701    /// Repository path where Git commands should run.
702    pub repo_path: PathBuf,
703    /// Review target.
704    pub target: ReviewTarget,
705    /// Review scope, when using durable workspace-scoped exchanges.
706    #[serde(default)]
707    pub scope: Option<ReviewScope>,
708}
709
710/// Request payload for `review.ai_exchange.delete`.
711#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
712pub struct DeleteReviewAiExchangeRequest {
713    /// Repository path where Git commands should run.
714    pub repo_path: PathBuf,
715    /// Review target.
716    pub target: ReviewTarget,
717    /// Review scope, when using durable workspace-scoped exchanges.
718    #[serde(default)]
719    pub scope: Option<ReviewScope>,
720    /// Stable exchange identity to delete.
721    pub exchange_id: String,
722}
723
724/// Response payload for `review.ai_exchange.delete`.
725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
726pub struct DeleteReviewAiExchangeResponse {
727    /// Whether a persisted exchange was deleted.
728    pub deleted: bool,
729}
730
731/// Request payload for `review.ai_exchange.save`.
732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
733pub struct SaveReviewAiExchangeRequest {
734    /// Repository path where Git commands should run.
735    pub repo_path: PathBuf,
736    /// Review target.
737    pub target: ReviewTarget,
738    /// Review scope, when using durable workspace-scoped exchanges.
739    #[serde(default)]
740    pub scope: Option<ReviewScope>,
741    /// Exchange to create or update.
742    pub exchange: ReviewAiExchange,
743}
744
745/// Response payload for `review.ai_exchanges.list`.
746#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
747pub struct ListReviewAiExchangesResponse {
748    /// Persisted, non-publishable AI exchanges.
749    pub exchanges: Vec<ReviewAiExchange>,
750}
751
752/// Response payload for `review.ai_exchange.save`.
753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
754pub struct SaveReviewAiExchangeResponse {
755    /// Persisted exchange.
756    pub exchange: ReviewAiExchange,
757}
758
759/// Durable lifecycle for one reviewer-authored AI exchange.
760#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
761#[serde(rename_all = "snake_case")]
762pub enum ReviewAiExchangeStatus {
763    /// The question is durable but no linked session has been established yet.
764    Pending,
765    /// The exchange is linked to a Bcode session.
766    Linked,
767    /// The latest attempt to establish or continue the linked session failed.
768    Failed,
769}
770
771/// Provider-neutral durable metadata for a non-publishable review AI exchange.
772#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
773pub struct ReviewAiExchange {
774    /// Persisted schema version.
775    pub schema_version: u16,
776    /// Stable exchange identity.
777    pub exchange_id: String,
778    /// Review anchor that owns the exchange.
779    pub anchor: DraftAnchor,
780    /// Reviewer-authored question shown in the review conversation.
781    pub question: String,
782    /// Linked Bcode session id, when established.
783    #[serde(default)]
784    pub session_id: Option<String>,
785    /// Durable exchange lifecycle.
786    pub status: ReviewAiExchangeStatus,
787    /// Normalized failure message for the latest failed attempt.
788    #[serde(default)]
789    pub error: Option<String>,
790    /// Creation timestamp in milliseconds since Unix epoch.
791    pub created_at_ms: u64,
792    /// Last update timestamp in milliseconds since Unix epoch.
793    pub updated_at_ms: u64,
794}
795
796/// Request payload for `review.suggestions.list`.
797#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
798pub struct ListReviewSuggestionsRequest {
799    /// Repository path where Git commands should run.
800    pub repo_path: PathBuf,
801    /// Review target.
802    pub target: ReviewTarget,
803    /// Review scope, when using durable workspace-scoped suggestions.
804    #[serde(default)]
805    pub scope: Option<ReviewScope>,
806}
807
808/// Request payload for `review.suggestion.save`.
809#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
810pub struct SaveReviewSuggestionRequest {
811    /// Repository path where Git commands should run.
812    pub repo_path: PathBuf,
813    /// Review target.
814    pub target: ReviewTarget,
815    /// Review scope, when using durable workspace-scoped suggestions.
816    #[serde(default)]
817    pub scope: Option<ReviewScope>,
818    /// Suggestion to create or update.
819    pub suggestion: ReviewSuggestion,
820}
821
822/// Response payload for `review.suggestions.list`.
823#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
824pub struct ListReviewSuggestionsResponse {
825    /// Persisted suggestions.
826    pub suggestions: Vec<ReviewSuggestion>,
827}
828
829/// Response payload for `review.suggestion.save`.
830#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
831pub struct SaveReviewSuggestionResponse {
832    /// Persisted suggestion.
833    pub suggestion: ReviewSuggestion,
834}
835
836/// Durable AI-suggested review comment lifecycle.
837#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
838#[serde(rename_all = "snake_case")]
839pub enum ReviewSuggestionStatus {
840    /// Suggested but not yet accepted or rejected.
841    Suggested,
842    /// A refinement request is in progress.
843    Refining,
844    /// Accepted into a normal draft comment.
845    Accepted,
846    /// Rejected by the reviewer and retained for audit/undo visibility.
847    Rejected,
848}
849
850/// Provider-neutral durable AI-suggested review comment.
851#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
852pub struct ReviewSuggestion {
853    /// Stable suggestion id.
854    pub suggestion_id: String,
855    /// Review anchor.
856    pub anchor: DraftAnchor,
857    /// Suggested Markdown body.
858    pub body: String,
859    /// Linked Bcode session id that produced the suggestion.
860    #[serde(default)]
861    pub session_id: Option<String>,
862    /// Suggestion lifecycle state.
863    pub status: ReviewSuggestionStatus,
864    /// Optional short rationale or provenance.
865    #[serde(default)]
866    pub rationale: Option<String>,
867    /// Creation timestamp in milliseconds since Unix epoch.
868    pub created_at_ms: u64,
869    /// Last update timestamp in milliseconds since Unix epoch.
870    pub updated_at_ms: u64,
871}
872
873/// Request payload for `review.diff.get`.
874#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
875pub struct GetReviewDiffRequest {
876    /// Repository path where Git commands should run.
877    pub repo_path: PathBuf,
878    /// Review target.
879    pub target: ReviewTarget,
880    /// File path to fetch, or all files when absent.
881    pub file_path: Option<String>,
882}
883
884/// Response payload for `draft.list`.
885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
886pub struct ListDraftsResponse {
887    /// Draft comments.
888    pub drafts: Vec<DraftComment>,
889}
890
891/// Response payload for `draft.save`.
892#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
893pub struct SaveDraftResponse {
894    /// Saved draft comment.
895    pub draft: DraftComment,
896}
897
898/// Response payload for `draft.delete`.
899#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
900pub struct DeleteDraftResponse {
901    /// Whether a persisted draft was deleted.
902    pub deleted: bool,
903}
904
905/// Response payload for `draft.update`.
906#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
907pub struct UpdateDraftResponse {
908    /// Whether a persisted draft was updated.
909    pub updated: bool,
910    /// Updated timestamp in milliseconds since Unix epoch, when updated.
911    pub updated_at_ms: Option<u64>,
912}
913
914/// Response payload for `thread.link_session`.
915#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
916pub struct LinkThreadSessionResponse {
917    /// Linked thread id.
918    pub thread_id: String,
919}
920
921/// Request payload for `thread.resolve`.
922#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
923pub struct ResolveThreadRequest {
924    /// Repository path where Git commands should run.
925    pub repo_path: PathBuf,
926    /// Review target.
927    pub target: ReviewTarget,
928    /// Review scope, when using durable workspace-scoped threads.
929    #[serde(default)]
930    pub scope: Option<ReviewScope>,
931    /// Thread anchor.
932    pub anchor: DraftAnchor,
933    /// Whether the thread should be resolved.
934    pub resolved: bool,
935}
936
937/// Response payload for `thread.resolve`.
938#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
939pub struct ResolveThreadResponse {
940    /// Updated thread id.
941    pub thread_id: String,
942    /// Resolution timestamp in milliseconds since Unix epoch, when resolved.
943    pub resolved_at_ms: Option<u64>,
944}
945
946/// Request payload for review context operations scoped to a target.
947#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
948pub struct ReviewContextRequest {
949    /// Repository path where Git commands should run.
950    pub repo_path: PathBuf,
951    /// Review target.
952    pub target: ReviewTarget,
953}
954
955/// Review thread anchor scope.
956#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
957#[serde(rename_all = "snake_case")]
958pub enum ReviewAnchorKind {
959    /// Thread applies to the entire review.
960    Review,
961    /// Thread applies to a whole file.
962    File,
963    /// Thread applies to a rendered line or range.
964    #[default]
965    Range,
966}
967
968/// Persisted draft anchor.
969#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
970pub struct DraftAnchor {
971    /// Anchor scope.
972    #[serde(default)]
973    pub kind: ReviewAnchorKind,
974    /// File path in the review.
975    pub file_path: String,
976    /// Rendered diff row.
977    pub diff_row: u64,
978    /// Start rendered diff row for range comments.
979    #[serde(default)]
980    pub start_diff_row: Option<u64>,
981    /// End rendered diff row for range comments.
982    #[serde(default)]
983    pub end_diff_row: Option<u64>,
984    /// Start line on old side, when known.
985    #[serde(default)]
986    pub old_start: Option<u32>,
987    /// End line on old side, when known.
988    #[serde(default)]
989    pub old_end: Option<u32>,
990    /// Start line on new side, when known.
991    #[serde(default)]
992    pub new_start: Option<u32>,
993    /// End line on new side, when known.
994    #[serde(default)]
995    pub new_end: Option<u32>,
996    /// Old line for single-line anchors, when known.
997    #[serde(default)]
998    pub old_line: Option<u32>,
999    /// New line for single-line anchors, when known.
1000    #[serde(default)]
1001    pub new_line: Option<u32>,
1002    /// Anchor line kind.
1003    pub line_kind: ReviewLineKind,
1004    /// Whether this anchor points at a file surface line rather than a diff row.
1005    #[serde(default)]
1006    pub is_file_anchor: bool,
1007    /// Surface id for normalized mixed-surface anchors.
1008    #[serde(default)]
1009    pub surface_id: Option<String>,
1010    /// Source id for normalized mixed-surface anchors.
1011    #[serde(default)]
1012    pub source_id: Option<String>,
1013}
1014
1015/// Local review thread kind.
1016#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1017#[serde(rename_all = "snake_case")]
1018pub enum ReviewThreadKind {
1019    /// General note.
1020    #[default]
1021    Note,
1022    /// Question to answer before finishing review.
1023    Question,
1024    /// Follow-up task.
1025    Todo,
1026    /// Review finding.
1027    Finding,
1028    /// Review summary.
1029    Summary,
1030}
1031
1032/// Local review thread severity.
1033#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1034#[serde(rename_all = "snake_case")]
1035pub enum ReviewThreadSeverity {
1036    /// Informational thread.
1037    #[default]
1038    Info,
1039    /// Minor nit.
1040    Nit,
1041    /// Warning that should be considered before publishing.
1042    Warning,
1043    /// Blocking issue.
1044    Blocker,
1045}
1046
1047/// Persisted draft comment.
1048#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1049pub struct DraftComment {
1050    /// Stable comment id.
1051    pub comment_id: String,
1052    /// Stable thread id for comments sharing an anchor.
1053    pub thread_id: String,
1054    /// Review anchor.
1055    pub anchor: DraftAnchor,
1056    /// Markdown comment body.
1057    pub body: String,
1058    /// Creation timestamp in milliseconds since Unix epoch.
1059    pub created_at_ms: u64,
1060    /// Last update timestamp in milliseconds since Unix epoch.
1061    pub updated_at_ms: u64,
1062    /// Linked Bcode session id, when present.
1063    #[serde(default)]
1064    pub session_id: Option<String>,
1065    /// Resolution timestamp in milliseconds since Unix epoch, when resolved.
1066    #[serde(default)]
1067    pub resolved_at_ms: Option<u64>,
1068    /// Thread kind.
1069    #[serde(default)]
1070    pub thread_kind: ReviewThreadKind,
1071    /// Thread severity.
1072    #[serde(default)]
1073    pub severity: ReviewThreadSeverity,
1074}
1075
1076/// File status in a review.
1077#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1078#[serde(rename_all = "snake_case")]
1079pub enum ReviewFileStatus {
1080    /// Modified file.
1081    Modified,
1082    /// Added file.
1083    Added,
1084    /// Deleted file.
1085    Deleted,
1086    /// Renamed file.
1087    Renamed,
1088    /// Copied file.
1089    Copied,
1090    /// Unknown or unsupported status.
1091    Unknown,
1092}
1093
1094/// Compact file summary for review context.
1095#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1096pub struct ReviewFileSummary {
1097    /// Display path.
1098    pub path: String,
1099    /// File status.
1100    pub status: ReviewFileStatus,
1101    /// Added lines.
1102    pub additions: u32,
1103    /// Removed lines.
1104    pub deletions: u32,
1105    /// Hunk count.
1106    pub hunks: usize,
1107    /// Whether Git reported a binary patch.
1108    pub is_binary: bool,
1109}
1110
1111/// Diff line kind.
1112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1113#[serde(rename_all = "snake_case")]
1114pub enum ReviewLineKind {
1115    /// Context line.
1116    Context,
1117    /// Added line.
1118    Added,
1119    /// Removed line.
1120    Removed,
1121}
1122
1123/// Unified diff hunk.
1124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1125pub struct ReviewHunk {
1126    /// Old start line.
1127    pub old_start: u32,
1128    /// Old line count.
1129    pub old_count: u32,
1130    /// New start line.
1131    pub new_start: u32,
1132    /// New line count.
1133    pub new_count: u32,
1134    /// Optional hunk heading.
1135    pub heading: Option<String>,
1136    /// Diff lines in this hunk.
1137    pub lines: Vec<ReviewLine>,
1138}
1139
1140/// A line in a unified diff hunk.
1141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1142pub struct ReviewLine {
1143    /// Line kind.
1144    pub kind: ReviewLineKind,
1145    /// Old file line number, when present.
1146    pub old_line: Option<u32>,
1147    /// New file line number, when present.
1148    pub new_line: Option<u32>,
1149    /// Line content without the leading unified diff marker.
1150    pub content: String,
1151}
1152
1153/// Request payload for repository file browsing.
1154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1155pub struct RepositoryFileRequest {
1156    /// Repository path where the file should be read.
1157    pub repo_path: PathBuf,
1158    /// Repository-relative file path.
1159    pub file_path: String,
1160}
1161
1162/// Response payload for repository file browsing.
1163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1164pub struct RepositoryFileResponse {
1165    /// Repository-relative file path.
1166    pub file_path: String,
1167    /// UTF-8 file content when available.
1168    pub content: Option<String>,
1169    /// File size in bytes.
1170    pub size_bytes: u64,
1171    /// File modification timestamp in milliseconds since Unix epoch, when available.
1172    #[serde(default)]
1173    pub mtime_ms: Option<u64>,
1174    /// Whether the file appears binary.
1175    pub is_binary: bool,
1176    /// Optional unavailable reason.
1177    #[serde(default)]
1178    pub unavailable_reason: Option<String>,
1179}
1180
1181/// Parsed review file.
1182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1183pub struct ReviewFile {
1184    /// Old path for deleted/renamed files.
1185    pub old_path: Option<String>,
1186    /// New path for added/modified/renamed files.
1187    pub new_path: Option<String>,
1188    /// File status.
1189    pub status: ReviewFileStatus,
1190    /// Added lines.
1191    pub additions: u32,
1192    /// Removed lines.
1193    pub deletions: u32,
1194    /// Parsed hunks.
1195    pub hunks: Vec<ReviewHunk>,
1196    /// Whether Git reported a binary patch.
1197    pub is_binary: bool,
1198}
1199
1200impl ReviewFile {
1201    /// Return display path for the file.
1202    #[must_use]
1203    pub fn display_path(&self) -> &str {
1204        self.new_path
1205            .as_deref()
1206            .or(self.old_path.as_deref())
1207            .unwrap_or("<unknown>")
1208    }
1209}
1210
1211/// A provider-neutral selected review line reference.
1212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1213pub struct ReviewBundleLine {
1214    /// File path.
1215    pub file_path: String,
1216    /// Line kind.
1217    pub kind: ReviewLineKind,
1218    /// Old file line number, when present.
1219    pub old_line: Option<u32>,
1220    /// New file line number, when present.
1221    pub new_line: Option<u32>,
1222    /// Rendered diff row.
1223    pub diff_row: u64,
1224    /// Line content without diff marker.
1225    pub content: String,
1226    /// Surface id that owns this selected line, when known.
1227    #[serde(default)]
1228    pub surface_id: Option<String>,
1229    /// Source id that owns this selected line, when known.
1230    #[serde(default)]
1231    pub source_id: Option<String>,
1232}
1233
1234/// Provider-neutral review bundle.
1235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1236pub struct ReviewBundle {
1237    /// Stable review id for this repository and target.
1238    pub review_id: String,
1239    /// Human-readable review title.
1240    pub title: String,
1241    /// Repository root.
1242    pub repo_root: PathBuf,
1243    /// Review target.
1244    pub target: ReviewTarget,
1245    /// Files in review order.
1246    pub files: Vec<ReviewFileSummary>,
1247    /// Materialized review surfaces in review order.
1248    #[serde(default)]
1249    pub surfaces: Vec<ReviewSurface>,
1250    /// Review threads.
1251    pub threads: Vec<ReviewBundleThread>,
1252    /// Generated timestamp in milliseconds since Unix epoch.
1253    pub generated_at_ms: u64,
1254}
1255
1256/// Provider-neutral review thread bundle.
1257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1258pub struct ReviewBundleThread {
1259    /// Thread id.
1260    pub thread_id: String,
1261    /// Thread anchor.
1262    pub anchor: DraftAnchor,
1263    /// Draft comments.
1264    pub comments: Vec<DraftComment>,
1265    /// Linked Bcode session id, when present.
1266    pub session_id: Option<String>,
1267    /// Resolution timestamp in milliseconds since Unix epoch, when resolved.
1268    #[serde(default)]
1269    pub resolved_at_ms: Option<u64>,
1270    /// Structured selected diff lines.
1271    #[serde(default)]
1272    pub selected_lines: Vec<ReviewBundleLine>,
1273    /// Selected diff lines.
1274    pub selected_diff_lines: Vec<String>,
1275    /// Hunk context.
1276    pub hunk_context: Vec<String>,
1277}
1278
1279/// Review publisher capabilities.
1280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1281#[allow(clippy::struct_excessive_bools)]
1282pub struct ReviewPublisherCapabilities {
1283    /// Whether preview is supported.
1284    pub preview: bool,
1285    /// Whether submit is supported.
1286    pub submit: bool,
1287    /// Whether existing output can be updated.
1288    pub update_existing: bool,
1289    /// Whether threaded comments are supported.
1290    pub supports_threads: bool,
1291    /// Whether range anchors are supported.
1292    pub supports_ranges: bool,
1293    /// Whether inline comments are supported.
1294    pub supports_inline_comments: bool,
1295    /// Whether a summary comment is supported.
1296    pub supports_summary_comment: bool,
1297}
1298
1299/// External publisher route metadata.
1300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1301pub struct ReviewPublisherRoute {
1302    /// Plugin id for external publisher.
1303    pub plugin_id: String,
1304    /// Service interface id.
1305    pub interface_id: String,
1306}
1307
1308/// Review publisher manifest.
1309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1310pub struct ReviewPublisherManifest {
1311    /// Publisher id.
1312    pub id: String,
1313    /// Human-readable label.
1314    pub label: String,
1315    /// Human-readable description.
1316    pub description: String,
1317    /// Publisher capabilities.
1318    pub capabilities: ReviewPublisherCapabilities,
1319    /// JSON-schema-like option description.
1320    pub options_schema: serde_json::Value,
1321    /// Optional external plugin route.
1322    #[serde(default)]
1323    pub route: Option<ReviewPublisherRoute>,
1324}
1325
1326/// Response payload for publisher list.
1327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1328pub struct ListReviewPublishersResponse {
1329    /// Available publishers.
1330    pub publishers: Vec<ReviewPublisherManifest>,
1331}
1332
1333/// Request payload for built-in publish operations.
1334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1335pub struct PublishReviewRequest {
1336    /// Repository path where Git commands should run.
1337    pub repo_path: PathBuf,
1338    /// Review target.
1339    pub target: ReviewTarget,
1340    /// Review workspace, when publishing a durable mixed-source workspace.
1341    #[serde(default)]
1342    pub workspace: Option<ReviewWorkspace>,
1343    /// Review thread anchors excluded by the reviewer from this publish.
1344    #[serde(default)]
1345    pub excluded_thread_anchors: Vec<DraftAnchor>,
1346    /// Publisher id.
1347    pub publisher_id: String,
1348    /// Publisher options.
1349    #[serde(default)]
1350    pub options: serde_json::Value,
1351}
1352
1353/// Request payload for recording an external publish result.
1354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1355pub struct SavePublishRecordRequest {
1356    /// Review workspace that was published.
1357    pub workspace: ReviewWorkspace,
1358    /// Review bundle id/key that was published.
1359    pub review_id: String,
1360    /// Publisher id.
1361    pub publisher_id: String,
1362    /// Whether submission happened.
1363    pub submitted: bool,
1364    /// Provider-neutral review thread anchors included in this publish.
1365    #[serde(default)]
1366    pub published_thread_anchors: Vec<DraftAnchor>,
1367    /// Output location, when available.
1368    #[serde(default)]
1369    pub output: Option<String>,
1370    /// Human-readable result message.
1371    pub message: String,
1372}
1373
1374/// Response payload for recording an external publish result.
1375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1376pub struct SavePublishRecordResponse {
1377    /// Saved publish record.
1378    pub record: ReviewPublishRecord,
1379}
1380
1381/// Request payload for external publisher operations.
1382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1383pub struct ExternalPublishReviewRequest {
1384    /// Provider-neutral review bundle.
1385    pub bundle: ReviewBundle,
1386    /// Publisher options.
1387    #[serde(default)]
1388    pub options: serde_json::Value,
1389}
1390
1391/// Response payload for publish preview.
1392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1393pub struct PublishReviewPreviewResponse {
1394    /// Publisher id.
1395    pub publisher_id: String,
1396    /// Preview content.
1397    pub preview: String,
1398}
1399
1400/// Provider-neutral state of an externally hosted review thread.
1401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1402#[serde(rename_all = "snake_case")]
1403pub enum ExternalReviewThreadState {
1404    /// Thread is active remotely.
1405    Open,
1406    /// Thread was resolved remotely.
1407    Resolved,
1408    /// Provider reports that the thread's diff context is outdated.
1409    Outdated,
1410}
1411
1412/// Confidence of mapping an external comment to the current local review.
1413#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1414#[serde(rename_all = "snake_case")]
1415pub enum ExternalAnchorMappingStatus {
1416    /// The provider supplied enough current information for an exact mapping.
1417    Exact,
1418    /// The anchor was mapped heuristically and should be reviewed.
1419    Approximate,
1420    /// The external anchor cannot be mapped to the current local review.
1421    Unmappable,
1422}
1423
1424/// Provider-neutral identity for an external review object.
1425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1426pub struct ExternalReviewIdentity {
1427    /// Provider id, such as `github`.
1428    pub provider_id: String,
1429    /// Provider-owned opaque identifier.
1430    pub external_id: String,
1431    /// Browser URL, when supplied by the provider.
1432    #[serde(default)]
1433    pub url: Option<String>,
1434}
1435
1436/// Provider-neutral mapping from external diff coordinates to a local anchor.
1437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1438pub struct ExternalReviewAnchorMapping {
1439    /// Mapping result.
1440    pub status: ExternalAnchorMappingStatus,
1441    /// Local anchor when mapping succeeded.
1442    #[serde(default)]
1443    pub anchor: Option<DraftAnchor>,
1444    /// Human-readable mapping explanation.
1445    #[serde(default)]
1446    pub message: Option<String>,
1447}
1448
1449/// A provider-neutral externally hosted review comment.
1450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1451pub struct ExternalReviewComment {
1452    /// Stable provider identity for the comment.
1453    pub identity: ExternalReviewIdentity,
1454    /// Stable provider identity for the author, when available.
1455    #[serde(default)]
1456    pub author: Option<ExternalReviewIdentity>,
1457    /// Author display label.
1458    #[serde(default)]
1459    pub author_label: Option<String>,
1460    /// Markdown comment body.
1461    pub body: String,
1462    /// Creation timestamp in milliseconds since Unix epoch, when available.
1463    #[serde(default)]
1464    pub created_at_ms: Option<u64>,
1465    /// Last update timestamp in milliseconds since Unix epoch, when available.
1466    #[serde(default)]
1467    pub updated_at_ms: Option<u64>,
1468}
1469
1470/// A provider-neutral externally hosted review thread.
1471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1472pub struct ExternalReviewThread {
1473    /// Stable provider identity for the thread.
1474    pub identity: ExternalReviewIdentity,
1475    /// Current remote state.
1476    pub state: ExternalReviewThreadState,
1477    /// Mapping to the current local review.
1478    pub mapping: ExternalReviewAnchorMapping,
1479    /// Comments ordered from oldest to newest.
1480    pub comments: Vec<ExternalReviewComment>,
1481}
1482
1483/// External review importer manifest.
1484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1485pub struct ReviewImporterManifest {
1486    /// Stable importer id.
1487    pub id: String,
1488    /// Human-readable label.
1489    pub label: String,
1490    /// Human-readable description.
1491    pub description: String,
1492    /// JSON-schema-like option description.
1493    pub options_schema: serde_json::Value,
1494}
1495
1496/// Read-only external review import request.
1497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1498pub struct ExternalReviewImportRequest {
1499    /// Repository root used for provider discovery.
1500    pub repo_root: PathBuf,
1501    /// Current local review files used for anchor mapping.
1502    #[serde(default)]
1503    pub files: Vec<ReviewFile>,
1504    /// Provider options such as repository and pull request.
1505    #[serde(default)]
1506    pub options: serde_json::Value,
1507}
1508
1509/// Read-only external review import response.
1510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1511pub struct ExternalReviewImportResponse {
1512    /// Importer that produced this response.
1513    pub importer_id: String,
1514    /// Imported provider-neutral threads.
1515    pub threads: Vec<ExternalReviewThread>,
1516    /// Non-fatal discovery or mapping warnings.
1517    #[serde(default)]
1518    pub warnings: Vec<String>,
1519}
1520
1521/// Durable provider-neutral external review import snapshot.
1522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1523pub struct ExternalReviewImportRecord {
1524    /// Durable workspace id.
1525    pub workspace_id: String,
1526    /// Importer that produced this snapshot.
1527    pub importer_id: String,
1528    /// Imported provider-neutral threads.
1529    pub threads: Vec<ExternalReviewThread>,
1530    /// Non-fatal discovery or mapping warnings.
1531    #[serde(default)]
1532    pub warnings: Vec<String>,
1533    /// Snapshot update time in milliseconds since Unix epoch.
1534    pub updated_at_ms: u64,
1535}
1536
1537/// Request to atomically replace an external import snapshot.
1538#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1539pub struct SaveExternalReviewImportRequest {
1540    /// Repository containing the durable workspace.
1541    pub repo_path: PathBuf,
1542    /// Durable workspace id.
1543    pub workspace_id: String,
1544    /// Read-only importer response to persist.
1545    pub import: ExternalReviewImportResponse,
1546}
1547
1548/// Response after saving an external import snapshot.
1549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1550pub struct SaveExternalReviewImportResponse {
1551    /// Persisted snapshot.
1552    pub record: ExternalReviewImportRecord,
1553}
1554
1555/// Request to list durable external import snapshots.
1556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1557pub struct ListExternalReviewImportsRequest {
1558    /// Repository containing the durable workspace.
1559    pub repo_path: PathBuf,
1560    /// Durable workspace id.
1561    pub workspace_id: String,
1562}
1563
1564/// Response containing durable external import snapshots.
1565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1566pub struct ListExternalReviewImportsResponse {
1567    /// Snapshots ordered by importer id.
1568    pub records: Vec<ExternalReviewImportRecord>,
1569}
1570
1571/// Response payload for publish submit.
1572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1573pub struct PublishReviewResponse {
1574    /// Publisher id.
1575    pub publisher_id: String,
1576    /// Whether submission happened.
1577    pub submitted: bool,
1578    /// Output location, when available.
1579    pub output: Option<String>,
1580    /// Human-readable result message.
1581    pub message: String,
1582}
1583
1584const fn default_true() -> bool {
1585    true
1586}
1587
1588/// Current portable adversarial review contract version.
1589pub const ADVERSARIAL_REVIEW_CONTRACT_VERSION: u32 = 1;
1590/// Maximum reviewers retained in one panel result.
1591pub const MAX_ADVERSARIAL_REVIEWERS: usize = 32;
1592/// Maximum findings retained in one report or consensus.
1593pub const MAX_ADVERSARIAL_REVIEW_FINDINGS: usize = 512;
1594/// Maximum UTF-8 bytes retained in one evidence/remediation field.
1595pub const MAX_ADVERSARIAL_REVIEW_TEXT_BYTES: usize = 16_384;
1596/// Maximum model/tool fact entries retained for one reviewer.
1597pub const MAX_ADVERSARIAL_REVIEW_FACTS: usize = 64;
1598
1599/// Portable validation error for adversarial review contracts.
1600#[derive(Debug, Clone, PartialEq, Eq)]
1601pub struct AdversarialReviewValidationError {
1602    /// Stable contract field path.
1603    pub path: String,
1604    /// Actionable validation message.
1605    pub message: String,
1606}
1607
1608impl fmt::Display for AdversarialReviewValidationError {
1609    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1610        write!(
1611            formatter,
1612            "invalid adversarial review at '{}': {}",
1613            self.path, self.message
1614        )
1615    }
1616}
1617
1618impl std::error::Error for AdversarialReviewValidationError {}
1619
1620fn adversarial_review_error(
1621    path: impl Into<String>,
1622    message: impl Into<String>,
1623) -> AdversarialReviewValidationError {
1624    AdversarialReviewValidationError {
1625        path: path.into(),
1626        message: message.into(),
1627    }
1628}
1629
1630fn validate_review_id(path: &str, value: &str) -> Result<(), AdversarialReviewValidationError> {
1631    if value.is_empty()
1632        || value.len() > 256
1633        || !value.bytes().all(|byte| {
1634            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'_' | b'-' | b':')
1635        })
1636    {
1637        return Err(adversarial_review_error(
1638            path,
1639            "identity must contain 1..=256 safe ASCII bytes",
1640        ));
1641    }
1642    Ok(())
1643}
1644
1645fn validate_sha256(path: &str, value: &str) -> Result<(), AdversarialReviewValidationError> {
1646    if value.len() != 64
1647        || !value
1648            .bytes()
1649            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1650    {
1651        return Err(adversarial_review_error(
1652            path,
1653            "digest must be 64 lowercase hexadecimal bytes",
1654        ));
1655    }
1656    Ok(())
1657}
1658
1659fn validate_review_path(path: &str, value: &str) -> Result<(), AdversarialReviewValidationError> {
1660    let candidate = Path::new(value);
1661    if value.is_empty()
1662        || value.len() > 4_096
1663        || candidate.is_absolute()
1664        || candidate
1665            .components()
1666            .any(|component| !matches!(component, Component::Normal(_)))
1667    {
1668        return Err(adversarial_review_error(
1669            path,
1670            "path must be a bounded confined repository-relative path",
1671        ));
1672    }
1673    Ok(())
1674}
1675
1676fn validate_review_text(path: &str, value: &str) -> Result<(), AdversarialReviewValidationError> {
1677    if value.trim().is_empty() || value.len() > MAX_ADVERSARIAL_REVIEW_TEXT_BYTES {
1678        return Err(adversarial_review_error(
1679            path,
1680            format!("text must contain 1..={MAX_ADVERSARIAL_REVIEW_TEXT_BYTES} UTF-8 bytes"),
1681        ));
1682    }
1683    Ok(())
1684}
1685
1686/// Exact immutable repository state reviewed by every member of one panel.
1687#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1688#[serde(deny_unknown_fields)]
1689pub struct ReviewRepositorySnapshot {
1690    /// Must equal [`ADVERSARIAL_REVIEW_CONTRACT_VERSION`].
1691    pub version: u32,
1692    /// Git-owner supplied stable repository identity digest.
1693    pub repository_identity_sha256: String,
1694    /// Exact HEAD object identity at assignment time.
1695    pub head_object_id: String,
1696    /// Git-owner supplied aggregate repository snapshot digest.
1697    pub aggregate_sha256: String,
1698    /// Exact project-instruction fingerprint included in the snapshot.
1699    pub project_instruction_fingerprint_sha256: String,
1700}
1701
1702impl ReviewRepositorySnapshot {
1703    /// Validate exact repository snapshot facts.
1704    ///
1705    /// # Errors
1706    ///
1707    /// Returns an error for unsupported versions or malformed digest/object identities.
1708    pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
1709        if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
1710            return Err(adversarial_review_error(
1711                "snapshot.version",
1712                "unsupported version",
1713            ));
1714        }
1715        validate_sha256(
1716            "snapshot.repository_identity_sha256",
1717            &self.repository_identity_sha256,
1718        )?;
1719        validate_review_id("snapshot.head_object_id", &self.head_object_id)?;
1720        validate_sha256("snapshot.aggregate_sha256", &self.aggregate_sha256)?;
1721        validate_sha256(
1722            "snapshot.project_instruction_fingerprint_sha256",
1723            &self.project_instruction_fingerprint_sha256,
1724        )
1725    }
1726}
1727
1728/// One independent reviewer assignment bound to one exact repository snapshot.
1729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1730#[serde(deny_unknown_fields)]
1731pub struct ReviewAssignment {
1732    pub version: u32,
1733    pub assignment_id: String,
1734    pub reviewer_id: String,
1735    pub snapshot: ReviewRepositorySnapshot,
1736    /// Bounded confined repository-relative paths selected for review; empty means the full snapshot.
1737    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1738    pub include_paths: Vec<String>,
1739    /// Stable review policy identity.
1740    pub policy_id: String,
1741}
1742
1743/// Deterministic request to materialize one independent assignment per reviewer.
1744#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1745#[serde(deny_unknown_fields)]
1746pub struct ReviewPanelAssignmentRequest {
1747    pub version: u32,
1748    /// Stable identity of this review round.
1749    pub round_id: String,
1750    pub snapshot: ReviewRepositorySnapshot,
1751    /// Reviewer identities in deterministic panel order.
1752    pub reviewer_ids: Vec<String>,
1753    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1754    pub include_paths: Vec<String>,
1755    pub policy_id: String,
1756}
1757
1758/// Ordered exact assignments for one review round and repository snapshot.
1759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1760#[serde(deny_unknown_fields)]
1761pub struct ReviewPanelAssignments {
1762    pub version: u32,
1763    pub round_id: String,
1764    pub snapshot_sha256: String,
1765    pub assignments: Vec<ReviewAssignment>,
1766}
1767
1768impl ReviewPanelAssignmentRequest {
1769    /// Materialize stable ordered assignments without runtime or persistence behavior.
1770    ///
1771    /// # Errors
1772    ///
1773    /// Returns an error for unsupported versions, malformed identities/paths/snapshot, fewer than
1774    /// two reviewers, excessive reviewers, or reviewer identities not in strict unique order.
1775    pub fn materialize(&self) -> Result<ReviewPanelAssignments, AdversarialReviewValidationError> {
1776        if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
1777            return Err(adversarial_review_error(
1778                "panel_assignment.version",
1779                "unsupported version",
1780            ));
1781        }
1782        validate_review_id("panel_assignment.round_id", &self.round_id)?;
1783        validate_review_id("panel_assignment.policy_id", &self.policy_id)?;
1784        self.snapshot.validate()?;
1785        if self.reviewer_ids.len() < 2 || self.reviewer_ids.len() > MAX_ADVERSARIAL_REVIEWERS {
1786            return Err(adversarial_review_error(
1787                "panel_assignment.reviewer_ids",
1788                format!("panel requires 2..={MAX_ADVERSARIAL_REVIEWERS} reviewers"),
1789            ));
1790        }
1791        if self.include_paths.len() > MAX_ADVERSARIAL_REVIEW_FINDINGS {
1792            return Err(adversarial_review_error(
1793                "panel_assignment.include_paths",
1794                "selected paths exceed the review bound",
1795            ));
1796        }
1797        let mut previous = None;
1798        for reviewer_id in &self.reviewer_ids {
1799            validate_review_id("panel_assignment.reviewer_ids", reviewer_id)?;
1800            if previous.is_some_and(|identity: &str| identity >= reviewer_id.as_str()) {
1801                return Err(adversarial_review_error(
1802                    "panel_assignment.reviewer_ids",
1803                    "reviewer identities must be strictly ordered and unique",
1804                ));
1805            }
1806            previous = Some(reviewer_id.as_str());
1807        }
1808        let mut paths = BTreeSet::new();
1809        for path in &self.include_paths {
1810            validate_review_path("panel_assignment.include_paths", path)?;
1811            if !paths.insert(path) {
1812                return Err(adversarial_review_error(
1813                    "panel_assignment.include_paths",
1814                    "selected paths must be unique",
1815                ));
1816            }
1817        }
1818        let assignments = self
1819            .reviewer_ids
1820            .iter()
1821            .map(|reviewer_id| ReviewAssignment {
1822                version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
1823                assignment_id: format!("{}:{reviewer_id}", self.round_id),
1824                reviewer_id: reviewer_id.clone(),
1825                snapshot: self.snapshot.clone(),
1826                include_paths: self.include_paths.clone(),
1827                policy_id: self.policy_id.clone(),
1828            })
1829            .collect::<Vec<_>>();
1830        for assignment in &assignments {
1831            assignment.validate()?;
1832        }
1833        Ok(ReviewPanelAssignments {
1834            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
1835            round_id: self.round_id.clone(),
1836            snapshot_sha256: self.snapshot.aggregate_sha256.clone(),
1837            assignments,
1838        })
1839    }
1840}
1841
1842impl ReviewAssignment {
1843    /// Validate assignment identity, exact snapshot binding, and selected paths.
1844    ///
1845    /// # Errors
1846    ///
1847    /// Returns an error for unsupported versions, malformed identities, duplicate paths, or an
1848    /// invalid repository snapshot.
1849    pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
1850        if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
1851            return Err(adversarial_review_error(
1852                "assignment.version",
1853                "unsupported version",
1854            ));
1855        }
1856        validate_review_id("assignment.assignment_id", &self.assignment_id)?;
1857        validate_review_id("assignment.reviewer_id", &self.reviewer_id)?;
1858        validate_review_id("assignment.policy_id", &self.policy_id)?;
1859        self.snapshot.validate()?;
1860        if self.include_paths.len() > MAX_ADVERSARIAL_REVIEW_FINDINGS {
1861            return Err(adversarial_review_error(
1862                "assignment.include_paths",
1863                "selected paths exceed the review bound",
1864            ));
1865        }
1866        let mut paths = BTreeSet::new();
1867        for path in &self.include_paths {
1868            validate_review_path("assignment.include_paths", path)?;
1869            if !paths.insert(path) {
1870                return Err(adversarial_review_error(
1871                    "assignment.include_paths",
1872                    "selected paths must be unique",
1873                ));
1874            }
1875        }
1876        Ok(())
1877    }
1878}
1879
1880/// Severity of one normalized review finding.
1881#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1882#[serde(rename_all = "snake_case")]
1883pub enum ReviewFindingSeverity {
1884    Advisory,
1885    Minor,
1886    Major,
1887    Critical,
1888}
1889
1890/// Reviewer's requested treatment before consensus disposition.
1891#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1892#[serde(rename_all = "snake_case")]
1893pub enum ReviewFindingDisposition {
1894    Required,
1895    Advisory,
1896}
1897
1898/// Exact optional repository source range for one finding.
1899#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1900#[serde(deny_unknown_fields)]
1901pub struct ReviewSourceRange {
1902    pub path: String,
1903    pub start_line: u32,
1904    #[serde(default, skip_serializing_if = "Option::is_none")]
1905    pub start_column: Option<u32>,
1906    pub end_line: u32,
1907    #[serde(default, skip_serializing_if = "Option::is_none")]
1908    pub end_column: Option<u32>,
1909}
1910
1911impl ReviewSourceRange {
1912    fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
1913        validate_review_path("finding.location.path", &self.path)?;
1914        if self.start_line == 0
1915            || self.end_line < self.start_line
1916            || self.start_column == Some(0)
1917            || self.end_column == Some(0)
1918            || (self.start_line == self.end_line
1919                && self
1920                    .start_column
1921                    .zip(self.end_column)
1922                    .is_some_and(|(start, end)| end < start))
1923        {
1924            return Err(adversarial_review_error(
1925                "finding.location",
1926                "source range is malformed or reversed",
1927            ));
1928        }
1929        Ok(())
1930    }
1931}
1932
1933/// Stable snapshot-bound normalized review finding.
1934#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1935#[serde(deny_unknown_fields)]
1936pub struct ReviewFinding {
1937    pub version: u32,
1938    pub finding_id: String,
1939    pub snapshot_sha256: String,
1940    pub severity: ReviewFindingSeverity,
1941    pub disposition: ReviewFindingDisposition,
1942    #[serde(default, skip_serializing_if = "Option::is_none")]
1943    pub location: Option<ReviewSourceRange>,
1944    pub title: String,
1945    pub evidence: String,
1946    pub remediation: String,
1947}
1948
1949impl ReviewFinding {
1950    /// Validate one stable bounded snapshot-bound finding.
1951    ///
1952    /// # Errors
1953    ///
1954    /// Returns an error for malformed identity/snapshot/range or oversized text.
1955    pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
1956        if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
1957            return Err(adversarial_review_error(
1958                "finding.version",
1959                "unsupported version",
1960            ));
1961        }
1962        validate_review_id("finding.finding_id", &self.finding_id)?;
1963        validate_sha256("finding.snapshot_sha256", &self.snapshot_sha256)?;
1964        if let Some(location) = &self.location {
1965            location.validate()?;
1966        }
1967        validate_review_text("finding.title", &self.title)?;
1968        validate_review_text("finding.evidence", &self.evidence)?;
1969        validate_review_text("finding.remediation", &self.remediation)
1970    }
1971}
1972
1973/// Exact normalized model and tool facts recorded for one reviewer.
1974#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1975#[serde(deny_unknown_fields)]
1976pub struct ReviewExecutionFacts {
1977    pub provider_id: String,
1978    pub model_id: String,
1979    pub agent_profile: String,
1980    /// Exact read-only tool identities available to this reviewer.
1981    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1982    pub tools: BTreeSet<String>,
1983}
1984
1985impl ReviewExecutionFacts {
1986    fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
1987        validate_review_id("report.execution.provider_id", &self.provider_id)?;
1988        validate_review_id("report.execution.model_id", &self.model_id)?;
1989        validate_review_id("report.execution.agent_profile", &self.agent_profile)?;
1990        if self.tools.len() > MAX_ADVERSARIAL_REVIEW_FACTS {
1991            return Err(adversarial_review_error(
1992                "report.execution.tools",
1993                "tool facts exceed the review bound",
1994            ));
1995        }
1996        for tool in &self.tools {
1997            validate_review_id("report.execution.tools", tool)?;
1998        }
1999        Ok(())
2000    }
2001}
2002
2003/// One independent typed reviewer report for one exact assignment and snapshot.
2004#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2005#[serde(deny_unknown_fields)]
2006pub struct ReviewReport {
2007    pub version: u32,
2008    pub assignment_id: String,
2009    pub reviewer_id: String,
2010    pub snapshot_sha256: String,
2011    pub execution: ReviewExecutionFacts,
2012    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2013    pub findings: Vec<ReviewFinding>,
2014}
2015
2016impl ReviewReport {
2017    /// Validate report identity, bounds, finding uniqueness, and exact snapshot binding.
2018    ///
2019    /// # Errors
2020    ///
2021    /// Returns an error for unsupported versions, malformed facts, duplicate/conflicting findings,
2022    /// or mixed snapshot identities.
2023    pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
2024        if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
2025            return Err(adversarial_review_error(
2026                "report.version",
2027                "unsupported version",
2028            ));
2029        }
2030        validate_review_id("report.assignment_id", &self.assignment_id)?;
2031        validate_review_id("report.reviewer_id", &self.reviewer_id)?;
2032        validate_sha256("report.snapshot_sha256", &self.snapshot_sha256)?;
2033        self.execution.validate()?;
2034        if self.findings.len() > MAX_ADVERSARIAL_REVIEW_FINDINGS {
2035            return Err(adversarial_review_error(
2036                "report.findings",
2037                "findings exceed the review bound",
2038            ));
2039        }
2040        let mut findings = BTreeMap::new();
2041        for finding in &self.findings {
2042            finding.validate()?;
2043            if finding.snapshot_sha256 != self.snapshot_sha256 {
2044                return Err(adversarial_review_error(
2045                    "report.findings.snapshot_sha256",
2046                    "every finding must bind to the report snapshot",
2047                ));
2048            }
2049            if findings.insert(&finding.finding_id, finding).is_some() {
2050                return Err(adversarial_review_error(
2051                    "report.findings.finding_id",
2052                    "finding identities must be unique",
2053                ));
2054            }
2055        }
2056        Ok(())
2057    }
2058
2059    /// Validate this report against the exact assignment it answers.
2060    ///
2061    /// # Errors
2062    ///
2063    /// Returns an error when assignment/reviewer/snapshot facts differ or either contract is
2064    /// malformed.
2065    pub fn validate_against_assignment(
2066        &self,
2067        assignment: &ReviewAssignment,
2068    ) -> Result<(), AdversarialReviewValidationError> {
2069        self.validate()?;
2070        assignment.validate()?;
2071        if self.assignment_id != assignment.assignment_id
2072            || self.reviewer_id != assignment.reviewer_id
2073            || self.snapshot_sha256 != assignment.snapshot.aggregate_sha256
2074        {
2075            return Err(adversarial_review_error(
2076                "report.assignment",
2077                "report must exactly match its assignment, reviewer, and repository snapshot",
2078            ));
2079        }
2080        Ok(())
2081    }
2082}
2083
2084/// Ordered independent reports for one exact repository snapshot.
2085#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2086#[serde(deny_unknown_fields)]
2087pub struct ReviewPanelResult {
2088    pub version: u32,
2089    pub snapshot_sha256: String,
2090    /// Reports ordered by deterministic panel member identity.
2091    pub reports: Vec<ReviewReport>,
2092}
2093
2094impl ReviewPanelResult {
2095    /// Validate bounded ordered unique reports and exact common snapshot binding.
2096    ///
2097    /// # Errors
2098    ///
2099    /// Returns an error for unsupported versions, malformed reports, ordering/identity conflicts,
2100    /// or mixed snapshots.
2101    pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
2102        if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
2103            return Err(adversarial_review_error(
2104                "panel.version",
2105                "unsupported version",
2106            ));
2107        }
2108        validate_sha256("panel.snapshot_sha256", &self.snapshot_sha256)?;
2109        if self.reports.len() < 2 || self.reports.len() > MAX_ADVERSARIAL_REVIEWERS {
2110            return Err(adversarial_review_error(
2111                "panel.reports",
2112                format!("panel requires 2..={MAX_ADVERSARIAL_REVIEWERS} reports"),
2113            ));
2114        }
2115        let mut previous = None;
2116        let mut findings = BTreeMap::<&str, &ReviewFinding>::new();
2117        for report in &self.reports {
2118            report.validate()?;
2119            if report.snapshot_sha256 != self.snapshot_sha256 {
2120                return Err(adversarial_review_error(
2121                    "panel.reports.snapshot_sha256",
2122                    "every report must bind to the panel snapshot",
2123                ));
2124            }
2125            if previous.is_some_and(|identity: &str| identity >= report.reviewer_id.as_str()) {
2126                return Err(adversarial_review_error(
2127                    "panel.reports.reviewer_id",
2128                    "reports must be strictly ordered by unique reviewer identity",
2129                ));
2130            }
2131            for finding in &report.findings {
2132                if findings
2133                    .insert(&finding.finding_id, finding)
2134                    .is_some_and(|existing| existing != finding)
2135                {
2136                    return Err(adversarial_review_error(
2137                        "panel.reports.findings.finding_id",
2138                        "duplicate finding identities must have identical normalized payloads",
2139                    ));
2140                }
2141            }
2142            previous = Some(report.reviewer_id.as_str());
2143        }
2144        Ok(())
2145    }
2146
2147    fn findings_by_id(&self) -> BTreeMap<&str, &ReviewFinding> {
2148        self.reports
2149            .iter()
2150            .flat_map(|report| &report.findings)
2151            .map(|finding| (finding.finding_id.as_str(), finding))
2152            .collect()
2153    }
2154}
2155
2156/// Consensus treatment of one submitted finding identity.
2157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2158#[serde(rename_all = "snake_case")]
2159pub enum ReviewConsensusDisposition {
2160    Required,
2161    Advisory,
2162    Invalid,
2163    Duplicate,
2164}
2165
2166/// One auditable consensus decision for a submitted finding.
2167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2168#[serde(deny_unknown_fields)]
2169pub struct ReviewConsensusDecision {
2170    pub finding_id: String,
2171    pub disposition: ReviewConsensusDisposition,
2172    /// Canonical retained finding for required/advisory decisions.
2173    #[serde(default, skip_serializing_if = "Option::is_none")]
2174    pub finding: Option<ReviewFinding>,
2175    /// Existing canonical finding identity for duplicate decisions.
2176    #[serde(default, skip_serializing_if = "Option::is_none")]
2177    pub duplicate_of: Option<String>,
2178    pub rationale: String,
2179}
2180
2181/// Complete bounded consensus over one exact panel result.
2182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2183#[serde(deny_unknown_fields)]
2184pub struct ReviewConsensus {
2185    pub version: u32,
2186    pub snapshot_sha256: String,
2187    /// Decisions ordered by submitted finding identity.
2188    pub decisions: Vec<ReviewConsensusDecision>,
2189}
2190
2191impl ReviewConsensus {
2192    /// Validate ordered decisions, disposition payloads, and snapshot binding.
2193    ///
2194    /// # Errors
2195    ///
2196    /// Returns an error for unsupported versions, malformed/duplicate decisions, inconsistent
2197    /// disposition payloads, or mixed snapshot identities.
2198    pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
2199        if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
2200            return Err(adversarial_review_error(
2201                "consensus.version",
2202                "unsupported version",
2203            ));
2204        }
2205        validate_sha256("consensus.snapshot_sha256", &self.snapshot_sha256)?;
2206        if self.decisions.len() > MAX_ADVERSARIAL_REVIEW_FINDINGS {
2207            return Err(adversarial_review_error(
2208                "consensus.decisions",
2209                "decisions exceed the review bound",
2210            ));
2211        }
2212        let mut previous = None;
2213        for decision in &self.decisions {
2214            validate_review_id("consensus.decisions.finding_id", &decision.finding_id)?;
2215            validate_review_text("consensus.decisions.rationale", &decision.rationale)?;
2216            if previous.is_some_and(|identity: &str| identity >= decision.finding_id.as_str()) {
2217                return Err(adversarial_review_error(
2218                    "consensus.decisions.finding_id",
2219                    "decisions must be strictly ordered by unique finding identity",
2220                ));
2221            }
2222            match decision.disposition {
2223                ReviewConsensusDisposition::Required | ReviewConsensusDisposition::Advisory => {
2224                    let finding = decision.finding.as_ref().ok_or_else(|| {
2225                        adversarial_review_error(
2226                            "consensus.decisions.finding",
2227                            "retained decision requires the canonical finding",
2228                        )
2229                    })?;
2230                    finding.validate()?;
2231                    if finding.finding_id != decision.finding_id
2232                        || finding.snapshot_sha256 != self.snapshot_sha256
2233                        || decision.duplicate_of.is_some()
2234                    {
2235                        return Err(adversarial_review_error(
2236                            "consensus.decisions",
2237                            "retained decision payload is inconsistent",
2238                        ));
2239                    }
2240                }
2241                ReviewConsensusDisposition::Duplicate => {
2242                    if decision.finding.is_some()
2243                        || decision.duplicate_of.as_deref().is_none_or(|identity| {
2244                            identity == decision.finding_id
2245                                || validate_review_id("duplicate_of", identity).is_err()
2246                        })
2247                    {
2248                        return Err(adversarial_review_error(
2249                            "consensus.decisions.duplicate_of",
2250                            "duplicate decision requires one different canonical finding identity",
2251                        ));
2252                    }
2253                }
2254                ReviewConsensusDisposition::Invalid => {
2255                    if decision.finding.is_some() || decision.duplicate_of.is_some() {
2256                        return Err(adversarial_review_error(
2257                            "consensus.decisions",
2258                            "invalid decision cannot retain a finding or duplicate target",
2259                        ));
2260                    }
2261                }
2262            }
2263            previous = Some(decision.finding_id.as_str());
2264        }
2265        Ok(())
2266    }
2267
2268    /// Validate consensus as a complete disposition of the exact panel findings.
2269    ///
2270    /// # Errors
2271    ///
2272    /// Returns an error when a panel finding is omitted, an unknown finding is introduced, a
2273    /// retained payload differs, a duplicate target is absent, or required reviewer findings are
2274    /// silently downgraded or invalidated.
2275    pub fn validate_against_panel(
2276        &self,
2277        panel: &ReviewPanelResult,
2278    ) -> Result<(), AdversarialReviewValidationError> {
2279        self.validate()?;
2280        panel.validate()?;
2281        if self.snapshot_sha256 != panel.snapshot_sha256 {
2282            return Err(adversarial_review_error(
2283                "consensus.snapshot_sha256",
2284                "consensus must bind to the panel snapshot",
2285            ));
2286        }
2287        let findings = panel.findings_by_id();
2288        if self.decisions.len() != findings.len() {
2289            return Err(adversarial_review_error(
2290                "consensus.decisions",
2291                "consensus must disposition every distinct panel finding exactly once",
2292            ));
2293        }
2294        let decisions = self
2295            .decisions
2296            .iter()
2297            .map(|decision| (decision.finding_id.as_str(), decision))
2298            .collect::<BTreeMap<_, _>>();
2299        for (finding_id, finding) in findings {
2300            let decision = decisions.get(finding_id).ok_or_else(|| {
2301                adversarial_review_error(
2302                    "consensus.decisions",
2303                    "consensus omitted a submitted finding",
2304                )
2305            })?;
2306            if finding.disposition == ReviewFindingDisposition::Required
2307                && !matches!(decision.disposition, ReviewConsensusDisposition::Required)
2308            {
2309                return Err(adversarial_review_error(
2310                    "consensus.decisions.disposition",
2311                    "required reviewer findings must remain required unless separately adjudicated with an explicit supported proof contract",
2312                ));
2313            }
2314            if matches!(
2315                decision.disposition,
2316                ReviewConsensusDisposition::Required | ReviewConsensusDisposition::Advisory
2317            ) && decision.finding.as_ref() != Some(finding)
2318            {
2319                return Err(adversarial_review_error(
2320                    "consensus.decisions.finding",
2321                    "retained consensus finding must equal the normalized panel finding",
2322                ));
2323            }
2324            if let Some(duplicate_of) = &decision.duplicate_of {
2325                let Some(target) = decisions.get(duplicate_of.as_str()) else {
2326                    return Err(adversarial_review_error(
2327                        "consensus.decisions.duplicate_of",
2328                        "duplicate target must be another submitted finding",
2329                    ));
2330                };
2331                if matches!(target.disposition, ReviewConsensusDisposition::Duplicate) {
2332                    return Err(adversarial_review_error(
2333                        "consensus.decisions.duplicate_of",
2334                        "duplicate chains are prohibited",
2335                    ));
2336                }
2337            }
2338        }
2339        Ok(())
2340    }
2341}
2342
2343/// Terminal result of one bounded adversarial review round.
2344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2345#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
2346pub enum ReviewRoundOutcome {
2347    Clean {
2348        version: u32,
2349        snapshot_sha256: String,
2350    },
2351    RemediationRequired {
2352        version: u32,
2353        snapshot_sha256: String,
2354        consensus: ReviewConsensus,
2355    },
2356    Exhausted {
2357        version: u32,
2358        snapshot_sha256: String,
2359        rounds: u32,
2360        consensus: ReviewConsensus,
2361    },
2362    Failed {
2363        version: u32,
2364        snapshot_sha256: String,
2365        message: String,
2366    },
2367    Cancelled {
2368        version: u32,
2369        snapshot_sha256: String,
2370    },
2371}
2372
2373impl ReviewRoundOutcome {
2374    /// Validate one terminal review outcome and its exact snapshot binding.
2375    ///
2376    /// # Errors
2377    ///
2378    /// Returns an error for unsupported versions, malformed snapshots/messages, zero exhaustion
2379    /// rounds, or consensus bound to another snapshot.
2380    pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
2381        let (version, snapshot) = match self {
2382            Self::Clean {
2383                version,
2384                snapshot_sha256,
2385            }
2386            | Self::Cancelled {
2387                version,
2388                snapshot_sha256,
2389            } => (*version, snapshot_sha256),
2390            Self::RemediationRequired {
2391                version,
2392                snapshot_sha256,
2393                consensus,
2394            } => {
2395                consensus.validate()?;
2396                if consensus.snapshot_sha256 != *snapshot_sha256 {
2397                    return Err(adversarial_review_error(
2398                        "outcome.consensus.snapshot_sha256",
2399                        "consensus must bind to the outcome snapshot",
2400                    ));
2401                }
2402                (*version, snapshot_sha256)
2403            }
2404            Self::Exhausted {
2405                version,
2406                snapshot_sha256,
2407                rounds,
2408                consensus,
2409            } => {
2410                if *rounds == 0 {
2411                    return Err(adversarial_review_error(
2412                        "outcome.rounds",
2413                        "exhausted outcome requires at least one round",
2414                    ));
2415                }
2416                consensus.validate()?;
2417                if consensus.snapshot_sha256 != *snapshot_sha256 {
2418                    return Err(adversarial_review_error(
2419                        "outcome.consensus.snapshot_sha256",
2420                        "consensus must bind to the outcome snapshot",
2421                    ));
2422                }
2423                (*version, snapshot_sha256)
2424            }
2425            Self::Failed {
2426                version,
2427                snapshot_sha256,
2428                message,
2429            } => {
2430                validate_review_text("outcome.message", message)?;
2431                (*version, snapshot_sha256)
2432            }
2433        };
2434        if version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
2435            return Err(adversarial_review_error(
2436                "outcome.version",
2437                "unsupported version",
2438            ));
2439        }
2440        validate_sha256("outcome.snapshot_sha256", snapshot)
2441    }
2442}
2443
2444#[cfg(test)]
2445mod tests {
2446    use super::*;
2447
2448    fn review_snapshot() -> ReviewRepositorySnapshot {
2449        ReviewRepositorySnapshot {
2450            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2451            repository_identity_sha256: "a".repeat(64),
2452            head_object_id: "0123456789abcdef".to_string(),
2453            aggregate_sha256: "b".repeat(64),
2454            project_instruction_fingerprint_sha256: "c".repeat(64),
2455        }
2456    }
2457
2458    fn review_finding(id: &str) -> ReviewFinding {
2459        ReviewFinding {
2460            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2461            finding_id: id.to_string(),
2462            snapshot_sha256: "b".repeat(64),
2463            severity: ReviewFindingSeverity::Major,
2464            disposition: ReviewFindingDisposition::Required,
2465            location: Some(ReviewSourceRange {
2466                path: "src/lib.rs".to_string(),
2467                start_line: 10,
2468                start_column: Some(2),
2469                end_line: 12,
2470                end_column: Some(8),
2471            }),
2472            title: "Terminal state can reopen".to_string(),
2473            evidence: "A stale update overwrites the persisted terminal state.".to_string(),
2474            remediation: "Reject updates after the authoritative terminal transition.".to_string(),
2475        }
2476    }
2477
2478    fn review_report(reviewer: &str, finding_id: &str) -> ReviewReport {
2479        ReviewReport {
2480            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2481            assignment_id: format!("assignment-{reviewer}"),
2482            reviewer_id: reviewer.to_string(),
2483            snapshot_sha256: "b".repeat(64),
2484            execution: ReviewExecutionFacts {
2485                provider_id: "provider".to_string(),
2486                model_id: "model".to_string(),
2487                agent_profile: "reviewer".to_string(),
2488                tools: BTreeSet::from(["filesystem.read".to_string()]),
2489            },
2490            findings: vec![review_finding(finding_id)],
2491        }
2492    }
2493
2494    #[test]
2495    fn adversarial_review_contracts_validate_and_round_trip() {
2496        let assignment_request = ReviewPanelAssignmentRequest {
2497            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2498            round_id: "round-1".to_string(),
2499            snapshot: review_snapshot(),
2500            reviewer_ids: vec!["reviewer-a".to_string(), "reviewer-b".to_string()],
2501            include_paths: vec!["src/lib.rs".to_string()],
2502            policy_id: "strict".to_string(),
2503        };
2504        let assignments = assignment_request.materialize().expect("assignments");
2505        assert_eq!(assignments.assignments.len(), 2);
2506        assert_eq!(
2507            assignments.assignments[0].assignment_id,
2508            "round-1:reviewer-a"
2509        );
2510        assert_eq!(
2511            assignments.assignments[1].assignment_id,
2512            "round-1:reviewer-b"
2513        );
2514
2515        let assignment = ReviewAssignment {
2516            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2517            assignment_id: "assignment-a".to_string(),
2518            reviewer_id: "reviewer-a".to_string(),
2519            snapshot: review_snapshot(),
2520            include_paths: vec!["src/lib.rs".to_string()],
2521            policy_id: "strict".to_string(),
2522        };
2523        assignment.validate().expect("assignment");
2524
2525        let panel = ReviewPanelResult {
2526            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2527            snapshot_sha256: "b".repeat(64),
2528            reports: vec![
2529                review_report("reviewer-a", "finding-a"),
2530                review_report("reviewer-b", "finding-b"),
2531            ],
2532        };
2533        panel.validate().expect("panel");
2534        let encoded = serde_json::to_string(&panel).expect("serialize panel");
2535        assert_eq!(
2536            serde_json::from_str::<ReviewPanelResult>(&encoded).expect("deserialize panel"),
2537            panel
2538        );
2539
2540        let findings = [review_finding("finding-a"), review_finding("finding-b")];
2541        let consensus = ReviewConsensus {
2542            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2543            snapshot_sha256: "b".repeat(64),
2544            decisions: findings
2545                .into_iter()
2546                .map(|finding| ReviewConsensusDecision {
2547                    finding_id: finding.finding_id.clone(),
2548                    disposition: ReviewConsensusDisposition::Required,
2549                    finding: Some(finding),
2550                    duplicate_of: None,
2551                    rationale: "The evidence demonstrates a correctness defect.".to_string(),
2552                })
2553                .collect(),
2554        };
2555        consensus.validate().expect("consensus");
2556        consensus
2557            .validate_against_panel(&panel)
2558            .expect("panel consensus");
2559        ReviewRoundOutcome::RemediationRequired {
2560            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2561            snapshot_sha256: "b".repeat(64),
2562            consensus,
2563        }
2564        .validate()
2565        .expect("outcome");
2566    }
2567
2568    #[test]
2569    fn adversarial_review_contracts_reject_mixed_snapshot_and_conflicting_identity() {
2570        let mut report = review_report("reviewer-a", "finding-a");
2571        report.findings[0].snapshot_sha256 = "d".repeat(64);
2572        assert!(report.validate().is_err());
2573
2574        let unordered = ReviewPanelAssignmentRequest {
2575            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2576            round_id: "round-1".to_string(),
2577            snapshot: review_snapshot(),
2578            reviewer_ids: vec!["reviewer-b".to_string(), "reviewer-a".to_string()],
2579            include_paths: Vec::new(),
2580            policy_id: "strict".to_string(),
2581        };
2582        assert!(unordered.materialize().is_err());
2583
2584        let duplicate_report = review_report("reviewer-a", "finding-b");
2585        let panel = ReviewPanelResult {
2586            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2587            snapshot_sha256: "b".repeat(64),
2588            reports: vec![review_report("reviewer-a", "finding-a"), duplicate_report],
2589        };
2590        assert!(panel.validate().is_err());
2591
2592        let decision = ReviewConsensusDecision {
2593            finding_id: "finding-a".to_string(),
2594            disposition: ReviewConsensusDisposition::Duplicate,
2595            finding: None,
2596            duplicate_of: Some("finding-a".to_string()),
2597            rationale: "duplicate".to_string(),
2598        };
2599        assert!(
2600            ReviewConsensus {
2601                version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2602                snapshot_sha256: "b".repeat(64),
2603                decisions: vec![decision],
2604            }
2605            .validate()
2606            .is_err()
2607        );
2608
2609        let panel = ReviewPanelResult {
2610            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2611            snapshot_sha256: "b".repeat(64),
2612            reports: vec![
2613                review_report("reviewer-a", "finding-a"),
2614                review_report("reviewer-b", "finding-b"),
2615            ],
2616        };
2617        let finding = review_finding("finding-a");
2618        let incomplete = ReviewConsensus {
2619            version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2620            snapshot_sha256: "b".repeat(64),
2621            decisions: vec![ReviewConsensusDecision {
2622                finding_id: finding.finding_id.clone(),
2623                disposition: ReviewConsensusDisposition::Advisory,
2624                finding: Some(finding),
2625                duplicate_of: None,
2626                rationale: "downgraded".to_string(),
2627            }],
2628        };
2629        assert!(incomplete.validate_against_panel(&panel).is_err());
2630    }
2631
2632    #[test]
2633    fn external_review_import_models_round_trip_json() {
2634        let response = ExternalReviewImportResponse {
2635            importer_id: "github_pr_review".to_string(),
2636            threads: vec![ExternalReviewThread {
2637                identity: ExternalReviewIdentity {
2638                    provider_id: "github".to_string(),
2639                    external_id: "thread-1".to_string(),
2640                    url: Some("https://github.com/owner/repo/pull/1#discussion_r1".to_string()),
2641                },
2642                state: ExternalReviewThreadState::Outdated,
2643                mapping: ExternalReviewAnchorMapping {
2644                    status: ExternalAnchorMappingStatus::Unmappable,
2645                    anchor: None,
2646                    message: Some("diff context is outdated".to_string()),
2647                },
2648                comments: vec![ExternalReviewComment {
2649                    identity: ExternalReviewIdentity {
2650                        provider_id: "github".to_string(),
2651                        external_id: "comment-1".to_string(),
2652                        url: None,
2653                    },
2654                    author: None,
2655                    author_label: Some("reviewer".to_string()),
2656                    body: "Please handle this case.".to_string(),
2657                    created_at_ms: Some(10),
2658                    updated_at_ms: Some(20),
2659                }],
2660            }],
2661            warnings: vec!["one thread was unmappable".to_string()],
2662        };
2663
2664        let json = serde_json::to_string(&response).expect("serialize import response");
2665        let decoded: ExternalReviewImportResponse =
2666            serde_json::from_str(&json).expect("deserialize import response");
2667
2668        assert_eq!(decoded, response);
2669    }
2670
2671    #[test]
2672    fn review_ai_exchange_models_round_trip_json() {
2673        let exchange = ReviewAiExchange {
2674            schema_version: REVIEW_AI_EXCHANGE_SCHEMA_VERSION,
2675            exchange_id: "exchange-1".to_string(),
2676            anchor: DraftAnchor {
2677                kind: ReviewAnchorKind::Range,
2678                file_path: "src/lib.rs".to_string(),
2679                diff_row: 3,
2680                start_diff_row: Some(3),
2681                end_diff_row: Some(4),
2682                old_start: Some(2),
2683                old_end: Some(2),
2684                new_start: Some(3),
2685                new_end: Some(4),
2686                old_line: None,
2687                new_line: None,
2688                line_kind: ReviewLineKind::Added,
2689                is_file_anchor: false,
2690                surface_id: Some("surface-1".to_string()),
2691                source_id: Some("source-1".to_string()),
2692            },
2693            question: "Why is this safe?".to_string(),
2694            session_id: Some("session-1".to_string()),
2695            status: ReviewAiExchangeStatus::Linked,
2696            error: None,
2697            created_at_ms: 10,
2698            updated_at_ms: 20,
2699        };
2700
2701        let json = serde_json::to_string(&exchange).expect("serialize exchange");
2702        let round_trip: ReviewAiExchange =
2703            serde_json::from_str(&json).expect("deserialize exchange");
2704
2705        assert_eq!(round_trip, exchange);
2706        assert_eq!(
2707            serde_json::to_value(ReviewAiExchangeStatus::Failed).expect("status json"),
2708            serde_json::json!("failed")
2709        );
2710    }
2711
2712    #[test]
2713    fn review_suggestion_models_round_trip_json() {
2714        let suggestion = ReviewSuggestion {
2715            suggestion_id: "suggestion-1".to_string(),
2716            anchor: DraftAnchor {
2717                kind: ReviewAnchorKind::Range,
2718                file_path: "src/lib.rs".to_string(),
2719                diff_row: 3,
2720                start_diff_row: Some(3),
2721                end_diff_row: Some(4),
2722                old_start: Some(2),
2723                old_end: Some(2),
2724                new_start: Some(3),
2725                new_end: Some(4),
2726                old_line: None,
2727                new_line: None,
2728                line_kind: ReviewLineKind::Added,
2729                is_file_anchor: false,
2730                surface_id: Some("surface-1".to_string()),
2731                source_id: Some("source-1".to_string()),
2732            },
2733            body: "Handle the error explicitly.".to_string(),
2734            session_id: Some("session-1".to_string()),
2735            status: ReviewSuggestionStatus::Rejected,
2736            rationale: Some("AI review".to_string()),
2737            created_at_ms: 10,
2738            updated_at_ms: 20,
2739        };
2740
2741        let json = serde_json::to_string(&suggestion).expect("serialize suggestion");
2742        let decoded: ReviewSuggestion =
2743            serde_json::from_str(&json).expect("deserialize suggestion");
2744
2745        assert_eq!(decoded, suggestion);
2746    }
2747
2748    #[test]
2749    fn review_suggestion_status_uses_provider_neutral_wire_values() {
2750        assert_eq!(
2751            serde_json::to_string(&ReviewSuggestionStatus::Refining)
2752                .expect("serialize suggestion status"),
2753            "\"refining\""
2754        );
2755        assert_eq!(
2756            serde_json::from_str::<ReviewSuggestionStatus>("\"accepted\"")
2757                .expect("deserialize suggestion status"),
2758            ReviewSuggestionStatus::Accepted
2759        );
2760    }
2761}