
The Poisoned Corpus: When Agent Chatter Becomes What the User Asked For
Turn 1 of the corpus was a teammate-message orchestrator dispatch, not a human-typed turn. The is_human filter did not exclude it, so every downstream agent would read lane dispatch as user intent.
View companion repoThe turn that was not a human
The question I want the corpus to answer is simple: what did the person actually ask for. A retrospective was mining session transcripts for that answer. Turn 1 of the session under inspection was not a human-typed turn.
Session 1f17b872-3018-434d-9af7-5099dc78d40d.jsonl line 538 names the tag: <teammate-message teammate_id="team-lead">. An orchestrator dispatch. is_human() did not exclude those blocks, so every WS-A agent would read lane-dispatch prompts and attribute them as "what the user asked for."
That is the whole plot. A corpus built to capture human intent silently filled with agent-generated text. The filter meant to stop it did not. Line 538 does not proceed past the miss. It names the defect, names the tag, and then quantifies before deciding whether to kill the run.
Turn 1 is a privileged slot in this kind of harvest. The opening turn is where a miner goes to recover the originating ask. If that slot is a coordinator brief, the answer is wrong before any later reader runs. The rest of the pipeline will be faithful to the wrong speaker.
The session basename is 1f17b872-3018-434d-9af7-5099dc78d40d.jsonl. File basename is not authorship. Line 538 is. The session id names a file. It does not name who spoke. On this file, Turn 1 is tagged team-lead. The text is a dispatch to lanes.
A harvest that treats "turn 1 of session X" as a user ask is making an authorship claim. On this file, that claim fails. Downstream readers who never see the tag still inherit the label is_human() put on it.
What is_human actually matched
Line 538 is a failure report, not a source listing of the predicate.
is_human() was the gate between a JSONL turn and the label "human." A typed user message should pass. A <teammate-message> from team-lead should not. The line says the filter does not exclude those blocks. It does not say which tokens it scored, which roles it allowlisted, or which XML tags it ignored. Those internals are not in the citation. The outcome is.
The miss is enough. Once Turn 1 is a dispatch and the filter calls it human, every later reader inherits the lie. WS-A agents in that run would read lane-dispatch prompts and attribute them as user intent. The corpus does not announce the swap. It just starts.
A human-typed turn and an orchestrator wrap can share a surface. Both are text. Both can arrive as Turn 1. Both look like instructions. The tag is the discriminator. The citation records it as <teammate-message teammate_id="team-lead">.
If the filter never looks at that tag, the discriminator never fires. The function can still be named is_human. The name is not the test. The test is whether a teammate-message block leaves with the human label. On this corpus, it did.
is_human sounds like a speaker check. Line 538 is what a speaker check looks like when it fails at the boundary it was supposed to own. Turn 1 was a teammate-message. The filter did not exclude it. That is the whole code-level claim the citation supports.
The downstream cost is already in the same line. Every WS-A agent will read lane-dispatch prompts and attribute them as what the user asked for. The poison is not a stray assistant reply sitting in the middle of a chat. It is the opening ask, already labeled human, already in the slot later agents will cite first.
Measuring contamination instead of guessing at it
Line 538 ends with the method: quantifying before deciding whether to kill the run.
That sentence is the discipline. A miss on Turn 1 is a defect. It is not yet a count. Killing the run on a single tagged block would have been a story. Counting how far the miss reached, then deciding, is a measurement.
The first citation is the defect and the pause. Downstream agents were about to read the corpus as user intent. Kill is a decision you make after a count.
A contaminated turn is one kind of evidence. A contaminated product is another. If you only inspect the opening tag, you know the filter is wrong. You do not know whether later artifacts recovered any human turns at all. The second citation is that product check, from an independent session.
Quantifying is the difference between a tagged example and a corpus problem. One <teammate-message> on Turn 1 proves the filter's boundary is wrong. It does not, by itself, tell you whether the rest of the harvest is still usable. Line 538 refuses that shortcut. See the tag. Measure. Then decide whether to kill the run. The order is the method.
Proceeding without the count would have shipped the miss into every WS-A reader. Stopping without the count would have treated one tagged block as a finished autopsy. Neither move answers the question the harvest was built to answer: how much of this text is actually a person.
The digests that were empty but well-formed
An independent session recorded a check of produced digests. Session agent-aa92b258fd7a00a77.jsonl line 44 is the record. All 22 omp digests were header-only. 0 human turns. 0 context turns. Then the operator moved on to the claude digests, testing output paths.
A header-only digest is a digest with no human turns extracted and no context turns extracted. Empty of the thing the corpus was built to hold.
That is the reinforcing case. A pipeline that emits header-only artifacts looks like it worked if you stop at the label "digest." Twenty-two omp digests. Zero recovered human turns. If you count digests, the job is done. If you count human turns, the job never started.
The citation is the count and the emptiness, scoped to that session's omp digests. 22. Header-only. 0 human turns. 0 context turns. The next clause in line 44 is a path check, not a recovery: now the claude digests, testing output paths. Empty omp output does not become a human corpus by switching folders.
The two citations sit next to each other because they fail two different checks. Turn 1 labeled a dispatch as human. That is a speaker-tag failure in 1f17b872-3018-434d-9af7-5099dc78d40d.jsonl. The digest check in agent-aa92b258fd7a00a77.jsonl then found 22 omp digests with nobody inside. That is a recovered-turn failure. One is a contaminated label. The other is an empty product that still carries the name digest.
Empty and missing are different failures. A missing digest is loud. A worker errors, a path is absent, a gate goes red. A header-only digest is quiet. The human-turn counter is 0, and 0 is a number you can store. If the next agent greps for digest names rather than for recovered turns, 22 zeros look like 22 successes.
Line 44 is useful because it names both counters. Not only "header-only." Human turns: 0. Context turns: 0. A digest can fail one of those and still hold something. This set held neither. The header was the whole artifact.
Why an orchestrator's own voice is the hardest contaminant to see
Agent chatter is easy to spot when it looks like an assistant reply. Role tags. Tool calls. A sentence that starts with a plan of tool use. An orchestrator's dispatch is harder. It is written in the operator's register. It assigns lanes. It names the goal. It sounds like a person running a team.
<teammate-message teammate_id="team-lead"> is that voice. Team-lead is not a user sitting at the keyboard. Line 538 names it an orchestrator dispatch. The text it emits is a prompt for a lane. If you later ask "what did the user ask for?" and you take Turn 1, you get the coordinator's brief, not the original request.
That is why the miss is quiet. The poison does not read as poison. It reads as a clear, well-scoped ask. Downstream agents will follow it with confidence. They will cite it. They will treat the dispatch as the originating intent, because that is what is_human() called it.
The instrument is is_human(). The speaker it was supposed to name is a typed turn. The speaker it named was a teammate dispatch. A label that looks like proof, attached to the wrong source.
A team-lead message is supposed to look like an operator. That is how lanes get a clean brief. The cost, once a harvest trusts Turn 1, is that the brief lands in the intent corpus. You cannot eyeball your way out of that. You have to name the tag, or later agents will keep agreeing with the coordinator about what the user wanted.
The empty digests are quiet for a different reason. A header-only digest does not look broken. It looks like output. The 22 omp digests in agent-aa92b258fd7a00a77.jsonl extracted 0 human turns and 0 context turns. A teammate-message that passes is_human() is speaker contamination. A digest with 0 human turns is an empty product. They can land in the same harvest. They are not the same miss.
The filter that would have caught both
These are recommendations. Neither citation reports that they ran.
Tag rejection is the speaker check. A typed user turn stays. A <teammate-message> block, including teammate_id="team-lead", is not human intent. It is orchestrator chatter. Reject it before the corpus is labeled. That is the gate is_human() failed on Turn 1 of 1f17b872-3018-434d-9af7-5099dc78d40d.jsonl. Line 538 found the defect. It did not exclude the block.
Nonzero-turn validation is the product check. A digest that extracts 0 human turns and 0 context turns is not a digest of a person. Refuse to treat it as recovered intent. Line 44 of agent-aa92b258fd7a00a77.jsonl found 22 omp digests in that state.
The two checks do not substitute for each other. Tag rejection would stop Turn 1 from entering the intent corpus as a user ask. Nonzero-turn validation would stop 22 header-only omp digests from looking like recovered intent. Neither check answers the other failure. Line 538 found a speaker-tag miss. Line 44 found an empty product.
Quantifying before deciding whether to kill the run is already the right order: see the tag, measure the spread, then choose. Tag rejection and nonzero-turn validation are that order, made mechanical. Two turns enter is_human(). Only one of them should leave with the human label. Twenty-two omp digests that extract 0 human turns should not leave as recovered intent.
Until both of those are true, a corpus built to capture what the user asked for will keep capturing what the orchestrator told the lanes to do, or it will capture nothing and still look done.
Continue the series
- 56SeriesThe Measuring Instrument Was Buggy TooTwo measurements over unchanged input disagreed. A greedy regex with re.S was swallowing turn headers.
- 58SeriesThe Truthy Type: How One `or` Silently Dropped Half the Corpusrole = r.get("type") or m.get("role") reads "message", which is truthy, so the inner "user" is never seen. One session format parsed. The other silently yielded nothing.
- 55SeriesThe Piped Exit: Why TSC_EXIT=0 Sat Next to Real Type Errorsbunx tsc --noEmit piped through tail; echo TSC_EXIT=$? reports tail's status, not the compiler's. A forge functional audit declared typechecks clean while the instrument was lying.
- 59SeriesLook at the Pixels Before You Trust the NumberThe plate locator returned the full search-band width on nearly every frame. It was not finding the chip. It was saturating the whole band and reporting a clean number for garbage.