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.
7.8 KiB
Skald dev-docs — architectural reference for coding agents. Index: README.md · Entry point: ../CLAUDE.md
Read this when: you touch projects, shared folders, the file explorer or the #files page.
Projects, shared folders & the file explorer
Projects
A project is a shareable, self-service workspace: a folder at {WD}/projects/{owner_userid}/{slug} plus membership in the registry. projects (accessor db/projects.rs — slug is immutable, UNIQUE(owner_user_id, slug)) + project_members (junction with can_write; the owner is always a write-member, so a private project = one member). Sharing is not admin-gated: the owner and any write-member can add/remove/re-grant members and edit metadata; only the owner can delete. Each membership mutation emits SystemEvent::UserMountsChanged for the affected user; the lifecycle reconciler remounts their container in place (Skald::refresh_user_mounts), so the folder is browsable at once (the explorer reads host-side) and reachable from execute_cmd a moment later. The mount appears in the agent namespace as projects/{owner_username}/{slug} (host keys on the stable userid, agent path on the username) — read-only members get a read-only bind mount in the container.
API (src/frontend/api/projects.rs): GET/POST /api/projects, GET/PUT/DELETE /api/projects/{id}, POST /api/projects/{id}/members, DELETE .../members/{user_id}, POST /api/projects/{id}/session. ProjectDetail carries root_path — the agent path of the folder, computed server-side (owner username ≠ owner_name, which may be a display name) — the explorer's root. A project-{id} chat source provisions the project-coordinator agent with a project RunContext (provisioning_for_source → skald_core::projects::build_project_run_context: project_root + a system block with name/description/folder/members); every member keeps their own private project-{id} session — only the folder is shared.
UI (web/components/projects/): index.js (<projects-page> host — hash-routed: #projects, #projects/{id}, #projects/{id}/sharing, back/forward-aware), project-list.js (card grid + create/edit/delete modal), project-board.js (<project-board-section> — the detail page: header with Open chat, then a Files / Sharing tab bar using the .project-tab-bar styles in css/projects/board.css, the Files tab being the shared <file-explorer> pointed at the project folder). The mobile app has its own read-only shared/projects-page.js (list → open project chat).
The explorer (web/components/shared/file-explorer.js, <file-explorer>): not a project component — it browses one subtree of the caller's namespace, given a root agent path (a project folder, a shared folder, the home, a memory store) and a rootLabel for the first crumb; projects are one caller of it. One directory at a time via GET /api/files/dir?path=… (src/frontend/api/files.rs: { path, can_write, entries }, each entry name/path/is_dir/size/created_at/modified_at, dirs-first; same resolve_view_path scoping as /api/file, except a memory path, classified before it and listed from memory_docs — see the memory-namespace note in database.md). can_write is read from that listing, never passed in: it changes per branch (a shared folder without the flag, skills/, docs/, a memory store) and comes from the same UserFs::can_write_to the server rejects writes with, so the buttons offered and the writes accepted cannot disagree — a caller that thought it knew better would be the one place they could. Breadcrumb rooted at root; file click → window.openFile (existing viewer); folder click → navigate. Live: it subscribes the open directory on the existing /api/file/watch socket (web/lib/file-watcher.js singleton — notify NonRecursive on a dir reports its direct children) and reloads debounced 300 ms, so files created by other members or by the agent in-container appear without a refresh. Write actions (new folder, upload incl. drag&drop, rename, delete) are shown only to can_write members and ride the existing /api/file endpoints — POST gained dir:true (mkdir), DELETE handles directories (remove_dir_all), and binary upload is the new POST /api/file/upload?path=… (raw body, 256 MiB DefaultBodyLimit). Server-side write gate: all /api/file write handlers now call UserFs::can_write_to(agent_path) (core-api) — home → true, shared//projects/ → the membership's can_write, docs/ → false — closing the host-side bypass of the read-only bind mount (the container mount only gates in-container writes).
Files (#files)
The general file section: one page over everything the caller can reach, and the second consumer of <file-explorer> (see Projects above for the component itself).
The root is virtual, and that is the whole design. Anchoring at ~ was the obvious move and is wrong: the explorer reads host-side, where the home is {WD}/homes/{userid} while shared/{X}, projects/{O}/{S}, skills/ and docs/ are bind mounts inside the container — so a page rooted at the home would show less than the user has, with no way to reach the rest, and on native Linux would show the mountpoint stubs Docker creates in the bind source: shared/, docs/, skills/ present and empty. That is the memory-signpost failure exactly — a door that appears to work and leads nowhere. So level 0 is a synthetic list from GET /api/files/roots, serialized from the caller's UserFs (plus the two memory roots, which are virtual and so are not in it): FsRoot { kind, path, name, owner, can_write }, with no label — the server sends the discriminant, the frontend maps kind → label + icon, because labels are copy and get translated. Seven kinds, not six: user-memory and shared-memory are separate rather than one memory with a scope, since they are two stores with two names and a scope field would be a discriminant inside a discriminant. skills/docs appear only if the UserFs has them.
The URL carries the agent path of the open folder — one path parameter (#files?path=shared/casa/foto), the same vocabulary the assistant uses, so a link is shareable and pasteable into a conversation. Which root it belongs to is derived (FilesPage._resolve, longest-prefix over the roots list), never stored beside it: two values that can disagree are two chances to be wrong. A path under no root — a hand-edited URL, or a container-only /tmp/…, which this page does not serve — falls back to the root list with an error, rather than to an explorer that cannot explain itself.
Deep-linking needed the explorer to be steerable without a two-way binding, hence rel in + explorer-navigate out. The loop those two would form is cut by what the event means: it fires only for a click (_navigate), never for a rel the host set (_open), so echoing the event back as a property is a no-op — and a host that ignores the event entirely (the project board) still gets a working explorer.
Memory is read-only here, and it is scope rather than a property (blueprint dir-explorer.md task 5): every writer in files.rs routes through resolve_view_path, which refuses memory paths, and shared-memory/* is @fs_write require for the agent — giving a user a button that walks past that rule is a decision of its own. The listing side is wired: list_dir classifies memory before resolve_view_path and derives one level from the flat key space via memory_docs::immediate_children.
Naming trap in the sidebar: the workspace group already holds "Shared folders", which is the admin's CRUD over one kind of root — not this. The two entries must stay obviously different in copy.