CLAUDE.md had grown to 152 KB (~21k words, ~40k tokens) and is loaded into
every coding-agent session. The cost is not the cache read, it is attention:
the rules that are genuinely invariant were drowning in the mechanics of
subsystems that most tasks never touch.
The split criterion is blast radius, not importance. A rule a change anywhere
could violate stays in CLAUDE.md — the commit rule, the production/schema
constraint, domain neutrality, the event-bus rule, the crate boundaries, and
the module map. The mechanism of one subsystem moves to dev-docs/, opened on
entry to that subsystem via a routing table at the top of CLAUDE.md.
Nothing was rewritten: every section was moved verbatim by line range and
verified line-by-line against the original. The only edits are cross-reference
repairs ("see the DB section" -> a link), the promotion of headings in the
extracted files, and a condensed "Current state" whose full text now lives in
dev-docs/users-auth-and-boot.md.
CLAUDE.md: 152 KB -> 31 KB. Twelve subsystem files plus an index under
dev-docs/, which now carries the same standing rule as docs/ and CHANGELOG.md:
a change to a subsystem updates its dev-doc in the same change.
No CHANGELOG entry: this is documentation for coding agents with no observable
effect on the application.
4.0 KiB
Skald dev-docs — architectural reference for coding agents. Index: README.md · Entry point: ../CLAUDE.md
Read this when: you add a grantable object (plugin, connector) or touch who gets it by default.
Default access — the grant tables are deny-by-default, but the rows are written for you
plugin_access, mcp_global_access and mcp_catalog_access still mean exactly what they meant: a row is access, its absence is none, every read fails closed. What changed is who writes the rows. Installing something used to leave it granted to nobody, so the admin then walked the user list; now db::access_defaults grants it to the household at the moment of installation and the admin's remaining job is removal.
The default is materialized, never evaluated. The tempting alternative — leave the junctions lazy and answer each check as COALESCE(grant.allowed, object.grant_by_default) with signed rows for exceptions — needs no seeding but costs two things worth more. The checkbox loses a state (an unticked box would mean either "denied" or "inheriting", indistinguishable to the admin), and "who has what" stops being one query: the gate, the plugin roster and the user checklist all read the same junction today, and plugin_access.plugin_id is bare TEXT with no plugins row to join a default against. So the default is applied at exactly two moments and never again:
| moment | seam | what fires |
|---|---|---|
| an object is created | access_defaults::seed_new_object |
PluginManager::update_config (first toggle — the plugins row's birth), mcp::global_enable, mcp::catalog_upsert, marketplace install |
| a user is created | access_defaults::seed_new_user |
UserManager::register_user — in the core, so no future user-creation endpoint can forget it |
Not on enable/disable, and that is the load-bearing part: re-enabling a plugin must never resurrect a grant the admin took away, so the trigger is the row's birth, not its flag. Every call site therefore checks existence before its upsert (is_new_row / is_new_server / is_new_entry) — a re-install or an edit seeds nothing. Seeding is additive-only and idempotent on the PK, which is why every call site is best-effort (a warn!, never a failed request): a grant that did not get written is fixable from the user's page, and nothing here can ever widen further than the two moments allow.
Who is included is a role attribute, not a role id (§0.1): roles.attrs.auto_grant, parsed by RoleAttrs like everything else there. It defaults to true — hence the hand-written impl Default for RoleAttrs, since a derived one would give false and silently invert the feature for every role predating the attribute. The seeded children preset sets it to false, which is the whole reason the attribute exists. admin answers false too, but as a skip, not a denial: admins hold everything implicitly (plugin_access::effective_access short-circuits), so rows for them would only be noise in every roster. Editable in the role editor (roles-page.js, which persists only the opt-out).
Per-object opt-out is grant_by_default on plugins / mcp_global_servers / mcp_catalog (additive via ensure_column, default 1). One thing sets it today: a binding-managed plugin (Plugin::manages_own_access, mobile-connector) is marked 0 at row creation, because it never reads plugin_access and rows for it would make its roster claim an audience that means nothing. There is no UI for the flag yet — access_defaults::set_grant_by_default is the seam when one is wanted. Changing it is deliberately not retroactive in either direction.
A role change does not re-seed. Promoting a child to an adult role leaves their grants as they were; the admin ticks the boxes once on that person's page. Deliberate: the reverse (demotion) would then have to revoke, and a revocation that fires as a side effect of an unrelated edit is exactly the class of surprise the two-moment rule exists to avoid.