ADR-0093 · Module system v1: file-modules, qualified-by-default imports, elaborate-to-flat
- Status: Accepted
- Summary: Q34's revisit signal has fired — the dogfood arc reached multi-file need (a JSON parser wanting the tokenizer's machinery; the operator names modules as the pending blocker for real projects). The ARCHITECTURE is already pinned (ADR-0076, Accepted: modules elaborate to the FLAT kernel · the compiler is a queryable service · the module DAG is acyclic + generated); this ADR decides Q34's v1 SURFACE forks inside those pins. Decision: (D1) one module = one FILE —
import tokenizerresolvestokenizer.bangrelative to the importing file (then a project root); the module's name IS its filename, no module header, no blocks; (D2) qualified access by default (tokenizer.lex input) + EXPLICIT selectiveuse tokenizer (lex, Token)— no glob/open-all import exists (implicit namespace pollution is the anti-agent-first move; ausecollision is a LOUD error, ADR-0046); (D3, operator-amended 2026-07-09) declarations are PRIVATE by default;pubmarks a declaration exported (riding the Rust convention — the dominant pattern-match for exactly this semantics). Private-by-default is the consistent agent-first choice: the module's interface is DECLARED at the definition site, nothing exports by accident, anduse/qualified access can only name what the author deliberately revealed (the Q34 interface-reveal lesson, made structural); (D4) semantics = whole-program elaboration: imports parse + merge with name-qualification at the Surf level, THEN the existing single-program pipeline runs unchanged — the kernel never learns modules exist (the ADR-0075/0088/0091 elaborate-away move, fourth application); import cycles are a loud error (the 0076 DAG pin); the prelude stays the one always-open implicit module. Q38 posture (deliberate): v1 mints NO new interface construct — a module is a file, not a signature; the module≟trait≟effect unification stress-test stays fully open for Stage 7, when theeffectdeclaration surface (ADR-0092/0085-D4) either converges withtraitsyntax or diverges-documented. Deferred per Q34's own sequencing: the stdlib partition (prelude scales for now), the hashing boundary + incremental build, the LSP query surface (its non-deferrable prerequisite — spans in the checker — already landed via #52/#59). Rejected: module blocks (Lean-stylemodule { }— nesting no v1 program needs; not foreclosed, a file is trivially one block); glob imports (use tokenizer *— resolution becomes context-dependent, breaking both ADR-0046 determinism and agent pattern-matching); anexport (…)LIST as the visibility mechanism (a second place to look — the def site should carry its own visibility; kept conceivable as future sugar overpub); default-PUBLIC (the draft's original D3, operator-rejected — exports-by-accident and an undeclared interface). - Depends-on: 0076 (the pinned architecture), 0075 (elaborate-to-mono precedent), 0046 (deterministic-or-loud resolution)
- Relates-to: Q34 (this decides its forks 1–3; 4–6 stay deferred), Q38 (posture: keep the unification testable, decide nothing), #33 (dogfood), ADR-0092/Stage-7 (the effect-decl surface that will test Q38),
docs/notes/dogfood-json-findings.md(the confirming evidence)
Status
Accepted — and LANDED on main (7a95dfa..6d69580, 2026-07-09 — 9 commits, gated on a fresh
clone: full build green, test-modules 27/27, test-check-json 22/22, kernel census 26 ctors and
axiom census both unchanged). As-landed refinements, each build- or oracle-forced:
- D5 point (c) mechanism: optional type ascription on plain
letdecls threads anOption TythroughDecl.letDand desugars to aSurf.annotSwrap — the same mechanism an ordinary(e : T)uses, no new construct.isLetDecl's lookahead skips the optional: Tyso decl-vs-script disambiguation cannot disagree with the real parse. Falsified by discarding the ascription (an ill-typedlet x : Unit = 3then wrongly checks). - Grammar finding (reference material for #65): effectful qualified calls need
$(mod.op) arg—$mod.op argparses as($mod).op. - Resolver-aware
bang check: resolves the entry file exactly asrundoes and checks the mergedProgDIRECTLY (not print-then-reparse, which is unsound — a D5-synthesizedmainbody can re-tokenize as one application with the preceding decl). Known v1 limitation, documented in the usage text: diagnostics for imported content carry"span":null; per-file span mapping is a named follow-up. - One-line deviation, accepted at gate:
Diagnostics.lean's existingjsonStrescaper flippedpublicso the resolver-failure JSON path reuses it (one construct per problem beats a duplicate escaper; visibility-only, no new dependency edge). - The transitive-import qualification fix and the
use-hoists-ctors-with-their-type rule (ADR-0069 interaction) were both caught by the merge≡hand-qualification differential oracle during implementation.
Originally Accepted (2026-07-09, operator ruling same day — after the dogfood evidence landed and confirmed
the design point-for-point: the JSON unit wanted exactly file-shaped modules
(Json/Parse/Print/main), needed no circularity, wanted tokenizer reuse by import, and suffered
the flat-scope shadowing pain that use-scoping removes; see
docs/notes/dogfood-json-findings.md). Implementation is
elaborator + CLI work (Surface.lean import parsing · TypeCheck.lean decl-merge — queue
behind current TypeCheck ownership; Main.lean file resolution), zero kernel surface.
- Layer: F (frontend) + CLI. Census untouchable by construction — the merged program elaborates through the SAME checkAndLower; a multi-file program and its hand-concatenated single-file equivalent produce identical kernel terms (that equivalence is the v1 oracle: a differential #guard).
Context
Today a bang program is ONE file + the injected prelude. The tokenizer (ce6d738) fits; a JSON parser wanting to REUSE the tokenizer does not — it must copy code, which is the update-anomaly smell the whole project exists to kill. ADR-0076 pinned how modules must relate to the kernel (elaborate to flat, invariant #5 untouched) and to tooling (queryable compiler, acyclic generated DAG, content-addressing later); what remained were Q34's surface forks.
Decision detail
-
D1 — file = module.
import tokenizerinjson.bangloadstokenizer.bang(same directory, then the project root — the resolution order is fixed and documented; a miss is a loud error naming both probed paths). The module name is the filename stem; no header ceremony. Content-addressing (deferred) hashes the file — D1 is what makes that trivial. -
D2 — qualified + explicit
use.tokenizer.lexworks immediately afterimport;use tokenizer (lex, Token)brings exactly the named decls into scope. Re-declaring an imported name locally, or twouses colliding = loud error with both origins named (agent-first: the error TEACHES the fix).dataconstructors travel with their type (use tokenizer (Token)brings Token's ctors — match arms need them). -
D3 — private by default,
pubopts in (operator ruling 2026-07-09). A bare decl is module-local;pub let/pub data/pub effectexport it. Qualified access andusecan only namepubdecls; naming a private one is a loud error that SAYS it exists but is private (agent-first: the error teaches the fix, not just "unknown name").datavisibility is all-or-nothing in v1 (apub dataexports its ctors — the abstract-type refinement, ctors hidden while the type is public, is deferred until a consumer needs it). The main/entry file's decls need nopub(nothing imports it). Cost accepted: one keyword per shared decl — the interface-reveal is exactly the documentation an agent reading the module wants anyway. -
D4 — whole-program elaboration. Import resolution produces a topologically-ordered decl list (cycle = error), name-qualified at Surf level (
tokenizer.lexbecomes an ordinary qualified identifier the elaborator resolves — the same mangling movedatactor tags already use), then ONE elaboration pass exactly as today. Incremental/per-module compilation is the ADR-0076 payoff LATER (content-addressed, falls out of immutability); v1 recompiles the world, which at dogfood scale costs nothing (invariant #7). -
D5 — entrypoint (operator fork, ruled 2026-07-09):
mainis a magic NAME, not a keyword. A decl namedmainmarks the file runnable (riding the C/Go/Rust convention — a keyword would be novel syntax for non-novel semantics, against the lens). The trailing-expression form STAYS as script mode (the REPL/eval/one-liner path and today's whole corpus);main-decl present → program mode; BOTH present → loud error (ADR-0046, no silent precedence); neither → a pure library file.mainneeds nopub(the runtime, not an importer, invokes it). The pinned payoff — main's row is the program's capability MANIFEST:bang runis the use site where the runtime installs handlers for exactlymain's declared row, so "runtime is a handler installed at the use site" (the kernel thesis) becomes the CLI's actual contract. Today that row can only be⊥/{Div}; when Stage 7 + ADR-0084 land IO, a row the runtime can't provide is a LOUD TYPE ERROR at the boundary — the program's powers become a checked fact of its signature. (This D pins the direction; the manifest-checking mechanics land with ADR-0084.)
The v1 oracle
elaborate(import-merged files) ≡ elaborate(hand-concatenated-and-qualified single file) —
a differential #guard per corpus case, plus check-examples gains its first multi-file
example project. Same stratification as everything else: the tested superset rides an oracle.
Revisit if
- The prelude stops scaling or the stdlib partition arrives → decide Q34 fork 4 (which modules, what stays always-open) as its own ADR.
- Stage 7 lands the
effectdeclaration surface → run the Q38 stress-test THEN (module-as-file deliberately left no construct to collide with it). - A consumer needs abstract types (public type, private ctors) → the
pub datarefinement, own slice. - Multi-file compile times bite → the ADR-0076 content-addressed incremental build (Q34 fork 5).
Evidence
ADR-0076 (the pinned architecture + generative-constraints rationale), Q34 (the fork list +
"revisit signal: users write multi-file programs" — fired 2026-07-09), Q38 (the
keep-it-testable directive), ADR-0075/0088/0091 (three prior elaborate-away wins), docs/notes/dogfood-json-findings.md (the concrete multi-file friction shapes that confirmed D1/D2).