{"slug": "one-compaction-four-actions-one-block-compaction-safety-is-a-property-of-the", "title": "One compaction, four actions, one block: compaction safety is a property of the pair", "summary": "A developer introduced compaction_omission_gate.py, a tool that checks whether a context compactor dropped records relevant to a proposed action, without comparing against a full-context oracle. The approach was motivated by Anannya Roy Chowdhury's report of an 82% cost cut via context compression and Prasad T's finding that full context and compacted context yield identical accuracy (0.64), showing that volume is not the key variable. The tool resolves declared predicates against stored records to detect omissions, addressing the silent failure of bad compression.", "body_md": "**Context compaction** is safe or unsafe only against a specific proposed action. Not in general. The compactor decides what your agent forgets before it knows what your agent will do, so at compaction time \"did this lose anything important?\" has no answer yet. The lie, if there is one, comes into existence later, when the agent proposes to act.\n\nThat sounds like philosophy. It has an exit code.\n\nAI disclosure.I wrote`compaction_omission_gate.py`\n\nwith an AI assistant and ran it myself: Python 3.13.5, offline, standard library only, no network, no keys, no funds. Every verdict, exit code and hash below is pasted from a real local run. I ran the whole demo twice and the two`output.txt`\n\nfiles are byte-for-byte identical (`sha256 2f0ae2c6d54b1d35caefb3da12af3a4189aff05e5d8c59321b63150f27eef376`\n\n). The fixtures are synthetic and mine. The numbers I quote from Anannya Roy Chowdhury, Prasad T and the Ratel team aretheirclaims from their own posts, attributed inline; I did not reproduce their systems.\n\n**In short:**\n\n`kept-sha256=3bfe38c85fcf`\n\non every row), same 36 dropped, and the same rule missing from all four: `amount 5000`\n\npasses, `amount 5001`\n\nblocks. Same words, one unit apart.Anannya Roy Chowdhury published [ My Multi-Agent AI Cost $1,847 in One Weekend](https://dev.to/royanannya/my-multi-agent-ai-cost-1847-in-one-weekend-heres-the-fix-that-cut-it-82-3mi4) on July 16. Her numbers: $1,847 burned, 82% cut, context compressed \"from 12,000 tokens to 340.\" That is 97% of the context gone. I asked her in the comments whether she'd found a way to catch a bad compression\n\nHer reply named the hole better than my question did:\n\n\"A bad compressor can absolutely 'lie' by omission, which is arguably more dangerous than naive replay because\n\nthe failure is silent.\"\n\nShe called it the core challenge of her Part 2, which was not out when I wrote this. So this post is not a rebuttal of anything. It is an attempt to answer the question she agreed was open.\n\nTwo other people hit the same wall in the same week. Prasad T's [ Teaching a Qwen agent to forget](https://dev.to/prasadt1/teaching-a-qwen-agent-to-forget-5bgb) put a\n\n`superseded_by`\n\nlink on memory records so a contradicted fact is dropped from recall but kept in an audit trail. The line I keep coming back to, from the thread under his post: forgetting you can't inspect is just data loss. And the My first design was the obvious one: run the decision on the full context, run it on the compacted context, and if the decision changes, BLOCK. Compare against the truth.\n\nPrasad's post killed it. His FAMA measurement (his numbers, not mine) reports **recency-only = 0.64** and **never-forgets = 0.64**. Identical. Never-forgets *is* the full context. So the \"truth\" I wanted to compare against is itself wrong 36% of the time. An oracle that lies in a third of cases is not an oracle, and any reader who has read his post gets to close my tab in one line.\n\nThose same two numbers kill a second thing, which is the framing I would have reached for by reflex: *compaction hurts because you lose volume.* If throwing almost everything away and throwing nothing away both land on 0.64, volume is not the variable. Prasad says it plainly: *\"the win isn't about trimming context; it's about knowing what's stale.\"*\n\nSo the gate has no oracle. It never asks what the model would have decided. It resolves **your** declared predicates against **your** stored records. That is a structural fact, not a prediction.\n\nOne closed question: *among the records the compactor dropped, is there one that binds this action?*\n\nInput is the full pre-compaction context, the ids the compactor **kept**, and a concrete action. The dropped set is computed, never supplied. I don't trust anyone's \"here's what I removed\" list. Records are typed: `policy`\n\n(scope, predicate, effect), `fact`\n\n(field, value, optional `supersedes`\n\n), `chatter`\n\n. Ordering is a declared `seq`\n\n, never a wall clock, so the verdict can't drift with the time of day.\n\nThe whole resolution step is this small:\n\n``` python\ndef resolve(field, params, facts):\n    \"\"\"action.params wins; otherwise the newest stored fact for that field.\"\"\"\n    if field in params:\n        return True, params[field]\n    if field in facts:\n        return True, facts[field][\"value\"]\n    return False, None\n```\n\nIf a field resolves nowhere, the gate fails closed rather than assuming harmless. There is no scoring anywhere in the verdict path. PASS, BLOCK, ERROR. No percentages. That's deliberate: on July 16 a Show HN called [ReasonGate](https://news.ycombinator.com/item?id=48941051) shipped a \"gate\" built on regexes, and Simon Willison took it apart in the thread within the hour. Recall of *\"76-96%... never 100%\"* is not a gate, and *\"this list of regular expressions does not inspire confidence.\"* A gate that guesses is a filter with good PR.\n\nHere is the claim as an experiment, and it needs nothing to compare against. One compaction. Four actions.\n\nThe fixture is a 52-record support thread. The refund request opens it, the resolution closes it, and the middle carries the boring stuff: internal notes, a tier update, and one dry line at `seq 11`\n\n: `Refunds over 5,000 need a human approver.`\n\nThe compaction is head+tail, keep the first 8 and the last 8, which is the shape most windowing schemes converge on. That rule sits at position 11 of 52. It is dropped. In all four rows.\n\n| Proposed action | Verdict | Exit |\n|---|---|---|\n`refunds.create {amount: 8400}` |\nBLOCK `dropped-binding-constraint`\n|\n1 |\n`refunds.create {amount: 120}` |\nPASS | 0 |\n`refunds.create {amount: 5000}` |\nPASS | 0 |\n`notes.append {ticket: 7741}` |\nPASS | 0 |\n\n\"Same compaction\" is a claim, and claims about counts are cheap, so the tool prints a fingerprint of the kept **set**:\n\n```\n    compaction: 52 records, 16 kept, 36 dropped (retention=given, kept-sha256=3bfe38c85fcf)\n```\n\nThat digest is identical on all four rows. Same 52 records, same 16 kept, record for record, same 36 dropped. One blocks. Nothing about the compaction moved, and the answer to \"is this compaction safe?\" moved anyway. That is the whole thesis, and there is no baseline in it, because the claim isn't that some other tool does worse. The claim is that the question is malformed until you name the action.\n\nThe `120`\n\nrow prints a line I like:\n\n```\nnote      : permissive: dropped policy 'f_020' (allow) resolves TRUE here (amount = 120 lt 500)\n            but losing a permission cannot license an action; not a block reason\n```\n\nA dropped `allow`\n\nrule is not danger. It costs you an auto-approval, nothing more. The gate says so instead of blocking on principle.\n\n**\"A good compactor\" is not a thing you can have.** There is only \"this compaction is safe for that action.\"\n\nThe `5000`\n\nrow is the one worth poking at. `5000 gt 5000`\n\nis FALSE, so the rule wouldn't have fired, so its absence changes nothing. But how would you know the gate computed that rather than pattern-matched its way there? Walk the boundary:\n\n`amount` |\nVerdict |\n|---|---|\n| 4999 | PASS |\n| 5000 | PASS |\n| 5001 | BLOCK `dropped-binding-constraint`\n|\n| 8400 | BLOCK `dropped-binding-constraint`\n|\n\n`5000`\n\nand `5001`\n\nare the same word to any tokenizer worth the name. The verdicts differ because the gate resolved `amount`\n\nout of the action's params and did arithmetic against a stored predicate. Lexical similarity has no way to know that `8400 > 5000`\n\n, because that is not a fact about words.\n\nTwo days ago I asked publicly whether you can catch a bad compression *before the turn runs*. The honest answer, which I did not want, is **mostly no**, and I can measure the cost of pretending otherwise.\n\n`--precheck`\n\ntakes the action away from the verdict and asks the general question instead: could any dropped record bind *any* reachable call of these tools?\n\n``` bash\n$ python3 compaction_omission_gate.py --precheck --retention given fixtures/fx1_refund_thread.json\n    action    : (the verdict below never consults it -- that is the experiment)\n    compaction: 52 records, 16 kept, 36 dropped (retention=given, kept-sha256=3bfe38c85fcf)\n    tools     : refunds.create\n    verdict   : BLOCK (action-blind) exit 1\n    reason    : may-bind-some-action: dropped policy 'f_011' scopes to refunds.create; some\n                reachable call of that tool satisfies (amount gt 5000). Which one? Unknown\n                here: no action was supplied\n```\n\nRun it against each of the four actions and you get the same line four times, because it never reads them. The gate blocks **1 of 4**. Precheck blocks **4 of 4**. Three of those four blocks are false, and they are not fixable by being cleverer: nothing recovers `120 > 5000 = FALSE`\n\nwithout the 120.\n\nSo the control point sits one step later than I hoped, and one step earlier than it costs money. The turn is already spent: the model has read the mutilated context and produced a proposal. Nothing has *executed*. That's the line my [pre-execution gate](https://finops.spinov.online/blog/pre-execution-gate-for-ai-agents/) has always drawn: before the action, not before the thinking. Reading the dropped records to check them costs zero tokens, since compaction governs what gets sent to the model, not what's on your disk. (Money is a different post: [the re-billing curve is here](https://finops.spinov.online/blog/context-tax-measure-transcript-rebill/).)\n\nHere's the bug my reviewer found in my own tool, and it is the mirror of the one I was proud of.\n\nI had a rule: *losing a permission cannot license an action.* True, and the gate leans on it. A dropped `allow`\n\nnever blocks. But I filtered the **kept**-rule check by the same logic, and that filter was wrong. A kept `allow`\n\nrule is a permission you still have. If the compactor drops the *value that permission reads*, the permission fires on a value you already took back:\n\n``` bash\n$ python3 compaction_omission_gate.py --retention given fixtures/fx6_stale_permit.json\n    verdict   : BLOCK (dropped-superseder-stale-permit) exit 1\n    reason    : dropped-superseder-stale-permit: kept policy 'f_003' (allow) reads\n                'customer.tier'. The compactor kept 'f_002' (customer.tier = gold, seq 2) and\n                dropped 'f_011' (customer.tier = standard, seq 11) and declares supersedes\n                'f_002'. The rule's own predicate (customer.tier eq gold) is TRUE on the kept\n                value and FALSE on the newest one: the agent will license this call with a\n                value you already retracted. Losing a permission cannot license an action, but\n                KEEPING one while its input is retracted can\n```\n\nBefore the fix, that fixture returned **PASS exit 0**. Silent. A 4,000 refund auto-approved on a `gold`\n\ntier that was revoked twenty records ago, and my gate, whose entire pitch is \"the failure is silent, so catch it,\" said nothing. I had written the general principle and then applied it to the wrong noun.\n\nFixing it forced a distinction I had been sloppy about. A stale value has two directions and they are **not** the same event:\n\nThe second one is a real fixture too (`fx7`\n\n), and the gate declines to block it:\n\n```\n    note      : stale value, safe direction: kept policy 'f_003' (deny) reads 'customer.tier'.\n                [...] The rule's own predicate (customer.tier eq restricted) is TRUE on the kept\n                value and FALSE on the newest one. This compaction makes the agent more cautious\n                than your records warrant: a cost, not a block reason\n```\n\nA gate that blocks whenever *anything* changed is just an alarm wired to the whole house. The direction is the point.\n\nSame fixtures, same `k`\n\n, two action-independent schemes:\n\n| fixture | head_tail | recency |\n|---|---|---|\n| refund thread (rule in the middle) | BLOCK binding | BLOCK binding |\n| superseded fact | BLOCK stale-kept | BLOCK binding |\n| genuinely safe | PASS | PASS |\nretracted tier, kept `allow` rule |\nBLOCK stale-permit | PASS |\nretracted tier, kept `deny` rule |\nPASS | PASS |\n\nI predicted recency would survive the supersession fixture. It doesn't. I was wrong, and the way it's wrong is more interesting than my guess: head+tail pins the **stale** `customer.tier = standard`\n\nat `seq 2`\n\nand drops the correction at `seq 20`\n\n, so the agent applies a kept rule to a retracted value. Recency keeps the correction and drops **the rule itself**. Two popular schemes, opposite failures, same input. \"Switch to recency\" doesn't fix the failure, it relocates it.\n\nI won't tell you no correct scheme exists; five synthetic fixtures can't carry that. The structural argument is stronger than the table anyway. Safety depends on the action, which the freeze-and-vary run above shows without appealing to any scheme at all. Neither of these schemes can see the action. So neither can be safe in general, and the row where recency wins is luck about where the record sat, not virtue.\n\nProbably, sometimes. And this is where I have to report a section that isn't here.\n\nAn earlier draft of this post had a better headline and a fifth act: my gate against a relevance-based compactor, IDF-weighted cosine, keep-top-k, the state of the art, confidently certifying a compaction that dropped a binding rule. The reviewer who gates my drafts took the code apart instead of reading the prose, and found that I had serialized the action as **structure** (`refunds.create amount 8400 currency USD customer c_889`\n\n) while indexing every record as **prose** (`Refunds over 5,000 need a human approver.`\n\n). Same JSON on both sides. My gate read six fields off that record. My baseline read one.\n\nWhatever that comparison showed, it was not a fact about relevance ranking. It was a fact about what I handed each side. So the ranker is gone, all ninety-odd lines of tokenizer and cosine that existed to lose a fight I had arranged. The thesis never needed it, which I'd have noticed sooner if the number hadn't been flattering.\n\nThe honest version of the question is harder. A ranker that sees the action doesn't produce *one* compaction you can freeze and audit; it produces a different compaction per action. That may well be the right engineering. It also means there is no fixed artifact to check, and \"is this compaction safe\" stops being a question you can ask once and cache. I don't have a clean answer for that shape. If you do, I'd like to hear it.\n\nOne fixture is rolling summarization output: the middle of the thread replaced by eight prose blobs typed as `summary`\n\n. The gate doesn't recognize that kind, can prove nothing about it, and blocks with `dropped-unevaluable-record`\n\n. Be precise about what fired, though: the *unrecognized kind*, not the prose itself. Retype those same eight blobs as `chatter`\n\n— a known kind the gate reads as your own declaration that a record is throwaway — and it believes you, drops them silently, and returns PASS. I checked; the verdict flips. So the honest version is narrower than *prose fails closed*: the gate fails closed on a kind it can't read, and trusts the one you've labelled inconsequential. Same knife as the schema limit further down — it holds you to your typing. There's an `--allow-unevaluable`\n\nflag that turns the unrecognized-kind block into PASS exit 0 with `[--allow-unevaluable: you chose to fly blind]`\n\nin the report. Not a fix. A signed decision.\n\nThis is the uncomfortable one, because rolling summarization is exactly what my own [context-tax post](https://finops.spinov.online/blog/context-tax-measure-transcript-rebill/) prescribes as the cure. I'm auditing my own prescription. If your memory is a pile of summarized text, this gate has nothing to work with, and neither does anything else. That's not a gap in the gate. It's a gap in the store. Prasad's `superseded_by`\n\nlink is the cheapest version of the fix.\n\n[ mandate-freshness-gate](https://finops.spinov.online/blog/mandate-freshness-gate/) expires\n\n`checkpoint-skip-gate`\n\n`agent-memory-tax-and-backdoor`\n\n`sliding-window-spend-guard`\n\nDrop the tool and the fixtures in a directory and run `bash run_demo.sh`\n\n. Three stdlib imports (`json`\n\n, `hashlib`\n\n, `sys`\n\n), no network, no keys, no model. The full sweep ends `2 PASS, 4 BLOCK, 1 ERROR -> overall exit 1`\n\n, malformed input exits 2 and never 0, and every report ends with a sha256 of its own body. Run it twice and diff. If it isn't byte-identical, I'd want to know.\n\nEvery command in the demo passes `--retention given`\n\nexplicitly even though it's the default, because an earlier version of that script quietly recomputed the compaction per action while the prose above it claimed the compaction was frozen. Two of the numbers I'd written were false and I hadn't run the command my own paragraph described. Spelling the flag out makes the sentence and the command the same object.\n\nThe schema is the price of admission. `policy`\n\nneeds a scope, a predicate and an effect; `fact`\n\nneeds a field, a value and ideally a `supersedes`\n\n. If your ingestion layer labels a binding rule as chatter, the gate believes it, and nothing saves you. It holds you to your own typing. That's the honest limit.\n\nSo: if compaction safety is a property of the pair rather than of the compactor, where does this check belong in your harness: inside the compactor, in the store, or at the execution gate? I lean toward the execution gate, because it's the only place that knows the action, but that's the place least likely to have the dropped records in hand. And what do you do when your context is prose with no schema? Pay to type it, or fly blind and admit it?\n\n*Follow for the numbers from the next gate I build and break. If you've watched an agent lose an instruction to auto-compact and only find out from the outcome, tell me what the dropped record was. I read every comment.*", "url": "https://wpnews.pro/news/one-compaction-four-actions-one-block-compaction-safety-is-a-property-of-the", "canonical_source": "https://dev.to/alex_spinov/one-compaction-four-actions-one-block-compaction-safety-is-a-property-of-the-pair-5a77", "published_at": "2026-07-18 01:06:26+00:00", "updated_at": "2026-07-18 01:58:15.107776+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-safety", "developer-tools", "machine-learning", "ai-research"], "entities": ["Anannya Roy Chowdhury", "Prasad T", "Ratel team", "compaction_omission_gate.py", "Qwen"], "alternates": {"html": "https://wpnews.pro/news/one-compaction-four-actions-one-block-compaction-safety-is-a-property-of-the", "markdown": "https://wpnews.pro/news/one-compaction-four-actions-one-block-compaction-safety-is-a-property-of-the.md", "text": "https://wpnews.pro/news/one-compaction-four-actions-one-block-compaction-safety-is-a-property-of-the.txt", "jsonld": "https://wpnews.pro/news/one-compaction-four-actions-one-block-compaction-safety-is-a-property-of-the.jsonld"}}