polyc-facts 2026.9.0

Shared semantic-fold library: decode-to-fact functions reused by every consumer that reads the event log, so a payment receipt or a tool call means the same thing everywhere it's read.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
//! Taint excision (`#590`): the provenance-sound recovery path from a
//! tainted conversation.
//!
//! The event log is append-only and tamper-evident, so excision cannot
//! delete entries. Instead the control plane appends a **signed
//! `taint_excision` marker** naming journal positions; this module verifies
//! those markers, expands their scope into the effective excised-position
//! set, and rewrites excised events into inert stand-ins for the transcript
//! projection. Grants recover because the *input* to grant derivation
//! changed (the excised events stop feeding the durable seed), never because
//! the rule weakened.
//!
//! Two scopes, both signed into the marker:
//!
//! - **cascade** (the sound default): the named positions plus every
//!   model-authored content event after the earliest of them (assistant
//!   output, tool traffic, summaries, compaction checkpoints). The recovery
//!   literature shows a model re-derives an injected instruction from its
//!   own retained reasoning if only the source is removed. Human-authored
//!   messages and structural markers are kept.
//! - **source-only**: exactly the named positions, plus the paired
//!   `tool_use` event of any named `tool_result` (providers reject orphaned
//!   pairs). An explicit, human-vouched override for content the person read
//!   and judged benign.
//!
//! Fail-closed at every step: an unverifiable marker, a marker bound to a
//! different conversation, or an unknown scope is ignored — taint stays.

use std::collections::BTreeSet;

use polyc_crypto::approval::{VerifiedExcision, verify_signed_excision};
use polyc_eventlog_model::{Event, TrustTag};
use polyc_proto::proto::polychrome::agent::v1::{Message, content};
use polyc_proto::{events_decode::decode_event_payload, kinds};

/// The kinds cascade excision removes from the poison point onward: every
/// event whose content is model-derived. `output_msg` carries assistant
/// text, tool calls, and tool results; `summary` and `compaction_checkpoint`
/// are LLM-derived projections that may bake the poisoned content in.
/// `summary_gate_rejected` and `summary_gate_admitted` (`#1783`) carry
/// `dropped_identifiers` extracted from that same candidate summary — the
/// content survives an excision of the paired `summary` event unless these
/// two kinds cascade too, so they must.
///
/// `grounded_content` (POLY-257) is the odd one, and it belongs here for a
/// different reason. It carries no content at all. It carries taint: the
/// marker says a model step used the provider's native search, and its
/// quarantined trust tag is what feeds the durable seed. A cascade that left
/// it standing would remove every byte the poison arrived in and still leave
/// the conversation permanently tainted, which defeats the whole recovery
/// path for exactly the conversations that searched.
///
/// This set GROWS: it went from three kinds to five when the lossy-anchor
/// work landed, and every future model-derived kind belongs here on the day
/// it is added. Nothing outside this module may restate its membership — a
/// second copy is a silent under-excision waiting for the next addition, and
/// that is precisely how the two kinds above were nearly lost when this
/// module moved inward while `#1783` was extending the copy it left behind.
const CASCADE_KINDS: [&str; 6] = [
    kinds::OUTPUT_MSG,
    kinds::SUMMARY,
    kinds::COMPACTION_CHECKPOINT,
    kinds::SUMMARY_GATE_REJECTED,
    kinds::SUMMARY_GATE_ADMITTED,
    kinds::GROUNDED_CONTENT,
];

/// Every verified `taint_excision` marker in `events` bound to
/// `conversation_id`.
///
/// Markers that fail signature verification, or that are bound to a different
/// conversation, are ignored and logged: taint stays — fail closed.
#[must_use]
pub fn verified_excisions(events: &[(u64, Event)], conversation_id: &str) -> Vec<VerifiedExcision> {
    verified_excisions_matching(events, conversation_id, |excision| {
        excision.conversation_id == conversation_id
    })
}

/// [`verified_excisions`] with the binding check supplied by the caller.
///
/// Every marker is still verified here — the predicate decides only WHICH
/// verified marker belongs to the caller's target, never whether to trust one.
/// Fail-closed identically: an unverifiable marker is never offered to the
/// predicate, so taint stays.
///
/// The search index needs this because it keys by SANITIZED partition name,
/// which is lossy (`web:<uuid>` and `web_<uuid>` both sanitize to the latter)
/// and cannot be inverted back to a conversation id. Without it that caller
/// would have to re-implement verification to match in the other direction —
/// and verification is the one part of this that must not be duplicated.
///
/// `target` names what the caller is matching against, for the log lines only.
/// It is threaded through rather than derived because an ignored marker with no
/// attribution is not a usable signal, and these two logs are the only signal
/// this fail-closed path emits.
#[must_use]
pub fn verified_excisions_matching(
    events: &[(u64, Event)],
    target: &str,
    matches: impl Fn(&VerifiedExcision) -> bool,
) -> Vec<VerifiedExcision> {
    events
        .iter()
        .filter(|(_, ev)| kinds::base(&ev.kind) == kinds::TAINT_EXCISION)
        .filter_map(|(pos, ev)| {
            let verified = verify_signed_excision(&ev.payload);
            match &verified {
                Some(v) if matches(v) => verified,
                Some(_) => {
                    tracing::warn!(
                        position = pos,
                        %target,
                        "ignoring taint-excision marker bound to a different conversation"
                    );
                    None
                }
                None => {
                    tracing::warn!(
                        position = pos,
                        %target,
                        "ignoring unverifiable taint-excision marker; taint stays (fail closed)"
                    );
                    None
                }
            }
        })
        .collect()
}

/// Expand verified markers into the effective excised journal-position set.
///
/// Cascade markers add every model-authored content event from their
/// earliest named position onward; source-only markers add the paired
/// `tool_use` of each named `tool_result` so the projected transcript never
/// carries an orphaned pair. Named positions that do not exist in `events`
/// contribute nothing.
#[must_use]
pub fn excised_positions(events: &[(u64, Event)], excisions: &[VerifiedExcision]) -> BTreeSet<u64> {
    let existing: BTreeSet<u64> = events.iter().map(|(pos, _)| *pos).collect();
    let mut excised: BTreeSet<u64> = BTreeSet::new();
    for excision in excisions {
        let named: BTreeSet<u64> = excision
            .positions
            .iter()
            .copied()
            .filter(|p| existing.contains(p))
            .collect();
        let Some(&earliest) = named.iter().next() else {
            continue;
        };
        if excision.is_cascade() {
            let from = earliest;
            for (pos, ev) in events {
                if *pos >= from && CASCADE_KINDS.contains(&kinds::base(&ev.kind)) {
                    excised.insert(*pos);
                }
            }
        } else {
            // source-only: whole-pair excision. For each named tool_result,
            // find and excise the output_msg carrying the matching tool_use.
            for &pos in &named {
                if let Some(id) = tool_result_id_at(events, pos)
                    && let Some(partner) = tool_use_position(events, &id)
                {
                    excised.insert(partner);
                }
            }
        }
        excised.extend(named);
    }
    excised
}

/// The `tool_call_id` of the tool-result content in the event at `pos`, if
/// that event carries one.
///
/// Reads the wire [`Message`]'s single content block directly rather than
/// normalizing through the LLM message type: a wire message carries exactly
/// one `Content` (`agent_service.proto`'s `Message.content`), so the list an
/// LLM message would present is the same one block, and this crate is a
/// foundation that cannot reach the agent loop's conversion helper.
/// [`crate::fold_message_content`] matches the same variants for the same
/// reason.
fn tool_result_id_at(events: &[(u64, Event)], pos: u64) -> Option<String> {
    let (_, ev) = events.iter().find(|(p, _)| *p == pos)?;
    if kinds::base(&ev.kind) != kinds::OUTPUT_MSG {
        return None;
    }
    let msg = decode_event_payload::<Message>(&ev.payload)?;
    match &msg.content.as_option()?.r#type {
        Some(content::Type::ToolResult(tr)) => Some(tr.call_id.clone()),
        _ => None,
    }
}

/// The journal position of the `output_msg` event whose content carries the
/// tool call with `tool_call_id`, if present.
fn tool_use_position(events: &[(u64, Event)], tool_call_id: &str) -> Option<u64> {
    events.iter().find_map(|(pos, ev)| {
        if kinds::base(&ev.kind) != kinds::OUTPUT_MSG {
            return None;
        }
        let msg = decode_event_payload::<Message>(&ev.payload)?;
        match &msg.content.as_option()?.r#type {
            Some(content::Type::ToolCall(tc)) if tc.id == tool_call_id => Some(*pos),
            _ => None,
        }
    })
}

/// Rewrite excised events into inert stand-ins, preserving the vector's
/// length and each event's turn tag.
///
/// The projection's positional accounting (a summary's `covers_through`
/// count, committed-turn grouping) must see exactly the shape it saw when
/// those records were created, so excised events are neither removed nor
/// reordered — their kind base becomes `excised` (which every projection
/// consumer already skips as a non-transcript kind), the payload is cleared,
/// and the trust tag drops to unspecified so no quarantined byte survives
/// into any derivation that reads this vector.
pub fn strip_excised(events: &mut [(u64, Event)], excised: &BTreeSet<u64>) {
    for (pos, ev) in events.iter_mut() {
        if !excised.contains(pos) {
            continue;
        }
        let (_, turn) = kinds::parse(&ev.kind);
        ev.kind = turn.map_or_else(|| "excised".to_owned(), |t| kinds::tagged("excised", &t));
        ev.trust = TrustTag::Unspecified;
        ev.payload = Vec::new();
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use polyc_crypto::approval::{
        ApprovalSigner, EXCISION_SCOPE_CASCADE, EXCISION_SCOPE_SOURCE_ONLY, excision_payload,
    };
    use polyc_eventlog_model::any_untrusted_excluding;

    use super::*;

    fn signer() -> ApprovalSigner {
        ApprovalSigner::from_seed(7)
    }

    /// A wire message carrying one tool-call block with `id` — the shape
    /// `tool_use_position` matches on.
    fn tool_call_msg(id: &str) -> Message {
        use polyc_proto::proto::polychrome::agent::v1::{Content, ToolCallContent};
        Message {
            role: "model".to_owned(),
            content: buffa::MessageField::some(Content {
                r#type: Some(content::Type::ToolCall(Box::new(ToolCallContent {
                    id: id.to_owned(),
                    ..Default::default()
                }))),
                ..Default::default()
            }),
            ..Default::default()
        }
    }

    /// A wire message carrying one tool-result block whose `call_id` is `id` —
    /// the shape `tool_result_id_at` matches on.
    fn tool_result_msg(id: &str) -> Message {
        use polyc_proto::proto::polychrome::agent::v1::{Content, ToolResultContent};
        Message {
            role: "user".to_owned(),
            content: buffa::MessageField::some(Content {
                r#type: Some(content::Type::ToolResult(Box::new(ToolResultContent {
                    call_id: id.to_owned(),
                    ..Default::default()
                }))),
                ..Default::default()
            }),
            ..Default::default()
        }
    }

    fn marker(conversation: &str, scope: &str, positions: &[u64]) -> Event {
        let (payload, _, _) = excision_payload(
            conversation,
            scope,
            positions,
            "persona-1",
            "test excision",
            &signer(),
        );
        Event::new(kinds::TAINT_EXCISION, payload)
    }

    /// A transcript shaped like the canonical poisoning: user asks, model
    /// fetches (tool_use), the poisoned result lands (quarantined), the model
    /// narrates, the user replies, the model works on.
    fn poisoned_log() -> Vec<(u64, Event)> {
        // Built directly against the wire types rather than through the agent
        // loop's message helpers: this crate is a foundation and cannot depend
        // on a Component even in tests, and the pairing logic under test reads
        // only the ids — `ToolCallContent::id` and `ToolResultContent::call_id`
        // — so the rest of a fully-populated message is noise here.
        let tool_use = tool_call_msg("call-1");
        let tool_result = tool_result_msg("call-1");
        use buffa::Message as _;
        vec![
            (
                0,
                Event::new("turn_start:0195000000007000800000000000aaaa", Vec::new()),
            ),
            (
                1,
                Event::trusted(
                    "user_msg:0195000000007000800000000000aaaa",
                    b"fetch it".to_vec(),
                ),
            ),
            (
                2,
                Event::new(
                    "output_msg:0195000000007000800000000000aaaa",
                    tool_use.encode_to_vec(),
                ),
            ),
            (
                3,
                Event::with_trust(
                    "output_msg:0195000000007000800000000000aaaa",
                    tool_result.encode_to_vec(),
                    TrustTag::QuarantinedContent,
                ),
            ),
            (
                4,
                Event::new(
                    "output_msg:0195000000007000800000000000aaaa",
                    Vec::new(), // model narration (payload irrelevant here)
                ),
            ),
            (
                5,
                Event::new("turn_complete:0195000000007000800000000000aaaa", Vec::new()),
            ),
            (
                6,
                Event::trusted(
                    "user_msg:0195000000007000800000000000bbbb",
                    b"thanks".to_vec(),
                ),
            ),
            (
                7,
                Event::new("output_msg:0195000000007000800000000000bbbb", Vec::new()),
            ),
        ]
    }

    #[test]
    fn cascade_excises_the_source_and_all_model_output_after_it() {
        let mut events = poisoned_log();
        events.push((8, marker("conv-1", EXCISION_SCOPE_CASCADE, &[3])));

        let excisions = verified_excisions(&events, "conv-1");
        assert_eq!(excisions.len(), 1);
        let excised = excised_positions(&events, &excisions);
        // The poison (3) and every model-authored event from it onward (4, 7)
        // — but never the user's messages (1, 6), the turn markers, or the
        // pre-poison tool_use (2).
        assert_eq!(excised, [3, 4, 7].into());

        // The durable seed re-derives clean; fresh taint after excision
        // re-taints exactly as before.
        assert!(!any_untrusted_excluding(&events, &excised));
        let mut later = events.clone();
        later.push((
            9,
            Event::quarantined(
                "output_msg:0195000000007000800000000000cccc",
                b"<new>".to_vec(),
            ),
        ));
        assert!(any_untrusted_excluding(&later, &excised));
    }

    /// POLY-257: a cascade must clear the grounding marker too.
    ///
    /// The marker carries no content, so it looks like a structural record
    /// the cascade should keep. It is not. Its quarantined trust tag is the
    /// durable form of "this conversation read the open web", and it is the
    /// only trace grounding leaves — grounding produces no tool result. A
    /// cascade that removed every byte of the poisoned turn and left this
    /// standing would report the conversation as still tainted forever, so
    /// the recovery path would silently do nothing for any conversation that
    /// had searched.
    ///
    /// The second half is the part that would have been missed: the seed
    /// predicate must actually come back clean. Asserting only the excised
    /// set would pass while the conversation stayed permanently gated.
    #[test]
    fn cascade_clears_a_grounding_marker_so_the_seed_recovers() {
        let mut events = poisoned_log();
        // A later turn grounded. No tool result — just the marker, carrying
        // taint and nothing else.
        events.push((
            8,
            Event::with_trust(
                "grounded_content:0195000000007000800000000000dddd".to_owned(),
                Vec::new(),
                TrustTag::QuarantinedContent,
            ),
        ));
        events.push((9, marker("conv-1", EXCISION_SCOPE_CASCADE, &[3])));

        let excisions = verified_excisions(&events, "conv-1");
        let excised = excised_positions(&events, &excisions);
        assert!(
            excised.contains(&8),
            "a cascade from position 3 must reach the grounding marker at 8: {excised:?}"
        );
        assert!(
            !any_untrusted_excluding(&events, &excised),
            "the conversation must recover — a surviving grounding marker gates \
             it forever with nothing left to excise"
        );
    }

    /// The mirror: a source-only excision is a human vouching for exactly the
    /// positions they named, and it must not sweep up a grounding they did
    /// not name. This is also what proves the case above is testing the
    /// cascade rule rather than an unconditional sweep.
    #[test]
    fn source_only_leaves_an_unnamed_grounding_marker_standing() {
        let mut events = poisoned_log();
        events.push((
            8,
            Event::with_trust(
                "grounded_content:0195000000007000800000000000dddd".to_owned(),
                Vec::new(),
                TrustTag::QuarantinedContent,
            ),
        ));
        events.push((9, marker("conv-1", EXCISION_SCOPE_SOURCE_ONLY, &[3])));

        let excisions = verified_excisions(&events, "conv-1");
        let excised = excised_positions(&events, &excisions);
        assert_eq!(
            excised,
            [2, 3].into(),
            "source-only excises the named pair and nothing else"
        );
        assert!(
            any_untrusted_excluding(&events, &excised),
            "a grounding the person never named still taints — narrowing the \
             scope narrows the recovery, which is the point of the scope"
        );
    }

    #[test]
    fn source_only_excises_the_whole_pair_and_nothing_downstream() {
        let mut events = poisoned_log();
        events.push((8, marker("conv-1", EXCISION_SCOPE_SOURCE_ONLY, &[3])));

        let excisions = verified_excisions(&events, "conv-1");
        let excised = excised_positions(&events, &excisions);
        // The named tool_result AND its paired tool_use — never the model's
        // later narration (the human vouched for it by choosing this scope).
        assert_eq!(excised, [2, 3].into());
        assert!(!any_untrusted_excluding(&events, &excised));
    }

    #[test]
    fn forged_and_foreign_markers_are_ignored() {
        let mut events = poisoned_log();
        // A marker bound to a DIFFERENT conversation.
        events.push((8, marker("conv-other", EXCISION_SCOPE_CASCADE, &[3])));
        // A tampered marker (position list widened after signing).
        let (payload, _, _) = excision_payload(
            "conv-1",
            EXCISION_SCOPE_CASCADE,
            &[3],
            "persona-1",
            "r",
            &signer(),
        );
        let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
        v["positions"] = serde_json::json!([0, 1, 2, 3, 4, 5, 6, 7]);
        events.push((
            9,
            Event::new(kinds::TAINT_EXCISION, v.to_string().into_bytes()),
        ));

        let excisions = verified_excisions(&events, "conv-1");
        assert!(excisions.is_empty(), "both markers must be ignored");
        let excised = excised_positions(&events, &excisions);
        assert!(excised.is_empty());
        assert!(
            any_untrusted_excluding(&events, &excised),
            "taint stays (fail closed)"
        );
    }

    #[test]
    fn named_positions_that_do_not_exist_contribute_nothing() {
        let mut events = poisoned_log();
        events.push((8, marker("conv-1", EXCISION_SCOPE_CASCADE, &[99])));
        let excisions = verified_excisions(&events, "conv-1");
        assert_eq!(excised_positions(&events, &excisions), BTreeSet::new());
    }

    #[test]
    fn strip_excised_preserves_shape_and_turn_tags() {
        let mut events = poisoned_log();
        let excised: BTreeSet<u64> = [3, 4].into();
        let before = events.len();
        strip_excised(&mut events, &excised);
        assert_eq!(
            events.len(),
            before,
            "length preserved for positional accounting"
        );
        let (_, ev3) = &events[3];
        assert_eq!(kinds::base(&ev3.kind), "excised");
        assert!(
            kinds::parse(&ev3.kind).1.is_some(),
            "the turn tag survives so committed-turn grouping is unchanged"
        );
        assert_eq!(ev3.trust, TrustTag::Unspecified);
        assert!(ev3.payload.is_empty());
        // Non-excised events are untouched.
        let (_, ev1) = &events[1];
        assert_eq!(kinds::base(&ev1.kind), "user_msg");
        assert_eq!(ev1.trust, TrustTag::TrustedUser);
    }
}