Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 87 additions & 22 deletions LANGUAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,14 +406,89 @@ same value when their bytes match, however they were reached.
(loadfile "a.txt") == (loadfile "copy-of-a.txt") // true, if the bytes match
```

Three kinds of value have no digest yet, and say so rather than inventing one:
a **directory** `File` (§3 hashes one over its entries including each file's
executable bit, which only the Linux target can report — see below), a
`Function`, and
`ctx.cache`. Hashing one is a fatal failure like any other (§8).

→ `examples/hashing.hb` (§3, §6, §15)

### Directories

A **directory** `File` hashes over its entries (§3), sorted by name so the
digest is the tree's rather than the order the filesystem listed it in. Each
entry contributes its name, plus its content hash and executable bit for a
regular file, its own directory hash for a sub-directory, or its target string
for a symlink — which is never followed, so a link to a directory is a link,
not a directory.

```hashedbuild
sha256 loadfile "examples" // the digest of a whole tree
(loadfile "examples") == (loadfile "examples") // true — one tree, two handles
```

Two things about it are worth knowing before you rely on it:

- **It reads the disk, and that read needs `io`.** Everything else hashes what
the value already holds; a directory's children are on the filesystem. The
read happens at the first `sha256` or comparison that needs the digest and is
checked against `ctx.permissions.io` at that moment, so a context that
revoked `io` can't pull a tree's contents through a handle it was handed. It
happens once: a `File` is an immutable handle, so the digest is fixed from
then on, and seeing a change means loading the directory again.
- **The executable bit is Linux-only, and a tree containing one hashes
differently elsewhere.** WASI's `filestat` has no permission bits and Windows
has no POSIX executable bit, so on those targets every file hashes as
non-executable. That is the language's answer rather than a gap — a Windows
checkout genuinely has no executable bits, and reporting one would be
inventing it, the same position git takes with `core.filemode`. If you need a
digest that agrees across all three, keep executables out of the tree.

An entry that is neither a file, a directory, nor a symlink — a socket, a
device node — fails rather than being skipped, since a digest that ignored part
of a tree would call two different trees the same value.

→ `examples/hashing-directories.hb` (§3, §9)

### Functions

A **closure** hashes as the shape of its body plus the values it captures
(§15) — so two functions hash alike exactly when they would compute the same
thing, and what else happened to be in scope where they were written doesn't
enter into it:

```hashedbuild
(sha256 (let x 1; let unrelated "zz"; func (#arg + x)))
== (sha256 (let x 1; func (#arg + x))) // true
(sha256 (let x 1; func (#arg + x)))
== (sha256 (let x 2; func (#arg + x))) // false — a different capture
```

The digest is of the program, though, not of what the program means: renaming a
local or respelling a literal gives a different function. A builtin has no body
to take a shape from, so it hashes as the operation it is — and a partially
applied one carries what it was applied to, which is why two `chperm` results
differ exactly when they grant different things.

→ `examples/hashing-functions.hb` (§15, §16)

### Values that reach themselves

A cyclic `let rec` value (see above) hashes too, but not by the same route: an
ordinary digest folds up from the leaves, and a cycle has none. Such a value is
instead reduced to a canonical form of the cycle and hashed from that, with two
properties that are the whole reason for the exercise — the digest doesn't
depend on which node you started from, and it doesn't depend on how the cycle
was written:

```hashedbuild
let rec g { .n = 1, .next = g };
let rec h { .a = { .n = 1, .next = h.b }, .b = { .n = 1, .next = h.a } };
(g == h.a) and ((sha256 g) == (sha256 h.a)) // true — both halves
```

That pairing is the requirement, not a coincidence. `g` and `h.a` are *equal*
under §6 because unrolling either gives the same infinite tree, so a digest
that told them apart would give a content-addressed language two addresses for
one value.

→ `examples/hashing-cyclic.hb` (§6, §10, §15)

## Context and permissions

`ctx` is the ambient context. The filesystem builtins check
Expand Down Expand Up @@ -468,22 +543,12 @@ uncatchable, but the work already in flight finishes first.
Parsed, specified, and rejected by the evaluator with "not implemented":
`import` and `cached`.

Partly built: **hashing**. `sha256` works for every value except a directory
`File`, a `Function`, and `ctx.cache`. The directory case is the interesting
one — `SPEC.md` §3 defines a directory's hash over its entries including each
file's executable bit, and two of the three targets cannot report one: WASI's
`filestat` has no permission bits at all, and Windows has no POSIX exec bit.
So there is no way to compute the specified digest everywhere the interpreter
runs. Building it means first deciding what a directory hashes as somewhere
that cannot see an exec bit. `Function` is unbuilt because §15 needs it for `cached` but never
says how a closure is encoded. A **cyclic value** (above) is the third case, and
unbuilt for a related reason: the digest is a Merkle fold, a composite's hash
built from its children's, and a cycle has no bottom to start from. `SPEC.md`
§6 describes what the answer looks like — components hashed canonically, so the
digest does not depend on where the walk entered the cycle — but §3 pins what a
digest encodes, so it is a spec decision first. `sha256` of one fails cleanly
meanwhile. Equality over cyclic values *is* built, and does not depend on any of
this.
**Hashing is complete**: `sha256` answers for every value, including the three
that used to be listed here — a directory `File`, a `Function`, and a cyclic
value — plus `ctx.cache`. See the Hashing section above for what each of them
encodes, and for the one place the answer is target-specific: a directory
containing an executable hashes differently on Windows and WASI than on Linux,
because neither of those has an executable bit to report.

Also absent: `true`/`false` literals, loops of any kind (recursion is the only
repetition there is — see above), a `Bytes`-returning counterpart to
Expand Down
14 changes: 13 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ A condition (of `then`, `and`, `or`, `is`) can itself be, or contain, an async e
hash(name, "symlink", target_path_string) // target is NOT followed/resolved
dir_hash = hash(sorted [dir_entry_hash(name, entry) for each entry])
```
A symlink entry hashes the link itself (its target path string) rather than resolving through it — consistent with symlink handling being a property of the containing directory, not a standalone `File` value in its own right.
A symlink entry hashes the link itself (its target path string) rather than resolving through it — consistent with symlink handling being a property of the containing directory, not a standalone `File` value in its own right. An entry that is none of the three — a fifo, a socket, a device node — has no encoding here, and hashing a directory containing one fails rather than skipping it: a digest that ignored part of a tree would call two different trees the same value.

**The executable bit where it cannot be observed (resolved 2026-08-31).** `is_executable` above is the entry's owner-execute bit *where the host reports one*, and **false wherever it does not**. WASI's `filestat` carries no permission bits at all and Windows has no POSIX executable bit, so on those two targets every regular file hashes as non-executable. The consequence is deliberate and worth stating plainly: a tree containing an executable has a different `dir_hash` on Linux than in a browser or on Windows. The alternative considered was dropping the bit from the encoding entirely, which would make one digest hold everywhere at the cost of a distinction a build language wants — a checked-out `configure` that lost its bit is not the same tree. Reporting the bit as *set* where it cannot be seen was never on the table; a Windows checkout genuinely has no executable bits, so claiming one would be inventing data. This is the same position git takes with `core.filemode`.

**When the entries are read (resolved 2026-08-31).** A directory `File`'s children live on the filesystem rather than in the value, so unlike every other digest this one performs I/O. It happens **at the first demand** — the first `sha256` or comparison that needs the digest — and is subject to `ctx.permissions.io` (§9) at that moment, exactly as `loadfile` is: a context that has revoked `io` cannot read a tree through a handle it was passed. The result is then part of the value and is never re-read, which is what `File` being an immutable handle (above) requires: a value whose digest changed under a program because someone touched the tree would not be one. Observing a change means loading the directory again, which produces a new value.

**Display (resolved 2026-08-27).** A `File` displays **the path it was reached by**, made absolute and cleaned of `.`/`..` segments — recorded when the value is constructed, not resolved through the filesystem afterwards. Two consequences worth stating: a symlinked route displays as the route taken rather than the target it resolves to, and a file renamed after it was loaded still displays the path it was loaded from. This is what makes the rule implementable off Linux at all — WASI (and the portable `*at()` family) offer no way to turn an open descriptor back into a path.

Expand Down Expand Up @@ -333,6 +337,14 @@ Two builtins operationalizing §6's "every value is hashable" claim. Like `impor

**Resolved 2026-08-26, amended 2026-08-28**: the hash is one consistent underlying system — one canonical byte encoding, one hash mechanism, shared with §6's general value-hash (used for ordering/equality everywhere, e.g. `Table`'s key-sorted hash and `File`'s directory hash, §3/§5). The encoding is a Merkle construction: a composite value hashes its children to fixed-width digests and mixes *those*, never inlining a child's own encoding. That is forced by §3, which pins a regular `File`'s hash to `hash(content_bytes)` with no tag or length of its own — so that `sha256 <file>` is the digest `sha256sum` reports for the same bytes — and an untagged, variable-length encoding cannot be inlined unambiguously. Leaves are otherwise domain-separated by a tag byte, so that values of different types with the same payload (`Integer` 5 and `Float` 5.0, which §6 does not equate) do not collide.

**A `Function`'s encoding (resolved 2026-08-31).** `cached` below hashes an expression "as a function", which needs a closure to have an encoding; this is it. A closure is **the shape of its body, mixed with the values it captures** — the body's syntax tree node by node, each leaf including its own spelling, and then for every free name of that body the digest of the value that name stood for where the closure was made, mixed in under the name. A builtin (§16) has no body, so it encodes as its operation's name together with anything it was partially applied to. A closure that reads `ctx` (§9) mixes in the context it captured; one that does not, does not — ambient authority is part of what a closure is only where the closure can see it.

Two things follow, and are the point rather than a limitation. Two closures hash alike exactly when they would compute the same thing, so a cache key survives an unrelated binding changing nearby — which is what makes `cached` hit at all. And the encoding is of the *program*, not of what the program means: renaming a local, or spelling a literal differently, is a different closure. Alpha-equivalence is not promised.

**A cyclic value's encoding (resolved 2026-08-31).** §10's `let rec` can build a `Table` that reaches itself, and §6 says every value is hashable. A Merkle fold has no bottom to start from on a cycle, so a value that reaches itself is encoded differently: the graph is split into strongly connected components, everything outside a cycle is folded as usual, and each node **inside** one is encoded by a canonical form of the cycle reachable from it — the nodes reduced by bisimulation, then numbered in the order a deterministic walk from that node first meets them. Two consequences are the whole requirement: the digest does not depend on which node the walk entered by, and it does not depend on how the cycle was written. A one-node cycle and a two-node cycle that unroll to the same infinite tree are **equal** under §6, so they must — and do — hash alike. Anything weaker would give a content-addressed language two addresses for one value.

**`ctx.cache`'s encoding (resolved 2026-08-31).** §9's cache is a value, so §6 makes it hashable; it encodes as a bare tag and nothing else, so all of them hash alike. It has no content to hash, and the one thing that would tell two apart — the directory it is rooted at — is precisely the path §9 spends its last paragraph keeping out of reach of programs. Hashing it to that path would hand the path back through a side door.

**`cached`'s mechanism (resolved 2026-08-26):** the cached expression is treated *as a function* and hashed as one — the cache key is the hash (per the one system above) of that function representation, not a hash of its resolved output value. `cached` is not inherently async by itself; async-ness is controlled explicitly by *where* `async` (§2) is placed: `async cached <expr>` makes the cache lookup/store itself asynchronous, while `cached async <expr>` instead makes the underlying expression's own evaluation asynchronous, with the caching wrapper around it synchronous.

> TODO: Where does the cache actually live (on-disk location, process-local vs. shared/distributed) — not addressed by this round's resolution of the cache-*key* mechanism.
Expand Down
3 changes: 3 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ prose around each of these.
| `files-sandboxed.hb` | Directory handles, contained sub-paths, `filetext`, path display |
| `files-symlink.hb` | `readlink` — and why symlinks aren't values of their own |
| `hashing.hb` | `sha256`, and the content identity that makes two Files one value |
| `hashing-directories.hb` | A directory's hash: its entries, and the one digest that reads |
| `hashing-functions.hb` | A closure's hash: its body's shape and the values it captures |
| `hashing-cyclic.hb` | Hashing a value that reaches itself, canonically |
| `option-picker.hb` | A real program: read, branch, write into `ctx.cache` |
| `context-permissions.hb` | `ctx`, `chctx chperm`, `withctx` — capability narrowing |
| `async-basics.hb` | Two reads in flight at once, awaited implicitly |
Expand Down
39 changes: 39 additions & 0 deletions examples/hashing-cyclic.hb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Hashing a value that reaches itself (SPEC.md §6/§10). `let rec` can build a
// Table that contains itself (see examples/cyclic-data.hb), and every value is
// hashable - including that one.
//
// It cannot be hashed the way everything else is. An ordinary digest is a fold
// from the leaves upward, and a cycle has no leaves to start from. So a cyclic
// value's digest is instead a canonical form of the cycle itself: the shape is
// reduced to what genuinely differs, and the digest is read off that. The
// point of "canonical" is the two properties below - it does not matter which
// node you started from, and it does not matter how the cycle was written.
//
// That second one is the same rule equality already follows. `g` below is a
// one-node cycle and `h.a` is one node of a two-node cycle, and they are
// *equal*, because unrolling either gives the same infinite tree. A digest
// that disagreed with that would be a language where two equal values have two
// different content addresses.
//
// There are no boolean literals, so "false" is spelled `1 > 2`.
//
// Evaluates to { a_cycle_hashes: true, equal_cycles_hash_alike: true,
// shape_still_matters: true, either_end_agrees: true }.
let rec g { .n = 1, .next = g };
let rec h { .a = { .n = 1, .next = h.b }, .b = { .n = 1, .next = h.a } };
let rec differs { .n = 2, .next = differs };
{
// It terminates and produces a digest, which is the first thing to want.
.a_cycle_hashes = (sha256 g) == (sha256 g),

// A 1-cycle and a 2-cycle that unroll the same way are one value, and hash
// as one value.
.equal_cycles_hash_alike = ((g == h.a) and ((sha256 g) == (sha256 h.a))),

// Canonical is not constant: cycles that unroll differently still differ.
.shape_still_matters = ((sha256 g) == (sha256 differs)) == (1 > 2),

// The same node reached two ways is one digest - the walk's entry point is
// not part of the answer.
.either_end_agrees = (sha256 g) == (sha256 g.next.next.next),
}
42 changes: 42 additions & 0 deletions examples/hashing-directories.hb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// A directory `File`'s hash (SPEC.md §3). A directory is a value like any
// other, so `sha256` answers for one - but its children are on the disk rather
// than in the value, which makes this the one digest that reads.
//
// §3 computes it over the directory's entries, each hashed with its name and
// sorted by name so the answer is the tree's rather than readdir's: a regular
// file contributes its content hash and whether it is executable, a
// sub-directory contributes its own directory hash, and a symlink contributes
// its target *string*, never followed. Nothing about where the directory sits
// enters into it, which is why two handles on the same tree are one value.
//
// The digests themselves are deliberately not written down here: a tree
// containing an executable hashes differently on Windows and in the browser
// than on Linux, because neither of those targets has an executable bit to
// report (see LANGUAGE.md). What is the same everywhere are the properties
// below.
//
// Reading a directory is I/O, so the first `sha256` of one needs
// `ctx.permissions.io` like `loadfile` does - and it is only the first, since
// a `File` is an immutable handle (§3) and the digest is fixed once read.
//
// There are no boolean literals, so "false" is spelled `1 > 2` here, the same
// way examples/comparison-and-logic.hb spells it.
//
// Evaluates to { a_tree_is_not_its_file: true, one_tree_is_one_value: true,
// reading_twice_agrees: true }.
let here loadfile ".";
{
// A directory holding a file is not that file. Both digests are built from
// the same bytes on disk, and §3's tagging is what keeps them apart.
.a_tree_is_not_its_file = ((sha256 here) == (sha256 loadfile "optiona.txt")) == (1 > 2),

// Two separate handles, one tree. §3 makes a File's identity its content,
// so these are the same value even though they are different handles - and
// comparing them is what reads the second one.
.one_tree_is_one_value = here == (loadfile "."),

// Two independent walks of the same directory agree. That is §3's "sorted
// by name for determinism" doing its job: the digest is the tree's, not the
// order the filesystem happened to hand the entries back in.
.reading_twice_agrees = (sha256 here) == (sha256 loadfile "."),
}
Loading