Skip to content

feat(framework): refuse an AI edit that breaks a named architecture rule - #885

Merged
blafourcade merged 12 commits into
nextfrom
chore/architecture-guard-on-write
Sep 21, 2026
Merged

blafourcade merged 12 commits into
nextfrom
chore/architecture-guard-on-write

Conversation

@blafourcade

Copy link
Copy Markdown
Contributor

🎯 What & why

docs/ARCHITECTURE.md stated cross-plugin orthogonality and nothing verified it, so a hardcoded sibling address landed and nobody noticed. Two rules now decide an AI-authored edit before it lands:

  • Orthogonality — a plugin's dispatch surface never names a sibling plugin by a hardcoded address.
  • Router coherence — a skill's ## Actions section cites every action file that skill provides.

Per the issue's 2026-09-14 comment, which supersedes the body's "Guardrail local et CI" section: no Git hook and no CI gate. The enforcement is a host-native PreToolUse hook, because the decision is to prevent the write, and PostToolUse can only report one already made.

🛠️ How it works

scripts/lib/architecture-rules.js is a pure function of a repository-relative path, the prospective content, and — for a SKILL.md — its action file names. It never reads the filesystem, so a test and a hook hand it the same shape. .claude/hooks/check-architecture-rules.js supplies the payload and emits the refusal; it fails open on every shape it does not recognise, because it gates every write in this repository.

The rule's own exceptions are encoded, not assumed: an agent's # Skills you may invoke list, an orchestration reference, and everything under a plugin's assets/ stay silent. aidd-context:00-onboard is exempt from rule one — its menus name addresses because those are what the skill hands a person to type — and that exemption is temporary, with #883 behind it.

Rule two enforces one direction only. A citation with no file behind it cannot be told apart from one written seconds before the file it names, and aidd-context:04-skill-generate documents writing the router first — enforcing that direction made adding an action refuse in both orders.

🧪 How to verify

  • node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js' → 501 pass, 0 fail. Never run that suite bare; it can overwrite this repository's .git/hooks.
  • pnpm exec lefthook run pre-push → green, including the full cli suite at 6237 tests.
  • Write a plugin reference naming a sibling and watch the call refuse; write the same address under an agent's # Skills you may invoke and watch it apply. A recorded run of both is in aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/wiring-proof.md.
  • The sweep test replays every governed file in plugins/ — 350 of them — through both rules and expects zero. It is the calibration guard, and it caught two regressions during this branch.

⚠️ Heads-up

  • Only Claude Code is wired. It is the one host this repository configures hooks for. Codex, Cursor and Copilot all expose PreToolUse and the same deny shape, but Codex delivers a file edit as an apply_patch command string — a different parse. The engine is host-agnostic, so each adapter is additive.
  • A write through a shell command is ungoverned. Deciding whether a shell line writes a plugin source means parsing arbitrary shell, which is not the deterministic verdict rule one needs. Stated as a non-goal rather than left to be discovered.
  • One plugin source was repaired, against the spec's own non-goal and named there as the exception: plugins/aidd-dev/skills/01-plan/actions/04-plan.md:16 addressed a sibling skill in prose, and leaving it would have made the guard red on its own tree.
  • Two table shapes are read generously by design; both are written down in the spec and tracked in fix(framework): the orthogonality guard's two generous table shapes #884.

The spec, plan, phases and the wiring proof are in aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/.

🔗 Linked issue

Closes #250

✅ I certify

  • I DO CERTIFY I READ EACH LINE OF THE PULL REQUEST BECAUSE I AM A SOFTWARE ENGINEER, NOT A AI PUPPY.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq

blafourcade and others added 7 commits September 18, 2026 11:57
docs/ARCHITECTURE.md stated cross-plugin orthogonality and nothing verified
it, so a hardcoded sibling address landed and no one noticed.

Two rules now decide a prospective edit: a dispatch surface never names a
sibling plugin, and a skill's `## Actions` section names exactly the actions
it provides. They live in a pure module, so a test hands them the same
inputs the hook does, without either touching disk through them.

A PreToolUse hook is the enforcement, not lefthook and not CI: the decision
is to prevent the write, and PostToolUse can only report one already made.
The hook fails open on every shape it does not recognise, because it gates
every write in this repository.

The rule's own exceptions are encoded rather than assumed. An agent's
permission list, an orchestration reference and everything a plugin keeps
under assets/ stay silent, so the guard reports nothing on the tree as it
stands — 474 script tests, and the 374 governed files replayed through the
hook as writes.

Closes #250

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq
AIDD-Session-Id: 2261efcd-a873-4e60-9893-2217f702bde2
An independent review broke the first cut in seven places. The worst: the
address pattern required a leading slash or at-sign, so the bare form
`aidd-pm:04-spec` — the one a contributor types by hand — walked straight
past it. Eight such addresses were already in the tree, six of them in
reference directories the classifier never looked into.

The rule now matches an address by its own shape at any depth, and the
router rule matches a stem as a whole token: `assert` no longer counts as
named because `assert-architecture` happens to contain it. A citation is
read from the column that actually carries action names, so a table with a
keyword column is no longer refused. Writing an action file now re-checks
its own skill's router, which is the direction a contributor creates one.

The hook spliced an Edit with String.replace, which expands `$&` and `$'`
as replacement patterns where the tool writes them literally. Both branches
splice literally now.

Two exemptions were removed and one added. Router coherence no longer skips
the orchestrator — addressing and coherence are different rules. Two branches
that no test could kill got the fixtures they were missing. `00-onboard` is
exempt because its menus hand a person the command to type; that exemption
is temporary and #883 closes it.

Refs #250

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq
AIDD-Session-Id: 2261efcd-a873-4e60-9893-2217f702bde2
A second review found the guard refused adding an action to a skill in
either order. Creating the file first was refused for not being named;
naming it first was refused for having no file behind it. The second order
is the one `aidd-context:04-skill-generate` documents, so the framework's
own generator was refused by the framework's own guard, with no way out in
the refusal text.

A citation with no file behind it cannot be told apart from a citation
written seconds before the file it names, so that direction is gone, and
with it the column heuristics and table parsing that existed only to serve
it. What remains is decidable at any moment: an action file the section
never cites.

The section names an action by citing it — a table cell, a fenced
`actions/<name>.md` path, a backticked file name — never by a word in
running prose. Containment over prose let `plan` pass because the same
section reads "the plan is the culmination"; measured over the tree it
missed 20 of 78 row deletions. Citation matching misses none that has a row.

Rule two fires on a `SKILL.md` write alone. An action file created and never
cited is caught at the next write to its router, not at its own creation —
recorded in the spec's non-goals rather than left to be discovered.

Refs #250

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq
AIDD-Session-Id: 2261efcd-a873-4e60-9893-2217f702bde2
A third review found the refusal told a contributor to keep the section
"naming exactly the action files, no more, no fewer" — the second half of
which the guard stopped enforcing a commit ago — and never said what
counts as naming one. Someone who had written the file's name in prose had
already done what the text asked. The refusal now names the three shapes a
citation takes.

Citations were collected from every cell of every table in the section, so
a glossary satisfied rule two while routing nothing, and an action whose row
was deleted stayed covered by its name sitting in a "next step" column. Each
table now declares its own action column and only that column cites.

Resolving that column once per section rather than once per table was a
defect the mutation caught: a glossary standing before the router made the
router's own column unreadable. A table is a table.

phase-1.md still specified the direction commit 29489f7 deleted. The
sweep's reason, the fixtures it names, and its acceptance criteria now say
what the engine does. One non-goal blamed the deadlock on the wrong half of
the mechanism, and claimed an orphan action file is caught at the next
router write — there is no such guarantee, and it now says so.

Refs #250

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq
AIDD-Session-Id: 2261efcd-a873-4e60-9893-2217f702bde2
A fourth review caught a regression the third commit's own hot path
introduced. A blank line inside a router table started a new block, that
block had no header row, so no column declared itself and every row below
the blank line cited nothing. Inserting one blank line — changing no
content — refused three actions the section visibly lists. A run of rows
with no `| --- |` under it is the same table resumed, and it keeps the
column its header declared.

Two header shapes were wrong in opposite directions. `Next action` was read
as an action column, so a deleted row could hide behind a routing hint; the
match is exact now. `Actions` was not read at all, so a router echoing its
own section heading refused every action it provides; the match takes the
plural. And a separator written `| -- |` — which `00-onboard` does, and
GitHub renders — was not recognised as a separator at all. The sweep over
the real tree is what caught that one, which is the whole reason it exists.

plan.md still claimed both directions of rule two were measured green, and
nothing recorded that one plugin source was repaired. `04-plan.md:16`
addressed a sibling skill in prose; the spec's non-goal keeps existing
violations out of scope, so the exception is now named where a reviewer
reads the contract, not left to be found in a diff.

Refs #250

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq
AIDD-Session-Id: 2261efcd-a873-4e60-9893-2217f702bde2
A fifth review asked for the one thing no test could supply: a recorded
refusal through a real tool call. Everything until now was verified upstream
of the wire — the tests spawn the script and hand it a payload, which proves
the script. The matcher, the `$CLAUDE_PROJECT_DIR` expansion and `node` on
`PATH` are only exercised by an actual Write, and the hook fails open at
every one of those steps, so a broken wire looks exactly like a clean tree.
`wiring-proof.md` records both halves: the refusal, verbatim, and the
permission list carrying the same address through untouched.

Two holes the review named are closed rather than disclosed. `01-plan.md`
and `04-plan.md` both reduce to `plan`, so one citation covered both and
deleting either row went unnoticed; a shared stem now cites nobody and the
numbered name still does. And a fenced block is an example: a documented
`## Actions` is no longer mistaken for the section, a `##` inside one no
longer ends it early, and an example table inside one no longer cites. The
exception is a fenced `actions/<name>.md` path, which `10-todo` uses for its
only action — the sweep over the real tree caught that the moment fences
went blank.

Refs #250

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq
AIDD-Session-Id: 2261efcd-a873-4e60-9893-2217f702bde2
A sixth review found the raw view carried two exceptions where the contract
named one. Reading a backticked `<name>.md` through a fence let an example
cite, so a deleted router row hid behind a fenced table — the exact defect
blanking fences was written for. `10-todo` needs the path shape and nothing
needs the other, so the exception is now one shape wide.

A fence nobody closed used to blank the rest of the file, so a `## Actions`
that is visibly there produced "has action files but no ## Actions section".
An unclosed fence is not a fence.

Two shapes stay generous and now say so in the spec instead of being found:
a separator-less run below the router is read as that router resumed, and
rule one reads addresses through fences. Both were measured against a width
check that did not discriminate them — the reviewer's own glossary has the
router's column count — so the check is gone and the limit is written down.

Refs #250

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq
AIDD-Session-Id: 2261efcd-a873-4e60-9893-2217f702bde2
blafourcade and others added 5 commits September 18, 2026 22:32
…hape

Six review rounds found the same class of defect one shape at a time: a
separator written with two dashes, a plural header, a blank line splitting
the table, a fenced example citing like a router, prose carrying a stem.
Each got its own test after the fact, which is how the next shape gets
missed rather than found.

One property replaces the hunt: a section citing every action it provides
is silent, and dropping any one citation yields exactly that one violation.
It runs over 432 generated shapes — three headers, three separators, four
citation forms, six kinds of surrounding noise, split and unsplit — and
over every router in the tree, removing each of the 152 citations in turn.

The shapes are enumerated, not random. A guard that fails on a seed nobody
can reproduce is worse than no guard, and the repository root carries six
dev dependencies, none of them a generator library — `fast-check` lives in
`cli/`, which this suite does not run in.

Five of the six engine mutations these rounds fixed are killed by the
property alone. The sixth, a stem shared by two action files, cannot live
in the matrix because a collision changes the expected verdict; it keeps
the unit test written for it.

Refs #250

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq
AIDD-Session-Id: 2261efcd-a873-4e60-9893-2217f702bde2
Six rounds of review left the rules correct and unreadable: 36% of the
engine was comment, and four functions had accumulated a branch per round
without anyone designing them together.

Names carry what the prose used to. `exemptAgentLines` is
`permissionListLines`, `findAddresses` is `addressesIn`, and the three
citation shapes are three functions instead of one paragraph and three
inline loops. `withoutFences` no longer repeats its fence detection twice,
`classifyFile` destructures the path instead of indexing it, and the
violation object is built in one place rather than three.

What stays is why, never what: the exemption that expires with #883, the
literal splice that `String.replace` would corrupt, the project root a
readdir must resolve against. The reasoning behind each rule already lives
in the plan's decisions table, and the header now points there instead of
restating it.

Comments fall from 36% to 9% in the engine and 30% to 11% in the hook, and
one function remains above the size threshold instead of six. Behaviour is
unchanged: the same 506 tests pass, and nine mutations — separator, plural
header, fences, backticked file name, resumed table, shared stem, action
column, and both exemptions — each still turn red.

Refs #250

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq
AIDD-Session-Id: 2261efcd-a873-4e60-9893-2217f702bde2
The refactor cut its comments on the premise that the why lives in the
plan's decisions table, then pointed there for three decisions the table
never held: a table resumed after a blank line keeping its column, a
separator row counting from two dashes, and an unterminated fence not being
a fence. A pointer that misses is worse than the comment it replaced, and it
undermines the refactor's own argument.

`citationsIn` also defended a parameter its single caller always passes.
The default is gone.

Both found by an independent review that also ran the old engine against the
new one over 4550 content cases, 60000 fuzzed paths and 26 hook payloads —
zero differences. That is stronger evidence of an unchanged behaviour than
the nine mutations the refactor commit cited.

Refs #250

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq
AIDD-Session-Id: 2261efcd-a873-4e60-9893-2217f702bde2
…r every tool

The write-time hook only ever sees Claude Code, so a contributor on Cursor,
Codex, Copilot or opencode met no guard at all. The rules now run as a
pre-commit job, which every edit reaching a commit passes through, and
validate.yml replays it over the whole tree on each pull request.

Nothing is duplicated to get there: architecture-scan.js holds the one
filesystem layer the pure engine refuses to own, and the hook and the
command are both thin callers of it. The hook stays as the fast path,
refusing in the same turn rather than at commit time.

--root exists so the refusal itself is testable against a tree that is not
this repository. A gate whose refusal nothing exercises is a gate nobody
can trust.

Refs #250

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPREgkoNtK4PYh2YyZbztq
AIDD-Session-Id: 2261efcd-a873-4e60-9893-2217f702bde2
@blafourcade
blafourcade marked this pull request as ready for review September 21, 2026 04:25
@blafourcade
blafourcade requested a review from a team as a code owner September 21, 2026 04:26
@blafourcade
blafourcade merged commit 822f178 into next Sep 21, 2026
24 checks passed
@blafourcade
blafourcade deleted the chore/architecture-guard-on-write branch September 21, 2026 04:26
@aidd-bot aidd-bot Bot mentioned this pull request Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant