Repository navigation
Description
When an append-only cumulative scan reuses a verified ALLOW prefix and the suffix scan produces redacted_text, the returned result contains only the suffix's redacted text. The clean skipped prefix is missing.
What happens
merge_suffix_result(suffix, prefix_len) in sdk/src/unplug/core/runtime/cache.py offsets findings globally (correct), but passes through suffix.redacted_text without the prefix body:
def merge_suffix_result(suffix: ScanResult, prefix_len: int) -> ScanResult:
findings = offset_findings(suffix.findings, prefix_len)
...
redacted: str | None = None
if suffix.redacted_text is not None and prefix_len > 0:
redacted = suffix.redacted_text # <-- suffix only, no prefix body
...The comment says "Caller must merge redacted bodies externally if needed", but neither call site does this:
Guard._run_input_with_cache()returns the merged result directly (guard.py:602).StreamScanner._scan_accumulated()returns it directly (streaming.py:96).
The prefix_len argument passed to merge_suffix_result is the scan start offset, not necessarily the full safe prefix length. The omitted text is body[:scan_start].
Reproduction
Direct helper check showing the gap:
from unplug.api.types import ScanResult, Finding
from unplug.api.enums import Action
from unplug.core.runtime.cache import merge_suffix_result
suffix_result = ScanResult(
safe=True,
action=Action.ALLOW,
risk_score=0.0,
findings=[Finding(
category="leakage", subcategory="email", stage="regex",
span_start=0, span_end=15, score=0.8, evidence="test",
)],
redacted_text="see [REDACTED] now",
latency_ms=0,
stages_run=["leakage"],
)
merged = merge_suffix_result(suffix_result, prefix_len=100)
# findings are at offset 100 (correct), but redacted_text is still
# just "see [REDACTED] now" with no prefix body prepended.
assert merged.findings[0].span_start == 100
assert merged.redacted_text == "see [REDACTED] now"The full document's redacted_text should be the clean prefix concatenated with the suffix's redacted text. The helper has no prefix-body argument with which to reconstruct it.
Both affected call paths return this incomplete result:
Guard.scan_request(..., document_id=...)via_run_input_with_cache().StreamScanner._scan_accumulated()viamerge_suffix_result().
Expected behavior
If redacted_text represents the safe/redacted version of the scanned document, it should retain the skipped verified-clean text before the redacted suffix. Otherwise the API must explicitly document that it returns a partial result. The current docs do not document it as partial.
Suggested direction
Recompose body[:scan_start] + suffix.redacted_text at call sites that possess the complete original body, or extend the helper with a backward-compatible explicit prefix-text input. Do not silently infer unavailable text inside the helper, and do not alter finding offset behavior.
Environment
Python 3.13.13, Windows 11, dev at cf0b0c7.
Activity
AaliyaKhan25 commentedon Sep 27, 2026
I'd like to take this one. The bug's clear from your repro: merge_suffix_result gets the finding offsets right but drops the safe prefix when rebuilding redacted_text, and neither call site patches that gap.
I'll go with your suggested fix: add an optional prefix-text param to the helper (backward-compatible, so nothing breaks for existing callers) and have both call sites pass body[:scan_start] in, since they're the ones with the full body in scope anyway. Will also check the case where redacted_text is None but the prefix still needs prepending, so the merged result is always the complete document either way.
I'll add a test based on your repro as the regression case, and won't touch the finding-offset logic.
chiruu12 commentedon Oct 6, 2026
Holding this one. You have #191 now, and I keep it to one open issue per person until a PR lands. Come back to it then if nobody else has picked it up.