Is your feature request related to a problem? Please describe.
AffineScript has no surface syntax for a mutable binding at module scope. const X: T = expr; works for immutable module-level constants (stdlib/math.affine lines 31, 34, 37, 40, 43 are the precedent); let mut x = …; works inside a function body (stdlib/Http.affine:163, stdlib/AlibSchema.affine:158); but neither const mut, let mut, nor any other spelling reaches the intersection of mutable and module scope.
This blocks every consumer port that needs a module-level singleton with in-place mutation — paradigmatic case: lazy / on-demand global handles (let coprocessor: ref<option<bridge>> = ref(None) plus an ensureCoprocessor() initialiser that flips it from None to Some(...)). idaptik alone has ~14 of these patterns across src/shared/*.res, src/app/devices/*.res, src/app/utils/*.res, src/engine/utils/*.res. The Coprocessor.backends: dict<backend> registry in src/shared/Coprocessor.res is the structural canonical (one global mutable Dict, populated by register(backend) at app boot).
Adjacent gap surfaced in the same probes: even the immutable let x = …; form fails to parse at module scope — const X = …; is the only spelling that compiles. The decision to make immutable module-level state explicit-only (via const) is consistent and not a bug; this issue is purely about the mutable case.
Grounded evidence (oracle probes against HEAD)
| Probe |
Result |
const pi: Float = 3.14159; (immutable, module scope) |
✅ parses (matches stdlib/math.affine:31 precedent) |
pub const pi: Float = 3.14159; |
✅ parses |
pub fn count_to(n: Int) -> Int { let mut c = 0; while c < n { c = c + 1; } c } (mutable, function scope) |
✅ parses |
let mut counter = 0; (mutable, module scope) |
❌ parse error at col 1 |
let mut counter: Int = 0; (mutable + type, module scope) |
❌ parse error at col 1 |
const mut counter: Int = 0; (const mut candidate spelling) |
❌ parse error at col 1 |
let pi: Float = 3.14159; (bare let at module scope, for comparison) |
❌ parse error at col 1 |
So the language admits immutable module-level state via const, mutable function-local state via let mut, but has no production for mutable module-level state.
Describe the solution you'd like
Add a module-level mutable-binding form, with the mutation tracked through the Mut effect (already declared in stdlib/effects.affine:8). Two production-shape options:
pub? const mut <name>: T = expr; — minimal surface change; reuses the existing const keyword + the existing mut qualifier. Reads of mut-marked top-level bindings are Mut-effectful; writes (e.g. name = new_value;) become an effectful statement.
pub? let mut <name>: T = expr; at module scope — analogous to function-scope let mut; one more arm in the top-level decl grammar.
Either way, the binding sits in the resulting prog_decls and is reachable from any function whose effect row admits Mut.
The owner call on canonical spelling (the const mut vs let mut choice) belongs in a SETTLED-DECISIONS.adoc line; this issue tracks the grammar+lowering gap, not the canonical choice.
Describe alternatives considered
-
Wrap every singleton in an ML-style Ref value passed explicitly. Possible if every consumer threads the registry through every call site; not viable for idaptik's coprocessor registry which is genuinely global (host-side wasm dispatch needs O(1) lookup from arbitrary call sites).
-
Always-eager initialisation. Some singletons (e.g. let language = LanguageSettings.getLanguage();) could be const if initialisation were pure; in idaptik they're not (async wasm load, async feature-flag check, deferred-to-frame-1 patterns).
-
Use stdlib dict::set to register backends into a passed-in dict. Same objection as the explicit-Ref alternative — the registry is genuinely module-scoped and called from N consumer call sites.
Additional context
- Mirrors
#228 and #547 in shape: a language facility AS almost admits (immutable module-level via const, mutable function-local via let mut) but is missing the diagonal — exactly the seam this issue closes.
- Surfaced during the type-surface ports in
hyperpolymath/idaptik#153. The 3 ports landed (DeviceType / Coprocessor / PuzzleFormat) deliberately ported only the immutable type surface; the mutable-registry / mutable-coprocessor-handle layer of each module stayed in ReScript because this gap is open.
- Estate impact: idaptik ~14 sites is the count I personally walked; the same pattern is general across any host with on-demand wasm loading (panll's plugin loader, gitbot-fleet's tool registry, neurophone's hardware shims).
- Oracle / build:
dune build bin/main.exe, AFFINESCRIPT_STDLIB=$PWD/stdlib, main.exe check <file>; classify on stdout (Type checking passed vs parse error).
Refs
Refs #228 (precedent — language-side grammar gap surfacing during the estate port)
Refs #229 (estate-wide ReScript-surface elimination — this is the residual blocker for the runtime layer of every Coprocessor wrapper)
Refs #547 (sibling — the other parser-asymmetry gap surfaced by the same port batch)
Refs hyperpolymath/idaptik#153 (the port batch that surfaced this)
Is your feature request related to a problem? Please describe.
AffineScript has no surface syntax for a mutable binding at module scope.
const X: T = expr;works for immutable module-level constants (stdlib/math.affinelines 31, 34, 37, 40, 43 are the precedent);let mut x = …;works inside a function body (stdlib/Http.affine:163,stdlib/AlibSchema.affine:158); but neitherconst mut,let mut, nor any other spelling reaches the intersection of mutable and module scope.This blocks every consumer port that needs a module-level singleton with in-place mutation — paradigmatic case: lazy / on-demand global handles (
let coprocessor: ref<option<bridge>> = ref(None)plus anensureCoprocessor()initialiser that flips it fromNonetoSome(...)). idaptik alone has ~14 of these patterns acrosssrc/shared/*.res,src/app/devices/*.res,src/app/utils/*.res,src/engine/utils/*.res. TheCoprocessor.backends: dict<backend>registry insrc/shared/Coprocessor.resis the structural canonical (one global mutable Dict, populated byregister(backend)at app boot).Adjacent gap surfaced in the same probes: even the immutable
let x = …;form fails to parse at module scope —const X = …;is the only spelling that compiles. The decision to make immutable module-level state explicit-only (viaconst) is consistent and not a bug; this issue is purely about the mutable case.Grounded evidence (oracle probes against HEAD)
const pi: Float = 3.14159;(immutable, module scope)stdlib/math.affine:31precedent)pub const pi: Float = 3.14159;pub fn count_to(n: Int) -> Int { let mut c = 0; while c < n { c = c + 1; } c }(mutable, function scope)let mut counter = 0;(mutable, module scope)parse errorat col 1let mut counter: Int = 0;(mutable + type, module scope)parse errorat col 1const mut counter: Int = 0;(const mutcandidate spelling)parse errorat col 1let pi: Float = 3.14159;(bareletat module scope, for comparison)parse errorat col 1So the language admits immutable module-level state via
const, mutable function-local state vialet mut, but has no production for mutable module-level state.Describe the solution you'd like
Add a module-level mutable-binding form, with the mutation tracked through the
Muteffect (already declared instdlib/effects.affine:8). Two production-shape options:pub? const mut <name>: T = expr;— minimal surface change; reuses the existingconstkeyword + the existingmutqualifier. Reads ofmut-marked top-level bindings areMut-effectful; writes (e.g.name = new_value;) become an effectful statement.pub? let mut <name>: T = expr;at module scope — analogous to function-scopelet mut; one more arm in the top-level decl grammar.Either way, the binding sits in the resulting
prog_declsand is reachable from any function whose effect row admitsMut.The owner call on canonical spelling (the
const mutvslet mutchoice) belongs in aSETTLED-DECISIONS.adocline; this issue tracks the grammar+lowering gap, not the canonical choice.Describe alternatives considered
Wrap every singleton in an ML-style
Refvalue passed explicitly. Possible if every consumer threads the registry through every call site; not viable for idaptik's coprocessor registry which is genuinely global (host-side wasm dispatch needs O(1) lookup from arbitrary call sites).Always-eager initialisation. Some singletons (e.g.
let language = LanguageSettings.getLanguage();) could beconstif initialisation were pure; in idaptik they're not (async wasm load, async feature-flag check, deferred-to-frame-1 patterns).Use stdlib
dict::setto register backends into a passed-in dict. Same objection as the explicit-Ref alternative — the registry is genuinely module-scoped and called from N consumer call sites.Additional context
#228and#547in shape: a language facility AS almost admits (immutable module-level viaconst, mutable function-local vialet mut) but is missing the diagonal — exactly the seam this issue closes.hyperpolymath/idaptik#153. The 3 ports landed (DeviceType / Coprocessor / PuzzleFormat) deliberately ported only the immutable type surface; the mutable-registry / mutable-coprocessor-handle layer of each module stayed in ReScript because this gap is open.dune build bin/main.exe,AFFINESCRIPT_STDLIB=$PWD/stdlib,main.exe check <file>; classify on stdout (Type checking passedvsparse error).Refs
Refs #228 (precedent — language-side grammar gap surfacing during the estate port)
Refs #229 (estate-wide ReScript-surface elimination — this is the residual blocker for the runtime layer of every Coprocessor wrapper)
Refs #547 (sibling — the other parser-asymmetry gap surfaced by the same port batch)
Refs hyperpolymath/idaptik#153 (the port batch that surfaced this)