kernel/layout.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Where a city keeps each kind of file on disk: one derivation from
7//! the city root and an address (`crates/kernel/spec/Layout.lean` §8-56).
8//!
9//! Every directory name and file name the city writes is declared here
10//! once, and every path it writes them to is spelled here once, so a
11//! rename reaches every caller and none is left reading an empty
12//! directory.
13//!
14//! Nothing here touches a disk. A `CityLayout` is a value: it answers
15//! where a file would be, and the caller that owns the I/O decides
16//! whether to create it, read it, or refuse. That keeps the layout
17//! inside `kernel`, where the address grammar it rests on already is.
18//!
19//! One rule governs the placement itself: **what governs a scope lives
20//! in that scope's reserved subtree, and no write domain reaches it**
21//! (`Address::is_reserved`). The rules of a building, its configuration
22//! layer and its filters are therefore under [`RESERVED_PREFIX`], while
23//! the documents residents write — the job, the handoff, the urbanite
24//! page, the archive — sit in the open where residents can edit them.
25
26use std::ffi::{OsStr, OsString};
27use std::path::{Path, PathBuf};
28
29use crate::{Address, RESERVED_PREFIX};
30
31/// The append-only history of a city, under the city's reserved subtree.
32pub const LEDGER_DIR: &str = "ledger";
33/// The content-addressed object store, under the city's reserved subtree.
34pub const CAS_DIR: &str = "cas";
35/// What the views held after one ledger line, under the city's reserved
36/// subtree; a projection a start may discard.
37pub const SNAPSHOT_DIR: &str = "snapshot";
38/// The city's shelves of skills, under the city's reserved subtree.
39pub const LIBRARY_DIR: &str = "library";
40/// A building's own shelf of skills, under that building's reserved
41/// subtree: what this building knows and no other building is given.
42pub const BUILDING_SHELF: &str = "skills";
43/// The one document of a skill filed as a directory: a package on a
44/// section shelf, and every skill on a shelf the city mounts from
45/// elsewhere. It is the layout pi, claude and agents all use; the
46/// catalog lists this file's first line, and `read` opens the files
47/// beside it by `<name>/<path>`.
48pub const SKILL_FILE: &str = "SKILL.md";
49/// What one configuration layer declares. The same file name at every
50/// layer, because the layer is the scope it sits in rather than a name.
51pub const CONFIG_FILE: &str = "CONFIG.toml";
52/// What a scope keeps out of the transcript it sends to a model.
53pub const FILTERS_FILE: &str = "FILTERS.toml";
54/// Where a building files work it has finished with, by kind and day.
55pub const ARCHIVE_DIR: &str = "Archive";
56/// The task of one session, in the room it is run from.
57pub const JOB_FILE: &str = "JOB.md";
58/// What the next agent needs before it starts, per room.
59pub const HANDOFF_FILE: &str = "Handoff.md";
60/// Who a standing resident is, in that resident's own directory.
61pub const URBANITE_FILE: &str = "URBANITE.md";
62/// How far the person has got through the first-run guide, under the
63/// city's reserved subtree.
64pub const GUIDE_FILE: &str = "GUIDE.toml";
65/// The extension of what one run saw, written in its room as
66/// `<run>.jsonl`.
67pub const TRANSCRIPT_EXT: &str = "jsonl";
68/// A run id as a git ignore pattern: one `?` for each character of the
69/// hyphenated form a [`crate::RunId`] displays, so a building can keep
70/// every run's transcript out of its history by name.
71pub const RUN_ID_PATTERN: &str = "????????-????-????-????-????????????";
72/// What the remote gate keeps on disk, under the city's reserved subtree.
73pub const REMOTE_DIR: &str = "remote";
74/// The devices paired to reach the city from outside the machine.
75pub const DEVICES_FILE: &str = "devices.toml";
76/// The browsers paired at this machine's own door, by public key.
77pub const BROWSERS_FILE: &str = "browsers.toml";
78/// Where residents' playback exports land, under the city's reserved
79/// subtree, one directory per building.
80pub const PLAYBACK_DIR: &str = "playback";
81/// The beat the performance monitor samples at, under the city's
82/// reserved subtree (`crates/sprawling/spec/Monitor.lean` D48).
83pub const MONITOR_FILE: &str = "MONITOR.toml";
84
85/// What the staging file of a document named `name` is called: the
86/// name hidden by a dot and suffixed `.staging`, in the document's own
87/// directory (`crates/kernel/spec/Layout.lean` §8-56).
88///
89/// The whole-or-nothing document writer stages here and renames over
90/// the document; the checkpoint scan refuses this name before git opens
91/// it, because the file can be renamed away between the scan's stat and
92/// its read (storage D30). Both ask this function, so the two spellings
93/// cannot drift apart.
94#[must_use]
95pub fn document_staging_name(name: &OsStr) -> OsString {
96 let mut staged = OsString::from(STAGING_PREFIX);
97 staged.push(name);
98 staged.push(STAGING_SUFFIX);
99 staged
100}
101
102/// Whether `name` is spelled the way [`document_staging_name`] spells
103/// the staging file of some document.
104#[must_use]
105pub fn is_document_staging_name(name: &OsStr) -> bool {
106 let spelled = name.as_encoded_bytes();
107 spelled.len() > STAGING_PREFIX.len().saturating_add(STAGING_SUFFIX.len())
108 && spelled.starts_with(STAGING_PREFIX.as_bytes())
109 && spelled.ends_with(STAGING_SUFFIX.as_bytes())
110}
111
112const STAGING_PREFIX: &str = ".";
113const STAGING_SUFFIX: &str = ".staging";
114
115/// The disk layout of one city, derived from its root.
116///
117/// Construct it once per city root and ask it for paths; the segments
118/// are never joined by hand at a call site.
119#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
120pub struct CityLayout {
121 root: PathBuf,
122}
123
124impl CityLayout {
125 /// The layout of the city rooted at `root`.
126 #[must_use]
127 pub fn new(root: &Path) -> Self {
128 Self {
129 root: root.to_path_buf(),
130 }
131 }
132
133 /// The city root this layout derives from.
134 #[must_use]
135 pub fn root(&self) -> &Path {
136 &self.root
137 }
138
139 /// The directory an address names: the city root with the address's
140 /// segments below it.
141 ///
142 /// Segment by segment rather than one joined string, because a
143 /// city served on Windows and a city served on Linux must lay the
144 /// same address down as the same directory.
145 #[must_use]
146 pub fn scope(&self, addr: &Address) -> PathBuf {
147 let mut path = self.root.clone();
148 for segment in addr.as_str().split('/') {
149 path.push(segment);
150 }
151 path
152 }
153
154 /// The whole city's history.
155 #[must_use]
156 pub fn ledger(&self) -> PathBuf {
157 self.governed_root().join(LEDGER_DIR)
158 }
159
160 /// The snapshot a start resumes the views from.
161 #[must_use]
162 pub fn snapshot(&self) -> PathBuf {
163 self.governed_root().join(SNAPSHOT_DIR)
164 }
165
166 /// The whole city's object store.
167 #[must_use]
168 pub fn cas(&self) -> PathBuf {
169 self.governed_root().join(CAS_DIR)
170 }
171
172 /// The city's shelves of skills.
173 #[must_use]
174 pub fn library(&self) -> PathBuf {
175 self.governed_root().join(LIBRARY_DIR)
176 }
177
178 /// The configuration layer a scope declares.
179 #[must_use]
180 pub fn config(&self, addr: &Address) -> PathBuf {
181 self.governed(addr).join(CONFIG_FILE)
182 }
183
184 /// The configuration layer the city itself declares.
185 ///
186 /// The same rule as [`config`](Self::config) at the root, and a
187 /// method of its own because the root is not an address, and a
188 /// caller without one would otherwise join the two names by hand.
189 #[must_use]
190 pub fn city_config(&self) -> PathBuf {
191 self.governed_root().join(CONFIG_FILE)
192 }
193
194 /// What the city as a whole keeps out of what it sends a model.
195 #[must_use]
196 pub fn city_filters(&self) -> PathBuf {
197 self.governed_root().join(FILTERS_FILE)
198 }
199
200 /// A building's own shelf of skills.
201 #[must_use]
202 pub fn building_skills(&self, addr: &Address) -> PathBuf {
203 self.governed(addr).join(BUILDING_SHELF)
204 }
205
206 /// What a scope keeps out of what it sends a model.
207 #[must_use]
208 pub fn filters(&self, addr: &Address) -> PathBuf {
209 self.governed(addr).join(FILTERS_FILE)
210 }
211
212 /// Where a building files finished work.
213 ///
214 /// In the open rather than under the reserved subtree: residents
215 /// write these documents and read them back.
216 #[must_use]
217 pub fn archive(&self, building: &Address) -> PathBuf {
218 self.scope(building).join(ARCHIVE_DIR)
219 }
220
221 /// The task written for the session run at this address.
222 #[must_use]
223 pub fn job(&self, addr: &Address) -> PathBuf {
224 self.scope(addr).join(JOB_FILE)
225 }
226
227 /// What one room hands to whoever picks it up next.
228 ///
229 /// The room's rather than the building's: two sessions of one
230 /// building can freeze at once, and one file for both would be two
231 /// contents fighting over one name.
232 #[must_use]
233 pub fn handoff(&self, room: &Address) -> PathBuf {
234 self.scope(room).join(HANDOFF_FILE)
235 }
236
237 /// Who the standing resident at this address is.
238 #[must_use]
239 pub fn urbanite(&self, addr: &Address) -> PathBuf {
240 self.scope(addr).join(URBANITE_FILE)
241 }
242
243 /// How far the person has got through this city's first-run guide
244 /// (`crates/wire/Spec.lean` §8-68).
245 ///
246 /// Under the city's reserved subtree, because it decides what this
247 /// city shows a person when it opens, and no write domain reaches it.
248 #[must_use]
249 pub fn guide(&self) -> PathBuf {
250 self.governed_root().join(GUIDE_FILE)
251 }
252
253 /// The devices paired to reach this city from outside the machine.
254 ///
255 /// Under the city's reserved subtree, because the table decides who
256 /// may reach the city and no write domain may change that
257 /// (`crates/kernel/spec/Layout.lean` §8-76).
258 #[must_use]
259 pub fn devices(&self) -> PathBuf {
260 self.governed_root().join(REMOTE_DIR).join(DEVICES_FILE)
261 }
262
263 /// The browsers paired at this machine's own door, kept as public
264 /// keys (`crates/wire/spec/Server.lean` §8-93).
265 ///
266 /// Under the city's reserved subtree for the reason [`Self::devices`]
267 /// is: the table decides who may reach the city.
268 #[must_use]
269 pub fn browsers(&self) -> PathBuf {
270 self.governed_root().join(BROWSERS_FILE)
271 }
272
273 /// Where residents' playback exports land, one directory per
274 /// building (`crates/accounting/Spec.lean` §8-13, `crates/sprawling/Spec.lean` §8-132).
275 ///
276 /// Under the city's reserved subtree, because no write domain reaches
277 /// it, so a written report is changed only by exporting another; the
278 /// city root's `.gitignore` keeps it out of history; and no worktree
279 /// holds it, so sweeping one loses no report.
280 #[must_use]
281 pub fn playback_exports(&self) -> PathBuf {
282 self.governed_root().join(PLAYBACK_DIR)
283 }
284
285 /// Where this city remembers its monitor's sampling beat: a setting
286 /// of how this machine measures, which no write domain reaches.
287 #[must_use]
288 pub fn monitor(&self) -> PathBuf {
289 self.governed_root().join(MONITOR_FILE)
290 }
291
292 /// The layout whose ledger is `dir`, when `dir` is a city's.
293 ///
294 /// The inverse of [`ledger`](Self::ledger), for the writer that is
295 /// handed the ledger directory and must find the root every other
296 /// path is derived from. `None` for a directory that is not a ledger
297 /// under a reserved prefix: a fixture, a bundle being verified, or a
298 /// store somebody opened directly, none of which is a city.
299 #[must_use]
300 pub fn of_ledger(dir: &Path) -> Option<CityLayout> {
301 if dir.file_name()? != LEDGER_DIR {
302 return None;
303 }
304 let governed = dir.parent()?;
305 if governed.file_name()? != RESERVED_PREFIX {
306 return None;
307 }
308 let root = governed.parent()?;
309 if root.as_os_str().is_empty() {
310 return None;
311 }
312 Some(CityLayout::new(root))
313 }
314
315 /// The city's own name, read from the directory it lives in.
316 ///
317 /// The one address that names the city rather than a room: the
318 /// genesis record carries it so a city can say who it is, and the
319 /// session projection must file it nowhere, because no building is
320 /// named after the city inside itself. Not every directory name is
321 /// an address - a path can hold characters an address may not - and
322 /// a city whose directory cannot be spelled as an address simply has
323 /// no name to show, which is honest and rare.
324 #[must_use]
325 pub fn city_address(&self) -> Option<Address> {
326 self.root
327 .file_name()
328 .and_then(|name| name.to_str())
329 .and_then(|name| Address::parse(name).ok())
330 }
331
332 /// The city root's own reserved subtree.
333 fn governed_root(&self) -> PathBuf {
334 self.root.join(RESERVED_PREFIX)
335 }
336
337 /// A scope's own reserved subtree: what governs that scope, where no
338 /// write domain reaches it.
339 fn governed(&self, addr: &Address) -> PathBuf {
340 self.scope(addr).join(RESERVED_PREFIX)
341 }
342}
343
344#[cfg(test)]
345#[allow(
346 clippy::unwrap_used,
347 clippy::expect_used,
348 clippy::panic,
349 clippy::indexing_slicing,
350 reason = "test code"
351)]
352mod tests;