Harden Guardian reviews after context compaction (#42852)

## Why

Guardian reviews must not lose user authorization constraints or reuse an
unreadable or incompatible parent checkpoint after compaction.

## What changed

- Retain bounded excerpts of oversized root user messages across compaction,
  resume, and rollback, and recover excerpts from legacy Guardian history when
  available.
- Require thread-owned Guardian review sessions and asynchronous scoring to
  reuse only non-empty checkpoints produced by a compatible review model.
- Fail closed instead of reviewing without the required checkpoint when reuse
  is disabled, compatibility is unknown, or checkpoint content is unusable.

## Testing

Expanded retained-context, review-session, asynchronous-scoring, and app-server
coverage for oversized instructions, legacy recovery, model incompatibility,
missing content, resume, rollback, and disabled checkpoint reuse.

GitOrigin-RevId: f30d837197e0d838e656a632109f490c38208f5e
This commit is contained in:
felixxia-oai
2026-09-04 21:07:14 +00:00
committed by copyberry
parent 8e4b7d31de
commit 4636819a35
15 changed files with 598 additions and 104 deletions
@@ -324,7 +324,13 @@ impl ApprovalReviewContributor for GuardianV2Extension {
let history = thread.conversation_history_snapshot().await;
if thread_context_enabled {
let sampler = thread_store.get::<LunaSampler>()?;
if requires_sync_for_compaction(&guardian_config, history.as_ref(), &sampler) {
if requires_sync_for_compaction(&guardian_config, history.as_ref(), &sampler)
|| encrypted_parent_compaction(
history.items(),
guardian_config.max_parent_compaction_tokens,
)
.is_err()
{
thread_store.insert(StrictReviewReason::IncompatibleCompaction);
record_fast_decision(
extension_metrics.as_deref(),
@@ -635,6 +641,7 @@ impl GuardianV2Extension {
guardian_config.max_parent_compaction_tokens,
) {
Ok(compaction) => compaction,
Err(ParentCompactionError::Unusable) if !thread_context_enabled => None,
Err(_) => {
Self::record_fail_closed_score(input.thread_store, sampled_at);
record_classification(
@@ -967,6 +974,7 @@ impl GuardianV2Extension {
enum ParentCompactionError {
Serialization,
Oversized,
Unusable,
}
// Sampling and fast approval must apply the same checkpoint eligibility policy.
@@ -975,18 +983,17 @@ pub(super) fn requires_sync_for_compaction(
history: &dyn ConversationHistorySnapshot,
sampler: &LunaSampler,
) -> bool {
config.reuse_parent_compaction
&& history.items().any(|item| {
matches!(
item,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. }
)
})
&& !sampler.supports_parent_compaction(history.latest_compaction_model_hash())
history.items().any(|item| {
matches!(
item,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. }
)
}) && (!config.reuse_parent_compaction
|| !sampler.supports_parent_compaction(history.latest_compaction_model_hash()))
}
// An unusable latest compaction must never fall back to an older one. Missing
// encrypted content can be omitted; content that cannot be bounded rejects the sample.
// encrypted content is rejected here; only legacy callers may omit that checkpoint.
fn encrypted_parent_compaction<'a>(
items: impl Iterator<Item = &'a ResponseItem>,
max_parent_compaction_tokens: usize,
@@ -1015,10 +1022,10 @@ fn encrypted_parent_compaction<'a>(
encrypted_content: Some(encrypted_content),
..
} => encrypted_content,
_ => return Ok(None),
_ => return Err(ParentCompactionError::Unusable),
};
if encrypted_content.is_empty() {
return Ok(None);
return Err(ParentCompactionError::Unusable);
}
let serialized = serde_json::to_vec(item).map_err(|_| ParentCompactionError::Serialization)?;
if serialized.len() > max_compaction_bytes {
@@ -857,7 +857,7 @@ fn encrypted_parent_compaction_rejects_invalid_latest_item() {
[&older, latest].into_iter(),
DEFAULT_PARENT_COMPACTION_TOKENS,
),
Ok(None),
Err(ParentCompactionError::Unusable),
"an unusable latest summary must not resurrect older context"
);
}
@@ -3335,7 +3335,7 @@ async fn assert_parent_compaction_reuse(thread_context_enabled: bool) -> Result<
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn contributor_can_disable_parent_compaction_reuse() -> Result<()> {
async fn legacy_contributor_can_disable_parent_compaction_reuse() -> Result<()> {
skip_if_no_network!(Ok(()));
let oversized_compaction = ResponseItem::Compaction {
@@ -3347,7 +3347,7 @@ async fn contributor_can_disable_parent_compaction_reuse() -> Result<()> {
oversized_compaction,
user_instruction("Inspect the repository guidelines."),
];
let configuration = "[features]\nguardian_thread_context = true\n\n[features.guardianv2]\nenabled = true\nreuse_parent_compaction = false\nmax_parent_compaction_tokens = 256\n";
let configuration = "[features]\nguardian_thread_context = false\n\n[features.guardianv2]\nenabled = true\nreuse_parent_compaction = false\nmax_parent_compaction_tokens = 256\n";
let (request, test, _registry) = sample_configured_conversation_history(
conversation_history,
r#"{"path":"README.md"}"#,