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.
19 KiB
Skald dev-docs — architectural reference for coding agents. Index: README.md · Entry point: ../CLAUDE.md
Read this when: you touch anything under web/ — components, chat tabs, routing, i18n, theme, the security-group picker.
Frontend components (web/components/)
All extend LightElement from web/lib/base.js (Lit). ChatSession (web/lib/chat-session.js) is the shared base for WS-connected chat UIs.
The chat is the home page. <app-copilot> is a single persistent element with two layout modes driven by the route (llm-page-change): mode="full" on the home route (it fills the workspace — the conversation IS the landing page, with a welcome hero + prompt suggestions as its empty state) and mode="dock" on every other route (the classic resizable side panel). Same element ⇒ WS, tabs, scroll and drafts survive navigation; you watch files/projects update live while the conversation keeps going. Collapse only applies to the dock. The old dashboard content (hero, LLM stats charts, pending inbox, quick guide) lives on as the separate #dashboard page; the debug toggle moved to the Settings page.
Two kinds of tab, and the difference is what a tab names. A primary tab is a source: it shows whatever web / project-7 currently points at (sources.active_session_id), which is also where background delivery lands — notify, a finished async task, an inbound Telegram message — and what a /new moves to a fresh row. At most one per source; a project's Open chat always lands on it and never mints a conversation (provision_session(reset:false)). A secondary tab is one specific conversation, opened with +: its source points elsewhere, so it is unreachable by source name and is addressed by id everywhere — REST, WebSocket, event filtering. Nothing is delivered to it from outside. POST /api/sessions/new creates one without touching sources, which is the entire difference from POST /api/sessions (a reset). Its agent and run-context still come from the source, so an extra project tab is the coordinator with the project's context.
The queue and the model pin are keyed by conversation, not by source (ChatHub.inboxes: HashMap<i64, ConversationInbox>, selected_clients: HashMap<i64, String>). This is the load-bearing half: two tabs on one source would otherwise serialize into one queue and one turn, and share a /model pin — while the security group was already per-session and persisted, so the pin was the odd one out. The source-taking methods survive as one-line resolvers (send_message → send_message_to_session, and _for_session twins for context/cost/compact/mcp/model/cancel/resume/upload), so Telegram, mobile and cron are untouched. Cost of the rekey: queues now grow with conversations-talked-to-since-boot rather than with the four-or-five sources, so a reset retires the queue it replaces (retire_inbox → ConversationInbox::close, consumer breaks) instead of leaving a parked task forever.
Events are filtered per conversation (ge.session_id == Some(session_id)), which is why anything a chat must see has to carry a session id — an untagged GlobalEvent now reaches nobody. Two emitters had to be fixed for exactly that: show_file_to_user's OpenFile (the tool takes a session_id from handler.session_id via the interface-tools builder) and revalidate_security_groups, which now returns (session_id, source, group). The inbox lifecycle events (Approval*/Clarification*/Elicitation*) stay the deliberate exception and go to every connection, since they carry ids only and drive the sidebar badge. A primary WS connection additionally follows NewSession for its source — re-binding session_id and its handler mid-loop — so a second window doesn't keep talking to a conversation another window just reset; a session-addressed one ignores it, having been pinned on purpose.
The tab bar is server-side state; the selection is not. Which conversations the copilot shows survives a reload through chat_sessions.is_open (owner table, additive via ensure_column) — GET /api/sessions/open restores them (computing primary per row, since only sources knows), PUT /api/sessions/{id}/open opens/closes one, PUT /api/sessions/{id}/title renames one (title predated all this and was dead; an empty title stores NULL, so the rename box is also the undo). It is deliberately not localStorage: that store is per-origin, so on a shared laptop one member's tabs would greet the next, and in the user's own encrypted file the set follows them across devices instead. Which tab is selected stays in sessionStorage (copilot-active-tab), because that one is per browser window — a shared value would have two windows fighting over it and turn every tab click into a write. Three consequences that are easy to get wrong: (a) is_open defaults to 0 and chat_sessions::create never sets it — every /new leaves its predecessor behind and every system-agent pass mints a row, so DEFAULT 1 would restore a bar full of conversations nobody opened; only the copilot writes the column. (b) The General tab is never stored — it exists because the copilot exists. (c) A reset moves the flag: provision_session(reset) mints a new row, so POST /api/sessions returns the new id and the new_session event carries it, and _bindTabSession closes the old row as it opens the new one — leaving both would restore the source twice and let a later close clear the stale one. Restoring the selection happens before super.connectedCallback() (sessionStorage is synchronous) so the first paint doesn't fetch General and throw it away; the set arrives over the network and reconciles after, awaiting the base's initial connection so it never opens a second WS.
Theme (web/css/variables.css): warm "paper" palette (terracotta accent, light by default, warm-charcoal dark), generous radius (--radius-sm/md/lg), 16px-base chat type, WCAG-fixed contrasts, global :focus-visible ring and prefers-reduced-motion support. Everything consumes CSS variables — never hardcode a hex in a component stylesheet.
i18n (web/lib/i18n.js + web/i18n/{en,it,fr}.js): t(key) helper, I18nMixin re-renders on locale-changed. Resolution order: user preference (users.locale, editable on the profile page) → instance default (registry config key ui_locale, editable by the admin in Settings — declared in skald_core::i18n::config_set) → English. Server-side, never re-implement that chain: skald_core::i18n::resolve_locale(pool, user_locale) is the one function (with default_locale(pool) and language_name(locale) for prompt rendering); they read through db::config because the bus only matters for writes and callers like the system-context source hold pools, not the manager. Pre-auth screens use the localStorage cache. Default locale is English. First-run setup asks the language in both shells — the console wizard writes ui_locale via skald_core::i18n::set_default_locale (no system bus exists there), the web setup page sends locale to POST /api/setup/user, which writes it through GlobalConfigManager::set. Supported locales are centralized in skald_core::i18n::SUPPORTED_LOCALES and enforced server-side on every write. Translated so far: chrome (sidebar/topbar), chat + approval cards, login/setup, profile, inbox; deep admin pages are still English (fallback is automatic per-key). Copy is the only place domain words may appear (§0.1).
Plugin & backend i18n — two seams, both keyed the same way. A plugin page fragment (served from its own router) localizes client-side: it ships a web/i18n.js module (export default { en, it, fr }, keys namespaced plugin.<id>.<key>) and calls addStrings(dicts) (in web/lib/i18n.js) once at module load to merge into the host's shared DICTS, then uses the same t()/I18nMixin as the app (the fragment imports them from the absolute /lib/i18n.js — the same module instance the host uses, so t() and locale-changed are shared; no endpoint, no per-locale fetch — all locales ride in the fragment, so a language switch is instant). Mobile-connector is the reference: common.js registers the dict + re-exports t, and MobileBase extends I18nMixin(LitElement). Backend-generated strings (a plugin's HTTP error/response text, notifications) go through core_api::i18n: a plugin declares Plugin::i18n() -> Vec<LocaleBundle> (mobile-connector loads them from embedded i18n/{en,it,fr}.json via include_str!), the PluginManager merges every plugin's bundles once at boot into an I18nCatalog (skald_core::i18n) and injects it as PluginContext.i18n: Arc<dyn I18nApi>. At request time the handler resolves the caller (Caller.user_id from the auth layer) and calls i18n.for_user(user_id, key, args).await — which reads users.locale, runs it through the same resolve_locale chain, and renders locale → en → key with {name} placeholders. The frontend surfaces these already-translated: jf() throws the server's response text verbatim. Front and back keep separate tables (UI labels ≠ error strings; overlap is minimal) but share the plugin.<id>. namespace convention. The mechanism is general (any plugin, and eventually the core, registers the same way); only mobile-connector uses it so far.
Role-driven interface (§0.1 — data, not enums): roles.attrs JSON may carry "ui_mode": "simple". /api/auth/me resolves it via RoleAttrs (admin is always full) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. MeResponse also carries locale, default_locale and encrypted.
Security-group picker (per-session, runtime, role-gated). A security-group is a permission bundle only — a tool_permission_groups id, driving tool visibility/approval — not a "mode" (no system-context injection; the RunContext.system_prompt substrate exists but is unused by the picker). The role carries the user's allowed set (default permission_group + attrs.permission_groups, §0.1); a new non-project session inherits the role's default group (sessions.rs::create → role_default_run_context). The chat surface switches it at runtime like the model pill: copilot.js renders a shield pill (hidden when ≤1 group) fed by GET /api/my/security-groups (the caller's role set, joined with group names; admin → all); selecting one sends the WS control message {type:"select_security_group", group} (chat-session.js::_selectGroup, twin of select_client). The server (ws.rs::handle_select_security_group_msg) validates against the role, persists it on chat_sessions.run_context, updates the live handler, and broadcasts ServerEvent::SecurityGroupSelected so every open tab re-syncs (the initial state is sent on WS connect). Enforcement is server-side via the shared run_context::validate_run_context_for_role (used by both the WS path and the REST set_session_run_context): a non-admin may only pick a group in its role's effective set (else 403), and every other RunContext field (system_prompt, allow_fs_writes/allow_fs_reads, working_directory) is discarded — closing an fs-escalation hole; admin passes through unchanged.
Selection is gated once; the persisted group is re-checked on every load. validate_run_context_for_role runs at selection time, and the result is persisted on chat_sessions.run_context — so on its own it let a group survive the role that granted it, indefinitely and across restarts (revoke ops from a role, and every session that had already picked it kept running on it). The fix is a second, narrower seam: run_context::reconcile_group_for_user, run by ChatSessionManager::get_or_create_handler on every handler build, which treats the stored group as advisory and degrades it when the owner's current role no longer allows it. Three properties are load-bearing: (a) it degrades to the role's default group (role_default_group, the same seam sessions.rs uses for a new session, so start-group and fallback-group cannot drift) — never to None, because a missing group means the catch-all default, whose rules are the fallback tier under every other group, so clearing widens; (b) it touches only security_group, unlike the selection path, so a project session's server-built project_root/system_prompt survive a permissions edit; (c) on uncertainty (unknown user, unreadable role, DB error) it leaves the stored group alone — guessing could only widen. The liveness half is Skald::revalidate_security_groups_for_{user,role}, called synchronously from the roles API (update) and the users API (role reassignment), which reconciles already-open handlers, persists, and emits SecurityGroupSelected so the pill re-syncs. Same rule as revocation: authorization is pushed, never left to the bus.
The role editor (roles-page.js) sets the default group + an allowed-groups checklist (→ attrs.permission_groups) + a default-assistant select (→ attrs.chat_agent) fed by GET /api/agents filtered to type:chat minus project-coordinator (source-driven); the same exclusion is enforced server-side in the roles API (validate_chat_agent).
| File | Element | Notes |
|---|---|---|
copilot.js |
<app-copilot> |
The chat surface (_wsSource='web'): full/dock roving layout, welcome hero empty state, privacy chip, composer with model pill, slash-command autocomplete |
shared/chat-page.js |
<chat-page> |
Mobile chat (_wsSource='mobile') |
copilot-render.js |
(helpers) | renderMsg, renderTool, renderDiff, etc. — shared by copilot and chat-page |
sidebar.js |
<app-sidebar> |
Nav sidebar; role-driven (ui_mode); inbox badge is live — the chat WS forwards the inbox lifecycle events (approval_requested/resolved, clarification_*, elicitation_*) regardless of source, chat-session.js re-dispatches them as the inbox-changed window event, and the sidebar (+ agent-inbox.js) refreshes on it; a 60 s poll remains as fallback |
topbar.js |
<app-topbar> |
Top nav bar; per-user avatar color hashed from the username |
dashboard-page.js |
<dashboard-page> |
#dashboard — status hero, LLM stats charts, pending inbox, quick guide |
shared/file-viewer-base.js |
FileViewerBase (base) |
Shared file-viewer engine (fetch, kind detection, markdown/PDF/SVG/LaTeX, watcher, _renderBody); driven by _show/_hide. Extended by desktop + mobile |
file-viewer-page.js |
<file-viewer-page> |
Desktop file viewer: FileViewerBase + hash routing via window.openFile(path) → #file_viewer?path=... |
shared/file-viewer-mobile.js |
<mobile-file-viewer-page> |
Mobile file viewer: FileViewerBase + prop-driven (visible/path), full-screen with back button |
agents.js |
<agents-page> |
Agent discovery and config |
agent-inbox.js |
<agent-inbox-page> |
Pending approvals + clarifications from background sessions |
approval-rules.js |
<approval-rules-page> |
Approval rule management |
cron-jobs.js |
<cron-jobs-page> |
Scheduled job management |
connectors.js |
<connectors-page> |
MCP Connectors row list (one row per connector): user activate/deactivate + granted globals; admin also gets the Add connector dropdown (Marketplace / manual form at #connectors/new), per-row removal from the catalog, and the Sign-in providers modal (§7/§14/§15) |
plugin-catalog.js |
<plugin-catalog> |
#plugins — admin status board: one card per plugin (enable toggle + health dot + Configure → #plugin-detail) |
plugin-detail.js |
<plugin-detail> |
#plugin-detail?id=<id> — one plugin's admin page: instance-config form (config_schema) + a read-only roster of who holds it, linking to #users/{id} (plugin twin of connector-detail.js) |
users-page.js |
<users-page> |
#users list + #users/{id} one user's page: Profile, Connectors, Plugins, Security. Both grant sections are the single write path for "what may this person use" |
plugin-page-host.js |
<plugin-page-host> |
Host for plugin-contributed pages (#plugin/<plugin_id>/<page_id>): dynamic-imports the fragment module, registers its element, mounts it with plugin-id |
system-agents.js |
<system-agents-page> |
#system-agents — one tab per background agent (plus "All"): its description, its settings (admin only) and the caller's own run history. Everyone sees the page; only an admin gets the config half |
shared/config-form.js |
ConfigFormController |
The schema-driven settings form, shared by config-page.js and the System agents page — one renderer and one write path (PUT /api/config/{key}) for every ConfigSet |
shared-folders.js |
<shared-folders-page> |
#shared-folders — admin-only CRUD for on-disk shared folders (§6): create/describe/delete + per-member read-only/read-write grants; description feeds the assistant's __SHARED_FOLDERS__ context |
projects/ |
<projects-page> |
#projects — host + list + board; the board is tabbed (Files explorer with live watcher + write actions, Sharing members), deep-linked #projects/{id}[/sharing]. See the Projects section |
files-page.js |
<files-page> |
#files — the caller's whole space. Level 0 is the virtual root (GET /api/files/roots), level 1 the shared <file-explorer>. See the Files section |
shared/file-explorer.js |
<file-explorer> |
The explorer itself, host-agnostic: root + rootLabel + optional rel, can_write read from the listing. Used by #files and the project board |
connector-detail.js |
<connector-detail-page> |
A connector's own page (#connector?name=X): env/secret form + Test, the OAuth login panel (sign in → paste code → complete, §15), global enable. Access grants live only on the Users page (users-page.js — the #users/{id} page's connectors section, with the plugin grants right below it), so "who has what" has a single surface |
shared/connector-common.js |
(helpers) | Shared Connectors vocabulary: statusOf (incl. needs_login for a pending OAuth row), STATUS_LABEL, schema normalization, jf fetch |
llm-providers.js |
<llm-providers-page> |
LLM provider management |
models-hub.js |
<models-hub-page> |
Models hub landing (LLM / Transcription / Image) |
models-llm.js |
<models-llm-section> |
LLM model CRUD + drag-and-drop priority |
models-transcribe.js |
<models-transcribe-section> |
Transcription model CRUD |
models-image.js |
<models-image-section> |
Image generation model CRUD |
mobile-app.js |
<mobile-app> |
Mobile app shell |
shared/settings-page.js |
<settings-page> |
Mobile settings: per-user avatar, locale picker (I18nMixin), profile/preferences |