Setlist

Known limitations, in full

This is the full text of every limitation the README lists in short form. It has the same titles and the same three classes with the same counts. Here the entries are ordered by who meets each one first. Each entry states its claim, then what to do, and then folds away the history and the measurements behind it. Nothing was cut when the README's section became short; it was folded. The titles are the keys that the test suite's hole ledger and the maintainer's release gates read. So a title here is a hole the suite asserts or names as unassertable.

The guarantee does not live in your Claude Code session. It used to be four hooks inside the session, which decided what a command would do by reading its text. As of 2026-08-04 that layer is advisory in mechanism as well as in name. Since 2.8.0 it is also smaller. The two gates that read commands to warn are gone. The scope hook reports what it would have decided about a write, why, and the code, and then lets the write proceed. The bypass deny's one refusal is a nudge that stops an obvious spelling of disarming the git hooks. The git hooks below are what refuse the work.

This wording once described a warning while the gates in fact denied, and the gap cost a release cycle. Successive rounds of adversarial review found that most new defects sat in parser code written that same day to fix the previous round's findings. While the parsers could deny, every one of those defects blocked a release.

The trade is explicit: a false positive is now noise instead of a blocked command. Since 2.9.0 the scope hook's reason reaches the agent's context. It rides additionalContext, the field Claude Code delivers to the model, and since 2.11.0 the hook answers no permission prompt. There the reason persuades and does not refuse, so an agent that receives it may judge the write harmless and proceed. Advisories persuade; hooks refuse. The in-session feedback that holds is the git hooks' refusal messages. They arrive as ordinary command output at the moment of the attempt, which is where the guarantee lives anyway. Through 2.8.0 the reason did not reach the agent at all; that history, and how it was measured, is under Design boundaries below.

The guarantee moved to git hooks, stamped into a tracked .githooks/ directory. Git runs them itself, after it has parsed the arguments and resolved the refs. For cooperating use nothing is left to spell around at that point: a merge is a merge however it was written. That is a claim about the SHELL, which the guarantee survives. It makes no claim about a committer who crafts merge topology to evade the audit, which the guarantee does not survive.

Within the git-hook layer, the guarantee is the push-time trunk audit specifically. The two per-merge hooks keep refusing at commit and merge time, and that early refusal is the feedback you feel day to day. The audit at pre-push is what stands between unreviewed work and a shared trunk. It decides by identity and ancestry, and never by recognising the shape of an operation. It asks whether this history descends from the instance's declared baseline. That is the commit the upgrade records when it delivers the git hooks, or the commit that stamped the instance when the two arrived together. It also asks whether this content arrived through a closed spec's lineage. So a route past a per-merge hook is a MAJOR in this project's severity model, and it does not block a release. The work is refused later than intended, at push, and it is still refused.

Why the change: across five releases, every hardening pass closed one spelling and the next release found another. A shell command can compute its own arguments, so the set of spellings has no end. Then the parser layer died on macOS for a reason that had nothing to do with command text. It allowed everything in silence, while a git hook on the same machine in the same run was untouched. A parser has failure modes its subject matter does not.

Both halves of the boundary are worth stating plainly. An enforcement layer whose edges you cannot see is one you cannot reason about.

The threat model is short. A git hook can refuse what git hands it after the shell is done: a commit, a merge or a push whose history skipped the process. It judges by identity and ancestry, whatever spelling the command used. It cannot refuse what never reaches it. That covers a flag or variable that skips it (the escapes below), a clone that never armed it, and a checkout that removes it. It also covers content the forge does not expose to the check, and a committer who crafts history to pass the audit. So every bullet below is stated as a fact about git, the forge or the harness, and none as a defect waiting for a fix. Every release's own adversarial review drives the shipped bytes with these routes as its brief. A route it finds is fixed before the release ships, or it is the boundary of a capability and is named here. A release never grows this list for any other reason.

The list below holds three kinds of thing, so it arrives in three groups. Design boundaries are decisions with a date and a reason, where the layer stops on purpose. Open limitations are real defects or gaps, each with its status: a fix scheduled, a candidate, or a named gate. Upstream conditions are things this project does not control, with what would lift them and how that is checked again every release. A design boundary read as a defect overstates the problem; a defect read as a decision understates it. The counts live on the group headings below and nowhere else. So there is one place for them to be wrong, and it is the place that moves when a bullet does.

Design boundaries (21)

These are boundaries, and none of them is a bug. Setlist governs the session. It is no server-side policy engine and does not try to be one. Each boundary is a decision, and each says when it was taken and why.

Week one, one developer

The history and the measurements

What is still per-clone is the CONFIG pointing at them, since .git/config is not cloned either, so a fresh clone is unprotected until it is set up. The same applies to merge.ff=false, which Setlist sets because a fast-forward merge fires no git hook at all. Expect merges that used to fast-forward to create merge commits; that is the setting doing its job. For a team, the stamped forge check (2.6.0) is the layer that does not live in a clone: required on the trunk, it verifies every pull request against the same predicates whether or not the committer's clone had its hooks armed. It is a boundary only where it is required, and the check says so when it is not.

The history and the measurements

This is a real hole and a different kind of hole from the others: a deliberate act with an obvious name, not an apostrophe in a commit message. Setlist's own escape hatch, SETLIST_SKIP_HOOKS=1, is the same thing said out loud. Since 2.6.0 the refusal text no longer names the escape; the escape is documented here and in the hooks' own headers, and the forge check has no escape variable at all: a workflow edit is a reviewed change under the stamped CODEOWNERS.

Until 2.11.0 this bullet was titled "--no-verify skips git hooks", and the escape variable was a bullet of its own, "The SETLIST_SKIP_HOOKS=1 escape skips EVERY git hook, pre-push's trunk audit and content scan included.", merged here in 2.11.0 because the two are one boundary: a deliberate act with an obvious name that a git hook cannot refuse, answered by the forge check, which reads neither. That bullet's own advice stands: Reach for the narrow variable when you mean to skip the audit alone, and never carry the wide one in a shell profile. Its history follows.

This bullet said the opposite from 2.1.0 through 2.4.1 ("not read by pre-push", so a refused push would not get through), which was true when it was written and false from the 2.2.0 hook onward, in the unsafe direction: a reader carrying the variable for a noisy commit hook was told it was inert at push and it was not. Corrected in 2.5.0 by that release's adversarial review, with the suite case behind it rewritten to pin the escape in its real direction (it had reported a pass either way). A deliberate act with an obvious name, like --no-verify above. Since 2.6.0 the refusal text no longer names the escape; the escape is documented here and in the hooks' own headers.

A third route to the same effect, measured by the 2.8.0 adversarial review. pre-push finds the trunk audit through CLAUDE_PLUGIN_ROOT before the instance's own stamped copy and does not check what it found, so a shell that sets that variable to a directory holding its own scripts/trunk-audit.sh runs that script instead, and a push of unclosed work can then succeed with nothing printed. The variable is not set in an ordinary Claude Code session's shell (Claude Code sets it for plugin hook and skill processes, pointing at the real plugin), so meeting this takes a deliberately planted directory, and the bypass deny does not recognise it.

The history and the measurements

Measured: the identical git commit was refused by .husky/pre-commit before the refresh and committed cleanly after it. What gets displaced is often itself a control, since gitleaks, detect-secrets and commit-msg validation are commonly wired exactly this way. Report mode now names the value it would displace and what stops running; --apply refuses and tells you the two ways forward, which are to move those checks into .githooks/ or to re-run with SETLIST_ADOPT_HOOKSPATH=1 and displace the other layer on purpose. Through 2.10.0 this read: "This is a limitation of git rather than of Setlist: the two layers genuinely cannot both run, and the only thing that was ever fixable is whether you find out."

Until 2.11.0 the title read "git allows one core.hooksPath, so Setlist cannot coexist with husky, lefthook or pre-commit, and refresh-instance.sh --apply now REFUSES rather than displace one silently." In 2.11.0 the refusal became a chain (spec 0173), because git's one-directory rule decides where git LOOKS, not what may run from there: every Setlist git hook runs the displaced manager's hook of the same name with the same arguments (the same ref lines on pre-push, and none on a push that updates nothing), at its exit, or in pre-commit and pre-merge-commit before its own checks, and the status carries both verdicts. Measured red and green on husky 9's and lefthook's layouts: before, the stamp refused, and under SETLIST_ADOPT_HOOKSPATH=1 both managers' pre-commit and commit-msg stopped running; after, the manager's pre-commit refusal refuses the commit with [SLH-CHAIN-REFUSED], its commit-msg rejects a non-conventional message through the pass-through, and a commit both layers refuse prints both. Measured against the real husky 9.1.7 installed from npm: husky init sets core.hooksPath=.husky/_; the stamp chains it; and npm install runs husky's prepare script, which sets core.hooksPath=.husky/_ again, after which only husky runs and the next session start reports it. The narrowed title is that fact: one directory, which Setlist now shares rather than takes.

The history and the measurements

So does a bare --ff. Unlike --no-verify neither looks like a bypass, which is why they are called out here: they are normal preference flags and some GUI clients pass them for you. What this costs you is the merge-time close verification, which does not run: the commit-time checks and the push-time trunk audit both still do, and the audit reads the close conditions off the commit itself, so a COMPLIANT fast-forward close is accepted at push WHEN THE CLOSE ARRIVES AS ONE COMMIT: a fast-forward of a single-commit branch, or a --squash. A fast-forward of a MULTI-commit branch lands the branch's earlier commits on the trunk as ordinary single-parent commits the audit reads one by one, so the intermediate work commits are refused as feature code with no close (measured by the 2.4.0 review; the close exemption covers the closing commit, not a range). --no-ff remains the recommended way to close, and is what /setlist:checkpoint does, because it is the only route that runs every layer. The suite is separately not re-run by the audit. Fixed as of 2.4.0 for a close that arrives as ONE commit, and a decided boundary for what remains (ruled 2026-09-02, when this bullet moved here from Open limitations): git fires no hook for a fast-forward, merge.ff=false is the stamped answer and --no-ff the recommended close, and a multi-commit fast-forward refusing at push on its intermediate commits is the audit doing its job. A compliant --squash close, or a fast-forward of a single-commit branch, is no longer refused at push; the two layers agree there, and the audit decides on the parent COUNT so one rule covers both flag names. A multi-commit fast-forward still refuses on the branch's intermediate commits, as the bullet now states (recorded by the 2.4.0 review, which is also what corrected this bullet's Status from claiming the fix whole).

The squash half, a bullet of its own from 2.4.0 to 2.9.0 ("git merge --squash needs one flag to work in a Setlist instance, and the error does not say so."), merged here in 2.10.0 because both are one setting, one boundary and one consequence, fixed jointly. The message names neither Setlist nor the setting, so there is nothing to search for. Use git -c merge.ff=true merge --squash <branch> for a one-off, or drop the setting for that command. A squash has no second parent, so the trunk audit can never see the branch the work came from; it reads the close conditions off the commit instead, so a COMPLIANT squash close is accepted at push. As with a fast-forward, what you lose is the merge-time close verification, and --no-ff stays the recommended close because it runs every layer. The setting stays because a plain fast-forward merge fires no git hook at all, and that hole is worth more than the convenience. Fixed as of 2.4.0, jointly with the fast-forward half above, for a close that arrives as ONE commit, and a decided boundary for what remains (ruled 2026-09-02, when this bullet moved here from Open limitations): git's own error message names neither Setlist nor the setting, and the setting stays. A compliant --squash close is accepted at push; the two layers agree there, and the one-off flag above remains the way to run a squash. (This bullet's Status read "fix scheduled for the next release cycle" for one release after the fix shipped, and was corrected 2026-09-02 by the release's own claims-vs-bytes review rather than by a reader.)

The history and the measurements

On a git-flow shaped repository, where the team works on trunk or develop which tracks origin/main while a local main also exists as the release branch, a recorded trunk of main means every hook takes its fail-open exit while merges really do advance the branch the project treats as its trunk. Measured on the shipped bytes: unspecced role-path code lands on that branch with the whole guarantee layer silent. Between 2026-08-05 and 2026-08-07 the hooks also consulted what the branch TRACKS, which closed this shape and broke a much commoner one: git checkout -b <name> origin/main is git's own documented way to branch from a remote trunk, git announces it with "set up to track", and every such branch became the trunk to the hooks, so merging role-path code into an ordinary spec or feature branch was hard-refused with a message naming main as the target of a merge that never touched it. The two shapes are indistinguishable from inside a hook, so the tracking test was removed and this limitation is the honest statement of what is left. The remedy is one line: record in .claude/sdd.json the branch you actually merge onto. The upgrade skill prescribes exactly that.

The baseline half arrived in 2.10.0, and this bullet, which named the trunk alone through 2.9.0 ("The trunk is recognised by the NAME recorded in .claude/sdd.json, so an instance that merges onto a differently-named branch is ungoverned."), was rewritten to carry both. An instance stamped before its git hooks existed and upgraded across them was refused on every push, for merges made before any hook could have witnessed them, because the audit's walk began at the commit that introduced .claude/sdd.json. The refresh now records audit.baseline in the migration commit (the commit that added .githooks/pre-push, or the trunk's tip when it delivers the hooks now) and never overwrites a key already there; where the recorded trunk is not a branch in that clone it records NOTHING and names the branch it looked for, because a key the audit cannot use is worse than no key. The audit refuses a key that is not a full commit id, the absent key and a present non-string included (SLH-BASELINE-MALFORMED), that does not resolve in the repository (SLH-BASELINE-UNRESOLVED), or that is not an ancestor of the tip being audited at all (SLH-BASELINE-NOT-ANCESTOR), each before anything is walked, and its report says whether the frame was (declared) or (default). A key that is an ancestor but sits off the trunk's first-parent line, which is the commit the refresh records when the hooks arrived through a merged branch, is not refused: the frame is computed rather than required, and the walk starts at the oldest commit on the tip's first-parent line that contains the declared commit (the commit itself, or the merge that brought it in), the report names both when they differ, and the refresh and /setlist:validate read the key through the same function the audit runs. A key moved forward to the tip walks nothing and reports clean: that is a declaration the reviewer of .claude/ signed, disclosed here rather than refused. The forge check passes its own --since, the pull request's base, and under --since the key is not read at all, so a pull request that moves the key in its own checkout does not move the forge's frame.

Ordinary use

The history and the measurements

Found by the 2.7.0 adversarial review, which measured a live-shaped secret reaching the remote at exit 0 by this route. What changed in 2.7.0 is the silence, not the scope: pre-push now emits SLH-REF-NOT-AUDITED naming the ref and both ways through, and it refuses nothing, so no ordinary push is denied by it. The scope was deliberately NOT widened, and the reason is this project's own history rather than indifference: teaching the hook another forge's ref grammar means shipping a spelling nobody here can exercise end to end, and an unexercised spelling is the class that produced the two defences removed on 2026-08-07 for refusing ordinary work. Native support for review-ref workflows waits on a field report from someone who runs one, which is the same trigger the forge-check work carried. On such a workflow the forge check is the layer that holds, because it judges the merge a change request would make rather than the ref that carried it.

The history and the measurements

Since 2.2.0 you scope them by declaring the paths, repo-relative globs, in .claude/sdd.json: "scan_exclusions": ["vendor/**", "test/fixtures/**"], honoured by both scans at the commit layer and the push layer alike. Every skip is printed, naming the file and the glob that matched it, because an exclusion nobody is told about is a hole one directory over. The deliberate limits, which are the reason this is scoping and not an off switch: the set reaches the two content scans and nothing else, so it cannot quiet the trunk audit, the close checks or role-path judgment; a pattern made only of wildcards is refused, which stops the obvious off switch but is NOT the same as "no spelling reaches everything": the guard strips *, ? and / and asks whether anything is left, so *.* leaves a dot, passes, and excludes every path with an extension. That was measured in 2.7.0 and the sentence here claimed the stronger property until then. Read the guard as refusing the bare catch-all, and read the exclusion set as a declaration you are responsible for; an unreadable set is refused with a named code rather than resolved in either direction; matching is case-exact and a path git had to quote is scanned and says so, because failing to exclude costs a refusal you can see while failing to scan costs a published secret. Declaring nothing leaves the scans exactly as they were. The one residue left in 2.8.0: through 2.7.0 the in-session advisory commit gate did not read exclusions and its message said so, naming the git-hook layer as where they are evaluated; that gate is gone, so a commit of excluded content draws nothing in a Claude Code session and commits cleanly through the hooks. Do not edit third-party content to satisfy a style gate. Built in 2.2.0 for the layer that carries the guarantee, and a decided boundary for the advisory half (ruled 2026-08-28; moved here from Open limitations 2026-09-02): the session gate is not path-scoped, the divergence is closed by honesty rather than by coupling, and the message that says so shipped in 2.3.0. (This bullet's Status called the advisory half "the remaining piece" for two releases after that ruling; corrected 2026-09-02.)

The history and the measurements

This limitation is written at this strength because the claim above it was narrowed three times and falsified three times under the 1.1.0 adversarial review (2026-08-06), and the mechanism is what decides which sentence is true. Two structural holes remain, and they are structural rather than a matter of pattern coverage. The scanner reads the output of git diff, which is a RENDERING the repository controls: a .gitattributes entry marking a path -diff or binary made git emit no added lines at all, and a live-shaped secret then reached a remote at exit 0 by the ORDINARY commit-and-push path (measured under adversarial review; narrowed in 2.11.0, below); and the repository's own diff CONFIGURATION shapes the same rendering, which is the structural class the .gitattributes case belongs to: colour forced into a pipe, an external or textconv driver, a driver whose hunk-header regex does not compile, the setting that renders a root commit as nothing, each found by measurement and each closed as of 2.4.1 by flags that ignore configuration and by reading git's own exit status, while the class itself stays open because a rendering has no last setting. And the pattern set is a first cut rather than a complete secret detector: it matches token-shaped and connection-string-shaped values, so it will miss exotic formats and can flag something innocent; tuning comes from field reports, so send them, and there is no scheduled cycle for it because the tuning that would help is the tuning real misses tell us about (that sentence was a bullet of its own until 2026-09-02, merged here because the list said it twice). Separately and not a hole in the scan: git fires no hook for cherry-pick, for rebase's intermediate commits, or for the apply step of git am, so those are read at push rather than at commit. What the scan is good for is catching an accident early on the ordinary path. It is not a control you can put between a secret and a remote. The four holes once scheduled here were FIXED in 2.2.0 (the endpoint diff became a per-commit walk of the pushed range, the first-push range derives against the remote's own refs instead of coming out empty, a tag push is scanned for what it publishes, and the +++ header strip is anchored to the header), and what remains is structural and stays by decision (2.2.0, restated 2026-09-02 when this bullet moved here from Open limitations): the rendering, which a .gitattributes entry can suppress and configuration can reshape, and the set of patterns; the hookless routes named above are git's and not the scan's. The sentence at the head of this bullet is the one to trust either way.

The attribute hole, narrowed in 2.11.0 (spec 0173). A path the plain rendering reports as binary is re-rendered with git diff --text and scanned when its new content passes git's own text test (no NUL byte in its first 8,000 bytes), at commit, at merge and for every pushed commit with one parent: a -diff or binary entry no longer hides a text file, and a real binary is still not read (a blanket --text would have read this project's own demo GIF, which carries the em-dash's three bytes, as an em-dash). Measured red and green: a live-shaped secret in a path marked -diff or binary reached the remote at exit 0 and is refused at commit and at push. What stays: a MERGE commit's own change to such a path is not read at push, because git's combined diff prints "Binary files differ" under --text as well (git 2.55.0, measured); pre-merge-commit reads it when the merge is made with the hooks armed.

What the scans read, a bullet of its own through 2.9.0 ("The scans read this project's own index."), merged here in 2.10.0. pre-commit reads the index git is committing, in the project it governs, so a commit aimed at another repository is not a hole in these bytes but a property of git; give a nested repository its own instance if it should be governed. A commit aimed at another repository is therefore not scanned: git -C some/nested/repo commit ... commits that repository's index and runs that repository's hooks, not this project's. A commit made in this project through GIT_INDEX_FILE=... IS scanned, because the hook reads whatever index git is committing; through 2.7.0 this bullet called that a second hole, which was the retired commit gate's alone, measured for 2.8.0 on an instance armed with the stamped hooks and pinned by the suite. Your own staged content is checked, every time. A nested repository is a different project, and if it should be governed it should have its own instance.

The history and the measurements

That guard is deliberate and load-bearing, because a core.hooksPath set in one repository must not govern unrelated work. The consequence is that the guard is also a switch: measured on the shipped bytes, a push of an unclosed merge is refused from the trunk and the IDENTICAL push of the IDENTICAL commits succeeds after checking out an orphan or legacy branch that has no sdd.json, and the work reaches the remote trunk (measured under adversarial review). If your repository carries branches without the file, the enforcement boundary is conditional on which branch happens to be checked out. Through 2.10.0 this was documented rather than closed: narrowing the claim was the recorded decision once repeated repair rounds kept introducing new defects, and a fix here would have to distinguish "not a Setlist project" from "a Setlist project standing on an unstamped branch", which the file alone cannot do. The forge check closes this at the one layer where a branch is read rather than checked out: a pull request whose head carries no .claude/sdd.json is refused, not waved through.

Until 2.11.0 the title read "A checkout is an enforcement switch: every git hook is inert on a branch without .claude/sdd.json." and every hook opened with that check, pre-push included. In 2.11.0 pre-push reads its ref lines first and asks whether the work being PUSHED is governed, which is a fact about the pushed commits rather than the checkout: governed when the checkout carries the file, as before, or when a pushed tip does, and then the trunk audit reads each audited tip's own configuration and the push's other readers the first governed tip's (until this release's adversarial review one tip's configuration served every ref, so a sibling branch listed first audited the trunk under its looser roles), and a trunk tip that carries none is reported unaudited by name; a push whose tips carry no sdd.json from a checkout without one stays ungoverned, which is the guard's purpose. Measured red and green: the same unclosed merge pushed after git checkout --orphan (the working tree, hooks included, kept) was accepted and is refused by the audit. What it cannot reach is measured too: a branch that never carried Setlist has no .githooks/, an ordinary checkout of it leaves git no pre-push to run, and the identical push succeeds, which is why the title narrowed rather than retired. pre-commit and pre-merge-commit keep the checked-out reading.

The history and the measurements

The awk axis is on the list because it is the axis that has actually broken this project, and the third awk was added by extending the list rather than by reasoning about it. A platform not on that list is untested, and the release notes say which list rather than implying the proof. A decided boundary rather than an open defect (ratified 2026-08-26; moved here 2026-09-02): the list is extended and never restated as a proof, because no number of platforms makes it one. The first extension landed 2026-09-02, in the shape the list describes: the Linux leg runs the suite a second time under GNU awk, so the list is Linux under two awks and macOS under the BWK awk, and the release's own CI is what backs it. Read the list as the claim: it is a list, not a proof about platforms.

The second extension landed in 2.11.0: Windows 11 under Git Bash, measured before it was built, on a probe machine and on a field report from an x64 PC running 2.9.0, where three causes had every role verdict of the scope hook and the trunk audit failing open without a word: a native jq ends each line in CRLF, and Git Bash drops only the last CR of a command substitution, so a role read as "src" plus a carriage return; MSYS rewrites a path it hands a native program; and Claude Code spells a path C:\.... The hooks read all three correctly since, and the platform smoke runs on Windows at every push. What stays different there is stated rather than hidden. A hook call spawns processes and each costs far more under Git Bash: on the x64 PC the scope hook took about 0.8 s a write and pre-commit about 0.9 s a commit, against about 0.2 s and 0.3 s on a Mac (the ARM64 probe machine, whose MSYS runtime runs under emulation, about twice the PC). NTFS holds no file name carrying a backslash, a colon, a quote, < > | * ? or a control character, and git for Windows creates no repository past 260 characters without long paths enabled, so the cases that exercise such names run where the filesystem holds them; and a file mode denies nothing on NTFS, and an account without the right (an administrator has it, or any account under Developer Mode) cannot create a symbolic link, so the cases that need either run where the platform gives it. The session layer's delivery there was measured by the release's own probes, live, at Claude Code 2.1.281: the scope hook's reason reaches the agent, the bypass deny refuses, the Stop hook's refusals and the session-start line arrive, and a resumed session keeps the re-grounding pointer.

The history and the measurements

New in 2.11.0 (spec 0175), the boundary of the close review, which the owner took into the release on 2026-09-23. What the gates do check is the block's shape and the skip: a finding without a file and a line is refused, a round reading PASS beside a FAIL criterion or a MAJOR finding is refused as a block that disagrees with itself, a third round is refused, and a SKIP-DOCS-ONLY block on a close that touches a role path is refused, because the role paths decide the skip and the reviewer never does. What they cannot check is whether the reviewer read well, or whether the block was the reviewer's at all: a PASS a person typed reads as a reviewer's PASS, which is why the human's decision at the cap has a token of its own and every round's findings stay in the spec for a later reader. The rule is dated by the close's own plugin version, so history made before 2.11.0 is not judged by it.

Diagrams as claims (2.7.0)

The history and the measurements

That a box, an arrow or a label is TRUE of the mechanism is what review establishes, and chore/diagram-baseline's file-by-file human review is the first instance of it: the one legitimate scan in the framework is the one a person signs. Three verdicts, by whose node it is: a stale node the CLOSING spec drew refuses (SLH-DIAGRAM-STALE-NODE), an earlier spec's stale node is REPORTED with two honest exits and refuses nothing, and a name that is not path-shaped is printed rather than checked (below). The twelve-node bound is reported at validate and refuses nothing, as are validate's other three diagram reports. The whole half is OPT-IN, and there are TWO ways to opt in, which is a correction this bullet owes you: a file under docs/diagrams/ carrying the four-line header, or a diagram_command declared in .claude/sdd.json. A project with neither behaves exactly as it did before 2.7.0, which a differential fixture proves rather than assumes. A project that declares diagram_command is armed by that declaration ALONE, with no docs/diagrams/ in the tree at all, because the lockfile arm is the half it opted into. What arms the checks is what the diagram half itself writes, not the directory, and that is a correction this release made to itself: until the second 2.7.0 leg the switch was the mere existence of any file under docs/diagrams/, an image file included, so an upgrade armed the checks for any project that already kept diagrams at that conventional path and started refusing closes it had passed the week before. The switch is now a file under docs/diagrams/ carrying a Synced by: header line, which chore/diagram-baseline writes on an upgrade and /setlist:new writes on a new project, OR a declared diagram_command, because a project whose only diagram surface is a generated view carries no header anywhere and the generated file is the command output rather than a drawing anyone signed. The residue is worth knowing and is not reported anywhere: a docs/diagrams/ tree whose files carry no header does not arm the checks and nothing tells you so, which is the quiet direction rather than the refusing one. If you have drawn diagrams there by hand and want them gate-checked, add the header (cut chore/diagram-baseline, or write the four lines) and the checks come on. One consequence of arming them is worth knowing before you do: on an armed instance the OLD field form (updated in this commit, naming no files) is refused as a claim that names nothing, because a claim nobody can check is the thing this release exists to remove.

Until 2.11.0 two bullets of their own stood beside this one, merged here in 2.11.0 because all three say what a diagram check compares and where it runs: "A drawn name is verified only when it is path-shaped, and everything else is reported rather than checked." and "The render check runs where a renderer runs, and the stamped workflow fails its job rather than passing without one.".

What a drawn name is. Every unverified name is PRINTED with what it is, a node, a subgraph id or a subgraph title (SLH-DIAGRAM-NODE-SKIPPED, up to ten listed with a count beyond that), so the boundary is visible at every close rather than discovered later. What the print cannot do is turn a prose label into a claim: a module identifier with dots and no slash, and a subgraph that draws a process or a trust boundary rather than a directory, have no path to resolve and are checked by nobody. Three Mermaid spellings are not read at all, and they are named here and in the diagrams skill so a drawer is told rather than believed: the rhombus and hexagon ({...}), the asymmetric (>...]), and a label separated from its id by a space (a ["src/a"]). The last of those is the price of a strict adjacency test, which was measured and kept because allowing whitespace between an id and its bracket would re-open the false-refusal direction described below. All three are a design boundary rather than a scheduled fix (2.10.0): opening them at the layer that carries the guarantee would surface new refusals in live instances, so they stay printed as unread rather than read. Until 2.10.0 an Open limitation sat beside this bullet, "Mermaid's inline edge-text form can refuse a close for a path nobody drew.": a -- reads(src/gone.json) --> b put its text where a declaration's label goes, so a path inside it was read as a node nobody drew and could refuse the close. It was the narrow remainder of a wider defect found and fixed before 2.7.0 shipped, when the node reader read any bracketed span. Since 2.10.0 the reader treats a span opened by Mermaid's text openers (two dashes, two equals, or a dash and a dot) and closed by a link as an edge label, as the pinned Mermaid parser does, and a declaration beside it is still read; the pipe form, -->|"text"|, stays what every type reference teaches.

Where the render check runs. A Mermaid block that does not parse is caught at the forge or on the rendered page, never at the commit, because a git hook that needs a toolchain fails for reasons unrelated to the work on a machine whose node nobody chose. The price is a network fetch inside a required check, and it is smaller than this bullet first said it would be: the pinned tool exposes a parse-only entry point, so the step installs mermaid@11.14.0 and jsdom and nothing else. Measured on one host, both paths: parse-only 1.8s and 143MB, a full render 30s and 484MB of packages plus a 493MB browser download, with the two verdicts agreeing on every case of a twelve-case corpus in both directions. So the browser download is not paid. The fetch is also a dependency: a required check that installs a parser from the npm registry can be blocked by a registry outage or by a bad transitive release of a package the pinned version pulls in, and no lockfile is stamped beside the workflow, by decision (2.10.0), because a lockfile removes only the second case and is one more maintained artifact in every instance. The claim the check makes is that the block PARSES under the pinned version, not that a picture was produced. FC-DIAGRAM-NO-RENDERER is a report and is reachable only OUTSIDE the stamped workflow, which is what keeps "never a pass on absence" true where the check is required; the step also verifies its own parser in both directions before the check runs, because a parser that accepts a broken block would turn every diagram into a claim nobody checked while reporting that it did.

The session layer: advisory, and its one parser frozen

The history and the measurements

This bullet is five merged into one (2.13.0). Through 2.12.0 four design boundaries each said how the session layer's one deny can be passed, and one upstream condition said that a timed-out hook is a skipped gate. Since 2.11.0 the session layer answers no permission prompt and refuses nothing but that one deny, so four bullets on how a nudge can be dodged described a guarantee the layer no longer claims; and since 2.13.0 the bypass deny blocks when it fails to run, which lifts the condition for the one session hook that refuses, while a skipped scope or Stop hook is advice missed, as the bullets on the scope hook's warning and on the Stop hook already say. Every claim of the five stays below, each under its title as it stood.

"The session layer's one hard deny is defeated by ordinary variable expansion, so read it as a nudge and never as a boundary." (a design boundary through 2.12.0). The bypass deny carries the session layer's single hard refusal, CM-BYPASS-SPELLED. It exists to stop a command that would disarm the git hooks. git commit -m ${MSG} --no-verify is allowed in silence, because an unquoted expansion before the flag defeats the lexer that looks for it. What it said to do: Treat the deny as a nudge that catches the obvious spelling. Put the boundary where it holds: the git hooks, and the forge check on a trunk that requires it.

This bullet is written at this strength because the alternative is worse than the hole. The gate's other verdicts have been advisory in mechanism since 2026-08-04; this one deny is the exception, and it is the one a reader is most likely to mistake for a boundary. What defeats it is not exotic. -m ${MSG} is how a shell script writes a commit message, so a developer meets this while doing nothing unusual, which is why the sentence above does not say "crafted spellings evade it". Crafted spellings evade it too, and they are the same hole rather than a second one: ANSI-C quoting (git commit $'--no-verify'), a global option that takes a separate value and shifts where the subcommand is found (git --attr-source HEAD commit -n), and --config-env=core.hooksPath=VAR, which moves the hooks path without tripping CM-HOOKSPATH-MOVED. All four were measured against the shipped bytes by the 2.7.0 adversarial review, each with a replay. The 2.8.0 adversarial review measured more of the same hole on the shipped bytes, each against a repository whose git hooks refuse: the escape variable behind a wrapper other than a bare env or export (/usr/bin/env SETLIST_SKIP_HOOKS=1 git commit -m x, time ..., nice in front of env, and env clearing the environment) and a redirection such as 2>&1 placed before the flag (git commit -m x 2>&1 --no-verify). A backslash-newline line continuation before git or the flag, measured here in 2.8.0, is no longer among them: since 2.10.0 the lexer joins a continued line as the shell does, so the flag is read in every spelling and the command is denied. None of the others is repaired, and the reason is the parser freeze of 2026-08-04, recorded in the bullet below: the guarantee moved to the git hooks, each parser repair here has historically introduced its own defect, and widening a lexer to chase spellings is the coverage chase this project has twice removed checks over. The git hooks are unaffected by every one of them: what is lost is an early refusal in the session, not the boundary at the trunk. And on a machine where jq or awk does not work, the deny says nothing at all: the rule is simply absent there, and because the spellings it governs are exactly the ones that disarm the git hooks, the git hooks are no fallback for it.

"The session layer's one command reader is a frozen text parser, and a short list of spellings reads a command wrongly." (a design boundary through 2.12.0). Since 2.8.0 the bypass deny's lexer is the only session code that reads command text; the scope hook reads file paths. The lexer moved verbatim, and it is frozen and pinned by digest. It has changed three times since, each by decision. In 2.8.0 it stopped the false denials its own review found. In 2.10.0 it stopped two more and began to read a heredoc piped into an interpreter. In 2.11.0 it began to read a walked wrapper's options by that wrapper's own grammar, and it stopped counting a reader that can run a shell as data. A short list of spellings still misleads it. What it said to do: Read the bypass deny as a nudge and the git hooks' refusals as the answer. Keep the Architecture diagram: label on one line of the spec, inside the Closing report, because the git hooks read that one item the same way.

For all but one item the git hooks judge the same operation correctly afterwards. The one exception is not a command and is recorded here because this bullet has always carried it: the architecture-diagram field is read from the whole spec rather than from the Closing report section, so a mention of the label ELSEWHERE in the spec can decide it in either direction (the readers take the FIRST matching line, ruled first-line-wins in 2.3.0), and that first-match reading is in the merge hook and the audit alike, so no later layer corrects it and it can let work reach the trunk. What the lexer misreads is measured: the spellings that defeat its one refusal are in the hard-deny bullet above. In the refusing direction the 2.8.0 adversarial review measured three classes on the shipped bytes, and the owner ruled them FIXED in 2.8.0 inside the pinned lexer, the one exception taken to the freeze: a heredoc body written through cat, tee or git that quotes a disarming spelling (in 2.8.0 for the spaced spelling, cat << EOF; since 2.10.0 also for the no-space spelling, cat<<EOF, and behind a prefix a person types before the command: an assignment, env, export, command, nohup, exec, sudo or one leading redirection), a read-only search whose pattern or post--- operand is the spelling (git log -S --no-verify, git grep -- --no-verify), and a combined short-flag commit whose message is the spelling (git commit -am "--no-verify"). One refusing case remains that blocks a command a person can actually run: a git word that is an argument to another command, such as echo git commit --no-verify, is still denied although it runs no git. A second was measured by this release's own fix round and left as it stands: a search that puts the spelling in OPTION position, such as git grep -n --no-verify or git log -p --no-verify, is still denied, and git itself refuses that spelling (error: unknown option), so what the deny turns away there is not a command that could have run. Every spelling git accepts as a search for the token is allowed, with or without other flags, and whether the search option stands alone or is combined with others: git grep -e --no-verify, git grep -n -e --no-verify, git grep -- --no-verify, git grep -n -- --no-verify, git log -S --no-verify and git log -pS --no-verify. (A commit message that is exactly an escape spelling, such as git commit -m SETLIST_SKIP_HOOKS=1, was denied until the 2.7.0 leg's fix and is not any more.) Everything else is FROZEN by decision: the lexer and its corpus are closed to new spellings as of 2026-08-04, because the guarantee moved to the git hooks and each parser repair has historically introduced its own defect, and since 2.8.0 its bytes are pinned by digest, so a widening is a visible decision rather than a drift. Through 2.7.0 this bullet described three advisory gates and listed some twenty spellings the commit gate and the close gate misread, from a checkout run in a different repository to a quoted merge operand outside the ASCII keep-class, two of them with bullets of their own (a <<\EOF heredoc read as code, and @{u} refused as unnameable). Every one was a property of those two parsers, and all of them left with the parsers in 2.8.0, the two bullets included; the bypass deny's own lexer read heredoc bodies as commands too, a separate refusal fixed in 2.8.0 and described above. 2.10.0 changed the lexer a second time, by decision, and names what it left. Two false denials of ordinary work were fixed (a prefix before the heredoc's command, and the no-space operator, with an arithmetic shift such as $((1<<2)) still never read as an operator), and one crafted bypass was closed: a heredoc piped into bash, sh or zsh later in the same pipeline (cat << EOF | bash, cat << EOF | tee log | bash) has its body judged as commands. What it left, each pinned in the suite in its observed direction: a prefix outside that list keeps the old denial (sudo -u root cat << EOF, nice cat, time cat, and env clearing the environment before cat), the conservative direction; an interpreter the re-judge does not name (| dash, or | awk '{system($0)}' behind a plain owner) is not re-judged; and four crafted spellings still carry a heredoc's body past the deny to an interpreter, each with a PLAIN owner (no prefix, no glued operator): an interpreter behind a wrapper spelled by its path (| /usr/bin/env bash), a heredoc inside a brace group whose output is piped on ({ cat << EOF; } | bash), a redirection such as 2>&1 before the pipe, and a pipe carried past the newline to a later line. This release's own adversarial review closed three more and fixed two false denials, each red first: a substitution around the opener is judged wherever it was opened, on the opener's line as on an earlier one (a command substitution that opens a heredoc on the same line and hands its output to the shell to run); a walked wrapper's no-value options no longer eat the interpreter behind them (| command -p bash, | exec -a x bash); each body is credited to its own opener, so a body one segment reads as data is no longer judged because a later opener on the same line is piped to a shell; a backslash-newline continuation is joined rather than made into a word, which stopped refusing an ordinary git commit -m \ across two lines and a read-only git log --grep \; and a new-owner heredoc piped to a data sink (cat<<EOF | tee -a FILE) is data, where the redirection spelling one character away already was. 2.11.0 changed it a third time, by decision, for the 2.10.0 second leg's findings: a walked wrapper's options are read by that wrapper's own option grammar, the rules getopt applies, so a glued, bundled or long option, one the old list lacked, or a NAME=value word no longer hides the interpreter behind it (| env -Sbash, | sudo -Hu root bash, | env --unset A bash, | env -C . bash, and env clearing the environment before an assignment and bash); nice and time are walked the same way (| nice bash, | time -p bash); sudo -s or sudo -i with no command after it is read as the shell it starts; and awk, sed, less and more left the list of data sinks, because each has a command that runs a shell, so a new-owner heredoc piped to one is judged (cat<<EOF | awk '{system($0)}'). The git hooks refuse what any of them would commit, and the deny is a nudge, as the bullet above says. The short form and this text used to disagree, one saying all but one item and this one saying two, because the heredoc member had once run your gate command before an ordinary commit; that stopped in 2.6.0 and the member has left, so both layers now say one.

"The session layer's one deny stops a command that disarms the hooks for its own run; a persistent change to core.hooksPath or merge.ff, and a fresh clone, are reported at the next session start and refused by nothing." (a design boundary through 2.12.0). git config core.hooksPath /dev/null, git config --unset core.hooksPath and a fresh clone leave the git hooks off for every later command; a fresh clone's .git/config was never armed. The deny does not read them. At the next session start the re-grounding hook reports the setting on one line, [SR-HOOKS-NOT-ARMED], with its value and the command that restores it. What it said to do: Run /setlist:upgrade when a session opens with that line (from the main worktree, if you are in a linked one). On a team, the forge check on a trunk that requires it is the layer a disarmed clone cannot switch off.

Found by the 2.9.0 external review; the report arrived in 2.10.0. It reads the repository's own configuration (git config --local, the file the stamp and the refresh write), so the environment forms of git's configuration (GIT_CONFIG_COUNT, -c) and a user's global setting are outside what it reads, and so is a hook file whose execute bit was removed (chmod -x), which is a mode rather than a setting. It is silent on an armed instance, before the repository exists, and outside an instance; where git cannot be asked at all (git missing, or a configuration it cannot read) it says THAT instead, [SR-CONFIG-UNREADABLE], rather than reporting an armed clone as unarmed. On a machine where jq does not work the line names the setting without its value, because a value is text the instance controls and that path has no escaper. It is a report and not a refusal: SessionStart has no deny, and the deny's own reach is the hard-deny bullet above.

The wiring check's spelling restriction, a bullet of its own through 2.10.0 ("The wiring check recognises only the command spellings settings.json.tmpl ships."), retired in 2.11.0. refresh-instance.sh used to certify a hook entry as wired by matching its command against an enumerated set of spellings, which failed both ways: bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/bypass-deny.sh, which runs the stamped file, was reported NOT WIRED and --apply exited 3, while "$CLAUDE_PROJECT_DIR"/.claude/hooks/bypass-deny.sh >/dev/null, which runs it and discards the decision it prints, was certified wired because its first word matched; and a comma-joined matcher (Write,Edit,MultiEdit,NotebookEdit), which the harness accepts, was reported NOT WIRED by the wiring predicate while the coverage predicate beside it accepted it. Since 2.11.0 an entry is wired when the file its command runs IS the stamped file (the same file on disk): the command must be one plain run of one file, optionally through bash or sh, named by an absolute path or through $CLAUDE_PROJECT_DIR (an unquoted 8.3 short path, such as C:/PROGRA~1/..., is not read as one plain word and reads NOT WIRED: a hand-written spelling no Setlist writer produces, so quote it or write the long form), and anything else (an argument, a redirect, another variable, a pipe, a subshell) reads NOT WIRED with the entry named; a comma-joined matcher is read as the alternation it means. The check reads the settings file; it does not run the hook, so what the wired file itself does is the hook's business, not the check's.

"The Bash escape hatch." (a design boundary through 2.12.0). The scope hook watches the file-writing tools (Write, Edit, MultiEdit, NotebookEdit). So a session that writes files through Bash instead (cat >, sed -i, a heredoc) does not trip it. pre-commit checks what a commit contains, whatever tool wrote the file. Since Claude Code 2.1.290, by its changelog, the harness no longer asks before a sandboxed heredoc piped into another command. So the harness prompt that stood in front of the crafted heredoc spellings the frozen lexer does not read is gone, and the trunk audit at push is the catch. What it said to do: Read the deny list as a spelling list (the conventional secret-bearing .env names and the deny entry for reading environment files), and read the audit as the backstop.

The same boundary applies to git itself: the bypass deny reads the command it is given, so a git command run through another interpreter (sh -c '...', a backtick, a script file) is not a command it can read; a heredoc piped into bash, sh or zsh is read since 2.10.0, with the crafted spellings the parser bullet names. Wrapper prefixes that a person actually types (command, env, nice, nohup, exec, and leading VAR=value assignments) are handled by its lexer for --no-verify and -c core.hooksPath, but NOT for the escape variable, which it reads only at assignment position after nothing but a bare env or export (the hard-deny bullet names the measured spellings); and that list is not a claim of completeness and cannot be one. The trunk audit below is the designed catch for this whole family: it reads what ended up in your history and does not care how the command was spelled. The stamped deny list is the same kind of thing and its template says so in a _comment key: the Read rules stop the Read tool on seven conventional secret-bearing names (.env, .env.local, .env.*.local, .env.production, .env.development, .env.staging, .env.test; narrowed in 2.10.0 from Read(.env.*), which also hid the .env.example the stamp writes), and, since 2.6.0, the deny entries for reading environment files stop one Bash spelling, on the same seven names since 2.14.0 (one pattern before that also refused reading .env.example); a secret file named any other way is not covered.

"A timed-out hook is a skipped gate." (the one upstream condition through 2.12.0). Claude Code cancels a hook that exceeds its timeout and lets the tool call proceed. That behavior belongs to the harness and is no part of the hook. A hook whose matcher is not a valid pattern is skipped too. On Claude Code 2.1.287 and 2.1.292 a PreToolUse hook with the matcher Write|( did not run and the write went through. The inverse case fails closed: since 2.1.288, by Claude Code's changelog, a call whose input the harness cannot hand to a hook is blocked; no prompt produced such an input to measure. What it said to do: Raise timeout in .claude/settings.json before the ceiling finds you. Fix PATH inside the gate command and leave your profile alone. Keep each matcher a pattern: /setlist:upgrade's refresh reports a scope-hook matcher it cannot read as one.

The stamped settings therefore set explicit timeouts (300 seconds for each Bash hook since 2.6.0; through 2.5.0 the close gate carried 30 minutes because it re-ran your full suite inside the hook, a run 2.6.0 removed: the git hook runs the suite once, at the merge). If a hook outgrows its timeout, raise the timeout in .claude/settings.json rather than letting the ceiling find you. Relatedly, hooks run in a non-interactive shell: version managers wired into your interactive profile (nvm, pyenv, asdf) may be off PATH there, which shows up as a gate command that fails in the hook while passing in your terminal. That is a false deny, not a false pass; fix the PATH in the gate command itself. The harness also moves a hook's output longer than 10,000 characters to a file and shows the agent a 2,000-character preview; measured inside a stamped instance since 2.11.0, the scope hook's longest output is 1,742 characters (a recorded trunk of any length is now cut at 80) and the Stop hook's 951, so neither comes near it.

Teams: the forge, ownership and custody

The history and the measurements

What it cannot do is require itself: a trunk that does not list it as a required check has a report, not a boundary, and under forge custody the check refuses for exactly that reason. It does not review merge diffs (the bullet below stands). A repository that merges by REBASE lands every branch commit on the trunk as direct feature code: the check reports the setting before the fact, no later run of the check sees the commit (each run audits only its own range), and an armed clone's pre-push refuses every push of that trunk from then on, so disable rebase merging on a trunk the check governs. And the check judges the merge against the base its checkout carries: the forge's own setting that requires a branch to be up to date before merging is what keeps that base current; the check reads it and, under forge custody, refuses a trunk that requires the check without it (FC-STRICT-NOT-REQUIRED, since 2.7.0; this sentence said the check did not read it until 2.10.0), and it is still a setting you turn on, which the check cannot turn on for you.

Until 2.11.0 this bullet was titled "The forge check governs the merge button only where the trunk requires it.", and two bullets of their own stood beside it, merged here in 2.11.0 because all three are one fact about the forge check, that it can judge only what the forge exposes when it runs: "The forge check reads the trunk's protection as it stands, not as it stood." and "The forge check runs the pull request's own copy of itself, and what makes an edit to it a reviewed change is the forge's code-owner review setting, which the check now reads.".

The protection as it stands. The stamped CODEOWNERS makes the paths that matter reviewed changes where the trunk requires review from Code Owners, which the check reads (2.10.0); the forge's audit log is where the rest of that history lives, and the check does not read it.

The pull request's own copy. Found by the 2.9.0 external review, and closed in 2.10.0 as far as a check can close it. Under forge custody the check reads the setting from the trunk's protection and refuses when it is off (FC-NO-CODE-OWNER-REVIEW); under every other custody it reports it beside the review count. When a pull request's diff touches .githooks/, .claude/hooks/ or .github/workflows/, the check reports so by name under every custody (FC-ENFORCEMENT-PATH-TOUCHED), before any step can refuse; a report and never a refusal, because the reviewed change is the mitigation. Running the base branch's copy instead was considered and declined: on a pull_request event the workflow definition itself is the pull request's, so a base-branch script run by a workflow the pull request controls moves the question one file up without answering it, and two copies of one mechanism is the shape the check's own header refuses.

The history and the measurements

Sections, negations and character classes are refused by name rather than guessed at. Why character classes stay refused (2.11.0, spec 0173): GitHub's own CODEOWNERS grammar evaluates no character class (its documentation says a [ ] range "doesn't work", and that a line with invalid syntax is skipped), and the forge check reads Owns: with this same reader, so a reader here that evaluated [ab] as a class would claim files for an owner the forge itself does not, a refusal of a close the forge accepts. Refusing the line by name keeps the two readings one. Measured on 2026-10-06 on a scratch repository, GitHub flags such a line as an invalid pattern and evaluates no class. Locally, only email owners can be matched; a handle or a team is resolved at the forge, and the audit reports rather than refuses when it cannot resolve one. Ownership here is what the file says, not what an organisation chart says.

The history and the measurements

But the declaration travels in the same commit it justifies: whoever writes the close can write the declaration, and the hooks verify coverage, not truth. With a declared attestation custody the declaration sits inside the approved, signed spec bytes, so changing it costs a human re-approval; without one, it is an explicit line in your history that review and /setlist:validate can catch, and nothing more. A spec that declares nothing closes under the pre-2.4.0 rules, the whole-commit exemption included, so the audit is only as complete as your adoption; chores declare their files in the machine record (chores.<id>.files), which no attestation covers, so the chore route is the weaker one by exactly that much. And the machine record in .claude/status.json is what the hooks read: the human STATUS.md page is for people, and a hand-edit can make the page disagree with the record until /setlist:validate reports it.

Two specs in flight that declare one file (2.11.0): at a two-parent close the audit reads the trunk's own first-parent commits since the branch left the trunk, and refuses the close by name (SLH-OWNS-OVERLAP) when a spec one of them closed declares a file this close declares; the remedy is to merge the trunk into the spec branch and close again. It reads no other branch, so three shapes are its boundary: a second close landed by squash or fast-forward has no branch point and is not compared; the git pull sync shape (a clone that closed on a stale trunk and pulled) puts the earlier close on the pull merge's second parent, off the line the audit reads, and passes; and a branch that took the earlier spec by merging that spec's BRANCH rather than the trunk is refused although it carries the change, a false refusal the same remedy clears. A close whose own .claude/sdd.json stamps a plugin older than 2.11.0 is not asked the question, so an upgrade never condemns parallel closes made before it. Since 2.15.0 the claims are refused earlier, at push: pre-push refuses a spec branch whose spec the trunk does not carry at ACTIVE and a spec number the remote holds under another slug. They read the push and the remote as they find them, by the branch's name and the spec file's name, so a claim nobody pushed, a push that skips the hooks, a trunk the remote refuses in a push that is not atomic and a forge merge are outside them, they compare no Owns: sets, and a pushed commit stamping a plugin older than 2.15.0 is not asked.

Until 2.11.0 this bullet was titled "The close audit's single-parent arm is as strong as your declarations, and a declaration is a claim, not a verified fact.", and the signing key was a bullet of its own, "The headless integrity chain is only as strong as where your signing key lives, and a key your build can reach is not custody.", merged here in 2.11.0 because both say one thing: what the hooks verify is that a declaration is present and signed, never that it is true. Its history follows.

It does not stop the unapproved build from HAPPENING: the only layer present when code is written is the session layer, and that layer has been advisory in mechanism since 2026-08-04. It stops the result becoming a commit, and at push, being shared. A signature proves a key was used, not that a person decided. If the signing key sits where the build process can read it, a headless run can sign its own approval and the chain will verify: what it then proves is that the run had the key, which is not the question. Setlist therefore makes you DECLARE your custody, and prints that declaration in every verification it emits, including the ones that pass, so the strength of the claim travels with the claim. Two things it does not close under signer custody, named rather than implied: the allowed-signers file is read from the tree under review, so a committer who can push a key enrolment and a signed attestation in one range verifies under that key (a forge that protects .claude/ through the stamped CODEOWNERS makes the enrolment a reviewed change, which is the mitigation and not a fix); and --no-verify and an unset core.hooksPath bypass this exactly as they bypass every other git hook. Under forge custody (built in 2.6.0) there is no key: the approval is the ACTIVE flip landing on the protected trunk through a required review, verified by the stamped forge check, and what that establishes is that the forge's review happened, with the forge's account security as the custody. If you need a boundary that holds against deliberate evasion, it is that check, required on your trunk.

The history and the measurements

This is a decided boundary, not an accident (2.4.0 review, ruled 2026-09-01): the ownership reader and the hash cut are kept byte-agreed, because a declaration the reader accepts but the signature does not cover would put the ownership set outside what a human approved, silently, exactly where this layout occurs. The refusal names the cause and the one-edit fix: move the declarations above the quote, or drop the quoted heading line. The suite pins the agreement, so a future fence-aware reader has to move the hash cut, the custody question and this bullet in one commit.

The history and the measurements

A session killed from outside, or one that never reaches its Stop event, fires nothing, and a change staged and never committed is not its question; the git hooks are what refuse the work. Its reason is delivered to the agent, as the bypass deny's is and, since 2.9.0, the scope hook's through additionalContext (Design boundaries above).

It is silent where the project root has no .git entry of any type, which is the state /setlist:new leaves until /scaffold creates the repository; 2.6.0 to 2.8.0 refused there, at the end of every new project's first session. The same silence covers an instance stamped below an enclosing repository's top, whose git layer the stamp already reports as not armed, so a spec left unstaged there ends the turn unrefused. A linked worktree (a .git file), a corrupt .git and a dangling .git symlink are still refused. Until 2.10.0 there was one exception, measured by the 2.9.0 adversarial review and standing since 2.6.0: a corrupt .git beneath an enclosing repository was not refused, because git walked past it and read the enclosing repository in its place. Since 2.10.0 the hook compares git's top-level with the project root, physically, and refuses by name (SP-NO-GIT) when they differ while a .git entry exists at the root.

The history and the measurements

Entered in 2.14.0 as the boundary of the repository split, under the rule that a new bullet enters only as the bound of a new capability. A gate that read across repositories would need the other repository's history at every commit, merge and push, which a git hook in one clone does not have, and the forge check runs in one repository's workflow with that repository's access. Reading across them would be a coordination plane, which Setlist does not have by design ("Why one spec at a time" in the README).

Deliberate evasion

The history and the measurements

Until 2.11.0 this bullet was titled "Merges crafted to evade the trunk audit can succeed, and the list of known routes is maintained rather than complete.", and two bullets of their own stood under this group beside it, merged here as its routes in 2.11.0 because all three are one boundary, a committer arranging history the audit judges: "Sideways routes to the trunk." and "The trunk audit reports a merge that edits a file outside its conflicts, and cannot tell an edit inside them from conflict resolution.".

Crafted merges. Two routes are known and OPEN: a chained merge (unspecced code merged INTO a spec branch, then that branch merged with a compliant close), and the OCTOPUS spelling of the same trick, where parent order hides the unspecced parent behind a catch-up parent. Both were found by adversarial review rather than by use. Both were refused for a while, and the checks that refused them were removed on 2026-08-07, because neither could tell the crafted shape apart from ordinary work: the chained-merge check reported git pull on a shared trunk as a violation, naming as the offender the compliant close merge it had passed clean minutes before. That is the second developer on any team, on the commonest git command there is. A false denial on ordinary work costs more here than the bypass it prevents, so the route is documented instead of defended. The history is the point of this bullet: the second route was introduced by the fix for the first, and the fix for both broke everyday use. A cooperating developer does not construct these; someone determined to bypass the audit will find the next one before we do. Put branch protection and required checks on your forge if that matters to you.

Since 2.11.0 an octopus ONTO the trunk is refused by name (SLH-OCTOPUS-MERGE), at merge by pre-merge-commit and pre-commit and at push by the audit for any such merge made after the instance adopted the rules, when the merge's own tree stamps plugin 2.11.0 or later (an octopus an earlier release accepted is not refused after an upgrade): a close merges one spec branch, so a merge of several branches onto the trunk at once is never a compliant close, and the shape needs no discriminator. It is not the octopus route above, which merges INTO a spec branch and reaches the trunk through an ordinary two-parent close; that route, and the chained one, stand as written.

Sideways routes. Merging a spec branch under a second NAME used to be on this list and left it in 1.0.8, when the close gate began resolving a merge argument to a COMMIT; since 2.8.0 the git hooks judge a merge by the spec rows it closes, which makes the name irrelevant (the alias paragraph below). The remaining routes are named rather than parsed, and a visible edge is more honest than a half-parser that would miss the next spelling. Each of them is caught by the trunk audit below FOR COOPERATING USE, which is why the layers exist: the per-merge hooks refuse at the moment of a merge they can see, and the audit reads history afterwards so it does not care how work arrived. It is not immune to being spelled around either: merge TOPOLOGY crafted to evade it (a chained merge, an octopus parent order) is a separate class, the first route above, and closing those routes has not been convergent. git pull <remote> spec/... used to be on this list as a fetch-and-merge the close gate did not intercept; its merge runs pre-merge-commit, which refuses unclosed work with SLH-CLOSES-NO-SPEC, measured for 2.8.0.

Three routes were bullets of their own from 2.6.0 to 2.9.0, merged here in 2.10.0, split out then because each is a command a person runs for ordinary reasons while the close gate was an early layer that missed them; since 2.8.0 there is no early layer to miss them, and all three ended in this bullet's sentence. Each was measured on the shipped bytes with controls in both directions (a compliant close pushes clean, an unclosed merge on the trunk is refused). git rebase onto a spec branch ("brings that branch's commits to the trunk with no closing merge"): Measured: run on the trunk, it leaves the spec's role-path file on the trunk, and the next git push is refused with VIOLATION ... feature code committed directly to main, remote unchanged. Through 2.7.0 the suite pinned this route through the close gate; since 2.8.0 no case drives it end to end, and the suite's ledger says so rather than claiming a pin. Close a spec with --no-ff instead, which is what /setlist:checkpoint does. One qualification, measured by adversarial review and corrected here: that refusal holds for the bare form. Composed with a compliant single-parent spec close that declares no ownership, the audit's close exemption applies and the push SUCCEEDS; a close that DOES declare is audited file by file instead. See the single-parent declarations bullet above for the exemption and its limits. git reset --hard <spec-branch> ("moves the trunk onto unclosed work outright"): Measured: the trunk ends up at the spec branch's tip carrying its role-path file, and the push is refused with the same VIOLATION, remote unchanged. Reach for git reset --hard on a trunk only when you mean to discard local commits, never as a way to take a branch's work. One qualification, measured by adversarial review and corrected here: that refusal holds for the bare form. Composed with a compliant single-parent spec close that declares no ownership, the audit's close exemption applies and the push SUCCEEDS; a close that DOES declare is audited file by file instead. See the single-parent declarations bullet above for the exemption and its limits. The pathspec git checkout <spec-branch> -- <path> ("copies role-path files onto the trunk without any merge to read"): Measured: the file lands on the trunk at exit 0 and the push is then refused with the same VIOLATION, remote unchanged. If you want one file from a spec branch, take it through the spec that owns it. One qualification, measured by adversarial review and corrected here: that refusal holds for the bare form. Composed with a compliant single-parent spec close that declares no ownership, the audit's close exemption applies and the push SUCCEEDS; a close that DOES declare is audited file by file instead. See the single-parent declarations bullet above for the exemption and its limits.

An alias of a spec branch was also a bullet of its own until 2.10.0 ("An alias of a spec branch is judged by what its merge closes, never by the name it was merged under."), and it left the list because it describes the mechanism working rather than a place it stops: the git hooks never identified a close by its branch name, so an alias of unclosed work is refused whatever it is named. Measured for 2.8.0 on an instance armed with the stamped hooks: an alias cut at an unclosed spec branch, merged onto the trunk after the spec branch advanced, is refused by pre-merge-commit with SLH-CLOSES-NO-SPEC and the trunk does not move; the suite pins that case. Through 2.7.0 both guarantee layers were verified by execution to refuse the same merge while the session layer's warning disappeared, and this bullet recorded that residue; the residue left with the gate. A decided boundary rather than an open defect, moved to this group 2026-09-02: a close is what a merge flips, not what a branch is called.

A merge that edits outside its conflicts. An edit INSIDE a file that conflicted is indistinguishable by content from a legitimate conflict resolution, so it is not flagged: flagging it would refuse every real merge. Until 2.11.0 the title read "The trunk audit cannot tell a merge that EDITS a file from ordinary conflict resolution.", and this history said that a merge editing a file a parent already had "is indistinguishable by content from a legitimate conflict resolution, so it is not flagged: doing so would refuse every real merge". That held for every edit then; since 2.11.0 it holds only inside the conflicted set, because git merge-tree --write-tree computes the merge git would have made, and a resolution changes only the files that conflicted. The report is dated by the release: a merge whose own .claude/sdd.json stamps a plugin older than 2.11.0 is not compared, so an upgrade never reports on history made under older rules. It reports rather than refuses for this release, measured across one before a refusal is considered; a merge made with a strategy that keeps one side whole (-s ours) is reported too, because its tree differs from the clean merge outside any conflict.

The history and the measurements

Until 2.9.0 this was an Upstream condition, "The scope hook's warnings do not reach the agent on current harnesses." The hook emitted its reason on permissionDecisionReason, systemMessage and a machine-readable setlistAdvisory field, and none of them reached the model when the hook's decision permitted the write, which an advisory's decision then always did. That bullet's own history recorded the plan: if a probe confirmed the sibling field additionalContext delivers an advisory to the model, the bullet would become an open limitation whose fix is to use that field. It skipped that step: the confirmation and the fix landed in the same release, so the open state never stood on a published list, and the bullet was replaced by this boundary rather than moved.

How 2.9.0 measured it, at Claude Code 2.1.274. Two arms, each a scratch instance whose minimal hook emits additionalContext carrying a per-run sentinel and an instruction to emit a token, and writes a marker when it fires: one on PreToolUse beside a decision that permitted the write, one on PostToolUse. Each was read against a negative twin (the 2.8.0 scope hook wired on the same event, with a marker hook beside it) in the same run, and a control proved a hook's deny reason reaches the session and the deny takes effect. Both arms read SEEN (marker fired, sentinel echoed, token emitted), both twins NOT-SEEN with their markers fired. The scope hook uses PreToolUse, so the warning arrives before the write.

What the old channels actually did. In that run the 2.8.0 hook's reason reached the session transcript exactly once, as a system informational notice addressed to the user (PreToolUse:Write says: setlist [SH-TRUNK-WRITE] ...), and never in the model's own text: routed to the user's channel, not dropped. That notice carries the setlist prefix only systemMessage carried. 2.9.0 removed systemMessage and kept permissionDecisionReason, which the vendor documents as shown to the user beside a decision that permits; 2.11.0 removes it with the decision itself.

How the NOT-SEEN lines of 2.6.0 to 2.8.0 were taken. The per-release probe of those releases had no marker to prove its hook fired, and its session wrote src/App.js into a scratch instance already holding src/app.js; on a case-insensitive filesystem that is one file, and the write tool refuses such a write before any PreToolUse hook runs. Those lines therefore could not tell a dropped reason from a hook never dispatched. The 2.9.0 twin measured the drop on a hook proven to have fired.

Why it persuades rather than refuses. An agent has been measured attributing an injected advisory correctly to the hook rather than the user, then proceeding on its own judgement that the action was harmless. That is the delivered channel working as designed, and it is why the scope hook stays advisory: the refusal is the git hooks' job.

The scope hook's path matching, enumerated in 2.9.0 and fixed in 2.10.0. Found in 2.9.0 by enumerating the scope hook's path predicate case by case before its warning began reaching the agent, because a wrong verdict the agent can read is louder than one it cannot. Nine cases: case variance of a role path, a symlinked leaf, a symlinked role directory, a path git would quote, a non-ASCII name, dot segments, a trailing slash on the recorded role, this one, and a docs path beginning with a role's letters (which must stay silent). Three missed, and each was a bullet through 2.9.0: a role directory spelled in a different case on a case-insensitive filesystem ("A role directory spelled in a different case is not seen by the session scope gate on macOS or Windows."), a symlinked leaf, which that bullet also carried, and a relative write path read against the project root ("A relative write path is read against the project root, so the scope hook can miss a role-path write made from a subdirectory."). Each is pinned in the framework's own suite in its observed direction, so a change to the predicate turns the suite red and this bullet moves with it. Changing the predicate is a separate decision from disclosing it. Since 2.10.0 a relative path resolves against the working directory the tool call carries (the project root only when it carries none), a symlinked leaf resolves as its directory already did, for chains up to sixteen links, beyond which the hook reports that it stopped following rather than judging what it had reached (SH-LINK-CAP, advisory like the rest of this gate), and a role spelled in another case matches where a probe at run time finds the filesystem case-insensitive (a probe that cannot write leaves the match case-exact); the nine cases stay pinned in their new directions. Measured at Claude Code 2.1.276 and 2.1.280: a write through a symbolic link reaches this hook at the path the session wrote, not the path it resolves to, so the hook's own resolution stays the reading; through a link to a FILE the Write and Edit tools themselves then refuse the write and name the link's target, and through a symlinked DIRECTORY component they write (Edit, measured at 2.1.285), which the hook's resolution of the directory reads. The trunk audit asks the same question at push, and until this release's adversarial review it asked it of the wrong filesystem: its probe was written under the temporary directory, so a repository whose disk disagreed with that directory was judged by a filesystem nobody was auditing, in both directions, and a probe that could not run left the answer unset and reported clean. It probes the role directory's own volume now, not the temporary directory's, and a probe that cannot run refuses by name. One residue is folded at neither layer: a non-ASCII capital in a role directory's name, written in another case, is matched case-exact by the scope hook and by the audit alike, because both fold ASCII only.

Open limitations (0)

These are defects and gaps, and none of them is a decision. Each carries its status: the spec that closes it, the cycle it is scheduled to, or the gate it waits on. This list shrinks by fixing, never by editing: a bullet leaves it in the commit that closes its hole.

None stands at 2.15.0. 2.10.0 was folded into 2.11.0 and never shipped. The two that stood at 2.9.0 were fixed in 2.11.0 and left the list. The first: completing a refused merge on the trunk side, as the hook's own message prescribes, was accepted at commit and refused at push. The push-time audit now reads the merge commit's own record for a two-parent merge, as pre-commit reads its index. The second: Mermaid's inline edge-text form could refuse a close for a path nobody drew. It is now read as an edge label (its history is under the path-shaped-names bullet above).

Upstream conditions (0)

These are conditions in software this project depends on, and they are neither its decisions nor its defects. Each names what would lift it and the check that measures it again each release. So it stops being true by measurement, and nobody has to remember to look.

None stands at 2.15.0. The one that stood through 2.12.0, "A timed-out hook is a skipped gate.", left with 2.13.0: the bypass deny, the one session hook that refuses, blocks the command when it times out or cannot start (Claude Code 2.1.295 and later). What still skips, and the measurements, are in the session layer's bullet.