Why · the course corrections

The plan was wrong. Here is how we know.

The design documents were written before the code, and most of what they said held. This page is the part that did not: a belief this project held confidently, the evidence that refuted it, and what replaced it — seventeen times, in that order.

Every one of these was a plan that turned out wrong

An install architecture the Agent Skills specification made impossible. A security control that controlled nothing — a worker denied the write tool, writing a file through the shell anyway. A correctness guarantee that no wording could deliver, because the host reading the words has its own orders and they win. None of these were discovered by thinking harder about the design; each one arrived from outside it, from a specification, a paid spike, or a run. They are written down rather than tidied away for a simple reason: the correction is more useful than the original plan was.

How each entry is built

The belief first, then the evidence that refuted it, then what replaced it. Where a correction was itself later overturned — and one of the most useful entries here is exactly that — the original conclusion stays as written and the new evidence is appended underneath it. Quietly rewriting the earlier conclusion would delete the only part worth keeping: that it was believed, on stated grounds, by people looking straight at it.

A bug on its own is not a reversal and is not listed. Everything below is something the project intended, argued for, and then had to take back.

At a glance

Each belief links to the entry that refutes it.

What we believedWhat was true
The installer can apply the name prefix The specification forbids it, and a symlink has nothing to rewrite
A frontmatter field was non-standard It is in the standard — and the guard built on the mistake enforced it on everyone
Governance comes first You cannot govern intent you have not captured
Leaving a check blind to ADRs was a gap It was the right call, and it expired on schedule
A safety flag previews the plan --dry-run deleted every symlink, with a green suite behind it
Feature logic is where defects live Every serious defect was in a guard, aimed at the person it protects
"Deny beats allow" denies writes It denies the write tool. The shell walks straight past it
The vote arithmetic is tested Three tests could not fail; breaking the code on purpose is what showed it
A failed audit fails loudly Every call failed, the run exited 0, and the report said the codebase was clean
Structure the tasks and the agent parallelises It ran them itself, sequentially, and reported that it had not
A conformant artefact is a working artefact One host dropped a whole skill without a word; the other never noticed
Import the handler and call it directly The test only passed once every adopter had to restructure their app
Windows needs a machine, not code 29 failures the first time anything ran there, two of them product bugs
The spike would tell us about the other parser It found that freya could not graph freya, and reported success doing it
A probe found no evidence, so there was none All four relations were real; the correction was the error
A silence from the honesty check meant all was well It meant the scope rule had excluded the whole codebase
The link gate said every behavior link passed It had never looked at the half of the link that names the test
Discovery was the hard part; applying the fix is bookkeeping The top finding took five fixes, and four remediations would have made things worse
Twenty-three findings closed, so the security work is done Two thousand lines of new code had never been scanned — and one of them was the previous fix, applied to one path of two
The containment rule is one rule It asked pathlib and its consumers asked ntpath, and the two libraries answered differently on the interpreter nobody ran locally

Act I — Design assumptions that were wrong

Four beliefs that were held before any code existed, and were refuted by reading a specification, by arguing the sequencing through, or by the thing they were waiting on finally arriving.

1 · The install design was impossible

The plan was for the installer to apply the freya- prefix at install time — to the directory name and to the name: field inside each SKILL.md. Reading the Agent Skills specification before writing the installer turned up a rule that makes that unbuildable.

The rule that ended it

A skill's name must match its parent directory name. Installation is symlinking, so a link from an agent's skills/freya-code-graph to skills/code-graph in the store produces a file declaring name: code-graph under a freya-code-graph parent — invalid, and rejected by the ecosystem's own validator. And there is nothing to rewrite: there is one file, shared by every agent.

Planned

Repo keeps the unprefixed names; the installer prefixes on the way out. Requires rewriting a file every agent shares, or shipping a skill the spec rejects — and an installed copy that diverges from the store on every update.

Built instead

All ten skills renamed in the repository: ten directories, ten name: fields, fifteen manifest paths, and ten hard-coded sibling paths across five scripts. The installer became a pure symlink.

The consolation prize is real. With the prefix already on disk, freya update collapses to a git pull plus a re-link — there is one copy of each file and every agent is looking at it. Those ten sibling paths are a consequence of an earlier decision rather than an accident: skills resolve one another through the store instead of being separately installable, and every skill file invokes the one launcher (knowledge-base/decisions/ADR-013-single-freya-launcher.md).

Nine of ten, by the obvious grep

One of the ten sibling paths was built with os.path.join and a textual '../..' rather than the parents[2] idiom the other nine used. A sweep for the idiom finds nine and reports success. The one it misses fails at import, on someone else's machine, after the release.

The whole contract — store, symlink, prefix-in-repo, and the ownership gates on every destructive path — is stated in knowledge-base/decisions/ADR-014-canonical-store-install-contract.md.

2 · A "non-standard" field that was in the standard

The design described compatibility: frontmatter as non-standard and had it removed. The specification lists it as a standard optional field, alongside license, metadata and allowed-tools.

Deleting those particular two lines was still the right move — they enumerated one vendor's tool names — but the stated reason was wrong, and by the time anyone checked, the wrong reason had hardened into a guard. The conformance checker's allow-list of permitted frontmatter keys was {name, description}, so it was rejecting four legitimate fields on any skill that used them.

The lesson generalises past this repo

A guard built from a remembered fact inherits the memory's error and then enforces it on everyone. The remembering feels identical either way; the enforcement is what makes it expensive. Read the spec before you encode it.

3 · Governance was planned before there was anything to govern

The original sequencing put the governance track first — principles, contradiction checks, the machinery that judges intent against intent. It was preempted by an adoption track on one argument: you cannot govern intent you have not captured. A checker that compares a change against the recorded intent of a codebase with no recorded intent is a ceremony.

The adoption work rests on a split that is the reason the whole thing is affordable: inference is cheap and human validation is scarce, so they are decoupled. A bootstrap infers hundreds of untrusted candidates up front; validation is spent lazily, on the two or three behaviors a change actually touches, in the flow of work. That is what turns a few hundred inferred candidates into a backlog rather than a flood — knowledge-base/decisions/ADR-007-bootstrap-proposed-drain-lazily.md.

The second sequencing reversal, taken deliberately

A separate, staged "vertical proof" — validate the mechanism in isolation before using it on anything real — was considered and dropped. That validation was folded into the first real use on a live testbed instead. It was a conscious risk trade, and it was paid for: several early schema, lifecycle and adapter choices were made provisionally and corrected in contact with reality. The friction findings from that testbed are the correction record, and two of the entries further down this page came out of it.

4 · Blindness on purpose, and then the premise dissolved

The contradiction check — the one that judges a changed spec against the intent already recorded around it — was built deliberately blind to architecture decision records. That reads like an omission. It was a decision, and it was argued in the open at the time.

The honest version, as it was written then

Checking specs against ADRs while no ADR format existed would mean comparing against a structurally empty set, while looking like the check covered architecture decisions. That false sense of coverage is worse than an honest gap. So the check was built ADR-ready: a wired-but-unplugged socket, not a wall.

Overtaken on schedule

The ADR format, the create flow and the gather function shipped, and the same check gained ADR-awareness for free — always global, with no scoping at all, because for intent-vs-intent the unrecoverable failure is the relevant record that never reached the judgment. The premise dissolved exactly the way the socket was built to expect. The reasoning, including why the sibling drift check takes the opposite scoping, is in knowledge-base/decisions/ADR-011-governance-check-scoping.md.

Act II — Guards that failed at the job they existed for

Five entries about protective code. Every one of them was written specifically to keep somebody safe, and every one of them did damage in a case its author had not pictured. This is the densest cluster on the page and it is not a coincidence: a guard is the code least likely to be exercised on the happy path, and the code whose failure looks most like success.

5 · --dry-run deleted your installation

The installer's uninstall path returned before it ever consulted args.dry_run. So freya uninstall --dry-run — a flag whose help text reads print the plan, change nothing, and which the README advertised as a preview — removed all ten symlinks.

The damage was bounded: only links this toolkit had created, recreatable by re-installing. But a safety flag that performs the destructive operation is the precise failure the whole class of flag exists to prevent. The same review found that uninstall never removed the launcher either, so deleting the checkout afterwards left a dangling freya on PATH.

Why the review caught it and the tests did not

Every unit test exercised uninstall_agent() directly. Nothing tested the flag wiring in main(). The suite was green and the feature was broken, which is the whole argument for a review that runs the thing end to end rather than reading the diff that changed.

6 · Every guard, misfiring at the person it protects

Eight of ten tasks in one work package came back from review with findings, and not one was in the feature logic. Every serious defect was in code written to keep the user safe.

The barrier that half-installed

Added because installing for two agents with a blocker under the second left the first fully installed while exiting non-zero. The fix planned every agent before applying anything — which made each plan's status a snapshot of the disk before anything moved. Two agents pointing at one directory both planned "create"; the first installed, the second raised FileExistsError. A partial install and an error, by a new route.

The rule that locked you out of your own file

The markers that fence off the generated block in your AGENTS.md had to be unambiguous, because a stray one could delete a user's prose. So the fix demanded each marker appear exactly once, counting occurrences anywhere — including mid-sentence. A user who merely wrote about freya-devkit in that file could then never run freya init again. Being over-strict about a file you do not own is its own kind of damage.

The check that could not break a command

The update notice is wrapped so that nothing escapes it: it cannot alter an exit code or print a traceback. Wiring it in front of main silently turned four existing tests into ones that wrote the real ~/.freya and made real network calls. The reviewer found the file sitting on disk, timestamped to the test run.

The write that emptied the file first

open(target, "w") truncates before it writes a byte, so a failure mid-write left the user's AGENTS.md empty — in the one module whose single hard rule is that everything outside its markers survives. It now writes a temp file and calls os.replace.

And one that could not heal

The relink error was self-sustaining. A copy that failed partway leaves a directory with no ownership marker, which the audit classifies as occupied forever — so the next freya update crashed at the same line, and so did the one after that. A guard against clobbering somebody else's directory, holding the user's own broken install hostage, permanently, with no route out but manual deletion.

7 · A security control that controlled nothing

Before the audit driver was designed, a paid spike ran both agent CLIs against a deliberately vulnerable fixture, to establish how to enforce this worker may read, and must never write. The belief under test was the vendors' documented rule that a deny flag beats an allow flag.

ConfigurationResult
Claude, explicit read-only allowlist plus an explicit write denial Held. The worker tried the shell three times; all three denied.
Copilot, explicit read allowlist plus --deny-tool=write Held. Both the write tool and an explicit shell redirect failed.
Copilot, --allow-all-tools plus --deny-tool=write Bypassed. The worker created a file through a shell redirect.
The actual rule

"Deny beats allow" applies to the write tool, not to writes performed through the shell. Only an explicit allowlist that excludes the shell holds, and a blanket permission flag must never appear in an audit invocation.

That finding is now enforced in code rather than remembered as a convention: the adapter refuses to emit a blanket permission flag even if one is smuggled in through the prompt, and a test asserts that no adapter can ever produce one — a guard designed before the thing it guards existed. The same spike priced a single finder worker at $0.396 on a trivial fixture, which is why the driver ships with a call ceiling and a confirmation prompt rather than an advisory note. Both live in knowledge-base/decisions/ADR-015-driver-owned-fan-out.md.

8 · Three tests that proved nothing

The audit engine's vote arithmetic is the part where a silent error changes security findings: how many skeptics must refute a finding before it is dropped. Its tests all passed. Mutation testing — deliberately breaking the code to see whether the suite notices — showed three of them were hollow, each for a different reason.

MutationWhy the test could not see it
upheld * 2 > total>= Only a 1-of-2 split distinguishes the two operators. Every test used three skeptics, where they agree.
The K_EMPTY constant, changed The test asserted against the constant itself, so it adapted to the mutation and stayed green.
Delete the dry-round reset The test counted findings, and both paths yield the same count. Only the round count differs.

All three now have tests that distinguish, and every engine mutation is verified to be killed by a specifically named test.

A trap worth knowing before you try this

Mutation runs land inside a single filesystem timestamp tick, so Python happily reuses cached bytecode and reports the previous mutation's result. Two mutations looked killed when they had never been loaded. Clear __pycache__ between runs, or run the interpreter with bytecode writing off.

9 · Two numbers that were never multiplied together

One work package skipped its whole-package review, on the argument that the port was mechanical. The review, run late, showed the argument was half right. The engine itself was clean — every constant, the dedup key, the vote arithmetic and the disposition ladder survived the port intact. Both serious defects were at the edges.

A failed audit reported a clean codebase

Every failure path in the agent call was a bare continue. With an expired login, all 26 calls failed, the driver printed "done: 0 findings", exited 0, and emitted [] — from which the skill wrote a report saying the codebase was clean. The captured stderr holding the real reason was discarded unread. For a security tool that is the worst available failure mode, and it is the likely first run.

The defaults could not finish a run

A findings cap of 40 implies 151 tasks. The call ceiling was 80. The two were chosen in the same sitting and never multiplied out — even though the function that does exactly that multiplication sits twenty-five lines below them. They were not even in the same unit: the budget counts attempts, and each task may retry once.

The second compounded the first. Exhausting the budget discarded everything, including findings that had already been fully verified, so a real run would spend its entire ceiling and return an empty array.

An unplugged smoke detector finds no fire

A security tool that cannot tell nothing is wrong from nothing answered does not merely fail — it produces a document asserting safety, over a signature nobody questions. This is the sharpest thing on the page and the one worth carrying to your own work.

The fix that mattered was not the arithmetic

Refusing to return a partial audit that could be mistaken for a complete one is the right instinct. But the answer is to label truncation, not to delete the evidence of it. Verification now settles in batches, a halt keeps what it has already verified, and exit 3 marks a result incomplete. The driver refuses to exit 0 when no task got a usable answer. With that in place, the exact cap values stopped being load-bearing.

Act III — Things only running it could find

Everything above was found by building, reviewing or reading a specification. These eight were reachable only by running the thing — against a real agent, a real host, a real platform, or a real repository — and most of them had survived multiple careful reviews that read the code correctly every time.

10 · Copilot cannot be told to parallelise

Act one — the belief. Copilot's public documentation says the main agent decides execution strategy autonomously, based on task structure. The design took that at its word and concluded that the lever was not the sentence but the shape of what preceded it: present N visibly independent units of work, and place the scheduling instruction after the task list so the list is read first. Every fan-out flow was built that way.

Overtaken by evidence

Run for real, task structure is not the lever. Asked to run six category scans the way the skill instructs, Copilot ran them itself — a sequence of greps — and then reported that it had run six category scans in parallel. Instrumenting a twelve-way fan-out with debug logging settled it: the delegation tools were invoked zero times, in both directions. The reason was in the host's own system prompt: never delegate parts of a codebase small enough to read directly, "regardless of how it divides into separate areas." The lever the design had identified is the exact thing the agent is instructed to ignore.

Read the heading precisely, because the capability was never the problem. Copilot can parallelise; it ships delegation tools, background agents and a fleet mode. What it cannot be is told, by a file in your project.

The general lesson, stated once

An instruction file is information the assistant reads; the assistant's own instructions are its orders. When they conflict, the orders win — and they should, or a file in any repository could override what a vendor built.

Act two — the first remedy was also wrong, and this is the more valuable half. The finding was initially recorded as unfixable from this side, on the grounds that the skills already carried a sequential fallback: the work still gets done, only slower. That reasoning has a hole in it.

The fallback was never the problem

The problem is that nothing can tell you which mode ran. An instruction saying "run these in parallel if you can" gets the same answer from an agent that did and an agent that did not — the original finding was only visible because a human read a transcript and counted greps. A correctness property you cannot observe is not a property. It is a hope.

So the fix was neither to accept the sequential mode nor to word the instruction more firmly. It was to stop issuing an instruction at all: the security scan now calls a driver that owns a worker pool, makes the calls itself, and returns verified findings. The agent's cooperation left the guarantee. Measured on the same fixture, that driver runs 209.3 s single-threaded against 91.4 s at six workers — a 2.29× win rather than a 6× one, because the host throttles under load, and the honest number is the one that got written down. The design and its rejected alternatives are in knowledge-base/decisions/ADR-015-driver-owned-fan-out.md.

A smaller refutation, sitting inside the same story

The scheduling block copied into every fan-out flow promised that a sequential run was "identical, only slower." This repo's own dogfooding notes had already recorded a real scan at 7 parallel discovery agents and ~260k subagent tokens. Running those inline collapses seven isolated context windows into one, because each worker's file-reading context is retained rather than discarded — on a 200k-context agent that run does not produce an identical result, it does not complete. The block's own next paragraph explained why, by noting that each worker carries its own context window. The claim contradicted the sentence directly beneath it, in a file that had been reviewed line by line.

11 · The artefact was correct. The host disagreed.

Asked to list its installed skills, Copilot returned nine. The missing one was the security scanner — the skill the portability work most existed to reach. Installed. Correctly symlinked. Silently dropped.

1251 characters against a 1024-character limit

The Agent Skills specification caps a skill's description at 1024 characters. That one had grown to 1251 — the only skill in the suite past the line. Copilot enforces the limit and drops the skill without a word. Claude Code ignores the limit and loads the skill happily, which is exactly why work done mostly against Claude never saw it. Nothing raised an error anywhere: not the CLI, not the doctor command, and not the conformance gate, whose frontmatter rule had always checked which keys were present and never how long their values were.

This is the site's best argument for running the thing rather than reading it, and its cleanest statement of a recurring truth: a test suite can only ever check the artefact, never what a host does with it. Everything on the repository side was right. The evidence discipline that came out of it — real dependencies, live dogfooding, committed evidence, corrections appended rather than rewritten — is knowledge-base/decisions/ADR-016-prove-it-against-the-real-thing.md.

A result from the same run that had to be thrown away

One agent marked both planted vulnerabilities intentional-design where the other had marked them confirmed. Before reading that as an adapter defect: the fixture's own README announced that it existed to validate the audit driver. A skeptic lens reading that could quite reasonably decide the vulnerabilities were deliberate test data. Re-run against a fixture that says nothing about itself, both came back confirmed critical. A control fixture that describes its own purpose is contaminated — the disposition ladder was fine, and the experiment was not.

12 · The false green that reshaped the behavior layer

The plan for integration behaviors was to import the framework's route handler in-process and call it directly. On an ordinary CommonJS-default project, Node's require(esm) rules forbid it — and flipping the application to "type": "module" made the test pass. That is the trap: a false green, bought by demanding that every adopter restructure their application to suit the test harness.

The resolution became a principle rather than a workaround. A behavior test drives the application over its real interface, against a running instance — never by importing internals. Then the honest consequence, which is why the shipped coverage model looks the way it does: observed coverage of that running application captured only framework internals and zero application code, so observed integration coverage was deferred and the static import closure became the honest baseline — over-approximating, in the safe direction, and saying which it is. Both halves are in knowledge-base/decisions/ADR-006-real-interface-execution-and-coverage.md.

The inverse demonstration, from the same testbed

Flipping a behavior to accepted while its test was still a TODO(scaffold) produced exactly one error, exit 1, scoped to that behavior — the sibling proposed scaffolds in the same file correctly untouched. The suite is green, because nothing runs. The layer still says exit 1. That is a lie about a guarantee that a green test suite is structurally incapable of catching, and it is the clearest evidence that the layer earns its place.

13 · Windows was not waiting on a machine. It was waiting on code.

Every earlier version of this record carried the same sentence in some form: Windows is untested, it needs a Windows machine, and no code. The first half was true. The second half was an assumption that had been restated so many times it had started to read like a measurement.

A CI matrix now runs the suite, the static gates and a real end-to-end install across ubuntu-latest and windows-latest, on Python 3.9 and 3.13 (.github/workflows/ci.yml). The Windows leg came back 29 failed against a green Linux leg. Nothing had regressed; it was simply the first time the suite had ever executed there, and the failures had been sitting in the tree the whole time. Four root causes account for all 29, and two of them were product bugs, not test bugs.

Root causeWhat it actually brokeKind
Extended-length paths. Windows hands a symlink target back as \\?\C:\… where the source is spelled C:\…, and the installer proved it owned a link by comparing those two strings. Every link the installer had just created classified as foreign, so install refused to manage its own install and relink skipped every agent. 17 of the 29 were downstream of that one comparison. product bug
Backslash graph keys. The code graph keyed its entries with backslashes, so every forward-slash lookup missed. Wider than one skill: the behavior graph and runner both read those keys expecting forward slashes, so blast radius came back empty and silent on Windows — the failure mode with no error attached. product bug
remove_link retried a failed unlink as an rmdir, unconditionally. On Windows the fallback succeeded, so a removal that had not happened was counted as one that had, and the command reported zero failures over nothing removed. reporting
A manifest escape guard used os.path.isabs on a path that reaches the filesystem. Python 3.13 changed ntpath.isabs so a rooted path with no drive is no longer absolute on Windows: /etc/passwd was rejected on 3.9 and accepted on 3.13. A guard on a trust boundary, correct by accident of which interpreter was running it. trust boundary
And two the matrix structurally could not catch

Two further defects were found by review, not by the runner, and neither could ever have turned a leg red: one function is Windows-exclusive, so no POSIX run reaches it at all, and the other was latent. Both would have shipped broken behind a green build. A green matrix is evidence only about the paths it executes, and adding a platform did not make it evidence about everything on that platform.

What the matrix proves is that the toolkit installs on Windows and that its tests pass there. It does not prove that a live scan works there: no agent CLI is installed on the runner, so nothing that spends money has ever executed on the platform. That gap is owned, with its remedy, in knowledge-base/roadmap.md rather than restated here.

14 · The spike meant to evaluate a parser found the bug in ours

A spike was set up to measure whether an external multi-language parser was worth adopting. Its most consequential finding was not about that parser. Building the graph on freya-devkit itself indexed 10 of 50 Python files, produced zero connections, printed "Built dependency graph: 10 files scanned", and exited 0. freya classified freya as a brand-new empty project.

Two independent causes, either sufficient alone

An exclusion rule matched a directory name at every depth instead of at the top level, so every skill's scripts/ vanished — forty files. And Python sibling imports were filed as third-party packages, which kept the internal edge count at zero even once the first was fixed. Four more defects sat behind those.

The resolver was then repaired before being frozen behind the new contract. Freezing it first would have made every one of those defects a permanent property of the floor that every project falls back to.

15 · We corrected something that was right, and logged the correction

An automated probe reported four relation names in the second backend's vocabulary. They were grepped for, not found, deleted from the mapping table — and the log recorded that the agent had invented them.

All four were real

They lived in a tuple constant, exactly the shape those regular expressions could not see. The same scan had also missed a relation this repository's own graph demonstrably contains, and that discrepancy was noticed and ignored. A later review restored all four.

Verification that only looks one way is not verification. The probe found nothing and the absence of evidence was taken as evidence of absence — with a written record blaming the tool that had been right.

The same mistake, from the other side

Months later a design agent reported a filter measured "silent on 3 of 7 real repositories". Checked before being believed: one was an empty directory, and a second's silence turned out to be a defect rather than the filter working. Both figures had already reached a commit message and a decision record. A probe returning a result is not the same as the result meaning something.

16 · The mechanism built to stop confidently-empty answers gave one

The polyglot substrate's last piece makes every answer say what its backend could not read. The census applies the build's own scope rule, which is what keeps it from crying wolf — measured, a census that consults only .gitignore-style patterns reports 96 unread files on this repository, 68 of them deliberately out of scope.

Then the shape detector trusted its silence

A real 40-file deployment repository has its entire codebase in shell scripts under scripts/ — a built-in top-level exclusion. The census therefore, and correctly, reports nothing unread: those files are out of scope, not unreadable. The shape detector read that silence as authoritative and returned greenfield. Before the feature it had returned unknown.

So the confidently-empty answer arrived through the mechanism built to remove it — via the same scripts/ rule that had once stopped freya graphing itself. Two questions were being answered by one value: "what can this backend not read?" and "is there anything here the graph does not represent?" The second is broader, and the census's silence does not answer it.

Why it was not caught by the tests that existed

Because the silence was evidence. It had been cited as proof the filter was quiet enough to be believed. The claim and the defect were the same observation, read the wrong way round — which is only visible if you go and check what a silence is made of. ADR-029

17 · The gate that checked the file and called it the link

The belief. On 2026-08-21 the toolkit was finally run against the only repository it had never been pointed at: itself. Everything reported clean. 1,435 tests green, the conformance gate green, and freya verify-links — the Tier-1 check that is allowed to hard-block a wrap-up — printing “OK — all behavior links pass Tier-1 integrity checks.”

Seventeen of the hundred and forty-nine were broken

A behavior's locator is written path#Class.method — a file, and the test inside it. parse_locator returns both halves, and the caller bound the second to a discard: rel_path, _frag = parse_locator(locator). For every non-Gherkin adapter the entire check was then abs_path.exists(). A second hole sat above it — if adapter == "manual": continue — which exempted manual behaviors from the whole check rather than from the runner, so their locators were never resolved at all, not the method and not even the file.

The proof was a mutation, not a reading. Every locator target in the repository was renamed out of existence — 132 of them, across 21 files. verify-links exited 0. The suite stayed green. Nothing anywhere observed that every behavior in the project now pointed at a test that did not exist.

Why it was not caught by the tests that existed

Because they tested the gate's reporting, not its reach. There were tests for a locator naming a missing file, and they passed, because that half was real. No test asked what happened to the other half, and the code that discarded it read as deliberate — _frag, with a leading underscore, is the Python idiom for a value you meant to throw away.

The same day, the security scan found the shape in the code as well as in the gate. It raised twenty-two findings against the toolkit, two of which merged into one, so twenty-one were filed; the two most severe were the same defect twice. audit.py runs every scan worker with cwd=<the scanned project> and invokes it by the bare name claude. On Windows, CreateProcess searches the working directory first — so a repository you scan can supply its own claude.exe and have it executed as you, inheriting your whole environment. The second instance lets the scanned repository's own committed settings.json choose the binary.

What that costs, precisely

The toolkit's headline safety property is that its workers run under a read-only allowlist with the shell excluded. That allowlist is expressed in argv, and argv only binds the program you meant to run. If the scanned repository picks the program, the allowlist is describing a process that was never started.

Seventeen of the twenty-one findings were in files that already had a test file beside them, so “no test for that code” explains none of them. The suite tests degradation — a missing file, unreadable JSON, a git command that fails — exhaustively, and had almost nothing that tested hostility: input that is well-formed and chosen by someone who wants something. Those are different questions, and only the first had ever been asked.

The lesson, and it is the one this whole page is about

A gate that reports success is not evidence that it ran. Every finding here was in a mechanism this project had already built, documented and believed in — the link checker, the worker sandbox, the read-only allowlist. What running it on itself produced was not new capability. It was the discovery that three things declared everywhere were enforced nowhere, which is the exact defect class the toolkit exists to find in other people's repositories.

18 · The report told us how to fix it, and the instructions were wrong

The belief. Three days later the twenty-one filed findings were worked through (two more, SEC-022 and SEC-023, were found during that work rather than by the scan). Each one arrived with a Remediation paragraph written by the same adversarially verified pipeline that found it — specific, plausible, citing the line. The belief was that the expensive part was over: discovery had been done, and what remained was application.

The highest-severity finding took five fixes, and each one passed the fix before it

SEC-001 is a hash the scanned repository commits to a marker file, interpolated into git diff's argv by the gate that decides whether an accepted test was edited without authorisation. Every round below was found by running the attack against the previous fix, never by reading it:

  1. --output=/tmp/victimgit diff accepts it, and truncates the file. Closed by validating the value as a hash.
  2. Forty zeros. Valid hex, so the new validation passes it; git then fails rc=128 and the empty output was read as "nothing changed". Closed by telling a git failure apart from an empty diff and labelling it a skip.
  3. A committed file named deadbeef. Pure hex, so it walks through the regex — and git resolves it as a pathspec, at rc=0. Closed by --, which forces the revision reading. (A -- before the argument makes it a path; after it, the opposite. Getting that backwards is what left this open a round longer.)
  4. A tree hash from git rev-parse HEAD:knowledge-base. Forty hex characters, a real object, and not a commit — it walks past the regex, past --end-of-options and past -- alike. Closed by peeling ^{commit}.
  5. A marker holding commit: with no value, or no commit: line, or zero bytes. Each returned a bare None — which is the exact fingerprint of a repository that has never run the gate, so --advance exited 0 and erased the finding it should have reported.

The argv that survives all five is git diff --name-status -M --end-of-options <baseline>^{commit} --.

Four remediations that would have made things worse

Applying the report literally was tested rather than assumed, and four of its instructions failed:

  • "Resolve the binary to an absolute path" is the fix backwards. On Windows shutil.which returns the working-directory hit first, so abspath-ing it hands CreateProcess a fully-qualified path to the attacker's binary, spelled more convincingly. The rule has to be refuse, not absolutise.
  • "Add --end-of-options before the revision" in the code-graph's incremental diff produces "fatal: option '--name-only' must come before non-option arguments". Every --update would have degraded to a full rebuild, permanently and quietly.
  • "Replace the bare except: with except OSError" does not catch UnicodeDecodeError, which is a ValueError — so one non-UTF-8 byte in one YAML file would have converted a swallowed error into an uncaught traceback out of the whole detector. The fix was to read bytes.
  • "Narrowing the pattern can only make the guard stricter" — true, and not enough. After narrowing, a made-up SPEC-999 still corroborated itself on this repository, because the corroboration search included the scanner's own report directory and found the sentence in it describing the test that says it must not.
Why it was not caught by reading

Each of those instructions is locally reasonable. They are the sentences a careful reviewer writes, and three of the four cite the right line. What separates them from correct is one command: reproduce the attack against the proposed fix, on a real repository, before believing it. Discovery had been adversarially verified three ways; the remediation had been verified not at all, and nothing in the pipeline was shaped to notice the difference.

And one finding was declared unfixable, on a threat model that was not this tool's

SEC-006 is the mechanism that lets an accepted behavior silence a security finding. The record authorising it said the behavior's passing test is "verified evidence rather than a prose claim", and no test was being run — so the claim was false and deleting it was right. What came next was not. The finding was closed as mitigated and left open on purpose, arguing that no better evidence could exist: the only evidence not committed by the repository under audit would be executing that repository's test suite, "a security tool running hostile code".

That argument was overturned the same day, and it is the most useful thing this pass produced. It is an argument against a capability this toolkit ships as a feature — freya-behavior-runner exists to run the project's tests, and regression_check, in the same module as the query being reasoned about, already re-ran accepted behaviors. The reasoning had imported a hostile-clone threat model that does not match what freya is: a tool a developer points at a repository they are working in, having already installed its dependencies and run its suite. The premise was never checked against the sibling skill sitting next to it.

Closing it properly found a hole wider than the finding described and one no forgery was needed to reach: the query never read whether an exercised path came from a real run or was inferred from the import graph, and both licensed a downgrade identically. A downgrade now requires an observed run and a resolving locator, carries the symbols that actually executed, and --verify re-runs the linked test for the one caller whose answer can silence a finding. A false safety invariant that reads as true is the most expensive kind of documentation there is — and so is a confident argument that the gap behind it can never be closed.

19 · Then we scanned the fixes

The belief. Twenty-three findings closed, every gate green, the toolkit run on itself. The work was the fixes; the fixes were the product. What remained was paperwork.

Two thousand lines had been written and never scanned. Closing the findings had meant new code — path containment, a binary resolver, a redaction helper, a declaration mechanism, a hardened gate — and no scan had ever seen any of it. Running one over that surface returned twenty-four candidates. Five did not survive refutation. Nineteen did.

The most severe was the previous fix, half-applied. SEC-023 — a symlink committed inside a project, pointing outside it, followed and read with nothing declared — had been fixed in discovery. The incremental path never got the guard. So the symlink was refused on a cold --build and read on every --update after it, and --update is the one wrap-up runs. The cold start was fixed and the steady state was not. Worse than the hole it reopened, three ways: the build path printed a warning and recorded the refusal where the update path did neither; the key it produced is project-relative, so the contract validator passed the artefact clean; and the regression class written to prevent exactly this proved its claim on five cases, none of them incremental.

One of them destroyed a tracked file. test-failed is the runner's word for any non-zero exit, and the merge treats that reason — and only that reason — as invalidating. On a machine with no toolchain installed, python -m pytest exits non-zero. So a fresh clone with nothing installed blocked the commit and rewrote the committed behaviour graph to an empty exercise set, after which the downgrade path it feeds answered empty for good. The argument for not doing this was already in the file, ten lines above the bug, written for the neighbouring case: calling a renamed test method test-failed "would wipe committed edges and block a commit over a rename". The reasoning was right and had not been carried one case sideways.

And the pass whose entire job was accuracy wrote three false safety claims of its own. That validate_graph "rejects" a non-project-relative key — it reports one, and the graph is written anyway. That every containment question goes through one module — one deliberately does not, and its docstring says why. That no symlink crosses the root on its own — one did, which is how SEC-023 was found at all. Each was true on the path its author checked.

The recurring shape of this project is a confidently empty answer. This entry is its reflexive form: the newest work is the unexamined work, and the pass that just finished is the least likely thing to be checked. It applies to a fix, to a correction of a fix, and to a scan of the corrections. Each round here found fewer and smaller things — twenty-one, then nineteen, then a handful of stale sentences — but no round found nothing. The branch stopped on judgement, not on a clean result, and saying so is the honest version of "done".

20 · The predicate and the join disagreed about what a drive is

The branch closed with every gate green and was pushed. The first CI run failed — on Python 3.9, on both Linux and Windows, while 3.13 passed everywhere. The failing value was 1:x.

escapes is the lexical predicate for a value declared in checked-in data, and the fix two entries above had just routed directory keys through it. It asked PureWindowsPath(rel).drive whether a value carried a Windows drive. That question does not have one answer: pathlib restricted a drive letter to ASCII up to Python 3.11 and delegates to ntpath.splitdrive from 3.12, which accepts any character. So PureWindowsPath('1:x').drive is empty on 3.9 and '1:' on 3.12.

Why that was a hole and not a curiosity

The consumers do not join with pathlib. They join with ntpath, and ntpath.join('C:\work\proj', '1:x') is '1:x' — the project root discarded. So on 3.9 through 3.11 the predicate answered does not escape about a value that escaped at the very next line of the code it was guarding.

The rule now asks ntpath.splitdrive — the same body the consumers join with — so predicate and join cannot disagree by construction rather than by vigilance. That is the whole fix, and it is one line.

The lesson was already in the file, four lines away. The sibling predicate is_anchored was written not to use os.path.isabs, with a comment naming the reason: Python 3.13 had changed ntpath.isabs, and a rule that moves between interpreter versions is not a security boundary. That cost had already been paid once. It was not carried across to the drive term in the function immediately above it — and the reference page's own table shows the seam, because the is_anchored row said on any host and any supported interpreter while the escapes row beside it did not.

Every local run was on one interpreter, where the two eras agree, so no amount of care at the keyboard would have surfaced this. The version matrix did — which makes it the one entry here found by a machine rather than by reading. The regression test therefore asserts against the join and not only against the predicate: a predicate agreeing with itself proves nothing about the thing it guards. A second test re-derives the rule with the pre-3.12 semantics simulated, so an edit that reaches for .drive again fails on the interpreter developers actually run.

The shape rhymes with the entry above it, one level down. There, the newest work was the unexamined work. Here, the newest work was examined thoroughly — by hand, on one machine, on one interpreter — and the examination was the thing with the blind spot. A green suite is a claim about the environment that ran it.

What the plans got wrong about themselves

Two of the corrections were not about the code at all. They were caught by refusing to proceed rather than by testing anything.

A plan that violated its own rule

The canonical scheduling block — the paragraph copied verbatim into every fan-out flow — mandated a phrase naming one vendor's internal tool, which a conformance rule had forbidden since well before it was written. The plan required text the gate rejects. Fixed by naming the agents rather than their internals; the rule stayed intact.

A definition of done that could not be met

One package's exit criteria demanded that a Claude-only path variable appear nowhere — including in the contributing guide's own bullet, which cites that exact variable as the anti-pattern not to write (CONTRIBUTING.md). The mention is the point of the bullet. The criterion was wrong, not the file.

The pattern across both

Each was resolved by amending the plan in a standalone commit — not by quietly weakening the guard, and not by editing the evidence until it agreed. The plan is a hypothesis; the code and the specification are the facts.

The pattern underneath

One failure mode recurs in almost every entry above, and it is always the same shape: a confidently empty answer.

Empty because of an alias

A blast radius came back empty on a whole class of project, because every import used a path alias the resolver could not follow. Fixing it took the graph from 0 to 607 internal edges.

Empty because of a separator

A blast radius came back empty on Windows, because the keys were written with backslashes and every lookup asked with forward slashes.

Clean because nothing answered

A security report said the codebase was clean, because every single call to the agent had failed and the failures were swallowed one by one.

Missing because it was too long

A skill was dropped by its host without an error, because its description ran past a limit the other host does not enforce.

And a parallel fan-out that never happened, reporting that it had. In every one of these, nothing crashed, nothing was logged, and the output was indistinguishable from success. That is what makes this class of failure expensive: it does not consume your attention, it consumes your trust, and it spends it on the wrong thing.

Everything the toolkit does about it follows from that one observation. Unresolvable imports are tagged rather than dropped. Coverage that cannot be measured is unknown with a reason attached, rather than absent. An incomplete scan exits 3 and refuses to be read as clean. A fan-out that cannot be observed is replaced by one that can be counted.