Persistence: Block Driver + Filesystem (v2)
Status: Design doc, non-normative. Like everything in
docs/, this trails the spec (CLAUDE.md) and does not amend it. Where this doc and the constitution disagree, the constitution wins. Items that would change the constitution (a new capability mechanism, dropping a service from the TCB) are called out as requiring a recordedCLAUDE.mdamendment with sign-off.
1. Scope and milestone
This is v2 work, not v1. The v1 milestone (§23) is complete - multi-core boot, ping↔pong cross-core IPC, supervisor restart. Real persistence was explicitly out of scope for v1 (§23.4, "Filesystem persistence beyond the trusted block driver"), and §6.3 names the remaining v2 work directly: "the remaining v2 work is block-driver / fs."
So this effort is the defining piece of v2: make block-driver and fs real, and put
them on the path out of the TCB (§6.3). It is held to the v2 bar (restartability,
honest crash semantics), not the v1 "a trusted stub is acceptable" bar.
Terminology note. To avoid colliding with the project's version numbers, the
filesystem's internal build stages are called Phase 1, Phase 2, …, never "v1/v2".
The component is called the filesystem (service name fs); it has no invented brand
name.
2. Decisions at a glance
| Decision | Choice | One-line reason |
|---|---|---|
| Filesystem | Our own, from first principles | ext4/btrfs are POSIX + enormous; both fights the constitution (§2.4, §3.3, §26.11). No interop requirement to justify the cost. |
| Authority model | File = capability (north star) | Authority by capability, not by mode bits (§3.3). Extends the capability model instead of bolting a permission model beside it. |
| Block device | ATA PIO (legacy IDE) | Simplest correct device; no DMA → least-privilege by construction; works in QEMU and has a hardware path; stepping-stone to AHCI. |
| Namespace | Hierarchical (directories + paths) - adopted; Phase 1 shipped flat | Flat name→blob isn't realistic for real use; real directories from the start (§6.2). Still no links/permissions (authority is by capability, §3.3). |
| Bounds | Fixed, bounded | Like the rest of the system (queue depth 16, MAX_ENDPOINTS): fixed file count / name length, no unbounded trees (§26.6). |
| Crash model (Phase 1) | Write-through, honest loss | A crash mid-write may lose that write; refuse to mount on bad magic (§3.12). Transactional recovery is Phase 3 (§6.3). |
3. Why not ext4 / btrfs / a standard filesystem
A standard on-disk format buys exactly one thing - interop (read Linux's disks, let Linux read ours) - and GodspeedOS has no interop requirement (it runs on a dedicated machine/VM, Appendix A.5, storing its own services' state). Against zero benefit, the costs are disqualifying:
- They encode identity-based authority. Every ext4 inode carries
uid/gid/mode. That is "user X may read this file" - precisely the identity-based authority §3.3 forbids ("All authority is explicit. Capabilities, not identity."). In our world the authority to touch a file is holding the capability to it; those inode fields would be dead, contradictory machinery. - They blow the whiteboard rule. §26.11 makes "explainable by one engineer in 30 minutes" a hard requirement. ext4 is tens of thousands of lines (extent trees, htree directories, journaling); btrfs is ~140k lines of CoW B-trees, checksums, subvolumes, snapshots, RAID. Neither is whiteboardable. Importing either violates the one thing §26 exists to protect - "The Model Is The Product" (§26.1).
- "Porting" is really a rewrite (the Appendix D.6 argument, applied to a filesystem):
they assume a POSIX VFS, a page cache, kernel locking,
errno. You reimplement from the on-disk-format spec - and that format is the part you marry forever.
So: a deliberately minimal filesystem, pulled into existence by what §15 needs (§26.2), not by what "a real filesystem has."
3.1 Why not just FAT (we already need it for the boot ESP)?
FAT is the sharpest version of the question - the UEFI boot ESP must be FAT, so
why not use FAT (or exFAT) for data too and skip GSFS? Note first that the §3
permission argument does not apply here: FAT has no uid/gid/mode, so it
does not clash with capability authority the way ext4 does. The real reasons:
- "We already need FAT" is softer than it looks. GodspeedOS implements zero
FAT today - the ESP is built by host tooling (
osdev image) and read by Limine; the kernel has never touched a FAT byte. And a minimal, write-once boot ESP (a few static files, or a stamped prebuilt blob) is far simpler than a general read/write/grow/delete data filesystem. Making a boot ESP does not hand us a free general FAT. - A correct FAT is not "simple." It carries 40 years of compatibility cruft -
8.3 names, the VFAT long-filename + checksum scheme, FAT-chain allocation, FSInfo,
two FAT copies. A clean inode+directory GSFS is comparable-or-less code than a
correct VFAT, and it is ours: our fields (the
drivesDEFAULTflag + drivelabel), our evolution path (file-as-capability, §7), no legacy, no exFAT patents. - No interop requirement (§3) - FAT's one real win is universal readability on other OSes, which we do not need.
Reconsider-if: the deciding factor is not "FAT is bad" - it is that we'd have to implement FAT inside the OS anyway and a correct one isn't simpler, plus we have no interop need. If reading GodspeedOS data drives on Windows/Mac/Linux ever becomes a goal, FAT/exFAT deserve a serious second look. Recorded so a future contributor sees the trade was made with eyes open, not by reflex.
3.2 Capacity is the wrong axis - the real difference is features
A natural question is "how much can GSFS hold vs ext4/NTFS/APFS/ZFS?" The honest answer is that capacity is just field width and a poor way to compare:
| Filesystem | Max file | Max volume |
|---|---|---|
| FAT32 | 4 GiB | 2 TiB |
| exFAT | 16 EiB | 128 PiB |
| NTFS | 8 PiB | 8 PiB |
| ext4 | 16 TiB | 1 EiB |
| APFS | 8 EiB | 8 EiB |
| ZFS | 16 EiB | 256 ZiB (2¹²⁸-addressable) |
| GSFS (Phase 1, u32 fields) | 4 GiB (u32 size) | 2 TiB (u32 blocks × 512 B) |
| GSFS (today, u64 fields) | 16 EiB | ~8 ZiB |
GSFS was FAT32-class in Phase 1 only because Phase 1 chose 32-bit fields; widening
size + total_blocks to u64 (done, §6.3) was a one-line change that vaults it past ext4. So
capacity does not distinguish these filesystems. What ext4/NTFS/APFS/ZFS actually buy
is features - journaling (crash-safety), checksums (bit-rot), snapshots,
copy-on-write, B-tree metadata that stays fast at millions of files. GSFS
deliberately had none of these at Phase 1 - that, not size, was its real trade-off: §26.6/§20
minimalism first, with journaling arriving as the Phase-3 transactional-recovery work
(§6.3) only when a real need pulled it in. (2026-10-25: it has since gained a metadata
redo-journal (§6.8) and checksums on every block (§6.6, §6.10); it still has no snapshots,
copy-on-write or B-tree metadata.)
4. Layering and responsibilities
clients (supervisor, services) hold a cap to fs (Phase 1)
│ ipc: ReadFile / WriteFile / Stat / List / Delete or, later, a per-file cap
▼
fs service owns the on-disk FORMAT: superblock, entry table, free bitmap,
│ name→blob logic, capability minting (Phase 2). No hardware access.
│ ipc: ReadBlocks(lba,count) / WriteBlocks(lba,data)
▼
block-driver owns the DEVICE: ATA PIO port I/O. No knowledge of files.
│ port I/O (hw_pio cap) Translates LBA ↔ sectors. No DMA.
▼
ATA disk (QEMU if=ide; real disk later)
The line between fs and block-driver is the same line every microkernel draws:
policy (file layout) above, mechanism (move sectors) below. block-driver never
learns what a file is; fs never touches a port.
5. Block driver - ATA PIO
Note (2026-10-09): §4's diagram and all of §5 are the Phase 1 bring-up backend, which is RETIRED.
block-driveris AHCI on x86 (MMIO + a DMA arena,services/block-driver/src/ahci.rs,docs/ahci.md) and a USB stick through thedwc2orxhciservice on every other board (usbdisk.rs,xhciblk.rs). There is nohw_piocapability, noPortRead/PortWritesyscall, nocapability/hw_pio.rsand no SDKpio.rsin the tree; the driver's own header says ATA PIO was retired once AHCI proved out. "No DMA" and "least-privilege by construction" therefore describe a driver that no longer exists - see §9's 2026-09-26 correction for the TCB consequence.
5.1 Why ATA PIO
- No DMA → least-privilege by construction. A PIO driver moves sectors through I/O
ports one 16-bit word at a time; it cannot DMA anywhere. So it never holds the
DMA-anywhere reach that forced H1 (the IOMMU work) into existence - it is not
kernel-equivalent even without an IOMMU. That is a strictly cleaner TCB story than a
DMA block driver, and it is what eventually lets
block-driverleave the TCB on its own merits (§6.3), independent of IOMMU presence. - Simplest correct device (§2.4, §20): no feature negotiation, no descriptor rings, no command lists. Set LBA, issue command, poll status, transfer.
- Hardware path + stepping-stone: works in QEMU (
if=ide, already the boot transport) and potentially on the T630 in legacy/IDE mode; ATA's command set (READ/WRITE SECTORS, LBA) carries directly into a future AHCI driver. (virtio-blk, by contrast, is a QEMU-only paravirtual device that runs on no real hardware.)
The cost - PIO is slow - is irrelevant for v2 persistence (§20: correctness over performance). We optimize transfer width later, never at the cost of clarity.
5.2 The new mechanism it needs: hw_pio
ATA PIO uses port-mapped I/O (ports 0x1F0-0x1F7 / 0x3F6 for the primary channel),
not memory-mapped I/O. Today the SDK exposes MMIO (Mmio::read32 etc.) but not port I/O.
So Phase 1 adds a small, audited mechanism, mirroring the existing MMIO story:
- A
hw_piocapability in the contract: a port range the driver may touch, validated and granted at spawn exactly likehw_mmio. - SDK port-I/O wrappers (
Pio::inb/outb/inw/outw/insw/outsw) in an auditedsdk/rust/src/pio.rs, each block carrying a// SAFETY:comment - the same isolation §18.1 already grants the MMIO/DMA accessor modules. The driver service itself staysunsafe-free behind the safe wrappers.
The kernel already performs port I/O for the serial console, so the kernel-side cost is
modest. This is a real new authority surface; it must be added to §18.1's audited
hardware/ABI layer and the unsafe audit, and hw_pio documented as a capability kind.
5.3 ATA PIO read/write (the whole protocol)
read sector (LBA28, polled):
outb(0x1F6, 0xE0 | ((lba >> 24) & 0x0F)) # drive 0, LBA mode, top nibble
outb(0x1F2, count) # sector count
outb(0x1F3, lba & 0xFF) # LBA low
outb(0x1F4, (lba >> 8) & 0xFF) # LBA mid
outb(0x1F5, (lba >> 16) & 0xFF) # LBA high
outb(0x1F7, 0x20) # READ SECTORS
poll 0x1F7 until (status & BSY)==0 && (status & DRQ)!=0 # or error bit → IoError
insw(0x1F0, buf, 256) # 256 words = 512 bytes
write sector: same addressing, command 0x30 (WRITE SECTORS), outsw, then CACHE FLUSH (0xE7).
Polled completion in Phase 1 (no interrupt handler) keeps it dead simple; the
hw_interrupt cap in the contract can stay unused until we want interrupt-driven
completion (a later optimization, not a correctness need).
6. Filesystem - on-disk format
READ THIS FIRST: §6 IS THE ORIGINAL DESIGN, AND THE SHIPPED FORMAT IS NOT IT. What follows describes a flat name-to-blob store with 4 KiB blocks and a fixed 256-entry table. GSFS has been GSFS0008 for a long time: 512-byte blocks, real directories as a tree (§6.2), a free bitmap rather than an entry table (§6.4), self-describing 64-byte records seven to a block with a CRC trailer (§6.6), extent lists for fragmented files (§6.12), and a metadata redo-journal (§6.8). Every one of those numbers below is superseded by the section named beside it.
It is kept because §1's rule holds here as in the constitution - the history of how a format reached its present shape is worth more than a tidy document - but it was NOT marked, and a reader arriving at "§6. Filesystem - on-disk format" reasonably takes the section with that title as the format. That is the §26.7 failure in its documentation form: a claim left standing because it is labelled "proposed" and nearly true, relied on by whoever trusts the document. The label was doing less work than it looked: "Final numbers set in Phase 1" was written before Phase 1, and Phase 1 finished several formats ago.
A flat name → blob store. Proposed geometry (concrete but tunable in Phase 1):
filesystem block = 4 KiB = 8 ATA sectors (matches the system's 4 KiB granularity)
┌───────────────┬──────────────────┬───────────────┬────────────────────────┐
│ Block 0 │ Blocks 1..E │ Blocks E..F │ Blocks F..end │
│ Superblock │ Entry table │ Free bitmap │ Data region │
│ magic,version,│ MAX_FILES entries │ 1 bit / data │ file contents, │
│ geometry, │ fixed-size │ block │ block-aligned │
│ checksum │ │ │ │
└───────────────┴──────────────────┴───────────────┴────────────────────────┘
Superblock - magic (refuse to mount if wrong - loud failure, never auto-reformat,
§3.12), version, block_size, total_blocks, entry_table_blocks, bitmap_blocks,
data_start, and a checksum over the superblock.
Entry (fixed-size, fills the entry table) - name[NAME_MAX], size_bytes,
first_block, block_count, generation, flags (in-use / free).
Allocation (Phase 1): contiguous extents. Each file occupies a contiguous run of data
blocks (first_block .. first_block+block_count). This is the whiteboard-simplest scheme
and a perfect fit for the actual workload - mostly write-once blobs (service binaries,
state snapshots). The known limitation is fragmentation and that growing a file may
require relocation; both are acceptable Phase 1 and revisited only if a real need pulls
(block lists / extents) into existence (§26.2).
Proposed bounds (in the spirit of queue-depth-16, MAX_ENDPOINTS - bounded everything,
§26.6): BLOCK_SIZE = 4096, NAME_MAX = 64, MAX_FILES = 256. Final numbers set in
Phase 1; the point is they are fixed and stated, not elastic.
SUPERSEDED. The shipped numbers are
BLOCK = 512andNAME_MAX = 38(services/fs/src/main.rs), andMAX_FILESdoes not exist at all - a directory is a tree of record blocks, so there is no global file ceiling to state. Only the principle in that last sentence survived; all three numbers did not.
6.1 Why bulk data is chunked and copied (a constitution consequence)
Two invariants shape the data path and are worth making explicit:
- §8.5: max IPC message is 4 KiB, kernel-copied.
- §2.5: zero-copy IPC is permanently rejected - so
fsandblock-drivermay not share a buffer; bulk data crosses between them as copied messages.
Therefore a large read (e.g. a 200 KiB service binary) is inherently a sequence of
message-sized, copied transfers - fs requests blocks from block-driver in chunks that
fit one message (≤ ~4 KiB minus header; Phase 1 may start at a single 512 B sector per
message for simplicity, widening later to several sectors per message). This is slower
than a shared-buffer design by construction, and that is the accepted cost of the
no-shared-memory invariant - a clean illustration of §20 (correctness and clarity over
performance) and §26.7 (the copy is the honest, bounded behavior).
6.2 Hierarchical evolution - real directories (adopted; built)
The flat single-entry-table format above shipped in Phase 1 (mount, named files,
reboot survival - all working). It was evolved to a real hierarchical filesystem
(magic GSFS0002) - flat name→blob isn't realistic for actual use, so GSFS gets real
directories from the start. (See docs/drives.md for the user-facing path/addressing
model, [N:]label/path/to/file.)
Status: built + QEMU-verified.
services/fsis the hierarchical format below (inode table + per-directory blocks + path walking);osdev mkfsformats it; the block-IPC LBA is u64 (§6.3).osdev test blockdevpins it ([GSFS.E]mkdir + nested/etc/motdpath-walk) andblockdev-rebootproves the tree survives a reboot (root →/etc→/etc/motd+/greetingwalked back from the persisted inode table). Geometry: 256 inodes (64 B each, 32 blocks), one 512-B block per directory (16 entries,name[27]), files = contiguous extents via the bump allocator.
The hierarchical format, still GodSpeed-minimal (bounded, no POSIX permissions -
authority is by capability, §3.3 - and no hard links). All capacity-bearing fields
are u64 from the start (block counts, file sizes, block pointers, the block-IPC
LBA) - see §6.3:
Superblock (LBA 0): magic "GSFS…", version, block_size, total_blocks:u64,
inode_table_start/blocks, data_start, next_free_block:u64,
root_inode, flags (DEFAULT bit), label[N] ← drives.md: default + identity
Inode table: fixed array of inodes. Each inode:
type (free | file | dir), size_bytes:u64, first_block:u64, block_count:u64
Directory: a file (inode.type = dir) whose contents are entries:
{ name[NAME_MAX], inode_number } ← name → inode within this dir
Data region: file contents + directory blocks.
6.3 Capacity: u64 fields from day one (decided)
Phase 1 used u32 for block counts + file size - FAT32-class limits (2 TiB volume,
4 GiB file, §3.2). The hierarchical format widens every capacity-bearing field to
u64: total_blocks, next_free_block, inode size_bytes/first_block/
block_count, and the block-IPC lba (ReadBlock/WriteBlock carry an 8-byte
LBA, and block-driver/AHCI already use a u64 LBA). These must move together -
widening only some unlocks nothing.
The resulting ceiling is effectively unlimited: ~8 ZiB volume (2⁶⁴ × 512 B) and a 16 EiB max file - past ext4, into ZFS territory. The cost is a handful of bytes per superblock/inode; the bound is stated and fixed, not elastic (§26.6). This is the "capacity is field width" point (§3.2) acted on: pick the wide field once, at the format's birth, and never revisit the ceiling.
Why now, not retrofitted onto the flat format: the flat Phase-1 format is being replaced by this hierarchical one, so
u64is baked into the new format at design time rather than churned into throwaway code.u64is the convention from here on.
- Path walking: resolve
/a/b/cby reading the root inode (a directory), findinga→ its inode, reading that directory, findingb, … Each component is one directory lookup. Bounded path depth + entries-per-directory (§26.6). - Operations:
mkdir(allocate a dir inode + add an entry to the parent), create/write(allocate a file inode + entry),dir(read a directory's entries),read(walk to the file inode, read its extent),cd(resolve a directory, update the session's current-directory inode). - Still bounded & loud: fixed inode count, fixed name length, contiguous extents (no reclamation yet, Phase-1 carry-over); bad magic still refuses to mount (§3.12).
- The
label+flags(DEFAULT)superblock fields are whatdrivesuses for drive identity (invariant 11) and the persistent default drive (docs/drives.md).
This supersedes the flat §6 format as the target; the flat version remains the historical Phase-1 record.
6.4 Phase 3 - the scalable format (GSFS0003): free bitmap + self-describing entries, no inode table
Decided 2026-06-14, from first principles. The §6.2 inode-table format shipped and works, but it reaches for two POSIX ideas it doesn't need and one that doesn't scale: the word/concept
inode(a metadata "node" - index-node - kept apart from the name, which exists in Unix mainly for hard links, which GSFS doesn't have), and a fixed global inode cap (256) - absurdly small on a 122 GB disk. This phase replaces both.
First-principles structure set - exactly three things on disk:
- Superblock.
- Free bitmap - one bit per block (set = used). The only global structure, and it is a free map, not a file index. Read on demand (one ~512-byte bitmap block covers 4096 data blocks) + a small "last allocated" hint, so the allocator's RAM footprint is a few hundred bytes regardless of file count. This is not a POSIX reflex - it answers a physical constraint (you cannot cheaply enumerate N allocations by walking the tree or caching every record in RAM) that applies to any OS. Battle-tested because of physics, not because Unix.
- The directory tree. A directory is a file (type = dir) whose blocks hold
self-describing entries - each entry carries its own metadata, so there is no inode
table and no inode number:
Superblock (LBA 0): magic "GSFS0003", version, block_size, total_blocks:u64, bitmap_start:u64, bitmap_blocks:u64, data_start:u64, root_first_block:u64, root_block_count:u64, ← root has no parent → its extent here flags (DEFAULT bit), label[N] Directory entry (64 B): type u8 (free|file|dir), name_len u8, name[38], size:u64, first_block:u64, block_count:u64
Why no separate file_record and no global index (the questions that drove this):
- No separate record - the self-describing entry is the record. Name and metadata travel together; there is no second copy to reconcile (on-disk duplication is what creates inconsistency, so we keep a single on-disk truth).
- No global index - its two possible jobs are already covered. Finding a file = walk the path from root: the directory tree is the index, hierarchical and distributed (each directory indexes its own children). Free space = the bitmap. Nothing needs a flat global list.
- A global accelerator (skip the walk for hot paths) would be a performance cache. If
a measured need ever pulls one in (§26.2), it lives on disk, rebuildable, disposable,
never RAM-resident, never authoritative (the tree stays truth) and visible, not a
hidden layer (§26.4). Not built now. (2026-10-20:
fskeeps ONE path walk in RAM, the last (WalkCache), so a streamed file is not looked up from the root per chunk. It is not this accelerator: one entry, cleared at every metadata write and when a transaction begins or aborts, never used inside one.)
Bounds (still bounded, §26.6) - but local, not a global wall: the only ceiling is the
disk itself (the bitmap). Directories grow (allocate a bigger extent when full), so
there is no per-directory entry cap either; name length is fixed (38). Reclamation is
intrinsic: delete/overwrite flip bits free; the allocator finds a free run in the bitmap.
Renaming: the metadata record is a file_record (self-justifying - it says what it
is), never an inode. "Index node" hid implementation jargon ("node") in a user-facing word.
This supersedes §6.2 as the target. §6.2 (GSFS0002, inode table) and the flat §6 (GSFS0001) remain the historical record.
6.5 fs_index - the deferred global index (built when search pulls it in)
Specified 2026-06-14, deliberately NOT built (§26.2). A design the conversation converged on and want on record, so the day it's needed it snaps on cleanly.
Two distinct things, two names - keep them apart:
file_record- the per-file/dir metadata: the self-describing directory entry{type, name, size, first_block, block_count}of §6.4. One record per file, lives in the tree. This is the unit (neverinode- "index node" smuggled impl-jargon into a user word;file_recordsays what it is).fs_index- a global index built over allfile_records, for fast whole-filesystem enumeration. This is the aggregate. (fs_index, notfs_record: "index" is the honest word for an aggregate lookup structure and doesn't collide with the per-filefile_record; it also keeps the half of "inode" that made sense - index - and drops the half that didn't - node.)
Why it's deferred. The three GSFS0003 structures already serve every current
operation: mount (superblock + bitmap), path lookup (walk the path), dir (one directory),
free space (the bitmap). The only thing fs_index accelerates is whole-FS enumeration -
a find, a global search, an "every file" view - which GodspeedOS did not have when this was
written. So by §26.2 it is built the day such a command pulls it into existence, not before.
(2026-10-25: the shell's find exists now, and it walks the tree directly; no walk has yet been
measured too slow, so fs_index is still not built.)
How it works when built (the part worth recording now):
- Single owner, single-threaded → no concurrency to reconcile.
fsserves one IPC request at a time; "operations in different places" are just different clients, but they queue atfsand run serialized. No structure is ever mutated in parallel - no races on the tree, the bitmap, or the index (the §8-ownership simplifier). - The truth - tree + bitmap - is ALWAYS strongly consistent.
fsupdates them inline, per op, before serving the next request. A million ops = a million serialized; there is never an eventual window on the truth. Any reader needing the authoritative answer reads the tree, always current. fs_indexis lazy, version-invalidated, rebuilt-from-truth - and that is the eventual part, safely. Writes do not touch the index; each mutation just bumps a cheap tree-version counter, so the hot path is untaxed even under a million ops. When the index is actually read (afind),fscompares versions; if stale, it rebuilds the index by walking the tree once - bounded by tree size, not by the churn that invalidated it. Eventual consistency is harmless here because the index is never authoritative: a fresh index, a stale index, or a fallback to the tree all yield a correct answer. (This refines an earlier "update inline on every write" framing - taxing every write to keep a rarely-read index live is the wrong trade; mark-stale + rebuild-on-read keeps writes fast and is still always-correct.)- No events, interrupts, or new caps.
fsis the sole mutation point, so "the index changed" is just "fsdid an op + bumped the version." Crash repair is the same mechanism: a stale/dirty version → rebuild from the tree. - On disk, disposable, rebuildable, non-authoritative, and visible (§26.4) - never RAM-resident (a trillion 64-byte records won't fit), never the source of truth, never a hidden layer.
- Cross-instance falls out for free. Unplug a drive, replug into another GodspeedOS
instance: it reads
fs_index, trusts it if the version matches, rebuilds from the tree if stale - a fast remount without re-walking the whole tree. - Escape hatch for a giant tree: if even the rebuild-on-
findwalk ever gets too slow, then add incremental maintenance - an append-only change log + a background applier that drains it into the index (classic write-ahead-log eventual consistency). A heavier machine pulled in only by a measured need (§26.2), not now.
Specified, not built. GSFS0003 ships the three structures (§6.4); fs_index is the layer
that snaps on when search arrives.
6.6 GSFS0004 - integrity checksums (the "robust filesystem" Phase A)
Built 2026-06-17. The first phase of the robustness program (crash-consistency + large files + checksums + restartability - all four selected). GSFS0004 keeps the three GSFS0003 structures and the whole directory-tree/bitmap design unchanged; it adds integrity checksums and bakes the final geometry once so later phases add behaviour, not new on-disk formats.
What changed vs GSFS0003 (magic GSFS0004, version 4):
- Superblock CRC32 - a
sb_crc32(u32 @124) over the superblock's first 124 bytes, verified on mount. A corrupt superblock is a loud refusal (§3.12), never trusted or silently repaired - alongside the existing bad-magic refusal. Re-stamped on every superblock write (persist_super). (2026-10-09: the CRC has since moved to @136,SB_CRC_OFF, widened to cover the GSFS0008 feature masks at @124..@136 - §6.15.) - Per-directory-block CRC32 - each 512-byte directory block now holds 7
file_records (was 8) and reserves its last 64 bytes as a trailer; the first 4 trailer bytes are a CRC32 over the block's 448-byte record region. Verified on every directory read (Fs::td_read) and stamped on every write (dir_write), so corruption in the metadata that defines the tree surfaces loudly instead of returning garbage records. Thefile_recordlayout is otherwise unchanged - names stay 38 bytes. (The other 60 trailer bytes, 452..512, went unused until Phase O put timestamps there - past this CRC and under one of their own, so nothing above is invalidated. See §6.17.) - Reserved journal region -
journal_start/journal_blocks(u64 @108/@116) carve a fixed 64-block (32 KiB) region between the bitmap and the data region. Empty and unused in Phase A; it is where Phase C's crash-consistency redo-journal lives. Baking it now means the on-disk geometry is fixed once, not churned across phases.
Scope of Phase A checksums: the superblock and the directory tree (the structural
metadata whose corruption breaks the whole filesystem). Bitmap blocks and file data
blocks are not yet checksummed - a corrupt bitmap is reconstructible from the tree, and
per-file data integrity is a deferred follow-on (a per-file-record data CRC), pulled in
only when a need does (§26.2). The CRC32 is the standard IEEE 802.3 algorithm
(services/fs/src/crc32.rs); osdev carries a byte-identical copy (osdev/src/crc32.rs)
so host-baked images (mkfs, script-disk) checksum exactly as fs would.
Migration: GSFS0004 is reformat-only - there is no GSFS0003→0004 upgrader. There is
no interop requirement (§3) and flash is user-initiated, so an existing GSFS0003 data
drive is simply re-flashed. (Mounting a GSFS0003 image under GSFS0004 fails loudly on the
magic check, as it should.)
This supersedes §6.4 (GSFS0003) as the on-disk target; §6.4/§6.2/§6 remain the historical record. The remaining robustness phases (B large files, C crash-consistency, D restartable/TCB-drop) build on this format.
6.7 Large files - streaming offset-addressed I/O (Phase B)
Built 2026-06-17. Phase B of the robustness program. The 3584-byte file ceiling was never an on-disk limit (a file is already a contiguous u64 extent;
block-drivermoves one sector per IPC) - it was the client↔fs message boundary: the whole file rode in one ≤4 KiB IPC message and one stack buffer. Phase B lifts it with a streaming API, no on-disk format change.
Mechanism - stateless offset-addressed ops (fit the single-owner, per-request model, §8; no open-file/session state):
WriteNew(path, total)(op 24) - create/truncatepathto a file sizedtotalbytes, allocating the whole contiguous extent up front (record size =total; data filled next).WriteAt(path, offset, chunk)(op 25) - writechunkat a block-aligned byte offset; whole blocks only (the last block of a partial chunk is zero-padded), so no read-modify-write. Bounded to the file's extent - a write past it is a loud error.ReadAt(path, offset, len)(op 26) - read up tolenbytes fromoffset(clamped to size).
A large write = WriteNew then a sequence of WriteAt chunks; a large read = StatFile (for
the size) then a sequence of ReadAt chunks. The streaming chunk is MAX_FILE_BYTES
(3584) - now the per-message size, no longer a file cap. The one-shot WriteFile/ReadFile
remain for small files.
Clients. The shell streams read, copy (incl. recursive), and the pipe write
sink through these ops - so it views/copies files far larger than one message with only a
single chunk buffer (important given the shell's tight stack). run (which must hold a whole
script to parse) stays on the one-shot path, bounded by what it can hold; the embedded
selfcheck continues to run from rodata, not from a disk file.
Crash behaviour (until Phase C). A large write is many separate writes; a crash mid-write leaves a partial file - the same honest-loss as any write today. Phase C's journal makes the metadata commit atomic.
Verified: osdev test fs-large writes + reads a 200 KiB file in streaming chunks and
re-reads it across a reboot (boot 1 writes, boot 2 re-verifies on the same disk); files
130/0 and script 2/2 confirm the shell read/copy streaming is regression-free.
6.8 Crash-consistency - the metadata redo-journal (Phase C)
Built 2026-06-17. Phase C of the robustness program - the headline property: any single power loss leaves the filesystem consistent. Every metadata mutation runs as one atomic transaction through the reserved journal region (§6.6); a crash either leaves the filesystem entirely unchanged or, once a transaction has committed, is replayed to completion on the next mount. Realizes the
CLAUDE.md§15 promise of transactional metadata recovery (the TCB-drop that this unlocks is Phase D).
The transaction. A metadata-mutating op (write, mkdir, mkdir -p, rename, move, delete,
label) STAGES all its structural block writes (directory blocks, bitmap blocks, superblock)
in an in-memory buffer instead of writing them to disk - with read-your-writes, so the
op sees its own staged changes. commit_txn then:
- writes the staged blocks into the journal region;
- writes a checksummed commit record (magic + the block count + the home LBAs + a CRC of
the staged payload + a CRC32 over all of that at @508,
COMMIT_CRC_OFF) - the atomic point; - checkpoints each staged block to its home LBA;
- invalidates the commit record.
File data blocks are written directly (not journaled): they land in a freshly-allocated extent that nothing references until the metadata transaction commits, so a crash before commit just leaves harmless garbage in free space.
Recovery (at mount, before serving). Read the commit record: a valid magic and CRC
means a committed-but-unfinished transaction → replay its blocks to their home LBAs
(idempotent) and invalidate. An absent/torn commit (no magic, or a CRC mismatch) → do
nothing. So a crash before the commit record is discarded (home untouched); a crash after
it is completed. There is no third outcome on a backend that attests durability (see the note below).
(2026-10-09: recovery also re-reads the staged blocks and checks them against the payload CRC
before applying any of them. A mismatch applies nothing and leaves the journal intact for the next
mount, with a loud log saying the tree is not known consistent - Fs::recover.)
Backends that cannot be ordered (2026-07-25, corrected 2026-09-23). The rule is BACKEND-specific
rather than architectural: a device that refuses or lies about a flush cannot enforce the journal-data
-> commit -> checkpoint ordering, so commit_txn asks explicitly (three barriers) and fs warns once
per mount when the answer is no.
The ARM USB backend was named here as the example, and it is not one. That claim was never tested
when it was written; when it finally was, the device accepted SYNCHRONIZE CACHE across seven sessions
and an unassisted power cut on the Pi 2 landed inside the commit window and REPLAYED - journal recovered 4 block(s), nothing torn, nothing repaired. So no board in this project is currently known
to be an unorderable backend. The rule stands and is worth keeping; the example is withdrawn.
CLAUDE.md §6.1 (amendments 2026-07-25 and 2026-09-23) carries the constitutional form, and
fs-lyingflush models an unorderable device in QEMU so the case stays tested without one.
Why ordered durability is free. block-driver flushes every sector write to the medium
before replying (FLUSH EXT), and fs serializes requests, so the journal-data → commit →
checkpoint ordering the protocol needs holds without any extra barrier op.
(2026-10-09: no longer true, and the paragraph above is history. Since 2026-08-22 ahci issues
FLUSH CACHE EXT only when fs sends OP_FLUSH, and commit_txn sends it at its barriers
(durable_or_warn) - staged blocks before the commit record, the commit record before any home
block moves. The ordering rests on those explicit barriers, not on a flush per sector. CLAUDE.md
§6.1, the 2026-07-25 amendment as amended.)
Bounds (§26.6). A transaction stages at most TXN_CAP (56) blocks - comfortably more
than any single op needs; an op that would exceed it fails loudly rather than committing
partially. delete of a whole subtree (delete_tree) is the one unbounded case: it commits
the unlink as one atomic transaction (after which the subtree is unreachable), then
reclaims the subtree's blocks in bounded per-extent transactions - a crash mid-reclaim
only leaks blocks (safe, never corruption).
Verified: osdev test fs-journal (11 checks). Part 1 (REPLAY): a journal-crash-test
build writes a file through a transaction that halts right after the commit record is durable
(simulated power loss); the next boot's mount replays it from the journal and the file is
present with the exact bytes. Part 2 (REJECT): a journal whose commit record has a bad CRC is
ignored - the fs mounts clean and serves normally, no spurious replay. All five earlier
suites (files, blockdev-reboot, fs-large, fs-corrupt, script) stay green with the journal
active on every mutation.
6.9 Restartable - fs + block-driver leave the TCB (Phase D)
Built 2026-06-17, with a
CLAUDE.md§6 amendment (signed off). The §6.3 / §9 goal: with crash-consistent recovery in hand (§6.8),fsandblock-driverno longer need to be trusted root - their death becomes a supervisor restart, not a panic+reboot. The non-restartable set shrinks to justinit+supervisor+ kernel.
Mechanism (mirrors the kernel name-directory recovery path).
- The kernel's death path notifies the supervisor for
fsandblock-driver(sending the dead service's name); nothing on that path treats them as unkillable, so killingfs/block-drivernever panics. (2026-10-09: no longer by name - the kernel notifies for any task the supervisor spawned withSPAWN_FLAG_WATCHED, which it sets from itsMANAGEDroster; CLAUDE.md 12.3, 2026-10-03.) - The supervisor's death-notification loop respawns the named service.
block-driverrespawns beforefs(fs's send-peer cap to it wires from the kernel name table at spawn). fsandblock-driverare recorded in the kernel name directory at spawn, so clients can reacquire a fresh cap by name after a restart.- On restart,
fsre-mounts - replaying the journal if the crash interrupted a commit (§6.8) - so it always comes back consistent. The persisted data is intact. - Clients reacquire on
EndpointDeadorReplyDead(§14.3), and re-send only what is safe to repeat.fs's block I/O (block_rpc) reacquiresblock-driver: it is a synchronous request/reply bounded at 30 s (request_with_reply_deadline_outcome_into), so ifblock-driverdies after receiving the request but before replying, the kernel wakesfswithReplyDead(the reply-side twin ofEndpointDead, §8.6) instead of hanging it on a reply that will never come -fsthen reacquiresblock-driverby name and retries once, as for a failed send, because a block transfer of the same sectors is safe to repeat. A reply that does not come within the 30 s is NOT re-sent: the request may still be in flight, and a second one would desync the replies. The shell'sfs_requestreacquiresfsand retries read-only operations only: a mutating op (op_is_mutating: write, mkdir, rename, delete, move) whose reply was lost is reported as possibly done and never re-sent, because a repeat can repeat a destructive operation. The next command covers the window before the service has re-registered.
Verified: osdev test fs-restart (§22 Test 13, 7 checks) - the shell writes a file, KILL fs over the control channel, the supervisor respawns fs, fs re-mounts + re-registers, and
the shell reacquires fs and reads the file back; the kernel never panics. The journal suite
and all file suites stay green.
This completes the four-part filesystem robustness program (A integrity, B large files, C crash-consistency, D restartable). The earlier "Phase 1/Phase 3 TCB trajectory" framing in §9 is now realized.
6.10 Data-block checksums - every block self-verifies (GSFS0005)
Built 2026-06-17. Phase A checksummed the structural metadata (superblock + directory tree); file data blocks were the one thing still unchecksummed (a noted follow-on). This closes it: each file-data block now carries its own CRC32, so silent bit-rot in file content is caught loudly on read (§3.12) - exactly like the superblock and directory blocks.
Format change (magic GSFS0004 → GSFS0005, reformat-only). A file-data block is now
508 bytes of payload + a 4-byte CRC32 trailer @508 (mirroring the directory-block trailer).
A file of N bytes spans ceil(N/508) data blocks; each block's CRC covers its own 508-byte
payload, verified on every read (data_read) and stamped on every write (data_write). This
makes every block in the filesystem self-verifying - superblock (CRC @124, @136 since GSFS0008 - §6.15), directory block
(CRC @448), data block (CRC @508) - a uniform, whiteboardable model with no side structure and
no read-modify-write.
Consequences, all localized:
- The per-message streaming chunk is now
7 × 508 = 3556bytes (MAX_FILE_BYTES, and the shell'sIO_CHUNK) - 7 whole data-block payloads, so a streamingWRITE_AToffset stays block-aligned (no RMW). The offset-addressed ops and the shell streaming loops are otherwise unchanged. - Data writes stay direct (not journaled), so the Phase-C crash model is unchanged: a pre-commit crash leaves the data (and its CRCs) in an unreferenced extent - harmless.
- ~0.8% space overhead (4 bytes per 512). The host baker (
osdevgsfs_add_file) writes data the same 508-payload-+-CRC way, so baked files (script-disk,mkfs) verify identically.
Scope. Bitmap blocks remain unchecksummed - they are reconstructible from the tree, and a bitmap bit-flip is a space-accounting error, not data loss; a dedicated bitmap CRC is a future add only if a need arises (§26.2).
Verified: osdev test fs-corrupt case 3 (now 11 checks total) - bake a file, flip a payload
byte in its data block, boot: fs logs a "data block CRC mismatch" and the read is refused
(never returns the corrupt bytes), no panic. files 130/0, fs-large, fs-journal 11/0, fs-restart
7/0, script 2/2, blockdev-reboot all stay green with 508-byte data blocks.
6.11 Further robustness - roadmap (recovery layer)
Adopted 2026-06-17. Phases A-E gave the filesystem detection (every block self-verifies), crash-consistency (the metadata journal), restartability, and persistence - all hardware-proven on the T630. The remaining robustness work is mostly the next layer: recover after detection, and remove the single fatal block. These are pulled in cheap-first; the heavy items stay deferred until a real need pulls them (§26.2).
Doing now (cheap, high value):
-
Phase F - Backup superblock (GSFS0006). ✅ Built + verified 2026-06-17. The superblock is a single block at LBA 0; if its CRC fails the whole volume won't mount. A second copy at the last LBA (
total_blocks-1, reserved in the bitmap at format) letsmountfall back to it when the primary fails - and heal the primary from it. The backup is located via the block-driver's reported capacity, so it works even when the primary is unreadable (no chicken-and-egg).formatwrites both;persist_superkeeps them in sync (staged in the same transaction, so they commit atomically);drives resetwipes both. Removes the single most catastrophic point of failure for ~no cost. Reformat-only bump 0005→0006. Verified byosdev test fs-corrupt(now 14 checks): corrupt the primary → recovers from backup; corrupt both → loud refusal. No regression (files 130/0, fs-journal 11/0, fs-restart 7/0, drives 9/0, script 2/2, blockdev-reboot). -
Phase G -
drives check(fsck / repair). ✅ Built + verified 2026-06-17. A CRC failure is detected loudly but was not recoverable, and a drifted bitmap/free-count had no repair. Because the directory tree is the source of truth, fsck falls out: zero the bitmap, mark the system blocks + backup, then walk the tree marking every referenced extent - rebuilding the free bitmap + free count from the tree (healing drift) - verifying every block's CRC and reporting (not deleting) files/dirs that fail, and refreshing both superblock copies viapersist_super. Turns "detected, dead" into "detected, repaired." A newdrives checkshell command (opOP_CHECK); no on-disk format change. Writes are DIRECT (not journaled): the rewrite is larger than one transaction and idempotent, so a crash mid-check is harmless (the tree is still truth; re-running converges). This subsumes a dedicated bitmap-block CRC - if the bitmap is rebuildable from the tree, checksumming it is redundant (§6.10's deferred bitmap-CRC is folded into fsck, not built separately). Verified byosdev test fs-check(5/0): drift the free count host-side (both copies, CRC re-stamped), boot,drives checkrebuilds the correct value, 0 bad, the file survives. the selfcheck suite also runsassert ok drives checkon a populated tree (in the script test). No regression (files 130/0). -
Phase H - block I/O error handling. ✅ Built + verified 2026-06-17. A failed block read/write just failed the op. Now
block-driverissues every read/write/zero throughissue_io, a bounded retry (MAX_IO_ATTEMPTS = 3) with port recovery between attempts (recover_portclears PxSERR/PxIS and restarts the command engine if it halted): a transient error (marginal sector, controller hiccup) is recovered transparently and logged; a persistent one is reported loudly (§3.12) and returns an error.fsalso logs the failing LBA on a block error. Bounded (§26.6) - never an infinite retry. No format change. Verified byosdev test fs-ioretry(5/0): aio-error-testbuild forces the first read/write commands to fail (QEMU never fails a real disk), and the driver retries + recovers (the boot self-test read still succeeds; fs mounts + round-trips), no panic. The persistent-failure branch (report + Err after N attempts) is the same loop's exhaustion path. No regression (files 130/0, reboot). -
Phase I - Extent lists (GSFS0007). ✅ Built + verified 2026-06-17. Files were a single contiguous run, so a big file needed a big contiguous free span - fragmentation could refuse a write the disk had room for. Now a file is contiguous (
ITYPE_FILE, the fast path, byte-for-byte unchanged) or, when no contiguous run is free, fragmented (ITYPE_FILE_FRAG): itsfirst_blockpoints to a single CRC'd extent block (block_count= 1) that lists up toEXT_MAX = 31scattered(start, len)data runs. So a file is stored across whatever free space exists. Full §6.12. Reformat-only bump 0006→0007. Verified byosdev test fs-frag(11/0): fill a small disk, delete every other file to scatter free space into ~2-block gaps, write a 20-block file that cannot fit contiguously → it lands fragmented, reads back exactly, and survives a reboot. No regression (files 130/0, fs-large 1/1, fs-journal 11/0, fs-check 5/0, fs-ioretry 5/0, fs-corrupt 14/0, fs-restart 7/0, drives 9/0, script 2/2, blockdev-reboot). -
Phase J - Opt-in data journaling. ✅ Built + verified 2026-06-17. The metadata journal made every structural mutation atomic, but streaming
write_atwrote data direct with no transaction - a crash mid-chunk left torn data (caught by the data CRC on read, but not recovered). Phase J adds a journaledwrite_atvariant (OP_WRITE_AT_J) that stages the chunk's data blocks in a transaction so the chunk commits atomically - a crash replays or discards it whole, never torn. Opt-in per write: the caller picks the contract; defaultWRITE_ATstays direct (the fast path). Bounded to one chunk (≤ 7 blocks) by the 64-block journal - whole-file atomicity across chunks is out of scope and stays honest (§20/§26.6). (WriteFileand overwrite were already crash-atomic - copy-on-write to a fresh extent, data flushed before the metadata commit - so only the streaming path had a torn-data window.) Full §6.13. No on-disk format change, no amendment. Verified byosdev test fs-djournal(7/0): a journaledwrite_athalts right after its commit record (data only in the journal, home blocks still zero), and the next boot replays the data home - a correct read proves the journal supplied it. No regression (fs-large 1/1, fs-frag 11/0, fs-journal 11/0, files 130/0, script 2/2). -
Phase K - Scrub (read-only integrity sweep). ✅ Built + verified 2026-06-17. Every block already self-verifies on read, but rarely-read blocks could rot undetected until you needed them.
drives scrub(OP_SCRUB) walks the tree and verifies every referenced block's CRC, reporting(files, dirs, bad, scanned)and changing nothing - distinct fromcheck, which repairs the bitmap (writes). Read-only ⇒ safe to run at any cadence on a healthy filesystem. Without redundancy (no RAID, by design) scrub detects bit-rot but cannot repair it: a bad block is reported and any read of it stays a loud refusal (§3.12), the data is already lost. The cadence is operator-driven - GodspeedOS has no background-task primitive, so "periodic" is policy (rundrives scrubon a schedule), not a hidden timer (§26.4: no silent complexity). Full §6.14. No format change, no amendment. Verified byosdev test fs-scrub(6/0): a corrupt data block is reported (1 bad), a second scrub still reports it (read-only - no repair), and the clean file is untouched; the selfcheck suite also runsassert ok drives scrubon a clean tree.
Deferred (heavier; pulled in only when a real need arises, §26.2 / §26.11):
- Snapshots / copy-on-write / mirroring (RAID) - real features, but each strains the 30-minute-whiteboard rule (§26.11); revisit only with a concrete need. (RAID would also let scrub repair, not just detect - but it needs a second disk and is out of scope.)
6.12 Extent lists - files across scattered free space (GSFS0007)
Adopted 2026-06-17 (Phase I). Lifts the contiguous-extent fragmentation limit (§6.11 roadmap) while leaving the common case untouched.
Through GSFS0006 a file was always a single contiguous run: first_block .. first_block + block_count. Simple and fast, but it meant a file needed a contiguous free
span as large as itself - on a fragmented disk a write could be refused for lack of one big run
even when the total free space was ample. GSFS0007 removes that limit with a minimal, opt-in
second representation, chosen so the existing fast path is byte-for-byte unchanged:
- Contiguous file -
ITYPE_FILE(= 1), the fast path. Exactly as before: the record'sfirst_block/block_countare the data extent; the n-th data block isfirst_block + n. Every existing test exercises this path, and the allocator still tries it first. - Fragmented file -
ITYPE_FILE_FRAG(= 3). Engaged only when no contiguous run is free (alloc_runreturns "no space"). The record'sfirst_blockpoints to a single extent block andblock_countis 1. The extent block is a normal 512-byte block:n_extents:u32 @0, then up toEXT_MAX = 31(start:u64, len:u64)runs from@8, and a CRC32 @508 over[0..508)- verified on every read, loud refusal on mismatch (§3.12), exactly like a data or directory block. The n-th data block is found by walking the runs.
Allocation (alloc_file). Try one contiguous run (fast path). On failure, alloc_extents
grabs free runs greedily (find_free_run scans the bitmap; take = len.min(need)) until the
request is satisfied - bounded to EXT_MAX runs, beyond which the write is refused loudly
(§26.6) - then one more block holds the CRC'd extent block. A file fragmented into more than 31
pieces is rejected, not silently mishandled. Freeing (free_file) mirrors it: a contiguous
file frees its one run; a fragmented file frees each listed run, then the extent block.
Where it plugs in. The extent block is metadata, so it is staged in the crash-consistency
transaction (ext_write via tb_write) and commits atomically with the record and bitmap - a
pre-commit crash leaves the data blocks unreferenced and the bitmap unchanged, identical to the
contiguous case (§6.8). read_path/read_at/write_at resolve the extent list once per
op when fragmented (a contiguous file maps by arithmetic, zero overhead), and delete /
recursive delete_tree / drives check (fsck) all walk the runs so blocks are freed, verified,
and accounted for whichever representation a file uses. The host writer (osdev) only ever bakes
into a fresh disk, so it always produces contiguous files; the fragmented path is fs-only.
Bounds & honesty (§26.6). One extent block ⇒ ≤ 31 runs per file - a hard, loud ceiling, not
an unbounded indirection tree. This is deliberately not a btree/multi-level extent map: it
fixes the fragmentation limit that can actually bite while staying inside the 30-minute
whiteboard rule (§26.11). A file too fragmented for 31 runs fails loudly; drives check can
report it. Verified by osdev test fs-frag (11/0) + full regression green.
6.13 Opt-in data journaling - atomic streaming chunks (Phase J)
Adopted 2026-06-17 (Phase J). Closes the one torn-data window left by the §6.8 model - the streaming
write_atpath - without pretending to whole-file atomicity the 64-block journal cannot give.
The crash-consistency journal (§6.8) makes every metadata mutation atomic, and the common file operations are already crash-safe for data too - almost by accident of the design:
WriteFileallocates a fresh extent, writes its data (flushed), then commits the metadata. A crash before the commit discards the metadata and leaks the data; after it, the data was already durably home. The file either fully appears with correct bytes or not at all.- Overwrite is copy-on-write: a new extent is filled, the record is re-pointed, the old extent freed - so the same "data-before-commit" ordering holds, and the old file survives a crash intact.
The exception is the streaming large-file path: write_new commits the metadata (file sized,
extent allocated) up front, then a sequence of write_at chunks fills the data direct, outside
any transaction. A crash mid-chunk leaves some blocks new and some stale - torn data, caught by the
per-block CRC on read (a loud refusal, never silent - §3.12) but not recovered.
Phase J makes that recoverable, opt-in per write:
OP_WRITE_AT_J- a journaledwrite_at. It stages the chunk's data blocks in a transaction (data_stage: stamp the CRC,tb_writeinto the staged set) and commits through the journal, so the chunk's data rides the same atomic mechanism as metadata:commit_txnwrites it to the journal, lands the checksummed commit record (the atomic point), checkpoints it home, and invalidates. A crash before the commit record discards the chunk; after it, the next mount'srecoverreplays it home. The chunk is applied whole or not at all - never torn.- Default
WRITE_ATstays direct - the fast streaming path is unchanged. The caller chooses the guarantee: pay the double-write (journal + home) for atomicity, or take the fast path and rely on the read-time CRC. The durability contract is explicit, not hidden (§26.5/§26.7).
Bounded & honest (§26.6/§20). The journal is 64 blocks (TXN_CAP = 56 staged), and a chunk is at
most 7 × 508 payload bytes (7 data blocks), so a journaled chunk always fits - but whole-file
data journaling does not, and Phase J does not pretend otherwise. The guarantee is per-chunk
atomicity; a multi-chunk file written with OP_WRITE_AT_J is a sequence of individually-atomic
chunks, not one atomic file. That is the honest ceiling of a fixed 64-block journal, stated plainly
rather than papered over. Verified by osdev test fs-djournal (7/0): a journaled write_at halts
right after its commit record - the data is only in the journal, the home blocks are still zero - and
the next boot replays it; because a zero block fails the data CRC, reading the file back correctly can
only mean the journal supplied the data. No on-disk format change; no constitutional amendment
(within §15/§26.6, no TCB or §7 change).
6.14 Scrub - a read-only integrity sweep (Phase K)
Adopted 2026-06-17 (Phase K). Turns the per-block CRC from a read-time guard into a proactive one, honestly bounded by what GodspeedOS actually has.
Every block self-verifies on read (§6.6/§6.10): a corrupt block is a loud refusal, never silent garbage. But a block you never read can rot undetected until the day you need it. A scrub closes that window: read everything periodically and verify it, so bit-rot surfaces early.
drives scrub (OP_SCRUB) walks the directory tree from root and verifies every referenced
block's CRC - directory blocks (td_read), extent blocks and the data runs they list (ext_of +
data_read), and contiguous file data (data_read) - reporting (files, dirs, bad, scanned). It
writes nothing: scrub/scrub_subtree are &self, no transaction. This is the deliberate
difference from check (fsck, §6.11 Phase G), which repairs - rebuilds the bitmap and free count
(and so must write). Scrub only detects and reports, which makes it safe to run routinely on a
healthy mounted filesystem without churning the disk.
Two honesty points the constitution forces (§26.4/§26.7):
- Detect, not repair. GodspeedOS has no redundancy (no RAID - §6.11, deliberately out of scope),
so there is no second copy to heal a rotted block from. Scrub reports
N bad; the data is already lost and the read stays a loud refusal. (RAID is exactly what would let scrub repair - and exactly what the whiteboard rule keeps out for now.) - Operator-driven cadence, not a hidden background task. "Periodic scrubbing" in other systems
rides a background scheduler GodspeedOS does not have, and inventing a silent kernel/daemon timer
would be precisely the hidden complexity §26.4 forbids. So the mechanism is a plain command and
the cadence is policy: an operator (or a script on a timer they control) runs
drives scrub. The trigger is explicit and visible, which is the point.
Verified by osdev test fs-scrub (6/0): a disk with one clean file and one whose data block was
flipped host-side - scrub reports 1 bad, a second scrub still reports 1 bad (proving it is
read-only and repaired nothing), and the clean file is untouched. the selfcheck suite runs
assert ok drives scrub over a populated clean tree (the 0-bad path). No on-disk format change; no
constitutional amendment (within §15/§26.6, no TCB or §7 change).
6.15 Versioning & compatibility - feature flags, and the last magic (GSFS0008)
Adopted 2026-06-18 (Phase L). The final monolithic format bump. After this, the format evolves by feature bits, not by minting a new magic - so a version difference no longer forces a reformat.
The problem. Through GSFS0007 the 8-byte magic did two jobs at once: "is this our filesystem?" and "which exact version?" Because all 8 bytes changed on every bump, any version difference failed the magic check, which made every bump reformat-only - upgrade the OS and your old disk won't mount. Fine for a learning OS with no production data, but it doesn't scale: there is no "GSFS00014" worth minting.
The fix (the ext/btrfs model). Separate identity from capability:
- The magic is FROZEN at
GSFS0008. It answers only "is this GSFS?" It never changes again. - Three feature-mask
u32s in the superblock (@124compat,@128ro_compat,@132incompat, all under the widened CRC@136) answer "what does this disk use?" Each bit is a feature, set on disk only when actually used. Future capabilities are new bits, not new magic.
The mount policy - explicit and loud (invariant 12), the opposite of a silent fallback:
| Mask | A bit this build doesn't recognise means… |
|---|---|
compat | mount read-write normally - ignoring the feature is safe |
ro_compat | mount READ-ONLY - reading is safe, but writing could corrupt the feature (every mutating op is refused loudly; reads pass) |
incompat | REFUSE to mount, loudly - the on-disk structure is fundamentally different |
This is more honest than the old scheme, not less: an old build meeting a newer disk gets a precise reason (refuse / read-only / fine) instead of a version mismatch indistinguishable from a foreign disk.
The bits defined so far. compat bit 0 = a backup superblock exists (Phase F). incompat
bit 0 = some file is fragmented, so an extent list must be read to find its blocks (Phase I).
ro_compat bit 0 = some file on this volume is SEALED (Phase O, §6.17).
Features earn forward-compatibility by being lazy. The bit is set only when the feature is
exercised. Extent lists (Phase I) are an incompat feature, but the FEAT_INCOMPAT_EXTENTS bit is
set the first time a file actually fragments - a freshly-formatted disk that never fragments
anything has the bit clear, so a build that predates extents could still mount it. You only lose
compatibility once you actually use the new capability. (Backup-superblock, Phase F, is a compat
bit - an old reader ignoring it is harmless.)
The honest caveat. This is forward-looking from 0008. It cannot retroactively rescue pre-0008 disks (0003-0007 predate the masks, and 0004/0005 genuinely changed per-block layout), so those need one last reflash. But 0008 is the last time the format ever forces that - every future GSFS feature is additive, governed by the masks. "GSFS00014" never happens; it would just be "GSFS0008 with more feature bits set."
Verified by osdev test fs-compat (12/0): three disks, each carrying an unknown bit in a different
mask (CRC re-stamped so they still validate) - the unknown incompat disk is refused, the unknown
ro_compat disk mounts read-only (reads work, writes refused), the unknown compat disk mounts
normally read-write. No regression across the whole suite.
6.16 Commandment VIII, completed in the fs - the mount loop's failure-truth exit + AHCI self-heal
Built 2026-07-09. Robustness surfaced by a real T630 run: a rapid second
kill all-services(the whole restartable set killed twice within ~6 s) wedged the storage stack for good - not a crash, a permanent thrash. Three defects compounded, and fixing them completes Commandment VIII (wait for truth, not time; and the truth must include failure) in the fs. No on-disk format change, no amendment.
The wedge. After the second respawn, fs's read of the superblock (LBA 0) returned an I/O error and
fs proceeded with a garbage capacity: it accepted a stale block-driver reply as the disk's sector
count (a nonsense value near 2^62), computed a backup-superblock LBA far past the end of the disk, and
asked block-driver to read it. The AHCI controller could not read a nonsense LBA - its command-issue bit
(PxCI) stuck without setting BSY, so recover_port's BSY-only recovery never escalated - and the
mount loop retried the impossible read forever. The keyboard looked dead because the whole system was
buried under an infinite storage thrash. (The trigger only reached because the shell's startup history
read - since made lazy, see docs/console-service.md - fired into the re-init; but a masked race is still
a race, so the storage stack itself is hardened here.)
The three fixes:
- AHCI self-heals a CI-stuck controller.
recover_portnow escalates its COMRESET onPxTFD.BSY != 0 || PxCI != 0, not onBSYalone. A stuckPxCIis hardware-owned - a soft recovery (clearingPxSERR/PxIS) cannot clear it; only a port COMRESET does. So a command that wedges the controller without setting BSY (exactly the garbage-LBA case) now self-heals instead of retrying into the identical stuck state. This extends the §6.11 Phase-H recovery (issue_io's bounded retry) to the one failure mode it did not cover. fsrejects a mis-shaped capacity reply.block_capacitynow accepts the block-driver's answer only if it is exactly[BLK_OK, sectors:u64](a bounded, precise shape) with a sane count. A stale or malformed reply - one whose leading byte merely happens to beBLK_OK- is rejected (returns "unknown"), never misread as a capacity;read_superblockthen bounds-checks the capacity before deriving the backup-superblock LBA from it. The false success that fed the garbage read is gone:fswaits on the right reply, not merely on a reply. (This scopes out the specific wedge; full request-id correlation of the fs↔block-driver channel is a later fs-IPC follow-on, deferred by §26.2.)- The mount loop has a failure-truth exit. Both mount waits - the capacity fetch and the read/
E_IOretry - were unbounded success-only loops: they waited for the truth of success forever and never treated a persistent read failure as terminal. They now cap atMOUNT_MAX_ATTEMPTSreal block-driver round-trips, after whichfscomes up degraded - it logs loudly and serves storage-unavailable to clients ("data intact; do NOT run 'drives flash'") instead of spinning forever on an unreadable disk. A persistent read failure is now a first-class terminal truth.
This is the executable form of Commandment VIII's second half - "a wait that cannot observe failure has
quietly become an infinite wait on time." The mount loop already waited on truth, not time (it never
timed out; see the loop's own note) - but only on the truth of success. Teaching it that failure is also
a truth is what closes the wedge. Verified: osdev test identity 24/0 (Test 13 fs-restart and the whole
file suite stay green); the live CI-stuck self-heal is T630-only (QEMU's emulated AHCI never raises the
CI-stuck condition).
6.17 Timestamps and SEALED - the first two features added under the frozen magic (Phase O)
Built 2026-09-17. The first change to the on-disk format since §6.15 froze the magic, and therefore the first test of whether that policy actually works. It does:
SB_MAGICis stillb"GSFS0008", no volume needs reformatting, and the two features arrive by the two routes §6.15 prescribes - one additive and invisible to an older build, one behind aro_compatbit.
Timestamps, in the 60 bytes every directory block already wasted. A 64-byte record is FULL -
type @0, name_len @1, name[38] @2, size @40, first_block @48, block_count @56 - so times could not go
inside it without shrinking NAME_MAX or halving how many entries a directory block holds. They did
not have to: seven 64-byte records are 448 bytes and the record CRC is a u32 at 448, so bytes
452..512 have been unused since the format was written. Two u32 epoch times for each of seven
records is 56 of them, and their own CRC32 is the four at 508.
Directory block (512 B): records [0..448) | record CRC32 @448 | 7 x (mtime:u32, ctime:u32) @452 | times CRC32 @508
mtime- when the content last changed.ctime- when the record last changed (rename, move, size), which is what distinguishes "the file changed" from "the file was moved".- No
atime: recording a read turns every read into a write, and on a journaled filesystem into a transaction. The cost is real, the value is low, and it is refused on purpose. u32epoch seconds, good to 2106. Four bytes rather than eight is what let both times fit beside a CRC in 60 bytes.
Why this needs no feature bit at all. The record CRC still covers exactly [0..448) and still
lives at 448, so a build that predates times reads the directory perfectly and never looks past the
CRC it knows about - it will also overwrite the times region without stamping its CRC, which is
precisely why the times carry their own. A build that knows times, reading a volume that has none,
finds the mismatch and reports unknown rather than inventing 1970: a wrong date is worse than an
absent one, because a date you can see is a date you will act on. No migration pass runs and nothing
back-fills a missing time - the first write to an old directory block stamps a valid CRC over zeros,
and zero is TIME_UNKNOWN, so old entries keep reading as unknown while new ones carry real times.
The clock behind it is the time service, which owns the wall clock, sets it from SNTP and persists a
floor across reboots (/system/services/time/floor); a file stamped on a machine that has never seen the network gets
the floor rather than zero, and the floor only moves forward.
SEALED - content frozen, permanently. A sealed file can be read, listed, renamed, moved and deleted; its bytes can never change again, and there is no unseal (a seal a holder can lift is a request, not a guarantee). It freezes CONTENT, not existence: refusing deletion would make a sealed file unremovable, so a disk could be filled with rubbish nobody is permitted to clear - a denial of service bought with a guarantee nobody asked for.
The record had no spare bit either, so the flag rides the top bit of the 64-bit size - room no
file can reach, since 2^63 bytes is eight exabytes. Every size read goes through an accessor that
masks it off, which is correctness rather than tidiness: write_at bounds a fragmented file's extent
by its size, so the flag leaking into that arithmetic would let a write run past the file's own blocks.
Three refusals enforce it, and the first is the one that matters:
fswill not mint a writable capability to a sealed file - refused atopen, not per write, because a capability that looks writable and fails on use is a worse answer than a plain refusal, and §7.3 says rights narrow, so a capability that cannot be honoured should never exist.- Every write path (
write, the streamingwrite_at, the journaled variant) refuses, because the seal is carried on theEntryeach of them already walks. A write route added later cannot forget to ask. - The volume sets
ro_compatbit 0 the first time anything is sealed, so a build that does not know the bit mounts the whole volume READ-ONLY (§6.15) instead of writing through a flag it cannot see. Notincompat: refusing to mount a volume because one file is read-only forever is a punishment out of proportion to the risk, and read-only is exactly the honest middle the mask policy exists to express. The one thing such a build gets wrong is cosmetic - a nonsense SIZE for a sealed entry - on a mount that can change nothing.
The version number this work does NOT have. During the build these two features were repeatedly
called "GSFS0009", in this branch's scope note, in code comments and in commit messages. That was
wrong and is corrected: §6.15 froze the magic and states that every later feature is additive under
the masks - "GSFS00014 never happens". Nothing here mints a new magic, and there is no GSFS0009 on
any disk. Recorded rather than quietly renamed, because a version a reader could build against is
exactly the kind of claim §26.7 says to write down instead of papering over.
Verified by osdev test fs-time (15/0), which stamps a file, reboots the machine, and proves the
date survived, that a file with no time is still unknown afterwards rather than given an invented
one, that the write to the times region did not break the record CRC every build reads, and that a
seal survives the reboot with the write still refused and the content still original.
6.18 What TWO CLIENTS see - the ordering guarantee, stated
Written down because it was not. docs/gsfs-carnage.md §3.5 asked for two clients'
observable ordering to be checked "against what is documented - which currently is nothing, so
documenting it is part of the gate". This is that.
The guarantee, in one sentence: every fs operation is atomic with respect to every other
client.
It falls out of two facts rather than any locking:
fsis single-threaded and serves one request to completion before dequeuing the next. The serve loop isloop { let msg = gs::ipc::recv(&ctx); ... }- there is no second thread, because nothing in this system has one (§9: a task is a service). A request is received, served, replied to, and only then is the next taken.- Every mutating op commits through the redo-journal (§6.8). Its blocks are staged, the commit record is made durable, and only then does any home block move. A reader either sees the state before the transaction or the state after it.
So there is no read-modify-write window a second client can enter. Two clients cannot interleave
inside one operation, because there is no inside to reach: the operation is the unit fs serves.
What is NOT atomic, and this is the part worth knowing. A single client's multi-request IDIOM is
not a transaction. Between a client's two requests, fs may serve another client. So:
client A: write /x.txt ...................... move /x.txt /y.txt
client B: .................. delete /x.txt ...
is a legal ordering, and A's move will correctly report that its source is gone. Each of the three
operations was atomic; the SEQUENCE was not, and nothing promises otherwise. A client that needs a
compound operation to be indivisible does not have one available - that would be a transaction API,
and none exists (nothing has needed it: §26.2).
Ordering between clients is arrival order at fs's endpoint, which is the kernel's per-endpoint
queue - FIFO, depth 16 (§8.3, §8.5). Not priority, not fairness: whoever's message is dequeued first is
served first. A client whose queue slot is taken while fs is busy blocks in send until space
frees (§8.2), which is backpressure rather than loss.
What this is NOT a claim about. Nothing here says two clients cannot produce a surprising RESULT - B deleting the file A is about to move is surprising and entirely legal. The guarantee is about state, not about outcomes being agreeable: the filesystem is never left half-applied, and no client ever observes a partial operation.
Pinned by osdev test fs-twoclient (8/0), which puts recorder and the shell on the same
directory - a genuine second client writing a capture on its own schedule - and churns one path
through create / read / rename / read / delete for six rounds while it does. Every read returns its
own round's payload, the other client's file survives every round, and drives check reports the
volume consistent with no corrupt blocks.
7. File = capability (the north star)
The spine that makes this filesystem ours rather than a generic store: a file is named and reached by capability, consistent with §3.3 and §7.
7.1 The mechanism problem
The kernel has no concept of a file (§4.4 anti-scope - no filesystem logic in the kernel), yet only the kernel can mint unforgeable capabilities (§7.3). "A file is a capability" must bridge those two facts. Three options were weighed:
| Option | What it is | Verdict |
|---|---|---|
| Bearer token | fs returns a 128-bit unguessable handle, presented per call | A service-level token, not a kernel cap - forgeable by guessing, sits beside the capability model. Rejected as the north star (weak claim). |
| Endpoint-per-open-file | fs creates a kernel endpoint per open file; client holds a real SEND cap | Genuinely unforgeable, but every endpoint costs ~64 KiB (our own efficiency measurement) - heavy with many open files. Rejected for cost. |
| Kernel-delegated resource caps | Extend the kernel's ResourceId+Rights+Generation model so a service can ask the kernel to mint a cap for a service-defined resource it owns | Chosen. Real kernel caps (unforgeable, revocable, generationed) with no file logic in the kernel - it tracks an opaque resource owned by fs. Generalizes the capability model; useful beyond files. |
7.2 How kernel-delegated resource caps work
The kernel already keys capabilities on ResourceId with a Generation (§7.2, §7.5). The
extension: a service (fs) owns a band of resource IDs and asks the kernel to mint
caps for them with chosen Rights. The kernel:
- mints/validates/revokes exactly as for any resource - generation bump invalidates every
outstanding file cap at once (§7.5), giving
fsclean revocation (delete a file → bump → all its caps go stale, surfacing as the usualCapRevoked/EndpointDead-class error); - never learns what the resource means.
fsmapsResourceId → file. A read/write cap to a file is then a first-class capability the holder can validate, be denied, or have revoked - identical machinery to endpoint caps.
This keeps every capability property (§7.3: unforgeable, non-escalating, scoped, revocable, generationed) true for files, while honoring §4.4 (kernel stays file-agnostic).
This is a capability-model extension and will require a recorded
CLAUDE.mdamendment (§7 / §4.4) with sign-off before Phase 2 lands. It is called out here as a known constitutional change, not slipped in.
7.3 Phasing
- Phase 1 - authority = the cap to
fs. Holdingipc_send=["fs"]is the authority; files are addressed by name in the request. This ships working, reboot-surviving persistence without the new kernel mechanism. - Phase 2 - per-file capabilities via §7.2. ✅ Built + verified end-to-end + HARDWARE-PROVEN on the
T630 2026-06-18 (
osdev test file-cap9/9; on-hardware shellselfcheck=ran 163, failed 0, which includes thefcapself-check -CLAUDE.md§23.3).fsOpen(path, rights)mints a delegated resource and returns a real file capability; the holder operates the file by invoking that cap (ResourceInvoke), the kernel badges the request with(resource_id, right)via an unforgeableMessagefield, andfsresolves it through its open-file table, enforcingop ≤ right. Unforgeable, non-escalating (kernel and fs layers), revocable on close/delete/rename. File-as-capability is now true, not approximate. See §7.4 (kernel mechanism) and §22 Test 14.
7.4 Phase 2 - concrete kernel mechanism (design, amendment-approved 2026-06-18)
The
CLAUDE.md§4.4/§7.10 amendment is landed and signed off. This section is the implementation spec, grounded in the actual kernel (capability/table.rs,ipc/routing.rs,syscall/dispatch.rs). It records two non-obvious correctness decisions so the code that follows is right, not just fast.
What already exists (reuse, don't reinvent). ResourceId + Rights + Generation, mint_cap,
register_resource(_at_gen), revoke_resource (gen bump + Revoked liveness), the per-task
CapTable, and lazy generation invalidation. The global table is pre-sized (DIRECT_CAP = 8192,
"P2 adds ~1000 entries"). Embedded-cap transfer (SendWithCap/TakePendingCap) already moves a cap
to a client - that's how fs will hand over a file cap. An endpoint cap's resource_id.0 is reused
directly as its EndpointId.
The delegated band. Reserve ResourceIds [DELEGATED_BASE, DELEGATED_BASE+DELEGATED_CAP)
(within [0, 8192) so they stay direct-indexed in the global table). A new capability/delegated.rs
owns: a [Option<EndpointId>; DELEGATED_CAP] owner array + an allocation cursor, under a SpinLock.
allocate(owner) -> ResourceId finds a free slot, records the owner, registers the id in the global
table, and returns it. is_delegated(id), owner_of(id), revoke_owned(id, caller).
Correctness decision 1 - ABA-safe id reuse. A freed delegated id may be reallocated. If it re-registered at generation 0, a stale file cap (also gen 0 from the id's previous life) would spuriously re-validate - a cap-reuse hole. So
allocatere-registers a reused id atget_resource_generation(id).bump()(viaregister_resource_at_gen), keeping the generation monotonic across lives. A stale cap can never match a future life of the same id. (First use of a never-allocated id starts at gen 0.)
Three syscalls (each validates a capability first - §3.1, no exceptions):
ResourceMint(rights_bits, out_id_ptr) -> cap_slot- gated by a newRESOURCE_MINTstable resource (ResourceId(7), gen 0 forever, granted only tofs+ a test probe; validated byholds_resource, likeSPAWN). Allocates a delegated id owned by the caller's endpoint, mints a cap withrights_bitsinto the caller's table, writes the id to*out_id_ptr, returns the slot.fscalls this onOpen, recordingResourceId → file, then GRANT-transfers a narrowed copy to the client in the reply (existing embedded-cap path).ResourceInvoke(cap_slot, right_bits, msg_ptr, msg_len, reply_cap…) -> i64- the "use = send" of §7.10, but as its own syscall rather than overloadingsend. It validates the cap carriesright_bits(CapTable::get(slot, right)), then routes the message to the owning endpoint with the badge[resource_id:u64, right:u8]prepended to the payload, reusingrouting::enqueuewith the owner endpoint's current generation (so the routing gen-check passes; the file gen was already validated against the global table). A reply cap is embedded exactly asSendWithCapdoes today, sofsreplies on the normal path. (2026-10-09: as built, the badge is NOT prepended to the payload - it rides in kernel-setMessagefields and the owner reads it withLastRecvBadge(syscall 33), which is what makes it unforgeable;kernel/src/syscall/dispatch.rs. The RESOURCE_MINT grant is a privilege bit in the spawn request now, held byfs,net-stackand theresource-serverexample in the supervisor's spawn table, not a name-keyed mint.)
Correctness decision 2 - rights are enforced by the kernel, per operation, without the kernel knowing what a file is. A read needs
READ, a write needsWRITE. The kernel can't read the file op, so the caller declares the right it intends (right_bits) and the kernel validates the cap actually holds it - a READ-only cap doingResourceInvoke(.., WRITE, ..)failsCapInsufficientRightsat the kernel (this is the non-escalation enforcement, §7.3). The kernel then badges the message with the validated right, andfsrefuses any op whose required right exceeds the badged right. Sofsnever has to trust the client, and the kernel never learns the op.
ResourceRevoke(resource_id) -> i64- owner-gated (ownership is the capability check:revoke_ownedonly proceeds if the calling task's endpoint owns the id). Bumps the generation (Revoked) so every outstanding file cap to it goes stale → nextResourceInvokereturnsCapRevoked(§7.5). Frees the owner slot.fscalls this on delete/close.
Why a dedicated ResourceInvoke rather than overloading send: a file cap carries READ/
WRITE/GRANT, not SEND, and handle_send requires SEND. Overloading would force file caps to
carry SEND and lose per-operation rights enforcement. A distinct invoke keeps the file cap's rights
meaning exactly the file operations, and keeps handle_send untouched (lower TCB risk).
Phase 2a deliverable: the three syscalls + delegated.rs + the RESOURCE_MINT grant, exercised
by two probe services (mint → grant a cap → invoke with READ ok / WRITE denied on a read-only cap →
revoke → invoke returns CapRevoked) - no fs needed, so the mechanism is proven in isolation
before 2b wires fs onto it. Then 2b (fs issues file caps), 2c (SDK File + shell demo), 2d (§22
Test 14).
8. IPC protocols (proposed)
Note (2026-10-09): the proposal, not the protocol. What shipped is opcode-addressed with a correlation tag at byte 0.
fs<->block-driver:[tag, op, lba:u64 LE, ...]->[tag, status, ...], ops 1OP_READ_BLOCK, 2OP_WRITE_BLOCK, 3CAPACITY, 4OP_WRITE_ZEROS, 5FLUSH- one block per request, not a count - and since 2026-10-20 6OP_WRITE_BLOCKS, a run of 1 to 7 consecutive blocks (count:u8). Client <->fs: ops 10-31 (OP_WRITE_FILE10,OP_READ_FILE11,OP_STAT_FILE12, directories, rename/move/delete, drives, streamingOP_WRITE_NEW/OP_WRITE_AT/OP_READ_AT24-26, check, scrub,OP_OPEN30,OP_SEAL31). The constants are the definition:services/fs/src/main.rsandservices/block-driver/src/main.rs. (2026-10-25: the client range now runs to 32,OP_QUIESCE, whichpowersends before a shutdown,docs/shutdown.md.)
Client ↔ fs (Phase 1, name-addressed):
| Request | Args | Reply |
|---|---|---|
WriteFile | name, data (chunked) | Ok / NoSpace / IoError |
ReadFile | name | data (chunked) / NotFound / IoError |
StatFile | name | {exists, size} |
ListFiles | - | names (chunked) |
DeleteFile | name | Ok / NotFound |
fs ↔ block-driver:
| Request | Args | Reply |
|---|---|---|
ReadBlocks | lba, count (message-bounded) | sector data / IoError |
WriteBlocks | lba, data (message-bounded) | Ok / IoError |
All replies are exactly one of {Ok-with-data, defined error} - never silent
(§3.12, mirrors the IPC send discipline of §8.6).
9. Crash, restart, and the TCB trajectory (§6.3)
- Phase 1 (write-through, in the TCB). Writes go straight to disk; no journal. A crash
mid-write can corrupt the file being written (and the entry table if it strikes during a
metadata update). On mount, a bad superblock magic/checksum is a loud refusal, never
a silent reformat.
block-driverandfsremain TCB members (§6.1); their death is a panic+reboot (§6.2) - the v1 posture, carried because nothing transactional exists yet. - Phase 3 (transactional, out of the TCB). The §6.3 goal: give
fsatomic-commit semantics so a restart can recover to a consistent state, then dropfsandblock-driverfrom the TCB (a recordedCLAUDE.md§6 amendment with sign-off). A log-structured layout is the natural route - appends with an atomic commit record make crash-consistency fall out for free, and pair well with the no-overwrite discipline. Corrected 2026-09-26: this said the opposite of the truth and is the reverse of a safety claim, so it is replaced rather than softened. It read "because the ATA PIO driver has no DMA reach (§5.1),block-drivercan leave the TCB without depending on IOMMU presence". The ATA-PIO backend is retired:block-driveris AHCI, it takes a DMA arena (services/block-driver/src/ahci.rs,arena: Dma), and it is spawned unconfined (hwclass::pci(0x01_06_01, 5, false)in the supervisor's spawn table). By CLAUDE.md 6.4's own rule any service granted a DMA arena on a machine that cannot confine it is trust-critical, soblock-driveris IN the TCB on every machine, IOMMU or not - which is whatdocs/ahci.mdand the 2026-09-12 DMA-census amendment both say.
10. Phased build plan
(Note 2026-10-09: the record of Phase 1 as it was built. Steps 1-3 name the ATA-PIO machinery -
hw_pio, PortRead/PortWrite, capability/hw_pio.rs, pio.rs, if=ide - all since retired
with that backend; see the note at the head of §5.)
- Block driver read path. ✅ done (
osdev test blockdev). Added thehw_piogrant (kernel-mediatedPortRead/PortWritesyscalls validated per access, grant store incapability/hw_pio.rs), SDKpio.rs(Pio), andblock-driverATA-PIO reads sector 0 of a QEMU secondary-channelif=idedisk and logs it - verified by reading back a host-written magic. Port I/O is kernel-mediated because ring-3 drivers cannot runin/out(granting IOPL would be ambient authority). - Block driver write + round-trip. ✅ done (
osdev test blockdev, case P1.2). The driver writes a known pattern to a scratch LBA (WRITE SECTORS + FLUSH CACHE), reads it back, and asserts equal - proving the device read/write path end to end. All in the driver via the existingPiowrapper; no new kernel surface. - Filesystem mount + format. ✅ done (
osdev test blockdev, case P1.3). Host-sideosdev mkfs <image>writes the superblock (magicGSFS0001, version, block_size, total_blocks) into LBA 0;block-drivergained an IPC server loop (ReadBlock/WriteBlock, the per-request reply-cap pattern);fsmounts by reading LBA 0 over that IPC (SDKrequest_with_reply), validating the magic loudly, and logging the geometry. The entry table + free bitmap are written bymkfsin a later step (no files exist yet). Forwardfs → block-drivercap wires viasend_peers(block-driver spawns first, kernel auto-registers its endpoint name); the reply rides a per-request cap fs embeds. - Filesystem read/write (name→blob). ✅ done (
osdev test blockdev, case P1.4). On-disk entry table (16 entries × 32 B, one block) + a bump allocator (next-free-block in the superblock; contiguous extents, no reclamation yet).fsstores/retrieves named files:write_pathallocates an extent, writes the data blocks + entry table + superblock through block-driverWriteBlock;read_filewalks the entry's extent viaReadBlock. Verified by a mount-time round-trip (greeting).fsalso serves the client API (WriteFile/ReadFile/StatFile, ops 10-12) over IPC via the reply-cap pattern. - Reboot survival (the headline). ✅ done (
osdev test blockdev-reboot, case P1.5). Format once; boot 1 -fscreatesgreeting; reboot on the same disk image without reformatting - boot 2 mounts (superblock persisted:next_free=3, 1 files) and reads the file back byte-for-byte (fs: persisted file 'greeting' verified across boot). The disk is a host file, so this is the real durability guarantee. Phase 1 complete. - Phase 2: file-as-capability. Kernel-delegated resource caps (after the §7 amendment
is signed off);
fsreturns/validates per-file caps.
11. Test plan - QEMU is sufficient (and authoritative here)
Unlike the H1 IO_PAGE_FAULT (which QEMU's lenient amd-iommu could not show), persistence
is a case where QEMU gives a real, trustworthy answer and the whole feature can be
built and verified headless, away from the T630:
- The disk is a host file (
-drive file=disk.img,if=ide). It survives across QEMU runs by definition, so reboot-survival is the real thing: boot → write → quit → boot → read. No flashing. - ATA PIO is faithfully emulated - real BSY/DRQ handshake, real status/error bits, real sector semantics. What passes in QEMU behaves the same on an ATA controller.
- Format + filesystem logic are hardware-independent - bytes in blocks.
The single thing QEMU cannot answer is whether the T630's storage controller speaks ATA PIO in legacy mode - a separate, later hardware bring-up question, not a filesystem question. Test layout follows the §22 pattern: an identity-style reboot-survival test (write, reboot, read, assert) is the executable form of "persistence persists," plus property tests (round-trip any bytes), fuzz (malformed superblock → loud refuse, never panic), and chaos (crash mid-write → mount refuses or recovers, never silently corrupts).
12. Open questions (to resolve as phases land)
- Final bounds (
MAX_FILES,NAME_MAX, transfer width per message). - Exact
hw_piocapability syntax in the contract schema (range form, likehw_mmio). - The precise §7 amendment wording for kernel-delegated resource caps (Phase 2).
- Whether Phase 3 is log-structured or journaled-update (decide when transactional recovery is actually built; both reach the §6.3 goal).
- Bare-metal storage controller for the T630 (ATA legacy mode vs. an eventual AHCI driver)
- deferred; does not block any QEMU phase.