Skip to content
This repository was archived by the owner on Aug 12, 2026. It is now read-only.

Commit e3a96b1

Browse files
committed
0020 accepted: generated namespaces, after the review inverted half of it
An adversarial review with fresh context ran every decision; what survived is implemented (cljwit.host.gen) and what did not is recorded: - D inverted. The draft generated a throwing (f ...) plus a tagged (f* ...), which gave the throwing form the short name -- opt-out, where 0012's accepted decision is opt-in -- and encoded a type-level fact a stale file cannot check. Now: one var per export, tagged, and host/unwrap in the library. The sugar cannot go stale because it is not generated. - C's collision rule was rebuilt on its own counterexamples. The review *built* a two-version world (every function collides pairwise; :rename would cost O(functions), re-supplied forever) and a legal interface where [method]counter.bump strips to the same var as a plain counter-bump -- the collision-by-construction 0014 B already named. Exits at two granularities now: :interface (a namespace per interface) and :rename. - count, map, get are legal WIT names; the generated ns computes (:refer-clojure :exclude ...) because the measured alternative is clj-kondo exit 2 -- this repo's own gate going red. - The header sha moved from the component bytes (churns on @producers, silent on real drift) to the describe output: the hash changes iff the API does. What a checked-in file cannot do -- notice it is stale -- is written in the note; the regenerate-and-diff belongs to cljwit.edn (S2). - 0020 B's wart is guarded: a handle lowered into a different instance's store now throws :wrong-instance instead of wasmtime's mismatched resource types, and lower-fn learned which store it compiles against. Generation is deterministic (regenerate == byte-identical, tested), the planning rules are pure and tested without a component, and the end-to-end test writes a namespace from the echo fixture, loads it with no .wasm in sight, and calls through it. 0020 opens S2: its option keys are cljwit.edn's. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 69b4ff0 commit e3a96b1

5 files changed

Lines changed: 454 additions & 47 deletions

File tree

‎doc/design/0020-generated-namespaces.md‎

Lines changed: 101 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
# 0020 — Generated namespaces: the ergonomics layer, as a file
22

3-
**Status:** proposed · 2026-07-30 · the shape `0015` accepted for later, picked
3+
**Status:** accepted · 2026-07-30 · the shape `0015` accepted for later, picked
44
up now that its two blockers — marshalling and host imports — are gone.
5+
Rewritten after an adversarial review: D inverted (the throwing variant lost),
6+
C gained the per-interface exit its collision rule needed, and the sha moved
7+
from the component bytes to the API. This layer **opens S2** — its option keys
8+
are `cljwit.edn`'s keys, so the file becomes the edn's implementation rather
9+
than its rival — and S1 closes without it.
510

611
## The question
712

@@ -29,9 +34,20 @@ file looks like.
2934
is a dev-time act that reads a component's type, and requiring it in
3035
production buys nothing. The generated file is ordinary checked-in source —
3136
reviewed in diffs, seen by clj-kondo, clojure-lsp, and CIDER exactly because
32-
it is not clever. Its header names the source component, its sha-256, and the
33-
form that regenerates it, so drift is visible where drift is looked for: in
34-
version control.
37+
it is not clever. Its header names the source component, a sha-256 **of the
38+
`describe` output** — the API, not the bytes, so a toolchain bump that
39+
changes the `@producers` section without changing a signature does not churn
40+
the header — and the form that regenerates it.
41+
42+
**What the file cannot do is notice it is stale.** Nothing reads the header
43+
at run time; a `.wasm` swapped under an unregenerated file produces no diff
44+
anywhere. The *loud* half is already covered — marshalling is rebuilt from
45+
the live component at instantiate, so a renamed export fails
46+
`:no-such-export` with a did-you-mean and a changed argument list fails
47+
`:wrong-arity`. The silent half is exactly the regenerate-and-diff check
48+
that `cljwit.edn` (S2) owes its components; the API hash makes it a string
49+
compare. Until S2 exists, staleness detection is the reviewer's, and this
50+
paragraph is the warning.
3551

3652
### B. Every var takes the instance first
3753

@@ -44,13 +60,20 @@ version control.
4460

4561
`0015` measured why ambience loses: `binding` conveys to exactly the paths
4662
that race (`future`, `pmap`) and a lazy seq realised under a second instance
47-
runs against the second silently. The explicit argument is not a compromise —
48-
it is what makes handing an instance to a `future` a visible act. Arity and
49-
names now exist statically: `(thumbnail i img)` is a clj-kondo warning and an
63+
runs against the second silently. The explicit argument is what makes handing
64+
an instance to a `future` a visible act. Arity and names now exist
65+
statically: `(thumbnail i img)` is a clj-kondo warning and an
5066
`ArityException` naming `acme.resize/thumbnail`, not a gensym (`0015`'s
5167
macro-arity finding was about interned gensyms; a named `defn` does not have
5268
that problem).
5369

70+
Two warts, named rather than hidden. A resource method takes **two**
71+
contexts — `(counter-bump i c 5)` — and the signature permits a handle from
72+
instance A with instance B. The runtime catches that mismatch when lowering
73+
the handle (`:wrong-instance`, since a `Handle` carries its context), which
74+
is a call-time error, not the visible act B advertises for instances; the
75+
docstring of every method var says which instance the handle must come from.
76+
5477
### C. Names: the last WIT segment, and a collision is an error
5578

5679
The exact WIT string stays the runtime key inside the generated body — `0014`
@@ -65,38 +88,72 @@ WIT's grammar demands it:
6588
| `…#[method]counter.bump` | `counter-bump` |
6689
| `…#[static]counter.reset` | `counter-reset` |
6790

68-
Two exports that munge to one name — two interfaces exporting `run`, say —
69-
**fail generation** with both WIT names in the message and a `:rename` option
70-
(`{:rename {"pkg:a/x@1.0.0#run" 'x-run}}`) as the exit. Silent suffixing
71-
(`run-2`) would bake an iteration order into an API; a namespace per
72-
interface multiplies files for a collision that `zoo.wit` — this repo's
73-
worst-case shape — does not have. Deterministic failure with a named exit
74-
follows `0014`'s nil-on-typo rule.
91+
The stripping **collides by construction** — the same finding that made
92+
`0014` B keep exact WIT strings at the key level. Both shapes were built and
93+
verified against the pinned wasm-tools during this note's adversarial
94+
review: a world exporting **two versions of one interface**
95+
(`acme:api/svc@1.0.0#run` / `…@2.0.0#run` — every function collides
96+
pairwise), and a single legal interface where `[method]counter.bump`
97+
strips to the same name as a plain export `counter-bump`.
98+
99+
So a collision **fails generation** with the colliding WIT names in the
100+
message, and there are two exits at two granularities:
101+
102+
- **`:interface`** — generate one namespace per interface:
103+
`(gen/write-ns! wasm {:ns 'acme.api.v1 :interface "acme:api/svc@1.0.0"})`.
104+
One option per interface, stable across regenerations. This is the exit
105+
for the two-version world, where per-function renames would cost
106+
O(functions) and have to be re-supplied verbatim forever.
107+
- **`:rename`** — `{:rename {"t:c/i#counter-bump" 'counter-bump2}}`, for a
108+
leaf collision inside one interface.
109+
110+
Silent suffixing (`run-2`) stays rejected: an API whose names depend on
111+
reflection order breaks on the component author's next reorder.
112+
Deterministic failure with a named exit follows `0014`'s nil-on-typo rule.
113+
114+
A generated name that shadows `clojure.core` — `count`, `map`, `get` are
115+
all legal WIT identifiers — gets `(:refer-clojure :exclude [...])` computed
116+
into the `ns` form, because the measured alternative is a load-time warning
117+
per consumer and clj-kondo exiting 2, which takes this repo's own gate red.
118+
119+
### D. One var per export, tagged — and `unwrap` lives in the library
120+
121+
**Amended in review: the first draft generated a throwing `(f …)` plus a
122+
tagged `(f* …)`, and that inverted `0012`.** `0012`'s accepted decision is
123+
that the tagged value *is* the mapping and throwing is opt-in sugar; giving
124+
the throwing form the short, completion-first name is opt-out, not opt-in —
125+
and it hands `(map #(f i %) coll)` exactly the laziness hazard `0012`
126+
documents. It also encoded a type-level fact in the file: a stale throwing
127+
wrapper over an export whose return stopped being a `result` fails in
128+
undefined ways, silently.
129+
130+
So: **one var per export, returning the value as `0012` maps it** — tagged,
131+
for a `result` return — and one function in the runtime library:
75132

76-
### D. `result` in return position gets `0012`'s sugar here
133+
```clojure
134+
(host/unwrap (add i 2 3)) ; ok payload, or ex-info with :wit/error
135+
```
77136

78-
This is the layer `0012` deferred to: `(f i args)` returns the ok payload and
79-
throws `ex-info` with the lifted error under `:wit/error` (the shrunk
80-
contract — no type name); `(f* i args)` returns `[:ok v]` / `[:err e]`
81-
untouched. Two vars, generated together, only for exports whose *return* is a
82-
`result`. Everywhere else one var, no throwing — `0012`'s laziness argument
83-
stands.
137+
Opt-in at the call site, zero generated duplication, and the sugar cannot go
138+
stale because it is not generated.
84139

85140
### E. Reflection moves to the artifact, and becomes public
86141

87-
Generation needs exports and signatures without instantiating, and users have
88-
asked the same question of instances (`host/exports`, `host/signature`). The
89-
walk that `instantiate` does over the component type is extracted and exposed:
142+
Generation needs exports and signatures without instantiating. The walk that
143+
`instantiate` does over the component type is extracted and exposed:
90144

91145
```clojure
92146
(host/describe artifact)
93147
;; => {"thumbnail" {:params [["img" {:kind :list :element :u8}] …]
94148
;; :result {:kind :list :element :u8}}, …}
95149
```
96150

97-
`describe` is useful on its own — a REPL user's first question about an alien
98-
`.wasm` — and it is the single source the generator consumes, so the
99-
generated file can never disagree with what `instantiate` will find.
151+
`describe` is the single source the generator consumes, so the generated
152+
file agrees with what `instantiate` finds **at generation time** — staleness
153+
after that is A's problem, not E's. Docstrings render these trees in WIT
154+
syntax, and they are **structural**: reflection has no declared type names
155+
(`0012`'s amendment), so `take-flags: func(p: perms) -> perms` documents as
156+
`flags{read, write, exec}`, never as `perms`.
100157

101158
## Why
102159

@@ -118,10 +175,15 @@ generated file can never disagree with what `instantiate` will find.
118175
- **Interning vars at instantiate** (`intern` from a plain function). Vars
119176
appear at run time, so clj-kondo and lsp are exactly as blind as with the
120177
macro, minus even the macro's syntactic marker.
121-
- **A namespace per WIT interface.** Buys collision-freedom `zoo.wit` does
122-
not need, at the cost of a file explosion and an import dance for the
123-
common one-interface world. Reopen if a real component's interface count
124-
makes the flat namespace unreadable.
178+
- **A namespace per interface as the only mode.** The common one-interface
179+
world would pay a file explosion for nothing. It survives as the
180+
`:interface` *exit* instead — the first draft rejected it outright, on the
181+
strength of `zoo.wit` not colliding, which is this project's recorded
182+
failure shape: checking the artifact in the repo, not the artifact the
183+
design is for.
184+
- **Two generated vars per `result` export** (the first draft's D). Inverted
185+
`0012`, doubled the surface, and created the one staleness mode the
186+
runtime cannot catch.
125187
- **Silent collision suffixes.** An API whose names depend on reflection
126188
order breaks on the component author's next reorder.
127189
- **Emitting `defprotocol`/`definterface` for call-site speed.** `0013`:
@@ -132,9 +194,11 @@ generated file can never disagree with what `instantiate` will find.
132194

133195
- A WIT export name whose last segment is not a readable Clojure symbol
134196
after the munge table above — the generator refuses it today; a real
135-
component hitting that refusal reopens C.
136-
- A component whose regenerated file churns on every toolchain bump (sha in
137-
the header making diffs noisy) — would argue for dropping the sha line.
138-
- clj-kondo flagging the generated shape itself — would demand a header
139-
directive, and is checkable in this repo's own gate by generating against
140-
a fixture and linting the output.
197+
component hitting that refusal reopens C. (Names that *shadow* — `count`,
198+
`map` — are already handled by the computed `:refer-clojure :exclude`.)
199+
- A real component whose interface count makes even `:interface`-split
200+
namespaces unreadable — reopens C's granularity.
201+
- clj-kondo flagging the generated shape itself — the gate generates
202+
against a fixture whose exports include a `clojure.core` shadow and a
203+
`result` return, and the review measured arity warnings working through
204+
exactly this shape.

‎src/cljwit/host.clj‎

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,11 @@
433433
(lower alloc m v)
434434
m))
435435

436-
(defn- lower-fn [tree]
436+
(defn- lower-fn
437+
"`ctx` is the store the compiled call belongs to, when there is one to
438+
check handles against; the import direction passes nil (a resource in an
439+
import's signature routes through the rep table, never here)."
440+
[tree ctx]
437441
(if (keyword? tree)
438442
(case tree
439443
:string (fn [alloc ^MemorySegment seg v]
@@ -444,6 +448,13 @@
444448
(fn [_ ^MemorySegment seg v]
445449
(let [^Handle h v
446450
^MemorySegment p (live-ptr h (if own? "transferring" "borrowing"))]
451+
;; A handle from another instance would surface as wasmtime's
452+
;; `mismatched resource types` -- an error about the wrong thing.
453+
;; The handle knows its store; say so instead (0020 B).
454+
(when (and ctx (not= (.address ^MemorySegment (.-ctx h))
455+
(.address ^MemorySegment ctx)))
456+
(throw (ex-info "resource handle belongs to a different instance"
457+
{:cljwit/error :wrong-instance})))
447458
(.set seg I8 (long 0) (byte RESOURCE))
448459
(.set seg ADDR (long UNION) p)
449460
;; A borrow leaves the handle live; an own is stale the moment the
@@ -454,15 +465,15 @@
454465
:enum (fn [alloc ^MemorySegment seg v]
455466
(.set seg I8 (long 0) (byte 17))
456467
(write-name! alloc seg UNION (name v)))
457-
:option (let [inner (lower-fn (:ty tree))]
468+
:option (let [inner (lower-fn (:ty tree) ctx)]
458469
(fn [alloc ^MemorySegment seg v]
459470
(.set seg I8 (long 0) (byte 18))
460471
(let [^MemorySegment q (if (nil? v)
461472
MemorySegment/NULL
462473
(val-of inner alloc v))]
463474
(.set seg ADDR (long UNION) q))))
464-
:result (let [lo (some-> (:ok tree) lower-fn)
465-
le (some-> (:err tree) lower-fn)]
475+
:result (let [lo (some-> (:ok tree) (lower-fn ctx))
476+
le (some-> (:err tree) (lower-fn ctx))]
466477
(fn [alloc ^MemorySegment seg v]
467478
(let [[tag payload] v
468479
ok? (= :ok tag)
@@ -473,7 +484,7 @@
473484
MemorySegment/NULL
474485
(val-of f alloc payload))]
475486
(.set seg ADDR (long RES-VAL) q)))))
476-
:variant (let [lowers (into {} (map (fn [[n t]] [n (some-> t lower-fn)]))
487+
:variant (let [lowers (into {} (map (fn [[n t]] [n (some-> t (lower-fn ctx))]))
477488
(:cases tree))]
478489
(fn [alloc ^MemorySegment seg v]
479490
(let [[tag payload] v
@@ -496,7 +507,7 @@
496507
(.set seg I8 (long 0) (byte 20))
497508
(.set seg I64 (long (+ UNION VEC-SIZE)) (long (count on)))
498509
(.set seg ADDR (long (+ UNION VEC-DATA)) buf))))
499-
:tuple (let [els (mapv lower-fn (:types tree))]
510+
:tuple (let [els (mapv #(lower-fn % ctx) (:types tree))]
500511
(fn [alloc ^MemorySegment seg v]
501512
(let [n (count els)
502513
buf ^MemorySegment (alloc (max 1 (* VAL n)))]
@@ -505,7 +516,7 @@
505516
(.set seg I8 (long 0) (byte 15))
506517
(.set seg I64 (long (+ UNION VEC-SIZE)) (long n))
507518
(.set seg ADDR (long (+ UNION VEC-DATA)) buf))))
508-
:list (let [el (lower-fn (:element tree))]
519+
:list (let [el (lower-fn (:element tree) ctx)]
509520
(fn [alloc ^MemorySegment seg v]
510521
(let [n (count v)
511522
buf ^MemorySegment (alloc (max 1 (* VAL n)))]
@@ -514,7 +525,7 @@
514525
(.set seg I8 (long 0) (byte 13))
515526
(.set seg I64 (long (+ UNION VEC-SIZE)) (long n))
516527
(.set seg ADDR (long (+ UNION VEC-DATA)) buf))))
517-
:record (let [fs (mapv (fn [[n t]] [n (lower-fn t)]) (:fields tree))]
528+
:record (let [fs (mapv (fn [[n t]] [n (lower-fn t ctx)]) (:fields tree))]
518529
(fn [alloc ^MemorySegment seg v]
519530
;; Fields go out in declaration order: a Clojure map has none.
520531
(let [buf ^MemorySegment (alloc (* ENTRY (count fs)))]
@@ -706,7 +717,7 @@
706717
(invoke (:hres-delete api) hr)
707718
(.set seg I8 (long 0) (byte RESOURCE))
708719
(.set seg ADDR (long UNION) ^MemorySegment (.get out ADDR (long 0)))))
709-
:else (lower-fn rt))
720+
:else (lower-fn rt nil))
710721
;; 0017 C: wasmtime frees what the callback writes, so every byte of a
711722
;; result comes from malloc. Measured: an Arena pointer survives one
712723
;; call and aborts the process at 2000.
@@ -913,7 +924,7 @@
913924
(`0014` E)."
914925
[api ^Arena arena ^MemorySegment ctx ^MemorySegment f sig
915926
^AtomicBoolean closed ^AtomicReference in-call nm rt dead]
916-
(let [ptypes (mapv (comp lower-fn second) (:params sig))
927+
(let [ptypes (mapv (fn [[_ t]] (lower-fn t ctx)) (:params sig))
917928
n (count ptypes)
918929
rlift (when (:result sig) (lift-fn (:result sig) rt))
919930
args ^MemorySegment (.allocate arena (long (max 1 (* VAL n))))
@@ -1321,6 +1332,23 @@
13211332
[^Instance i]
13221333
(let [a (:errs (.-rtab i))] (first (swap-vals! a (constantly [])))))
13231334

1335+
(defn unwrap
1336+
"`0012`'s result sugar, as a library function rather than a generated
1337+
wrapper (`0020` D): `[:ok v]` → v, `[:err e]` → ex-info carrying e under
1338+
:wit/error. Anything else passes through untouched, so it composes over
1339+
exports that are not result-typed — which also means a *variant* whose
1340+
case happens to be named ok or err is indistinguishable here; unwrap is
1341+
opt-in for exactly that reason."
1342+
[v]
1343+
(if (and (vector? v) (= 2 (count v)))
1344+
(case (nth v 0)
1345+
:ok (nth v 1)
1346+
:err (throw (ex-info (str "component returned an error: " (pr-str (nth v 1)))
1347+
{:cljwit/error :wit-error
1348+
:wit/error (nth v 1)}))
1349+
v)
1350+
v))
1351+
13241352
(defn exports
13251353
"Every exported function, by its exact WIT name."
13261354
[^Instance i]

0 commit comments

Comments
 (0)