Skip to content

Decision 096: a repository may append its own hooks to the-loop's graph, and may do nothing else to it

  • Status: proposed
  • Date: 2026-08-18
  • Work item: issue-248
  • Deciders: MadaraUchiha-314 (owner, via the ticket), the-loop (proposal)
  • Refines: decision-041 (two concepts: nodes and hooks), decision-042 (hooks are registered code, edges route on outcomes), decision-043 (reviews.critics[] is executable configuration), decision-044 (which config a setting belongs in)

Context

issue-109 shipped ten hooks and deferred "user-defined graphs and user-authored hooks", stating that "the declarative form and the registry exist so it can arrive safely". Issue-248 asks for the hooks half: "users of the-loop's CLI should be able to point to their own hooks as well".

A project with a rule the-loop does not ship — a licence header, an architecture board's sign-off, a ping to its own change-management system — had two options: fork the-loop, or keep the rule outside the loop where nothing gates on it. Both are worse than the third, provided the third cannot become a way to opt out of the PDLC.

Decision

Sub-decisionWhat was chosenWhy
D1Declared in the repository's harness config (graph.hooks), not the operator's CLI configdecision-044's direction rule: a hook gating this project's artifacts is this project's policy, and the code it names lives in this project's tree. It is where reviews.critics[] already is, and it carries the same label — executable configuration, reviewed like code (decision-043).
D2Append-only. An attachment goes at the end of the node's shipped chain; nothing can be removed, reordered or replacedThe chain short-circuits at the first hook that does not pass, so a shipped gate always decides first. This single property is what makes the feature safe to have on by default: a repository hook can add a constraint and never relax one.
D3A reserved x- namespace, required for repository hooks and refused for shipped onesA chain reads honestly (validate-artifacts, lint-artifacts, x-arch-signoff), a repository module cannot shadow a shipped hook, and the collision rule needs no resolution logic.
D4A repository hook cannot route: data["outcome"] is dropped, with a warningAn outcome is how a gate is classified (approved) and how an edge is chosen. Honouring one from repository code would make "add a check of your own" a way to approve your own work item. Status only — so the strongest thing a repository hook can do to the loop is stop it.
D5Hooks are resolved from a per-repository table on the compiled Graph, not the process-global registryA daemon walks several repositories in one process; two of them may legitimately define x-house-rules. A global registry would silently run one repository's code for another.
D6The same @hook decorator, redirected by a collector during the module's executionOne authoring API. A repository hook is written exactly like a shipped one, which is what the documentation can then say in a sentence.
D7Every declaration failure is a load failure, naming the file — nothing degrades to "no hooks"The repository asked for the gate. A compliance check that silently stopped running is the failure mode worth being strict about; a repository graph file is still merely ignored, because the-loop never promised to honour that.
D8An operator kill switch (routing.graph.repoHooks: false) plus a no-import inspection command (the-loop graph hooks)The modules execute in the-loop's own process, with its environment. That is not a new grant on the daemon path — it already spawns a permissions-bypassed harness in the same checkout — but it is a new statement, and an operator gets both a way to read what a repository would run and a way to refuse all of it.
D9Graphs stay the-loop's. No nodes, no edges, no loops from a repositoryOnly the hook half of issue-109's deferred item is delivered; _warn_on_repo_graph stands unchanged. The process is the product.

Consequences

Good. A project can enforce its own rules through the loop instead of beside it, in code its reviewers see, with one hook API and one contract. The extension point is exactly one function (load_graph(repo=…)), so the-loop check, every graph verb and the daemon get it identically. A repository that declares nothing imports nothing and behaves as before.

Costs, accepted. Repository code runs in the-loop's process — mitigated by D2/D4, the kill switch and the inspection command, not by a sandbox (deliberately out of scope, and said so in the requirements). A hook module is imported once per process, so a daemon needs a restart to pick up an edited one. The compiled-graph cache is now keyed per repository. And attaching is per loop: an attachment names a node of the loop being walked, so a hook wanted on both the outer and the inner loop is declared twice.

Alternatives considered

AlternativeWhy not
Let a repository supply a whole graphMuch larger surface (nodes, edges, gates, authorization) and it lets a repository delete gates. The hooks half is what the ticket asked for.
An insertion index (before: validate-artifacts)Buys ordering, costs the guarantee that no repository hook can pre-empt a shipped gate.
Register repository hooks in the global registrySimplest code, wrong for the daemon: two repositories with one name silently share an implementation.
Namespace by module instead of an x- prefixNames become house_rules:check, chains stop being readable, and shadowing is prevented by machinery rather than by a rule you can state in one line.
Shell/exec hooks from configurationRefused in issue-109 and still refused: argv from configuration is the thing the hook registry exists to avoid.
Warn-and-continue on a broken declarationThe failure mode is a gate that stopped running and told nobody.
Default the kill switch to falseRepository hooks would then work under a local the-loop check and silently not under the daemon — the same gate, two answers.

Released under the MIT License.