ADR-319: A File Name Is fs::Path, the Platform’s Own Bytes — Text Stands Wherever One Is Asked, an f"…" Builds One Where the Use Asks, and Only a Name the System Handed Over May Fail to Be Text

Status: Accepted Date: October 5, 2026 Answers: what Path is from the former open-decisions.md (Part I 7.1 and Part III 17.1 wrote a type nothing declared, NK1135); ADR-096 §4 left it to the owner Related: Part I 1.2 (one source, every target, the same bytes); ADR-108 (every path names its fs::Root); ADR-309 D2 (a literal’s type is read off its syntax); ADR-152 D4 and ADR-135 (a literal takes the type its use asks for); ADR-010 D1 (a value’s source is tracked along the program’s edges); ADR-313 (the prelude is a closed list); ADR-024 D1 (? in the ledger is the absence of a claim)

1. Question

What is a file name in a program: text, bytes, or a type of its own — and what happens to a name the program cannot show as text?

A String is always valid UTF-8. A file name is not always text: on Linux it is any bytes but / and zero, on Windows it is UTF-16 that may hold unpaired surrogates, on macOS (APFS), WASI and the browser’s file system it is Unicode — and a volume mounted over NFS or SMB can hand any of them other bytes. With a name held as a String, fs::walk stops with NotText at the first such name, and a file the system has is one no Nikaia program can open, rename or remove. A language meant to be usable everywhere cannot leave files out.

2. How others do it

Language A file name is A name that is not text
Rust Path over OsStr: Unix bytes, Windows WTF-8 to_str() is an Option; display() shows it lossily
camino (Rust) Utf8Path refused — chosen by tools that prefer the convenience
Haskell FilePath = String, since 2022 also OsPath over the system’s bytes the lossy String was the reason for OsPath
Swift FilePath (System) over the platform’s bytes as Rust
Python 3 str, with surrogateescape (PEP 383) smuggled into the str; printing it later fails. 3.0 silently dropped such names from listdir
Go, Zig a string, which is bytes nothing to fail; Zig uses WTF-8 on Windows
.NET, Java text (UTF-16) converted lossily; such names can be unreachable on Linux

What transfers: Go’s and Zig’s exactness, and Rust’s separation of the system’s bytes from text. What does not: Zig’s answer as it stands — its string is bytes, and Nikaia’s is text.

3. Options, weighed

Option Why it lost
A name is a String a file the system has is a file the program cannot reach
A name is a plain byte view, ref Array[u8] (Zig) on Windows not every byte run is a name, so a call could fail by target; showing and joining would hang on every byte array, binary data included
Path → String fails only on the targets where it can the same source would compile on one target and not another (Part I 1.2) — and a Mac can mount a volume whose names are not text
Path → String never fails, � for what is not text silent: the text names a different file or none
f"…" is a Path when a hole is one the contents would decide a literal’s type, which ADR-309 D2 rules out
Track a shown name and refuse it where a path is asked once f"…" builds a Path, only an explicit display() is left to track, at the cost of a second lattice and the precision #431 owes

4. Decision

D1 — fs::Path is a type of std: a file name in the platform’s own encoding.

Unix keeps the bytes as they are, Windows holds WTF-8, and a target whose names are Unicode holds UTF-8. A Path is made from text, by the operating system (walk, read_dir, a program’s arguments, the environment), or by D3’s and D4’s operations on those; there is no constructor from arbitrary bytes. So every Path is a valid name on its target, and no file system call fails for its encoding on any target. Its bytes can be read; they are the platform’s, and what does not hold as text on one target is not carried to another unchanged.

D2 — Text stands wherever a Path is asked, as a view, without a copy.

UTF-8 is valid in every encoding of D1, so a String or a ref String is a Path as it stands. The other direction is D5’s.

D3 — What a program does with a name, it does on the Path.

== against a Path or text, starts_with, ends_with, file_name, extension, with_extension, parent, join and strip_prefix (handing back Path?). Each result is a Path, and each keeps D1’s validity — joining two halves of a surrogate pair on Windows makes one character, inside the type.

Amended 2026-10-28 (P3a): strip_prefix, and fs::canonical in Part III 17.1, for a name shown relative to a root.

D4 — An f"…" builds a Path where its use asks for one.

As a list literal takes the array type its use asks for (ADR-152 D4). Its holes may be a Path, text or a number, and the parts are joined in the platform’s encoding, so fs::write(f"{p}.bak", root, data) writes next to a name that is not text without changing it. A let without a type takes it from the binding’s first use, as an empty list does (ADR-135). Where the use asks for text, an f"…" is a String as before, and a Path in a hole is refused with NK1201, the code for a hole whose value has no text form, at the hole and the same at every position; the help names D5’s two ways. Rejected: each position’s own mismatch (NK1103, NK1102, …), which names the line and not the hole; a code of its own, for a case NK1201 already describes. Where the use takes either — print, println, eprint, eprintln — the bytes are written as they are, so a name reaches the output unchanged. The contents never decide; the use does.

D5 — p.to_text() gives the String, and fails only for a name the system handed over.

A Path the compiler knows was made from text converts without throws. One whose source is the operating system throws io::IoError::NotText, on every target — the source decides, not the target. The source is tracked along the edges ADR-010 D1 tracks trust on.

D6 — p.display() is text for showing: � for what is not.

It never fails. What it gives back is ordinary text: a program that hands it back to the file system names another file or none, and nothing tracks it. The word says what it is.

D7 — A failure about a name carries the Path, and a root is one.

io::IoError’s NotFound, PermissionDenied and Outside carry the name as a Path, and fs::Root::Dir holds one. Other carries a message: most of what it reports names no file (an HTTP head over its cap, a connection closed while writing), and an operating system’s failure about a name is said in the message, with the name shown. Rejected: Other(fs::Path), which would put HTTP and network messages into a file name; a new variant for failures without a name, which renames every handler of Other for no case that needs it.

Amended October 6, 2026 (#449). fs::walk hands back Vec[fs::Path] and no longer fails with NotText for a name under the directory.

D8 — A String is valid UTF-8, and Part I says so.

Bytes that are not are refused where they enter, with NotText; bytes that need not be text are Bytes, or a Path.

5. Consequences

6. What this does not decide

fs::Metadata gains three readers, filled by the same fs::metadata call: m.modified(), the last modification as a time::Duration since the Unix epoch (zero where the platform does not say or before the epoch); m.links(), the number of hard links (1 off Unix); and m.file_id(), the device and inode as (u64, u64)? (null off Unix). They are readings of the name, like len and is_file, and need no capability beyond the Root the metadata call already took.

8. Amendment (2026-10-31, architect, Language decisions): D9 — a Path has an order

D9 — Paths have an order and a hash, the ones == agrees with.

Met by the move of proofs.rs: a set keyed by (fs::Path?, i64) was refused NK1247, because the ledger row of fs::Path said compares and nothing more. A name is a key often (files seen, files written, a cache by file), and every language with a path type orders it: Rust’s PathBuf is Ord and Hash, compared part by part; Python’s PurePath orders by its parts too. So fs::Path orders and hashes: whole parts are compared one after the other, each by its bytes, which is the order D3’s == agrees with (a/b and a//b are equal and so sort together). The ledger row says equates, orders and hashes (Part III 13.4’s columns). Nobody pays who does not key by a path; a Path inside a tuple or an option orders as its parts do.

Decided by the architect under Language decisions (the obvious answer: the order the type’s own == implies, the one every comparable path type has).

9. Amendment (2026-11-02, supervisor, Language decisions): D10 — a Path’s parts

D10 — p.parts() gives the whole parts of the name, in order, each a Path.

Met by the move of describe.rs: without_dots resolves . and .. in a name the program built, before the name exists, so fs::canonical (which reads the file system and throws NotFound) cannot do it, and D3 had no way to walk a name part by part. The parts are the ones D9 already compares: p.parts() is a Vec[Path], the root (and on Windows the prefix) as its own first part where the name has one, then each whole part as written: .. always, a . only where it leads the name; empty parts and a . inside are dropped, as D9’s == drops them (a/.//b has the parts a, b). Joining the parts in order with join gives back a name == to p. It reads no file system. Rust’s Path::components and Python’s PurePath.parts are the same operation; a lexical ./.. resolution is a loop over them, so std does not add that as a method of its own.

Decided by the supervisor under Language decisions (the obvious answer: the parts D9 already orders by, handed out the way every path type does).

This site is open source. Improve this page.