#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecallPastVerdictsRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub query_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_id: Option<String>,
pub scope: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
pub k: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_file: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PastVerdictDto {
pub extraction_id: String,
pub code_snippet: String,
pub issue_text: String,
pub status: String,
#[serde(default)]
pub reason: Option<String>,
pub similarity: f32,
pub created_at: String,
#[serde(default)]
pub signature: Option<String>,
#[serde(default)]
pub source_pr_number: Option<i64>,
#[serde(default)]
pub source_pr_title: Option<String>,
#[serde(default)]
pub source_pr_url: Option<String>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordReviewMetricsRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub input_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub estimated_cost_usd: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub perspective_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub past_verdicts_used: Option<u32>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveTrajectoryRequest {
pub steps: serde_json::Value,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetTrajectoryResponse {
pub id: String,
pub pr_review_id: String,
pub team_id: Option<String>,
pub steps: Vec<crate::observability::trajectory::TrajectoryStep>,
pub created_at: String,
}
pub fn accepted_edit_diff_signature(before: &str, after: &str) -> String {
use sha2::{Digest as _, Sha256};
let mut hasher = Sha256::new();
hasher.update(before.as_bytes());
hasher.update(b"\n---\n");
hasher.update(after.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
use core::fmt::Write as _;
write!(&mut out, "{byte:02x}").ok();
}
out
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordAcceptedEditRequest {
pub before_code: String,
pub after_code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_full_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_pr_number: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub acceptance_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub diff_signature: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rule_ids: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordAcceptedEditResponse {
pub ok: bool,
pub acceptance_recorded: bool,
pub acceptance_id: Option<String>,
pub diff_signature: Option<String>,
pub team_id: Option<String>,
pub attributed_rule_ids: Vec<String>,
pub observations_inserted: u32,
pub memory_reinforcement_recorded: bool,
pub memory_reinforcement_deduped: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UploadImportedReviewsRequest {
pub reviews: Vec<ImportedReviewUpload>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportedReviewUpload {
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_host: Option<String>,
pub repo_full_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_repo_full_name: Option<String>,
pub pr_number: i32,
pub pr_title: Option<String>,
pub comments: Vec<ImportedCommentUpload>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportedCommentEventType {
PullRequestReviewComment,
PullRequestReview,
IssueComment,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportedCommentUpload {
#[serde(skip_serializing_if = "Option::is_none")]
pub event_type: Option<ImportedCommentEventType>,
pub file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_number: Option<i32>,
pub content: String,
pub author: Option<String>,
pub comment_url: String,
pub thread_id: Option<String>,
pub occurred_at: Option<String>,
}
#[cfg(test)]
mod tests {
use super::{
GetTrajectoryResponse, ImpactCoverageDto, ImpactFixScorecardDto, ImpactTopRulesDto,
RecordAcceptedEditRequest, RecordAcceptedEditResponse, accepted_edit_diff_signature,
};
use crate::contract::RegisterDeviceResult;
use crate::observability::trajectory::TrajectoryStep;
#[test]
fn register_device_result_accepts_null_device_token() {
let device: RegisterDeviceResult = serde_json::from_value(serde_json::json!({
"id": "dev_123",
"name": "workstation",
"platform": "macos",
"createdAt": "2026-06-13T12:00:00.000Z",
"deviceToken": null
}))
.expect("deviceToken null must decode for already-registered devices");
assert_eq!(device.id, "dev_123");
assert!(device.device_token.is_none());
}
#[test]
fn get_trajectory_response_decodes_cloud_envelope_and_steps() {
let payload = r#"{
"id": "11111111-1111-1111-1111-111111111111",
"prReviewId": "22222222-2222-2222-2222-222222222222",
"teamId": "33333333-3333-3333-3333-333333333333",
"createdAt": "2026-05-29T12:00:00.000Z",
"steps": [
{ "kind": "chunks_retrieved", "count": 2, "symbols": ["foo"], "similarity_scores": [0.91] },
{ "kind": "rules_applied", "rule_ids": ["r1", "r2"], "source": "team" },
{ "kind": "past_verdicts_recalled", "count": 1, "top_similarities": [0.95],
"recalled_items": [{ "id": "v1", "title": "no unwrap", "similarity": 0.95, "excerpt": "..." }] },
{ "kind": "self_check", "keep_count": 3, "drop_count": 1, "avg_confidence": 0.87 },
{ "kind": "final_decision", "issue_ids_emitted": ["issue-1"] }
]
}"#;
let doc: GetTrajectoryResponse = serde_json::from_str(payload).unwrap();
assert_eq!(doc.pr_review_id, "22222222-2222-2222-2222-222222222222");
assert_eq!(
doc.team_id.as_deref(),
Some("33333333-3333-3333-3333-333333333333")
);
assert_eq!(doc.steps.len(), 5);
assert!(matches!(
doc.steps[0],
TrajectoryStep::ChunksRetrieved { count: 2, .. }
));
assert!(matches!(
&doc.steps[1],
TrajectoryStep::RulesApplied { rule_ids, .. } if rule_ids.len() == 2
));
assert!(matches!(
doc.steps[4],
TrajectoryStep::FinalDecision { ref issue_ids_emitted } if issue_ids_emitted.len() == 1
));
}
#[test]
fn get_trajectory_response_accepts_empty_placeholder() {
let payload = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"prReviewId": "22222222-2222-2222-2222-222222222222",
"teamId": null,
"createdAt": "2026-05-29T12:00:00.000Z",
"steps": []
}"#;
let doc: GetTrajectoryResponse = serde_json::from_str(payload).unwrap();
assert!(doc.steps.is_empty());
assert!(doc.team_id.is_none());
}
#[test]
fn accepted_edit_defaults_missing_rule_ids_for_legacy_outbox_rows() {
let payload = r#"{
"beforeCode": "old",
"afterCode": "new",
"filePath": "src/lib.rs"
}"#;
let req: RecordAcceptedEditRequest = serde_json::from_str(payload).unwrap();
assert!(req.rule_ids.is_empty());
}
#[test]
fn accepted_edit_serializes_rule_ids_when_present() {
let req = RecordAcceptedEditRequest {
before_code: "old".into(),
after_code: "new".into(),
file_path: Some("src/lib.rs".into()),
repo_full_name: Some("difflore-fixtures/gin".into()),
target_pr_number: Some(4543),
language: Some("rust".into()),
acceptance_source: Some("difflore_fix".into()),
client: Some("difflore_cli".into()),
diff_signature: Some(accepted_edit_diff_signature("old", "new")),
rule_ids: vec!["rule-1".into(), "rule-2".into()],
};
let value = serde_json::to_value(req).unwrap();
assert_eq!(value["acceptanceSource"], "difflore_fix");
assert_eq!(value["client"], "difflore_cli");
assert_eq!(value["targetPrNumber"], 4543);
assert_eq!(value["ruleIds"][0], "rule-1");
assert_eq!(value["ruleIds"][1], "rule-2");
assert_eq!(value["diffSignature"].as_str().unwrap().len(), 64);
}
#[test]
fn accepted_edit_diff_signature_is_stable_without_raw_code() {
let a = accepted_edit_diff_signature("let a = 1;\n", "let a = 2;\n");
let b = accepted_edit_diff_signature("let a = 1;\n", "let a = 2;\n");
let c = accepted_edit_diff_signature("let a = 1;\n", "let a = 3;\n");
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(a.len(), 64);
assert!(a.chars().all(|ch| ch.is_ascii_hexdigit()));
}
#[test]
fn openapi_contract_only_exposes_local_fix_acceptance_proof() {
let spec = include_str!("../../contracts/openapi-spec.json");
assert!(spec.contains("\"/accepted-edits\""));
assert!(spec.contains("\"operationId\": \"acceptedEdits.record\""));
assert!(spec.contains("\"repoFullName\""));
assert!(spec.contains("\"acceptanceSource\""));
assert!(spec.contains("\"client\""));
assert!(spec.contains("\"ruleIds\""));
assert!(spec.contains("\"acceptanceRecorded\""));
assert!(spec.contains("\"observationsInserted\""));
assert!(spec.contains("\"attributedRuleIds\""));
for forbidden in [
"\"/fix-runs/acceptances\"",
"\"/fix-runs\"",
"\"/fix-runs/{id}\"",
"\"/fix-runs/{id}/cancel\"",
"\"/fix-runs/trigger\"",
"\"fixRunId\"",
"\"FIX_RUN_NOT_FOUND\"",
"\"operationId\": \"fixRuns.recordFixAcceptance\"",
"\"operationId\": \"fixRuns.list\"",
"\"operationId\": \"fixRuns.get\"",
"\"operationId\": \"fixRuns.cancel\"",
"\"operationId\": \"fixRuns.manualTrigger\"",
"\"FixRunItem\"",
"\"FixRunDetail\"",
"\"FixRunList\"",
"\"FixTriggerResult\"",
"\"/fix-configs\"",
"\"/fix-configs/{repoFullName}\"",
"\"operationId\": \"fixConfigs.list\"",
"\"operationId\": \"fixConfigs.get\"",
"\"operationId\": \"fixConfigs.upsert\"",
"\"FixConfigSummary\"",
"\"FixConfigDetail\"",
"\"FixUpsertResult\"",
"\"monthlyFixQuota\"",
"\"fixQuota\"",
"\"fixRunsQuota\"",
"\"fixRunsUsed\"",
] {
assert!(
!spec.contains(forbidden),
"OpenAPI contract reintroduced obsolete managed fix-run surface `{forbidden}`"
);
}
}
#[test]
fn accepted_edit_response_deserializes_attribution_details() {
let payload = r#"{
"ok": true,
"acceptanceRecorded": true,
"acceptanceId": "acc-1",
"diffSignature": "sig-1",
"teamId": "team-1",
"attributedRuleIds": ["rule-1"],
"observationsInserted": 1,
"memoryReinforcementRecorded": true,
"memoryReinforcementDeduped": false,
"error": null
}"#;
let response: RecordAcceptedEditResponse = serde_json::from_str(payload).unwrap();
assert!(response.ok);
assert!(response.acceptance_recorded);
assert_eq!(response.attributed_rule_ids, vec!["rule-1"]);
assert_eq!(response.observations_inserted, 1);
}
#[test]
fn impact_top_rules_accepts_missing_or_present_proof_source() {
let legacy_payload = r#"{
"rules": [{
"id": "rule-1",
"name": "Prefer structured parsing",
"severity": "medium",
"language": "rust",
"acceptanceCount": 2,
"distinctUsers": 1,
"citedCount": 4,
"trustRate": 0.5
}],
"promotionProgress": []
}"#;
let legacy: ImpactTopRulesDto = serde_json::from_str(legacy_payload).unwrap();
assert_eq!(legacy.rules[0].accepted_proof_source, None);
assert_eq!(legacy.rules[0].reviewer_proof_ready_count, 0);
assert_eq!(legacy.rules[0].reviewer_context_serves, 0);
assert_eq!(legacy.rules[0].reviewer_mentions, 0);
assert_eq!(legacy.rules[0].source_repo, None);
let current_payload = r#"{
"rules": [{
"id": "rule-1",
"name": "Prefer structured parsing",
"acceptanceCount": 2,
"distinctUsers": 1,
"acceptedProofSource": "local_fix",
"reviewerProofReadyCount": 2,
"reviewerContextServes": 5,
"reviewerMentions": 2,
"sourceRepo": "gin-gonic/gin"
}],
"promotionProgress": []
}"#;
let current: ImpactTopRulesDto = serde_json::from_str(current_payload).unwrap();
assert_eq!(
current.rules[0].accepted_proof_source.as_deref(),
Some("local_fix")
);
assert_eq!(current.rules[0].reviewer_proof_ready_count, 2);
assert_eq!(current.rules[0].reviewer_context_serves, 5);
assert_eq!(current.rules[0].reviewer_mentions, 2);
assert_eq!(
current.rules[0].source_repo.as_deref(),
Some("gin-gonic/gin")
);
}
#[test]
fn impact_coverage_defaults_missing_review_comment_count() {
let payload = r#"{
"repos": 3,
"prs": 12,
"files": 40
}"#;
let coverage: ImpactCoverageDto = serde_json::from_str(payload).unwrap();
assert_eq!(coverage.review_comments_indexed, 0);
}
#[test]
fn impact_fix_scorecard_accepts_roi_when_present() {
let payload = r#"{
"last30": { "accepted": 3, "total": 4 },
"prior30": { "accepted": 1, "total": 2 },
"trendPct": 50,
"roi": {
"acceptedFixesLast30": 3,
"reviewCommentsAvoided": 3,
"savedReviewMinutes": 12,
"repeatFeedbackReduced": 1,
"sourceEvidenceItems": 4
}
}"#;
let scorecard: ImpactFixScorecardDto = serde_json::from_str(payload).unwrap();
let roi = scorecard.roi.unwrap();
assert_eq!(roi.saved_review_minutes, 12);
}
const DTO_SOURCE: &str = include_str!("dto.rs");
const SPEC_JSON: &str = include_str!("../../contracts/openapi-spec.json");
fn registry_endpoint_cells() -> Vec<String> {
DTO_SOURCE
.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
let body = trimmed.strip_prefix("//!")?.trim_start();
let inner = body.strip_prefix('|')?;
let first_cell = inner.split('|').next()?.trim();
if first_cell.is_empty() || first_cell == "Endpoint" || first_cell == "---" {
return None;
}
Some(first_cell.to_owned())
})
.collect()
}
fn method_paths_with_trailer(cell: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let bytes = cell.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'`' {
if let Some(rel_end) = cell[i + 1..].find('`') {
let token = &cell[i + 1..i + 1 + rel_end];
let after = &cell[i + 1 + rel_end + 1..];
let method_prefixes = ["GET ", "POST ", "PUT ", "PATCH ", "DELETE "];
if method_prefixes.iter().any(|m| token.starts_with(m)) {
out.push((token.to_owned(), after.to_owned()));
}
i = i + 1 + rel_end + 1;
continue;
}
}
i += 1;
}
out
}
fn spec_paths() -> std::collections::BTreeSet<String> {
let doc: serde_json::Value = serde_json::from_str(SPEC_JSON).expect("spec is valid JSON");
doc.get("paths")
.and_then(serde_json::Value::as_object)
.map(|paths| paths.keys().cloned().collect())
.unwrap_or_default()
}
fn path_is_in_spec(path: &str, spec: &std::collections::BTreeSet<String>) -> bool {
if let Some(prefix) = path.strip_suffix('*') {
spec.iter().any(|p| p.starts_with(prefix))
} else {
spec.contains(path)
}
}
#[test]
fn dto_registry_paths_not_overlapping_spec() {
let spec = spec_paths();
assert!(
!spec.is_empty(),
"spec parsed to zero paths — include_str path or JSON shape changed"
);
let cells = registry_endpoint_cells();
assert!(
cells.len() >= 8,
"expected the DTO registry table to have several rows, found {}",
cells.len()
);
let mut unmarked_overlaps = Vec::new();
for cell in &cells {
for (token, trailer) in method_paths_with_trailer(cell) {
let path = token.split_once(' ').map_or(token.as_str(), |(_, p)| p);
if path_is_in_spec(path, &spec) {
let marked =
trailer.trim_start().starts_with("(in spec") || cell.contains("(in spec");
if !marked {
unmarked_overlaps.push(token.clone());
}
}
}
}
assert!(
unmarked_overlaps.is_empty(),
"DTO registry has hand-written endpoints that ARE in the OpenAPI spec \
but are NOT marked `(in spec ...)` — this is undocumented \
generated/hand-written double-tracking. Either migrate them to \
generated.rs or mark the registry row: {unmarked_overlaps:?}"
);
}
#[test]
fn hand_written_dto_names_disjoint_from_spec_schema_names() {
let mut dto_names = std::collections::BTreeSet::new();
for line in DTO_SOURCE.lines() {
let t = line.trim_start();
for kw in ["pub struct ", "pub enum "] {
if let Some(rest) = t.strip_prefix(kw) {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !name.is_empty() {
dto_names.insert(name);
}
}
}
}
assert!(
!dto_names.is_empty(),
"found no hand-written DTO type names — parser or file layout changed"
);
let doc: serde_json::Value = serde_json::from_str(SPEC_JSON).expect("spec is valid JSON");
let schema_names: std::collections::BTreeSet<String> = doc
.get("components")
.and_then(|c| c.get("schemas"))
.and_then(serde_json::Value::as_object)
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
assert!(
!schema_names.is_empty(),
"spec parsed to zero component schemas — include_str path or shape changed"
);
let collisions: Vec<&String> = dto_names.intersection(&schema_names).collect();
assert!(
collisions.is_empty(),
"hand-written DTO type name(s) collide with generated spec component-schema \
name(s): {collisions:?}. A generated type and a hand-written type would \
both want `contract::Name`. Rename the hand-written DTO or migrate it."
);
}
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImpactBannerDto {
pub past_verdicts_this_week: i64,
pub week_start_iso: String,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImpactWeeklyPointDto {
pub week_start_iso: String,
pub rules_sedimented: i64,
pub past_verdicts_recalled: i64,
pub fixes_accepted: i64,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImpactWeeklyDto {
pub weeks: Vec<ImpactWeeklyPointDto>,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImpactTopRuleDto {
pub id: String,
pub name: String,
pub severity: Option<String>,
pub language: Option<String>,
pub acceptance_count: i64,
pub distinct_users: i64,
#[serde(default)]
pub cited_count: i64,
#[serde(default)]
pub trust_rate: Option<f64>,
#[serde(default)]
pub accepted_proof_source: Option<String>,
#[serde(default)]
pub reviewer_proof_ready_count: i64,
#[serde(default)]
pub reviewer_context_serves: i64,
#[serde(default)]
pub reviewer_mentions: i64,
#[serde(default)]
pub source_repo: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImpactPromotionProgressDto {
pub file_path: Option<String>,
pub language: Option<String>,
pub acceptance_count: i64,
pub required_count: i64,
pub distinct_users: i64,
pub required_distinct_users: i64,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImpactTopRulesDto {
pub rules: Vec<ImpactTopRuleDto>,
#[serde(default)]
pub promotion_progress: Vec<ImpactPromotionProgressDto>,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImpactCoverageDto {
pub repos: i64,
pub prs: i64,
pub files: i64,
#[serde(default)]
pub review_comments_indexed: i64,
#[serde(default)]
pub ai_reviewer_comments_indexed: i64,
#[serde(default)]
pub human_review_comments_indexed: i64,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImpactFixWindowDto {
pub accepted: i64,
pub total: i64,
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImpactRoiDto {
#[serde(default)]
pub accepted_fixes_last30: i64,
#[serde(default)]
pub review_comments_avoided: i64,
#[serde(default)]
pub saved_review_minutes: i64,
#[serde(default)]
pub repeat_feedback_reduced: i64,
#[serde(default)]
pub source_evidence_items: i64,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImpactFixScorecardDto {
pub last30: ImpactFixWindowDto,
pub prior30: ImpactFixWindowDto,
pub trend_pct: Option<f64>,
#[serde(default)]
pub roi: Option<ImpactRoiDto>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct ObservationScope {
pub anchor_kind: String,
pub anchor_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct Observation {
pub session_id: String,
pub ts_ms: i64,
pub obs_type: String,
pub tool: String,
pub file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<ObservationScope>,
pub title: String,
pub narrative: Option<String>,
pub diff_excerpt: Option<String>,
pub content_hash: String,
}