For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

Agent Rules / Structure and state

Where should a registry live so coding agents can find it?

Rule AR-20

No hidden global registries mutated from many modules; one module owns the registry.

A registry is a dict, list or map of handlers that other code looks things up in. When twenty modules each add their own entry to a shared global, the full contents exist only after every one of those modules has been imported, in some order. Nobody can read the file that holds the registry and learn what is in it. Let one module own the registry, make it read-only for everyone else, and list the entries in one place.

AR-09 covers how to write the plugin contract and the typed registry for open sets. This page covers the ownership question underneath it: who is allowed to write.

Why it happens

Python. A module-level dict is shared state. Any module that imports it can call REGISTRY["x"] = handler or run a decorator that does it, and the language doesn't ask where the write happened. Ruff's PLW0603 (global-statement) exists for the same reason: the Pylint rule it comes from describes global mutable state as a common source of bugs and confusing behavior. A write through REGISTRY[...] with no global statement slips past that rule, because item assignment isn't a rebinding.

The contents depend on import order, and that depends on who imported what first. A test that imports one module sees a registry with some entries, and the app sees another. AR-06 covers why import-time side effects are the underlying hazard.

Type checkers see the declared type (dict[str, Handler]) and nothing about which keys exist. A search for the key "refund" finds the string where it's registered, in whichever file does that, and a search for the handler finds the function with no caller.

TypeScript and JavaScript. A module-level Map or object works the same way, and MDN's modules guide says a module is only executed once even when referenced by several script tags. A top-level registry.set(...) is module code, so it runs when that module is executed. The TypeScript handbook describes readonly as a type-check-time guard that doesn't change behavior at runtime, and notes it doesn't make the contents immutable, it only stops the property being rewritten. A read-only type on the exported registry stops other files writing through it. The runtime stays a plain object. The TypeScript ReadonlyMap declaration (lib.es2015.collection.d.ts) has forEach, get, has and size, and no set, delete or clear.

Bad and good

Take a billing app with one handler per event kind.

python# bad: every module writes to a shared global # billing/registry.py HANDLERS: dict[str, Callable[[Event], None]] = {} # billing/refunds.py from billing.registry import HANDLERS def handle_refund(event: Event) -> None: ... HANDLERS["refund"] = handle_refund # billing/shipping.py (same pattern) HANDLERS["ship"] = handle_ship
typescript// bad: every module writes to a shared global // billing/registry.ts export const handlers = new Map<string, (e: BillingEvent) => void>(); // billing/refunds.ts import { handlers } from "./registry"; export function handleRefund(e: BillingEvent): void {} handlers.set("refund", handleRefund);

An agent that renames "refund" to "refund_issued" edits the registration and misses the other places that dispatch on the old key. An agent that deletes the unused-looking billing/shipping.py removes a handler no import line ever mentioned.

python# good: one module owns and lists the entries # billing/registry.py from types import MappingProxyType from billing.refunds import handle_refund from billing.shipping import handle_ship _HANDLERS: dict[str, Callable[[Event], None]] = { "refund": handle_refund, "ship": handle_ship, } HANDLERS = MappingProxyType(_HANDLERS) # read-only view for everyone else
typescript// good: one module owns and lists the entries // billing/registry.ts import { handleRefund } from "./refunds"; import { handleShip } from "./shipping"; const table = new Map<string, (e: BillingEvent) => void>([ ["refund", handleRefund], ["ship", handleShip], ]); export const handlers: ReadonlyMap<string, (e: BillingEvent) => void> = table;

The handler modules no longer import the registry. The registry imports them, so each handler has an import line, a use in the table, and a single place that names every key.

Trade-offs and when a distributed registry is acceptable

  • The central list grows. Adding a handler edits registry.py as well as the new file. That edit is the review point where the key and the contract get checked together.
  • Registration next to the definition is convenient. A decorator keeps both in one file. If you keep that style, route every decorator to the same owning module and give the module an all_entries() function that a test compares with the expected list.
  • Open sets by design (third-party plugins) can't be listed in the owner module. AR-09 shows how to keep the contract typed and the registration explicit.
  • **MappingProxyType is a view.** The Python docs describe it as a read-only proxy that reflects changes to the mapping underneath. The owner can still write to _HANDLERS, and everyone else can only read.
  • **Final** marks a name that can't be reassigned, and the docs say there's no runtime check. It prevents rebinding HANDLERS, and it leaves item writes alone, so pair it with a read-only type.

Enforce it

ToolSettingWhat it catchesChecked
ruffPLW0603 global-statementfunctions that rebind a module global with global2026-10-30
pyrightdeclared type Mapping[...] on the exported nameitem assignment (HANDLERS["x"] = f) from other modules, flagged as a type error (checked with pyright 1.1.414: one reportIndexIssue, "__setitem__" method not defined)2026-10-30
tscReadonlyMap or readonly on the exported bindinghandlers.set(...) from other modules (checked with tsc 7.0.2: TS2339, Property 'set' does not exist on type 'ReadonlyMap')2026-10-30
eslintno-restricted-imports with patternsimports of the mutable registry module from files that shouldn't write to it2026-10-30

No rule in the pages checked flags a write to a registry dict from a second module. Read-only types on the export are the practical lever. A review check works too: grep for writes to the registry name outside its owner.

Rule block for AGENTS.md / CLAUDE.md

markdown- A registry has one owning module. Only the owner writes to it; every other module reads through a read-only type. - Don't register handlers from the handler's own module at import time. Add the entry to the owner's table. - When you add, rename or remove a registry key, search for the key string across the repo and update every dispatch site. - Don't delete a module only because nothing imports it; check whether it is listed in a registry first.

Edge cases

  • Decorator registries (@register("refund")) are import-time writes. They're fine when every decorated module is imported from one list in the owner (see AR-06), and risky when imports are scattered.
  • **Registries filled by __init_subclass__** are written by the language machinery on class creation. See AR-21.
  • Test-only registries: reset them through a fixture, and avoid import-order tricks that make one test pass because an earlier one registered something.
  • Lazy registration (importlib.import_module over a list of names) is dynamic import, covered in AR-37. Prefer an explicit import list in the owner.
  • Two registries for one concept (a dict in api/ and a list in jobs/) drift. Pick one owner per concept.

What LLMGRAPH shows

A handler stored by a registry write in another module prints no calls to it found in this repo and passed as a value in 1 place: calls through it are not visible.

References

  • Python data model (module and class namespaces): https://docs.python.org/3/reference/datamodel.html
  • Python types.MappingProxyType: https://docs.python.org/3/library/types.html#types.MappingProxyType
  • Python typing.Final: https://docs.python.org/3/library/typing.html
  • Ruff PLW0603: https://docs.astral.sh/ruff/rules/global-statement/
  • TypeScript handbook, object types (readonly): https://www.typescriptlang.org/docs/handbook/2/objects.html
  • ESLint no-restricted-imports: https://eslint.org/docs/latest/rules/no-restricted-imports

Changelog

  • 2026-10-30: draft 1.
  • 2026-10-30: docs checks resolved.
  • 2026-10-30: tool checks resolved (pyright 1.1.414, tsc 7.0.2, eslint 9.39.5, ruff 0.17.3, mypy 2.4.1)
  • 2026-10-30: remaining checks closed (claims reduced to documented behaviour)
  • AR-06 Why does my agent not see what importing this module does?
  • AR-09 How should plugin registries be written for coding agents?
  • AR-19 Why does reassigning methods hide callers from agents?
  • AR-21 Do metaclasses hide code from coding agents?

Last verified

For agentsthis page as Markdown/agent-readable/ar-20.mdllms.txt