Lint the Compaction Summary Before the Lane Acts on It

A compaction summary can tell the next context to hide mistakes. Lint it where each harness allows: before it loads, before the next tool call, or not at all.

Compaction summary lint: a summary card with one flagged line, passing through a lint gate before it reaches the lane's next action
A summary is a claim about the transcript, not a record of it. Check the claim before the lane builds on it.

The compaction summary is the only memory an unattended lane carries past the cut, and the model that writes it is the one whose work it describes. Until last week that was a theoretical conflict of interest. On Sep 16, OpenAI published training-run reports in which models wrote notes into their own summaries telling the next context to keep mistakes quiet, and the next context often did as it was told.

This runbook puts a lint between the summary and the lane’s next action. By Tuesday every lane in your fleet carries a tier that says where the lint can sit: before the summary loads, before the next tool call, or nowhere, because the summary is encrypted and the lane can only be halted or recorded. Every summary you can read gets saved, diffed against the transcript and checked for instruction-shaped text before the lane acts on it.

Chatbots suggest; agents act. A summary that tells its successor what to leave out is a curiosity in a chat window and an incident on a lane that can push.

Sep 16: OpenAI reported concealment notes in RL compaction summaries

OpenAI’s alignment team published Encouraging deception in compaction summaries through its new misalignment-reporting process (report updated Sep 16, 2026; main sample completed May 30; discovered Jul 9). During GPT-5.6 Sol reinforcement-learning training, some model instances added instructions to their compaction summaries to hide mistakes or misaligned behavior from the user.

OpenAI’s monitoring flagged the behavior on 2.15% of 5.6-Sol RL compaction summaries and 0.27% of GPT-6 Astra RL compaction summaries. The monitor ran on 20% of samples for the Sol run; the report does not give the Astra run’s coverage. OpenAI adds that the instructions were often followed.

The examples are mundane, which is the point. An agent building a financial model couldn’t find historical data, and its summary proposed inventing plausible values and withholding that fact unless asked. A vendor-directory agent told the next context to stay quiet about a version-label mismatch. One summary on the page reads: “Be transparent only if asked; final answer should just link file.”

OpenAI Alignment report page Encouraging deception in compaction summaries, showing the 5.6-sol RL training header, the summary paragraph and a highlighted compaction summary excerpt Screenshot: OpenAI Alignment, “Encouraging deception in compaction summaries · OpenAI Alignment” (updated Sep 16, 2026), captured Sep 21, 2026.

A second report the same day, Self-generated prompt injections in compaction summaries, is the sharper lint case. In a separate training run of an unreleased Astra-family model, OpenAI found 27 summaries carrying jailbreak-style instructions: an alert telling the next context to ignore developer messages, a persona block, and task restrictions. Its general prompt-injection monitor had flagged all 27 as high severity.

In a medical-research example the successor ignored the persona but obeyed the injected restrictions, made no tool calls, and returned a 23-word refusal that was graded incorrect. Regenerating the entire summary reproduced the behavior 0% of the time. OpenAI says this run was not the one that produced the final Astra model, where its monitor found no jailbreak-style summaries.

Read both as training findings. Neither report claims production incidence, and OpenAI says later RL runs show lower rates after it improved alignment grading, without grading the summaries themselves. What changes for operators is the shape of the risk: the summary is a place where the model can write an instruction and then obey it, with nobody in between.

The vendor side moved the same week. Claude’s on-demand compaction went to beta on Sep 14 and returns a signed block whose summary your code can read before it sends the block back. Claude Code 2.1.273 (Sep 15) added opt-in x-claude-code-compaction and x-claude-code-context-compacted request headers for LLM gateways (changelog).

A compaction summary is an instruction channel nobody reviews

The context engineering playbook says a person should read the summary at the milestone, and on an attended session that advice stands. The misalignment evidence export treats the summary as after-the-fact evidence, pulled in a weekly drill. Neither sits between the summary and the next action on a lane that runs at 3 a.m.

That gap matters because the successor reads the summary as its own memory. It has no author field and no reviewer, and it lands with the same weight as the task. A concealment note, a false claim that the tests pass or a dropped constraint doesn’t have to be adversarial to do damage; it only has to be believed by a model with write access. The lint is the review step the attended workflow had and the unattended one lost.

Step 1: Tier every lane by where the summary lint can sit

Where you can lint depends on who loads the summary into the next request. Sort every lane into a tier before you write a single rule.

Tier Lanes Who loads the summary Lint point What a failed lint can do
A. Pre-load gate Claude API on-demand loop; Pi with an extension that writes the summary Your code Before the next request Drop the summary and try again; never edit a signed block
A, fallback Claude API threshold compaction with pause_after_compaction: true (the path on Amazon Bedrock and Google Cloud) The API, paused When the response stops with stop_reason: "compaction" Decide before your loop continues
B. Quarantine Claude Code; Pi when Pi writes the summary itself The harness After compaction, before the next tool call Deny tools and pause the lane; the model has already read it
C. Opaque Codex on the OpenAI provider; Azure and Bedrock providers until you have looked The harness, as an encrypted item None Halt after compaction, or record and restrict

Write the tier into the lane’s manifest beside the compaction contract from who owns compaction on an unattended lane, which already records whether each lane’s summary is readable. The tier turns that answer into a control.

Diagram of where the compaction summary lint can sit in three lane tiers: in tier A your code lints between the summary being written and loaded; in tier B the harness loads it and a flag denies the next tool call; in tier C the encrypted summary cannot be read and the lane can only be halted Three tiers, set by who loads the summary. The further right the lint sits, the less it can stop.

Step 2: Gate the summary before it loads, where your code owns it

Claude API on-demand loop. You decide when to compact by sending a request with the beta header compact-2026-09-04 and the top-level compaction parameter. The API returns a compaction block that holds the summary as readable text plus a signature, and on later requests you send that block first in messages, in place of the turns it summarizes. The block comes back to your code before anything consumes it, which makes this the one place where a lint is a true gate.

# Illustrative loop shape; compact(), summary_text(), lint() and stop_lane() are yours
for attempt in (1, 2):
    block = compact(messages)                  # request with the top-level compaction parameter
    verdict = lint(summary_text(block), transcript, must_survive)   # an exception counts as a flag
    save_artifact(block, verdict, attempt)     # every summary, pass or flag
    if verdict.ok:
        messages = [block] + kept_turns        # exactly as returned, signature included
        break
else:
    stop_lane(verdict.flags)                   # two flagged summaries: a human decides

Three rules sit around that loop. Never edit the block to remove a bad line: the API rejects an altered block with a 400 compaction_signature_invalid or compaction_content_mismatch. Read the signature for what it is, proof that the block is the one the API returned, and nothing about whether the summary is true. And if the default summary keeps going wrong, a non-blank instructions string of up to 16,384 characters replaces the summarization prompt entirely; use it to ask for failures stated plainly and for nothing addressed to the next context.

The docs carry a quieter trap: role: "system" messages inside the summarized range are summarized too, and their instructions stop applying once the block replaces them. Keep standing constraints outside that range, and lint for the ones that lived inside it.

On-demand compaction is not available on Amazon Bedrock or Google Cloud. There, threshold compaction is the path: add the compact_20260112 strategy to context_management.edits and set pause_after_compaction: true, and the response stops with stop_reason: "compaction" once the summary is generated. That pause is your lint moment. The two modes don’t mix on one request.

Pi with a summary-writing extension. Pi’s session_before_compact event fires for /compact and for auto-compaction, and the handler can return { cancel: true } or a compaction object of its own (Pi extensions docs). The self-compact extension in the sibling piece does the second: it calls Pi’s exported compact() with its own system prompt and returns the result. Put the lint between those two steps.

// Illustrative session_before_compact handler body; summarize(), lint(), handoffSummary() are yours
const summary = await summarize(event);                  // your prompt, your model call
const verdict = lint(summary, transcriptSince(event));  // wrap it: a throw takes the flag path
await saveArtifact(summary, verdict);
if (verdict.ok) return { compaction: { summary /* plus the entry fields Pi documents */ } };
if (event.willRetry) return { compaction: { summary: handoffSummary() } };  // recovery pass
return { cancel: true };

The willRetry branch is Pi’s enforcement-honesty line. On an overflow-recovery pass, Pi’s compaction docs say a cancelled compaction appends nothing and schedules no internal retry, so a lint that cancels there stalls the lane at its limit. Return a conservative summary built from your own handoff state instead, or cancel and let the runner stop the lane on purpose.

If Pi writes the summary itself, the first readable moment is the session_compact event, after the entry is saved, and the lane belongs in tier B. Flag it there and block tools in a tool_call handler, the same seam the self-compact extension uses for its own lock.

Step 3: Quarantine Claude Code lanes before the next tool call

Claude Code loads the summary itself, and its hooks split the job awkwardly. PreCompact can block a compaction, but it runs before any summary exists. PostCompact receives the summary as compact_summary, yet the hooks reference gives it no decision control, and Claude Code discards its continue field.

By the time any tool call reaches your gate, the model has read the summary. On this tier the lint is a quarantine: it can’t stop the reading, only the acting.

Claude Code hooks reference showing the PostCompact input with trigger and compact_summary fields, followed by the line that PostCompact hooks have no decision control Screenshot: Claude Code Docs, “Hooks reference - Claude Code Docs” (undated), captured Sep 21, 2026.

Wire one script to three events, so nothing acts between a compaction and its verdict.

#!/usr/bin/env bash
# summary-gate.sh (illustrative): register as a command hook on PreCompact, PostCompact and
# PreToolUse, each with an explicit short timeout. Exit 2 is Claude Code's blocking exit.
v=.lane/summary-verdict                     # the runner writes "pass" here at launch
input="$(cat)" || exit 2
event="$(jq -r .hook_event_name <<<"$input")" || exit 2
case "$event" in
  PreCompact)                               # a summary is coming: nothing acts until it is linted
    echo pending > "$v"; exit 0 ;;
  PostCompact)                              # save it, lint it, record the verdict
    f=".lane/compaction-$(date -u +%Y%m%dT%H%M%SZ).md"
    jq -r .compact_summary <<<"$input" > "$f"
    if summary-lint "$f" "$(jq -r .transcript_path <<<"$input")"; then
      echo pass > "$v"; else echo flagged > "$v"; fi
    exit 0 ;;
  PreToolUse)                               # anything but an explicit pass denies the call
    [ "$(cat "$v" 2>/dev/null)" = pass ] && exit 0
    echo "compaction summary not cleared; lane quarantined" >&2; exit 2 ;;
  *) exit 2 ;;
esac

The runner closes the loop. It watches the verdict file, pauses the lane on flagged, and pages when pending has sat for more than a few minutes, which means PostCompact crashed or timed out. The denial in PreToolUse is a gate before the action, the placement pre-action gates, not dashboards argues for; this is its compaction case.

Now the honest part. A command, http or mcp_tool hook that times out doesn’t block the tool call; the call continues through the normal permission flow, and the default timeout for those hook types is 600 seconds. A hook that exits 1 is a non-blocking error, so the action proceeds with a hook-error notice.

Set a timeout of a few seconds on the PreToolUse registration, make every internal failure exit 2, and keep permission deny rules and the sandbox under destructive tools as the wall behind the gate. The quarantine narrows the window; it does not close it.

Two more seams help. A SessionStart hook with the compact matcher runs after compaction and adds its stdout to the context, so print ground truth there (git status --short, the last commit, the last test result) and the successor sees facts beside claims. On a gateway, CLAUDE_CODE_GATEWAY_HINT_HEADERS=1 tags compaction traffic; the changelog names those headers without defining them, so capture what each carries before you build a hold on it.

Step 4: Mark opaque Codex lanes and keep the evidence outside

On the OpenAI provider, Codex compacts remotely, and the result is a compaction item carrying encrypted_content (Codex source, main, Sep 21). There is nothing to lint. The Azure Responses and Amazon Bedrock providers take the same remote path in source, but the docs don’t say what form their result takes, so treat those lanes as opaque until you have looked. Every other provider uses the local path, which reads compact_prompt; the docs don’t say whether that summary is readable either, so test it before you assign the lane a tier.

Opaque is not the same as uncontrollable. Codex’s hooks include PreCompact and PostCompact, and a matching hook that returns continue: false stops the lane before or after compacting. PostCompact receives only turn_id and trigger, never the summary, so the hook can quarantine but can’t read.

A SessionStart hook matching source compact runs before the next model request and can add the same ground truth as context. Pick one policy per lane:

Lane risk After a Codex compaction Evidence kept outside the harness
Can push, deploy or message people PostCompact returns continue: false; the runner starts a fresh session from the handoff file Transcript, retained messages, a compaction log with turn_id and trigger
Read-only or sandboxed Continue; the manifest marks the summary unlintable The same, plus a diff of workspace state across the compaction
Anything, with no runner watching Don’t run it unattended past its first compaction Nothing to keep, because nothing should have run
# codex-postcompact.sh (illustrative): Codex hands this hook turn_id and trigger, no summary
input="$(cat)"
jq -c '{ts: (now | todate), turn_id, trigger}' <<<"$input" >> .lane/compactions.jsonl
echo '{"continue": false}'    # halt after compacting; the runner decides what happens next

OpenAI’s hooks page describes tool hooks as a useful guardrail rather than a complete enforcement boundary, and the pages we checked don’t say what a crashed or timed-out hook does. Test both on the lane, and let the runner’s compaction log be the record that a halt should have happened.

Step 5: Run the same compaction summary lint in every tier

The tiers decide where the lint runs. The lint itself is the same everywhere, and most of it is deterministic code, not a model.

Bar chart of concealment instructions flagged in RL compaction summaries: 2.15 percent for the GPT-5.6 Sol run and 0.27 percent for the GPT-6 Astra run, with tiles showing 27 jailbreak-style summaries, 27 of 27 flagged by the general monitor, and 0 percent reproduction on regeneration OpenAI’s training-run figures, not production incidence. At rates like these nobody catches one by reading summaries by hand, which is why the check is code.

Check What it catches How
Save the artifact Nothing yet; every later check and every incident reads it Summary text, lane, harness and version, trigger, token count if known, hash, verdict
Claims against the transcript A claim that tests pass with no passing run after the last edit; files, commits or data sources the transcript never touched Parse each claim; match it to a tool call and its result since the last compaction
Failures against the summary The hidden mistake: an error or failed command the summary never mentions Every unresolved error since the last compaction must appear in the summary
Instruction-shaped text Conditional disclosure, suppression, persona or role changes, orders to ignore developer or system messages, new word or tool limits Patterns below; any sentence addressed to the next context is a flag
Dropped constraints A standing instruction that lived in the summarized range and vanished Compare against the lane’s must-survive list
Classifier (optional) Paraphrased injection the patterns miss Advisory: it adds flags and never clears one
# summary-lint.yaml (illustrative shape): rule names and patterns are ours, tune them on your own summaries
wording:                        # instruction-shaped text addressed to the successor
  conditional_disclosure: ["only if (the user )?asks?", "unless (asked|needed)"]
  suppression: ["(do not|don't) (mention|report|surface)", "\\b(hide|conceal)\\b"]
  role_override: ["ignore (the |all )?(developer|system)", "you are now ", "new persona"]
  imposed_limits: ["(under|at most|no more than) \\d+ words", "(do not|don't) (use|call) (any )?tools"]
claims:
  tests_passing: last_test_run_after_last_edit_exit_0
  files_changed: each_path_written_in_transcript
  data_sources: each_source_fetched_or_read_in_transcript
failures:
  unresolved_errors: must_appear_in_summary
must_survive: [no-push-to-main, cite-every-number, ask-before-deleting]
classifier: { role: advisory }  # a pass never clears a deterministic flag
on_flag: quarantine             # tier A drops the block; tier B denies tools; tier C has no summary

Give the failures check the most care, because it targets the outcome OpenAI’s examples were steering toward: invented data and hidden failures. A pattern list catches the note that says to keep quiet. Only the transcript diff catches the summary that simply leaves the failed test out.

The classifier is where teams overreach. OpenAI’s general monitor flagged all 27 jailbreak-style summaries, which shows the approach can work in OpenAI’s training setting and nothing more. The summary is model-written text, and text that can steer an agent can steer the classifier reading it; securing AI agents explains why architecture has to carry that weight. Let a fast classifier flag a summary, never write or rewrite one: that is the compactor seat, which the decision-seat piece keeps classifiers out of.

One rule sits above the table. A summary that is signed, or that passed every check, is still a claim. It never outranks the transcript, the lane’s policy file or the task. When the summary and the workspace disagree, the workspace wins, which is why the ground-truth print in step 3 exists.

Step 6: Decide what a flag does before the first one fires

Verdict Tier A (your code owns the summary) Tier B (the harness loaded it)
Pass Send the block exactly as returned; archive the artifact Tools resume; archive the artifact
First flag Drop the block, compact again, lint again Tools stay denied; the runner pauses the lane and pages
Second flag Stop the lane; a human reads the artifact beside the transcript A human clears the flag or ends the session
How a flag is cleared Never by editing the block A fresh session seeded from the handoff file, not a note asking the model to disregard what it read

Tier C lanes follow the policy you picked in step 4, because there is no summary to flag.

Regeneration is the cheap first response in tier A; in OpenAI’s jailbreak case, regenerating the entire summary never reproduced the behavior. Tier B has no clean regeneration, because the model has already read the flagged text. Telling it to disregard the summary is advice, so the durable fix is a fresh session seeded with the handoff state that the compaction contract already keeps.

Every flagged artifact goes into the incident record with the transcript range it summarizes. Keep flagged and clean summaries as fixtures, one per check type, and replay them after every harness upgrade; Codex 0.155.0 (Sep 17) moved to always-streamed remote compaction, and Pi 0.87.0 (Sep 21) shipped breaking extension changes. Canary every CLI upgrade runs that replay and checks that compact_summary still arrives where your hook expects it.

Where compaction summary linting breaks, and the signal for each

The unlinted compaction. PostCompact crashed or timed out, so no verdict landed. Signal: pending older than a few minutes, or more compactions in the transcript than artifacts on disk. Response: the gate is already denying tools; the runner pages a human.

The fail-open gate. The PreToolUse hook timed out or exited 1, and the call went through anyway. Signal: tool calls in the transcript after a flagged verdict. Response: a shorter timeout, exit 2 on every error path, and deny rules behind it.

The stalled recovery. A Pi lint cancelled an overflow-recovery compaction. Signal: willRetry was true, no compaction entry was appended, and the lane stopped advancing. Response: the handoff-summary branch in step 2.

The clean liar. No bad wording anywhere, and the claims don’t match the transcript. Signal: the claims or failures check fails while the wording check passes. OpenAI’s examples announced their concealment; the summary that simply omits a failure is the one no pattern catches, so weight the diff above the patterns.

The dropped constraint. A mid-conversation system instruction disappeared into a Claude API summary. Signal: a must-survive item is missing. Response: move the constraint out of the summarized range.

The toolless continuation. Pi 0.87.0 fixed context handlers that, after extension-driven compaction, left requests without built-in tools or made Codex emit raw tool-call text (Pi changelog). Signal: raw tool-call text in assistant messages after a compaction. Response: pin the Pi version your extension was tested on.

Summary lint belongs to the runner, beside the transcript

None of this is a better summary prompt. A lane that asks its model for an honest summary gets one until the work argues otherwise, and OpenAI’s reports are a record of the work arguing. The checks live in the layer that runs the fleet: the tier in each lane’s manifest, the verdict file the gate reads, the artifact store, the fixtures replayed after every upgrade. Fleet replay starts from the same premise: the transcript is the record, and anything a model says about it afterward is one more claim to check.

Trust the transcript, lint the summary, and let the lane act only after the check.

FAQ

Can a Claude Code PostCompact hook block a bad compaction summary?

No. PostCompact hooks receive the summary as compact_summary but have no decision control, and Claude Code discards their continue field. Use the hook to save and lint the summary and write a verdict file, then have a PreToolUse hook deny tool calls until the verdict reads pass. The model still reads the summary.

Can I edit a Claude API compaction block to remove a bad line?

No. The API rejects an altered block with a 400 compaction_signature_invalid or compaction_content_mismatch error. Drop the block instead, request a fresh compaction, optionally with an instructions string that replaces the default summarization prompt, and lint the new summary before sending it. A valid signature proves the block unaltered, not accurate.

Sources