Skip to content

INodes

The internal data model for file metadata. How fileservers represent ownership, permissions, and native executable references — and what the kernel sees through Stat.

The Problem

MemoryNode currently stores five fields: name, type, content, children, mtime, logicalSize. This is enough for basic file I/O but missing the metadata that Unix commands and the kernel need:

  • Permissionschmod is a no-op. ls -l can’t show mode bits. The kernel can’t check +x during PATH resolution.
  • Ownership — no uid/gid. Not enforced in v1, but the data model should carry it for ls -l, chown, and future permission enforcement.
  • Change time — no ctime. stat can’t distinguish “content changed” from “metadata changed.”
  • Native exec — BinFS is being removed. Native function references need a home on the file node itself, not in a separate fileserver.

INode

The INode is the internal representation for file nodes within fileservers that support full metadata. It is not a protocol type — it’s an implementation detail of MemoryFS.

interface INode {
type: 'file' | 'dir'
content: Uint8Array
children: Map<string, INode>
mtime: number // content modification time (ms since epoch)
ctime: number // metadata change time (ms since epoch)
size: number // logical size in bytes
mode: number // Unix permission bits (e.g., 0o644, 0o755)
uid: number // owner user id
gid: number // owner group id
exec?: BinFunction // native executable reference (platform extension)
}

Defaults for new nodes:

  • Files: mode: 0o644, uid: 0, gid: 0, ctime: Date.now()
  • Directories: mode: 0o755, uid: 0, gid: 0, ctime: Date.now()

The exec field is our platform extension — it has no Unix equivalent because Unix stores machine code as file bytes. In fishbowl, native executables are live closures that can’t be serialized to bytes. The exec field stores the closure directly on the node. See How exec Gets Set for details.

Decision: INode is an internal concept, not a protocol type. Each fileserver MAY use INode, but the Fileserver protocol doesn’t mandate it. MemoryFS uses INode as its backing store. ProcFS and DevFS return synthetic Stat with sensible defaults — they have no INode tree. This mirrors real Unix: ext4, procfs, and tmpfs all have different on-disk/in-memory representations, but stat(2) returns a common struct stat. The protocol boundary is Stat, not INode.

Decision: name is removed from INode. The name is a property of the directory entry (the parent’s children map key), not of the node itself. This matches Unix inodes — an inode has no name; a directory entry maps a name to an inode number. MemoryNode conflated these; INode separates them.

Stat Interface Changes

The public Stat interface gains four optional fields:

interface Stat {
name: string // basename (unchanged)
size: number // bytes (unchanged)
type: NodeType // 'file' | 'dir' | 'device' (unchanged)
mtime: number // content modification time (unchanged)
mode?: number // permission bits
uid?: number // owner user id
gid?: number // owner group id
ctime?: number // metadata change time
}

All new fields are optional. Consumers apply defaults when absent:

  • mode defaults to 0o644 for files, 0o755 for dirs
  • uid and gid default to 0
  • ctime defaults to mtime

This preserves backward compatibility. Existing fileservers (ProcFS, DevFS, TtyFS) continue returning four-field Stat objects. Consumers that don’t care about permissions work unchanged.

Decision: exec is NOT on Stat. Stat is the public metadata view — the equivalent of struct stat from stat(2). Native function references are a kernel-internal concern. Exposing them through Stat would leak implementation details to user-space bins. The kernel accesses exec through a separate mechanism (see How exec Gets Set).

Decision: new Stat fields are optional, not required. Mandatory fields would force every fileserver to synthesize permissions and ownership, even when meaningless (what’s the uid of /dev/null?). Optional fields let each fileserver provide what it naturally has. The consumer decides what absence means.

Decision: mode holds permission bits only, not file type. POSIX st_mode packs file type (S_IFDIR, S_IFREG, S_IFLNK) in bits 15-12 alongside permission bits. We split these into separate fields: type: NodeType for the file type and mode for permissions only. This gives us TypeScript exhaustiveness checking on type and avoids bitmask gymnastics. The WASI host maps type → WASI filetype separately from mode, so WASI programs see correct values. C/Rust WASM programs that bypass WASI and inspect raw st_mode type bits would get wrong answers — but well-behaved WASI programs use the filetype field instead. Fields not present in our Stat (st_rdev, st_blocks, st_blksize, st_atim) are omitted intentionally: we have no block layer, no device major/minor numbers, and no access-time tracking (matching Plan 9’s design and Linux’s noatime best practice).

Metadata Mutation: wstat

The Fileserver protocol gains a 10th method for metadata changes:

interface Fileserver {
// ... existing 9 methods ...
wstat(path: string, changes: Partial<StatChanges>): Promise<void>
}
interface StatChanges {
mode: number
uid: number
gid: number
mtime: number
}

wstat applies partial metadata updates to a path. Only the fields present in changes are modified. Examples:

wstat("/bin/greet", { mode: 0o755 }) // chmod +x
wstat("/home/user/file", { uid: 1, gid: 1 }) // chown
wstat("/tmp/cache", { mtime: 0 }) // touch -t

The kernel exposes this through familiar commands:

  • chmod 755 file calls wstat(path, { mode: 0o755 })
  • chown user:group file calls wstat(path, { uid, gid })
  • touch -t can call wstat(path, { mtime }) for explicit timestamp setting

Fileservers that don’t support metadata mutation throw EPERM from wstat. This is the same pattern as mkdir/remove/rename — synthetic fileservers reject operations they can’t perform.

Decision: wstat over separate chmod/chown methods. One method for all metadata changes keeps the protocol lean. Adding chmod and chown as separate methods inflates the protocol from 9 to 11 methods, and a future chtime would make it 12. wstat is a single extension point: any new metadata field becomes a new key in StatChanges, not a new protocol method. The name comes from Plan 9, but the semantics are straightforward — it’s Object.assign for file metadata.

Decision: StatChanges is a separate type from Stat. Stat includes name, size, and type, which are not changeable through wstat — name changes go through rename, size through write/truncate, type is immutable. StatChanges contains only the mutable metadata fields. This makes the type system enforce what’s changeable.

How exec Gets Set

When the builder processes Extension.bins, it writes executable nodes into MemoryFS:

Builder, for each bin entry (e.g., "echo" → echoFn):
1. Create INode at /bin/echo in the root MemoryFS:
- type: 'file'
- mode: 0o755
- exec: echoFn
- content: encoder.encode("# native: echo\n") // metadata for cat

At runtime, the kernel resolves /bin/echo through PATH resolution. It finds the file node, checks the executable bit, and accesses the exec field through ExecCapable.getExec(). The filesystem is the single source of truth — there is no separate exec table or registry. See the executables spec for the full PATH resolution flow.

Kernel Access to exec

The kernel needs to get from a Fileserver (protocol-level) to the exec field (implementation-level). The Fileserver protocol returns Uint8Array from read(), not function references.

Options considered:

a) Add exec(path): BinFunction | undefined to Fileserver protocol. Adds a method that only one fileserver implements. Pollutes the protocol with a concern that’s specific to executable loading.

b) The kernel knows certain fileservers support exec and uses type narrowing. The kernel checks if the fileserver implements an ExecCapable interface and accesses the exec field through it.

c) Add a generic metadata method for arbitrary typed data. Over-general. Creates a stringly-typed escape hatch that undermines the protocol’s type safety.

Decision: option (b) — the kernel narrows to an ExecCapable interface. The kernel is the only consumer of exec. It’s the equivalent of the Unix kernel reading ELF headers — a kernel concern, not a filesystem concern. The kernel knows which mount points could contain executables (the ones on PATH) and checks if their fileservers implement ExecCapable:

interface ExecCapable {
getExec(path: string): BinFunction | undefined
}
function isExecCapable(fs: Fileserver): fs is Fileserver & ExecCapable {
return 'getExec' in fs
}

This keeps exec out of the general Fileserver protocol. MemoryFS implements ExecCapable; ProcFS, DevFS, TtyFS do not. The kernel’s exec path: resolve the path, find the fileserver for that mount, check isExecCapable, call getExec. If the fileserver isn’t exec-capable or returns undefined, fall back to reading the file for a shebang.

No Exec Table

There is no separate exec table or native function registry. The INode exec field on file nodes in the filesystem is the sole source of truth for native executables. The kernel accesses it through ExecCapable.getExec() during PATH resolution.

This means rm /bin/echo removes the function reference. Overwriting /bin/echo with a script replaces the node — exec field gone. The filesystem has full authority over what executables exist. See the executables spec for detailed write semantics and PATH resolution flow.

Impact on Fileservers

MemoryFS

Full INode support. The MemoryNode type is replaced by INode. All metadata fields are stored and mutable. wstat updates fields in place and sets ctime to Date.now(). Implements ExecCapablegetExec walks the node tree and returns node.exec if present.

ProcFS

Returns synthetic Stat: { mode: 0o444, uid: 0, gid: 0, ctime: 0 }. All files are read-only. wstat throws EPERM — process metadata is not user-modifiable through filesystem operations. Not ExecCapable.

DevFS

Returns synthetic Stat: { mode: 0o666, uid: 0, gid: 0, ctime: 0 }. Devices are read-write for all. wstat throws EPERM — device permissions are fixed. Not ExecCapable.

TtyFS

Returns synthetic Stat: { mode: 0o666, uid: 0, gid: 0, ctime: 0 }. Same as DevFS. wstat throws EPERM. Not ExecCapable.

BinFS

Removed. Its responsibilities are absorbed:

BinFS responsibilityNew home
Store function referencesINode exec field on MemoryFS nodes
resolve(name) → BinFunctionExecCapable.getExec() on MemoryFS
readdir() listingReal directory entries in MemoryFS /bin
stat() for inspectionReal Stat from MemoryFS nodes
read() for catStub content in INode content

Cross-references

  • Executables — the exec model, PATH resolution, shebangs. INode’s exec field is where native function references live on the filesystem. The filesystem is the single source of truth — no separate exec table.

  • Developer APIExtension.bins is how native functions enter the system. The builder writes INodes with exec fields into MemoryFS. The builder is the only code that sets exec fields in v1; runtime bin creation uses scripts with shebangs.

  • Fileservers — the 10-method protocol including wstat. INode is the backing model for MemoryFS but is not visible through the protocol. Stat gains optional fields but remains backward-compatible.

  • Future: permission enforcement — the data model (mode, uid, gid on every INode) supports full Unix permission checking. In v1, permissions are stored but not enforced — any process can read/write any file. Enforcement would be a fileserver middleware wrapper (as sketched in the fileservers spec) that checks the calling process’s uid/gid against the INode’s mode before delegating to the inner fileserver.