use super::*;
pub(crate) async fn handle_gotcha_upsert(
store: &Store,
ctx: &RequestContext,
request_id: Uuid,
input: &protocol::GotchaDraftInput,
) -> HandlerResult {
let now = now_secs();
let key = &input.key;
if !key.starts_with("gotcha:") {
return Err((
ErrorCode::ValidationFailed,
"key must start with gotcha:".into(),
));
}
if input.rule.is_empty() {
return Err((ErrorCode::ValidationFailed, "rule must not be empty".into()));
}
let affected_files =
crate::store::gotcha_ops::normalize_affected_files(&input.affected_files, &ctx.repo_root);
let (record, is_new, old_affected_files) = retry_on_write_conflict(|| {
upsert_commit_once(store, ctx, request_id, input, &affected_files, now)
})
.await?;
let quality_val = record.quality.value;
let tier_label = format!("{:?}", record.quality.tier);
crate::store::repair::mark_dirty(store, key, "gotcha_upsert: pre-arm cancellation guard").await;
if crate::store::gotcha_ops::sync_has_gotcha_edges(
store,
key,
&old_affected_files,
&affected_files,
)
.await
{
crate::store::repair::clear_dirty_key_if_solo(store, key).await;
}
let change_kind = if is_new {
crate::store::enforcement::ControlChangeKind::Created
} else {
crate::store::enforcement::ControlChangeKind::Updated
};
let reason_code = if is_new {
"control_created"
} else {
"control_updated"
};
if let Err(e) = crate::store::enforcement::record_event(
store,
crate::store::enforcement::EnforcementEventType::ControlChanged { change_kind },
crate::store::enforcement::SubjectKind::Control,
key.clone(),
"developer".to_string(),
None,
reason_code.to_string(),
None,
)
.await
{
tracing::warn!("gotcha_upsert: enforcement event recording failed for {key}: {e}");
}
if is_new {
let _ =
crate::store::extraction::write_on_extraction(store, key, &input.tags, &affected_files)
.await;
}
Ok(serde_json::json!({
"ok": true,
"key": key,
"confidence": record.confidence.value,
"quality": quality_val,
"tier": tier_label,
}))
}
async fn upsert_commit_once(
store: &Store,
ctx: &RequestContext,
request_id: Uuid,
input: &protocol::GotchaDraftInput,
affected_files: &[String],
now: u64,
) -> Result<(Record, bool, Vec<String>), (ErrorCode, String)> {
let key = &input.key;
let existing = store
.get(key)
.await
.map_err(|e| (ErrorCode::StoreError, format!("store read failed: {e}")))?;
let is_tombstoned = existing
.as_ref()
.map(|r| matches!(r.lifecycle, RecordLifecycle::Tombstoned { .. }))
.unwrap_or(false);
let is_new = existing.is_none() || is_tombstoned;
let old_affected_files: Vec<String> = existing
.as_ref()
.filter(|_| !is_tombstoned)
.and_then(|r| r.payload_as::<GotchaRecord>())
.map(|g| g.affected_files)
.unwrap_or_default();
let source = match input.source.as_deref() {
Some("developer_manual") => RecordSource::DeveloperManual,
Some("import") => RecordSource::Import,
_ => RecordSource::ClaudeEnrich,
};
let confirmed = input.confirmed && matches!(source, RecordSource::DeveloperManual);
let previous = existing
.as_ref()
.and_then(|r| r.payload_as::<GotchaRecord>());
let confirmed_content = match (confirmed, is_new) {
(true, false) => previous
.as_ref()
.map(|g| g.confirmed_content.clone())
.unwrap_or_default(),
_ => Default::default(),
};
let mut gotcha = GotchaRecord {
rule: input.rule.clone(),
reason: input.reason.clone(),
severity: map_severity(&input.severity),
affected_files: affected_files.to_vec(),
ref_url: input.ref_url.clone(),
discovered_session: if is_new {
now
} else {
previous
.as_ref()
.map(|g| g.discovered_session)
.unwrap_or(now)
},
confirmed,
confirmed_content,
};
if confirmed && is_new {
gotcha.confirmed_content =
crate::store::gotcha_ops::confirm_content_stamp(&ctx.repo_root, affected_files);
}
let mut record = match existing {
Some(mut r) if !is_tombstoned => {
r.updated_at = now;
r.version.logical_clock += 1;
r.version.wall_clock = now;
r
}
_ => Record {
key: key.clone(),
value: String::new(),
category: Category::Gotcha,
priority: StorePriority::Normal,
tags: vec![],
created_at: now,
updated_at: now,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: crate::store::stable_device_id(),
logical_clock: 1,
wall_clock: now,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
payload: None,
},
};
record.value = format!("{} because {}", input.rule, input.reason);
record.category = Category::Gotcha;
record.lifecycle = RecordLifecycle::Active;
record.priority = map_priority(&input.priority);
record.tags = input.tags.clone();
record.source = source.clone();
record.confidence = ConfidenceScore::for_new_record(&source);
if is_tombstoned {
record.confidence.confirmation_count = 0;
}
if confirmed {
record.confidence.confirmation_count = 1;
}
record.payload = serde_json::to_value(&gotcha).ok();
record.quality = quality::analyze(&record);
let file_link_updates =
compute_file_link_updates(store, key, &old_affected_files, affected_files).await;
let (audit_key, audit_bytes) = make_audit(ctx, request_id, "gotcha_upsert", key, true, None)
.ok_or_else(|| (ErrorCode::Internal, "audit serialization failed".into()))?;
let mut ops: Vec<KnowledgeWriteOp<'_>> = Vec::new();
ops.push(KnowledgeWriteOp::PutRecord {
key,
record: &record,
});
for (fkey, frec) in &file_link_updates {
ops.push(KnowledgeWriteOp::PutRecord {
key: fkey.as_str(),
record: frec,
});
}
ops.push(KnowledgeWriteOp::PutRaw {
key: &audit_key,
value: &audit_bytes,
});
store
.transact_knowledge(&ops)
.await
.map_err(|e| (ErrorCode::StoreError, format!("transact failed: {e}")))?;
Ok((record, is_new, old_affected_files))
}
pub(crate) async fn handle_gotcha_confirm(
store: &Store,
ctx: &RequestContext,
request_id: Uuid,
input: &protocol::GotchaConfirmInput,
) -> HandlerResult {
let now = now_secs();
let key = &input.key;
if !key.starts_with("gotcha:") {
return Err((
ErrorCode::ValidationFailed,
"confirm only applies to gotcha: keys".into(),
));
}
let (record, affected_files) =
retry_on_write_conflict(|| confirm_commit_once(store, ctx, request_id, key, now)).await?;
let confidence_val = record.confidence.value;
let quality_val = record.quality.value;
crate::store::repair::mark_dirty(store, key, "gotcha_confirm: pre-arm cancellation guard")
.await;
if crate::store::gotcha_ops::sync_has_gotcha_edges(store, key, &[], &affected_files).await {
crate::store::repair::clear_dirty_key_if_solo(store, key).await;
}
crate::store::gotcha_ops::invalidate_consultation_receipts(store, &affected_files).await;
let reason_code = if input.via_elicitation {
"control_confirmed_elicited"
} else {
"control_confirmed"
};
if let Err(e) = crate::store::enforcement::record_event(
store,
crate::store::enforcement::EnforcementEventType::ControlChanged {
change_kind: crate::store::enforcement::ControlChangeKind::Confirmed,
},
crate::store::enforcement::SubjectKind::Control,
key.clone(),
"developer".to_string(),
None,
reason_code.to_string(),
None,
)
.await
{
tracing::warn!("gotcha_confirm: enforcement event recording failed for {key}: {e}");
}
let _ = crate::store::extraction::mark_outcome(
store,
key,
crate::store::extraction::ExtractionOutcome::Confirmed,
)
.await;
Ok(serde_json::json!({
"ok": true,
"key": key,
"confirmed": true,
"confidence": confidence_val,
"quality": quality_val,
}))
}
async fn confirm_commit_once(
store: &Store,
ctx: &RequestContext,
request_id: Uuid,
key: &str,
now: u64,
) -> Result<(Record, Vec<String>), (ErrorCode, String)> {
let mut record = store
.get(key)
.await
.map_err(|e| (ErrorCode::StoreError, format!("store read: {e}")))?
.ok_or_else(|| (ErrorCode::NotFound, format!("record not found: {key}")))?;
if record.category != Category::Gotcha {
return Err((
ErrorCode::ValidationFailed,
format!("{key} is not a gotcha record"),
));
}
if !matches!(record.lifecycle, RecordLifecycle::Active) {
return Err((
ErrorCode::InvalidStateTransition,
format!("{key} is tombstoned — cannot confirm"),
));
}
if let Some(ref mut payload) = record.payload {
if let Some(obj) = payload.as_object_mut() {
if let Some(sev) = obj
.get("severity")
.and_then(|v| v.as_str())
.map(|s| s.to_lowercase())
{
obj.insert("severity".to_string(), serde_json::Value::String(sev));
}
obj.insert("confirmed".to_string(), serde_json::Value::Bool(true));
}
}
record.source = RecordSource::DeveloperManual;
record.confidence.value = ConfidenceScore::base_for_source(&RecordSource::DeveloperManual);
record.confidence.confirmation_count += 1;
record.quality = quality::analyze(&record);
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
let affected_files = crate::store::gotcha_ops::normalize_affected_files(
&record
.payload_as::<GotchaRecord>()
.map(|g| g.affected_files)
.unwrap_or_default(),
&ctx.repo_root,
);
if let Some(rekeyed) =
crate::store::gotcha_ops::with_normalized_affected_files(&record, &affected_files)
{
record = rekeyed;
}
let mut file_updates = compute_file_link_updates(store, key, &[], &affected_files).await;
apply_confirmation_propagation(store, &affected_files, &mut file_updates).await;
let stamp = crate::store::gotcha_ops::confirm_content_stamp(&ctx.repo_root, &affected_files);
crate::store::gotcha_ops::set_confirm_stamp(&mut record, &stamp);
let (audit_key, audit_bytes) =
make_audit(ctx, request_id, "gotcha_confirm", key, true, None)
.ok_or_else(|| (ErrorCode::Internal, "audit serialization failed".into()))?;
let mut ops: Vec<KnowledgeWriteOp<'_>> = Vec::new();
ops.push(KnowledgeWriteOp::PutRecord {
key,
record: &record,
});
for (fkey, frec) in &file_updates {
ops.push(KnowledgeWriteOp::PutRecord {
key: fkey.as_str(),
record: frec,
});
}
ops.push(KnowledgeWriteOp::PutRaw {
key: &audit_key,
value: &audit_bytes,
});
store
.transact_knowledge(&ops)
.await
.map_err(|e| (ErrorCode::StoreError, format!("transact failed: {e}")))?;
Ok((record, affected_files))
}
pub(crate) async fn handle_gotcha_tombstone(
store: &Store,
ctx: &RequestContext,
request_id: Uuid,
input: &protocol::GotchaTombstoneInput,
) -> HandlerResult {
let now = now_secs();
let key = &input.key;
if !key.starts_with("gotcha:") {
return Err((
ErrorCode::ValidationFailed,
"tombstone only applies to gotcha: keys".into(),
));
}
let (affected_files, neg_exemplar_data) =
retry_on_write_conflict(|| tombstone_commit_once(store, ctx, request_id, key, now)).await?;
crate::store::repair::mark_dirty(store, key, "gotcha_tombstone: pre-arm cancellation guard")
.await;
if crate::store::gotcha_ops::sync_has_gotcha_edges(store, key, &affected_files, &[]).await {
crate::store::repair::clear_dirty_key_if_solo(store, key).await;
}
if let Err(e) = crate::store::enforcement::record_event(
store,
crate::store::enforcement::EnforcementEventType::ControlChanged {
change_kind: crate::store::enforcement::ControlChangeKind::Deleted,
},
crate::store::enforcement::SubjectKind::Control,
key.clone(),
"developer".to_string(),
None,
"control_deleted".to_string(),
None,
)
.await
{
tracing::warn!("gotcha_tombstone: enforcement event recording failed for {key}: {e}");
}
if let Some((rule, reason, severity)) = neg_exemplar_data.as_ref() {
match crate::store::negative_exemplar::write_on_tombstone(
store,
key,
rule,
reason,
severity,
&affected_files,
)
.await
{
Ok(n) => tracing::debug!(
"gotcha_tombstone (mcp): negative_exemplar archived for {key} across {n} dirname(s)"
),
Err(e) => tracing::warn!(
"gotcha_tombstone (mcp): negative_exemplar write failed for {key}: {e}"
),
}
}
let _ = crate::store::extraction::mark_outcome(
store,
key,
crate::store::extraction::ExtractionOutcome::Tombstoned,
)
.await;
Ok(serde_json::json!({"ok": true, "key": key, "tombstoned": true}))
}
async fn tombstone_commit_once(
store: &Store,
ctx: &RequestContext,
request_id: Uuid,
key: &str,
now: u64,
) -> Result<
(
Vec<String>,
Option<(String, String, crate::store::Priority)>,
),
(ErrorCode, String),
> {
let mut record = store
.get(key)
.await
.map_err(|e| (ErrorCode::StoreError, format!("store read: {e}")))?
.ok_or_else(|| (ErrorCode::NotFound, format!("record not found: {key}")))?;
let gotcha_snapshot = record.payload_as::<GotchaRecord>();
let affected_files: Vec<String> = gotcha_snapshot
.as_ref()
.map(|g| g.affected_files.clone())
.unwrap_or_default();
let neg_exemplar_data: Option<(String, String, crate::store::Priority)> = gotcha_snapshot
.as_ref()
.map(|g| (g.rule.clone(), g.reason.clone(), g.severity.clone()));
record.lifecycle = RecordLifecycle::Tombstoned {
reason: TombstoneReason::ManualDeletion,
at: now,
};
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
let file_link_updates = compute_file_link_updates(store, key, &affected_files, &[]).await;
let (audit_key, audit_bytes) = make_audit(ctx, request_id, "gotcha_tombstone", key, true, None)
.ok_or_else(|| (ErrorCode::Internal, "audit serialization failed".into()))?;
let mut ops: Vec<KnowledgeWriteOp<'_>> = Vec::new();
ops.push(KnowledgeWriteOp::PutRecord {
key,
record: &record,
});
for (fkey, frec) in &file_link_updates {
ops.push(KnowledgeWriteOp::PutRecord {
key: fkey.as_str(),
record: frec,
});
}
ops.push(KnowledgeWriteOp::PutRaw {
key: &audit_key,
value: &audit_bytes,
});
store
.transact_knowledge(&ops)
.await
.map_err(|e| (ErrorCode::StoreError, format!("transact failed: {e}")))?;
Ok((affected_files, neg_exemplar_data))
}