polyc-facts 2026.8.3

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
//! 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 — see `docs/reference/taint-excision.md` for the design
//! record and its prior-art anchoring.
//!
//! 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::{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.
///
/// 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; 5] = [
    kinds::OUTPUT_MSG,
    kinds::SUMMARY,
    kinds::COMPACTION_CHECKPOINT,
    kinds::SUMMARY_GATE_REJECTED,
    kinds::SUMMARY_GATE_ADMITTED,
];

/// 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::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));
    }

    #[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);
    }
}