Spec: A single file is a synthesized project #917
Replies: 2 comments
Status, 2026-09-23: steps 5 and 6 are implementedSteps 5 (capability-driven provider selection and source collection) and 6 (delete the old CLI route) are merged in #925, with finos/morphir-rust#217 and the Elm extension Two items from this spec are not done yet:
Details are in the working thread, #915. |
Contract refinement, 2026-09-23: explicit Elm package names have one normal formThe ad-hoc discovery contract says an explicit name "must pass the provider's own package-name contract". The three Elm providers read that differently. The built-in Rust binding accepted Refinement. For Elm, an explicit name is read and reported as follows:
Why the IR does not change. In Morphir a Synthesized names ( Recorded as kb decision 0005 in the |
Uh oh!
There was an error while loading. Please reload this page.
A single file is a synthesized project
Date: 2026-09-21
Status: Accepted. Ready to plan; the contracts below are settled during planning, not during implementation.
Repo: finos/morphir (
crates/morphir) andecosystem/morphir-rust(morphir-extension-sdk,morphir-workspace, language bindings)Problem
morphir compile --input Foo.elmtakes a different code path frommorphir compilein a projectdirectory. That second path exists because a lone file has no
morphir.toml, so there is nothingto load, so the CLI synthesizes a package in memory and calls the provider directly.
The route is chosen by a hardcoded language check.
is_single_file_requestreturns true onlywhen
--languageiselm, or when no language is given and the file ends.elm(
compile.rs:99).A non-Elm input therefore never enters this route at all.
morphir compile --input Foo.gleamfalls through to the project path and dies on:
That is worse than a language refusal, not better. The user is told to create a configuration
file, so they create one, and fail again for a different reason. Nothing names the language, so
nothing tells them the request was never viable.
prepare_single_file_contextdoes carry a language refusal:but it is unreachable. The router admits only Elm, and
infer_languagethen returns"elm"in both admitting cases, so
language_idis always"elm"by the time the check runs. It is deadcode, and deleting it is not part of the work: the decision below removes the route it sits on.
Both facts point at the same defect, which is the one the decision addresses.
is_single_file_requestconflates the user selected one source with there is no project, and those are different
questions. Because the router answers the wrong one, a language it does not recognise is not
refused; it is silently sent somewhere that cannot serve it.
Two dispatch decisions must agree, and nothing makes them. The session's
startupphasedecides whether to resolve configuration;
run_compiledecides which handler runs.startupmustknow every command that reaches the provider path, and it does not by construction:
morphir gleam compilehardcodeslanguage: Some("gleam")three frames away, sostartupgrew an armenumerating
GleamAction::Compile | GleamAction::Roundtrip. That arm exists only because anintegration test caught the regression.
Decision
A single source file is a project whose manifest was synthesized rather than read. Compilation
has one path. What varies is where the project came from.
Synthesis is the language provider's job, not the CLI's. The CLI does not know Elm's module
syntax and should not.
Source sets
--inputis not a route. Each--inputcontributes to an ad-hoc source set: a root, thesources under it, and the modules it exposes. A compile operates on source sets. Where they came
from is a separate question, answered by project origin.
This vocabulary is not imported from elsewhere; the codebase already holds the ingredients. It does
not already contain the abstraction:
ProjectSectionmixes package identity (name, version) withsource configuration (
morphir-common/src/config/model.rs:79), somorphir.jsonis not a sourceset descriptor so much as a project descriptor with one embedded:
{ "name": "My.Pkg", "sourceDirectory": "src", "exposedModules": [...] }a root plus an exposure.
sourceRootUrialready crosses the protocol as a compile option, andcollect_source_documentsreturns(documents, source_root_uri), which is a source set beingmaterialized. It already handles the degenerate case:
So a lone file is already a set rooted at its parent. The machinery exists; only the Elm-only
route bypasses it.
Adopt the concept as vocabulary and structure, not as a feature. No named source sets, no
per-set configuration, no source-set DSL in this increment.
sourceDirectorybeing singular is alimitation build systems reliably outgrow, so the concept will earn more later; none of that is
built now.
--inputrepeats, so an ad-hoc set can contain N sources.--input--inputThe protocol has always accepted a set:
CompileRequest.documentsisVec<SourceDocument>, andthe CLI's synthesis entry point takes
inputs: &[PathBuf]. An earlier draft concluded from thisthat the plural was nearly free, and that the CLI's guard wrongly blamed "the configured process
compiler" for a CLI restriction. Both conclusions were wrong, and the correction is the single
largest cost in this specification.
The default Elm provider is the
morphir-elmprocess extension, and it rejects multi-documentrequests outright:
It also builds its worker snapshot from
documents[0]alone, and validates every exposed moduleagainst that one document's module name (
ecosystem/morphir-elm/cli2/mep/compiler.ts:355,:78,:413). ItsValidatedRequestholds a singulardocument. The CLI's guard is therefore accurate.A slice parameter and a protocol vector establish representational capacity, not implemented
support.
Removing the CLI guard alone would be actively dangerous: the worker snapshot would silently drop
every document after the first. Calling the provider once per file and merging results does not
work either, because that is not cross-module compilation.
So multi-document compilation is a provider capability that must be negotiated, separately
from synthesis. A provider may support synthesis and not plurality.
Decided: negotiate on a declared capability.
N = 1works with every provider;N > 1requiresone that declares support, and asking for more fails with a message naming the provider rather
than a protocol error. The native Elm and Gleam bindings already order multiple modules by
dependency and assemble one distribution
(
morphir-elm-binding/src/frontend/compile.rs:220,morphir-gleam-binding/src/frontend/compile.rs:300),so they can declare it; the Elm process extension currently cannot, and works at
N = 1.The reason is capability negotiation, not compatibility. An earlier draft justified this as
protecting installed providers from a flag day, which does not apply: the Elm MEP extension is
pre-release work on
vnext, reachable only through the submodule pin. If that extension latergains multi-document support it declares it, and nothing in the host changes. The host asks; it
does not assume.
This framing is partly descriptive of current behaviour.
--input A.elmin a project thatexposes
AandBalready keeps the configured package name while synthesizing exposure forexactly the submitted module (
compile.rs:378). That much is real, and it retires the firstdraft's claim that
--configor--projectmeans "not a synthesis case", which confused anorigin question with a selection one.
But an earlier draft said "the existing code implements the override; only its routing is wrong",
and review showed that is too strong. Today the route compiles an isolated document using
the selected configuration. The project's other sources are not collected and not submitted, so
Bnever reaches the frontend. Keeping project identity is implemented; giving a selection accessto its surrounding project is not.
"No manifest anywhere" in the table should be read as no manifest selected or loaded, which
keeps the settled ambient-manifest policy intact.
It also explains the Elm-only check.
is_single_file_requestconflates "the user selected onesource" with "there is no project", which is only coincidentally true for the case it was written
for.
Decision: restrictions come from declared capabilities, and errors name the claimant
Characterization testing surfaced a third hardcode, of the same family as the Elm-only routing
check but harder to see, because it looks like a property of single-file compilation.
The two routes do opposite things with the IR version. The provider route negotiates against
what the selected extension advertises:
The single-file route hardcodes, before a provider has even been resolved:
Meanwhile the native Elm binding declares it can emit both:
So the CLI refuses a version the selected provider advertises. The restriction was true when
the
morphir-elmprocess extension was the only single-file provider, and that extension enforcesv3 itself (
ecosystem/morphir-elm/cli2/mep/compiler.ts:369). It became false, silently, when thenative binding gained v4. Nothing detected that, because nothing consulted the capability.
Two rules follow, and they generalize beyond this one case:
providers used to do. After the refactor there is one route, so it negotiates the IR version
the way the provider route already does. A single file producing v4 is then simply what happens
when the provider says it can.
supports only IR v3" attributes the limit to single-file compilation, which is neither where it
comes from nor, for the native provider, true. The message must name the provider and the
capability, so a reader can see that choosing a different provider lifts it. This is the same
rule the capability gate already carries for synthesis support.
This is a deliberate behaviour change, not a preservation. The compatibility test
ir_version_4_on_standalone_single_file_compilepins today's refusal, and in doing so pins adefect. When the refactor lands, that test must be changed rather than kept, and the change is
the evidence the defect was fixed. Note also that the storage descriptor is hardcoded alongside the
check (
v3_json_descriptor(),compile.rs:728), so version negotiation and storage selection movetogether.
Decision: the source universe
Exposure is not the set of documents compiled. The project route already keeps these apart: it
collects every matching file under the input directory and submits them all, while separately
supplying the configured exposure (
compile.rs:1662,compile.rs:1496). The two-axes modelabove conflates them, and that is the gap that stops implementation.
A selection implies three distinct sets, and the design must name all three:
Today, on the single-file route, all three collapse to the one submitted document. On the project
route, available and compiled are the whole source tree while selected is the manifest's exposure.
"Try a subset of an existing project" needs them to differ, and nothing implements that.
The obvious implementation does not work. Submitting every project source while overriding
exposure to the selection means current providers compile them all, so an error in an unrelated,
unselected module fails the run. That is not "try this subset" in any useful sense.
Decided: an ad-hoc source set gets no additional local source search path.
An earlier draft said "empty compile path", and review showed that is false as written. A compile
is never resolved against submitted documents alone: native Elm resolves against package modules,
a prelude, and dependency interfaces as separate inputs
(
morphir-elm-binding/src/frontend/compile.rs:613), and MEP carries dependency distributions(
morphir-extension-sdk/src/types.rs:303). The CLI happens to send empty dependency vectors today;that does not remove the provider's prelude. What is empty is the additional local source search
path, and only that.
The equality
selected = available = compiledalso does not follow from removing a search path,because submitted sources are available too. It follows from two policies, which are the
things actually being decided:
A selection is therefore an isolated compile. Manifest origin contributes package identity and
configuration, and nothing else. That is exactly what the code does today, so this is
preservation rather than retreat, and it corrects the earlier mislabelling:
--input A.elmin aproject has never been "a subset of the project", it has been "an isolated compile that borrows
the project's identity".
"Subset with access to its surrounding project" becomes a named future capability: give the
ad-hoc set a local source search path drawn from the project's declared set. It is not part of this
work, and the slot is deliberately left unshaped beyond that. Describing those sources as "read but
not compiled" would prejudge one of the three deferred questions, namely whether supporting modules
enter the distribution. It would need the provider to own dependency-closure compilation, and three questions
answered against a real use case rather than guessed:
Deferring it also sidesteps the incremental trap below, rather than solving it.
Incremental behaviour is entangled with this and needs its own decision. The single-file route
always sends
baseline: None; the project route reads and writes a cache. Both native providerstreat baseline modules absent from the submitted documents as deleted
(
morphir-elm-binding/src/frontend/compile.rs:131,morphir-gleam-binding/.../compile.rs:125),and the CLI replaces cached module results with the run's results, deliberately retiring omitted
ones (
compile.rs:1635). So a warm project cache could not have been used to make unselectedmodules "still available" — it would delete them. With the sets coinciding this does not arise:
a selection compiles what it names, and the existing cache semantics are already correct for that.
When a set is not compilable
A selection can be invalid in ways a single file never was. Validation happens at three tiers,
because the host cannot check everything: module names are derived by the provider, so the host
cannot detect duplicate module names before invoking the component that derives them.
Host tier, before discovery:
language_idis shared by the submitted documents, and Gleam validates both the request languageand every document's (
morphir-extension-sdk/src/types.rs:304,morphir-gleam-binding/src/lib.rs:175). Host rejection makes that contract fail earlier andbetter.
--languagecan assign a language; it cannot make mixed source syntax compilable.inputs — the existing project collector accepts them.
ordering, symlink confinement, and what counts toward N.
a new invariant this increment enforces, not an existing contract: MEP's root is optional, the
SDK preserves rootless cases (
morphir-extension-sdk/src/source.rs:71), and Gleam has its ownfallback when none is supplied (
morphir-gleam-binding/src/lib.rs:262). Today's collectorhappens to produce one. Requiring exactly one captured root for this path is sensible because
Gleam derives module names relative to it, but naming it part of a source set does not enforce
it: discovery and execution must demonstrably reuse the same value. Spanning directories
therefore either picks one root by rule or is rejected; it must not silently produce two.
Provider tier:
detects distinct module names that write the same IR path
(
morphir-elm-binding/src/frontend/compile.rs:910).Two boundaries need stating explicitly, because the frame invites the opposite reading:
srccontaining onlysrc/A.elmis exactly that set. Provider discovery must not expand it using the project'ssourceDirectory. Declared project configuration and the invocation's exact documents stayseparate.
DiscoveryPurposesketch belownames
ManifestProjectsandStandaloneSources, then describes only the synthesized response.The contract must also cover a manifest-origin invocation obtaining provider-derived exposure
while keeping its existing identity, including how the selected root and any
--package-nameoverride reach provider validation.
Host tier, before execution:
default Elm process extension does. This must fail with a message naming the provider, not a
protocol error.
One rule is a new restriction, and should be stated as such rather than as preservation:
rejecting sources outside the selected project's source root is not something today's configured
single-file route does.
An earlier draft claimed unresolved imports behave differently per origin: that with a manifest
the project supplies whatever the selection did not name. That is false today. Nothing on the
configured single-file route feeds unselected sources to the frontend, and the native providers
work from submitted documents plus supplied dependency data, neither of which project origin
populates. See the blocking decision below, which this question belongs to.
"The set is all there is" should mean all local source documents. Providers still supply
preludes, and the protocol carries external dependencies. Import and cycle diagnostics stay
frontend responsibilities either way.
Package identity for a synthesized selection
Require
--package-namewhen a synthesized selection contains more than one distinct source,applying the rule after deduplication so passing
Foo.elmtwice does not change the requirement.The frame explains half of this and does not derive the other half, which is worth separating.
Package identity is a project's, not a source set's, and the code agrees: identity travels in
CompileRequest.package, independent of document location, and configured single-file preparationexplicitly borrows
current_project.name(compile.rs:391). That is why a manifest origin lendsits name to whatever set is compiled. Source directories influence module identity, especially in
Gleam; that is a different identity and must not be conflated with the package's.
A synthesized origin still has a project — a synthesized one — whose identity must be
established rather than borrowed. For a single source the historical rule establishes it. For
several, nothing does, so requiring the flag is a policy choice justified by the instability of
every alternative, not a consequence of the frame.
Foo.elmandBar.elmimply a package containing both modules. They do not imply a package name,and every derivation rule is worse than asking:
For exactly one source, preserve the historical algorithm byte for byte: declared module name
including nested-comment, port-module and effect-module handling; filename fallback; then
"Main";ASCII lowercased with dots replaced by dashes, as
local/<name>. An explicit name must be trimmed,non-empty, and validated through the provider's package contract.
Decision: the manifest-plus-selection cell stays natural, and the artifact is marked
No force flag. It is natural today, costs nothing to keep, and is a useful inner-loop workflow. The hazard
is not the action but its output: the run produces a narrowed IR that does not match the
project's declared exposure, and it overwrites the task record, so a later
generateconsumes apartial distribution believing it is the project. The plural makes this worse, not better: a
selection of N modules looks more like a real build than a selection of one. A force flag gates the ergonomic action;
marking the artifact so
generatecan refuse or warn addresses the actual failure. The second ischosen, and review confirmed it is feasible, with limits worth stating.
TaskResultalready carries an extensible metadata map, and itsmodulefield means workspacemodule path rather than source-language module, so a typed compile-scope field fits
(
morphir-devkit/src/out/result.rs:92). The lifecycle is: invalidate the previous result, writethe new distribution, publish a successful record carrying selection provenance, and have implicit
generateread it under the existing shared task lock.prepare_destclearsextrawhen itwrites its tombstone (
out_context.rs:136), so the marker is written fresh on success —compatible, not contradictory.
Two limits. Marking the task record does not mark an exported IR artifact, and explicit
generate --inputbypasses the record entirely (generate.rs:100), so either the guarantee islimited to implicit consumption or portable provenance is defined for copied artifacts. And the
marker should record "explicit selection", not "narrowed": a selection may equal the declared
exposure, or expose a previously private module. Records written before this field exists need a
stated policy.
Do not change historical single-file IR bytes to add a marker.
Deleting the route is necessary but not sufficient
Review corrected an overclaim in the first draft. Merging the two compile implementations does
not by itself remove
startup's prediction: it must still distinguish commands that compilefrom commands that must run without touching a project, and
Compileand the Gleam wrappers stayseparate entry points.
What removes the prediction is that every compile entry point produces the same prepared value
through the same preparation operation, which
startupstores:PreparedCompileholds a resolved project, effective configuration, the selected provider, andthe captured sources.
startupstores a prepared operation rather than predicting what a laterhandler will need. So the prepared-command pattern is adopted for the single remaining route,
not rejected; what is rejected is using it to keep two routes consistent.
Why the provider owns synthesis
Strip the two hardcoded language checks and exactly three things remain language-dependent:
has_elm_extensionLanguageCapability.file_extensionselm_module_nameparsesmodule Foo.Bar exposing (..)fallback_elm_module_name,elm_module_pathProvider selection needs its own design, and is on the critical path. Synthesis cannot start
until the language is known, and today the source collector still calls a hardcoded
language_file_extensionmatch over Elm, Gleam, Python and Rust (compile.rs:1747). Until that iscapability-driven, "any future language with no CLI change" is false. Selection rules must state
what happens for
--language,--extension, an ambiguous suffix, and no provider at all.Gleam already derives module names from the document URI and
sourceRootUri(
morphir-gleam-binding/src/lib.rs:262). Because the root belongs to the source set rather thanbeing passed separately, synthesis and compilation cannot disagree about it, which is what stops
domain/customer.gleamacquiring two different names.The protocol: same method, revised contract
The first draft claimed
morphir.workspace.discover"already describes this exactly". That waswrong. The corrected claim is: no new MEP method, but a revised discovery contract and
capability negotiation.
What already works: the request can carry source text.
FileEntry::File { text }andDiscoveryRequest(morphir-workspace/src/file_tree.rs:13) transport contents today, and"confined portable" means an in-memory map of validated mount-relative paths — absolute paths,
backslashes, drive prefixes and
..are rejected. A tree containingFoo.elmand its text fitsthat representation exactly. No request revision is needed to transport contents.
Three contract gaps remain:
required_layerbefore discoveringprojects, so a source-only tree returns
workspace.config.missingrather than a synthesizedproject (
morphir-workspace/src/discovery/mod.rs:161,discovery/layers.rs:39).filters recognized configuration filenames
(
morphir-devkit/src/config/workspace_discovery/traversal.rs:327). Synthesis needs asource-specific builder with explicit size and confinement rules.
ProjectSnapshotcarries name, paths, version, state anddiagnostics — no exposure list, no source selection, no synthesized manifest.
WorkspaceSnapshot.config_anchoris mandatory (morphir-workspace/src/snapshot.rs:31).So this needs discovery protocol v2, with the purpose stated in the request and the origin
stated in the response:
The synthesized response carries package name, exact exposed modules, source root and exact source
selection. It returns provider-derived project data for the host's configuration resolver; it
does not make each provider implement configuration precedence.
Versioning is not optional.
workspace.discover = truecurrently advertises manifest discovery,and existing providers must not be assumed to support synthesis. The daemon also checks protocol
v1 and
ExtensionType::Workspace(
morphir-daemon/src/extensions/session/controller.rs:71), so changing the capability declarationalone is insufficient.
Versioning while this is pre-release
These protocols are drafts, and a draft is refined in place rather than versioned alongside
itself. The workspace discovery protocol, MEP, and the Elm MEP extension are all pre-release and
reachable only through pinned submodules and beta builds. There is no installed base in the field
to keep working.
So a change that breaks one of these contracts is a refinement, not a migration. Do not add a
protocol version beside the existing one, do not keep a compatibility path for the shape being
replaced, and do not freeze serialization fixtures against it. Update the contract, update every
constructor and corpus that encodes it, and move on.
This is not a minor process note. An earlier draft of the discovery plan treated protocol 1 as
frozen and invented a protocol 2 to sit beside it, which produced a self-contradictory
compatibility constraint, a demand for versioned wire handling, and a decoding path for old
responses that no one will ever send. All of that work existed only because the draft was being
treated as a release.
The rule stops applying once a contract ships in a non-prerelease build. Until then, churn from
premature versioning costs more than the breakage it avoids.
Rollout
Declaring the capability is not an implementation plan. The native adapter currently rejects
an advertised workspace capability because it has no native workspace handle
(
morphir-extension-sdk/src/native.rs:162). Built-ins need SDK work before they can servediscovery at all. That is real work, and it is step 3 of the sequence.
There is no installed-provider hazard. The Elm MEP extension lives on the
vnextbranch offinos/morphir-elmand reaches this repository only through the submodule pin, which currentlysits at
b065e493on that branch and not onmain. It is iteration work, not a shipped artifactwith users to protect.
Morphir-elm changes belong on
vnext, pinned by submodule. That branch exists for exactly thisiteration work. A change there is picked up by bumping the pin; it does not need a release, and it
does not gate anything in this repository on an upstream publication.
What must not change
Each of these was corrected during review. They are the behaviours most likely to break.
Ambient manifests stay ignored. A standalone compile must not start reading a
morphir.tomlit did not ask for. Achievable, and the host must choose standalone mode before loading file
layers rather than loading them and discarding the effects afterwards.
But the environment story is a change, not a preservation. The first draft said "the
environment layer still applies". That overstates today: the standalone path reads environment
values for the Elm modes only, and explicitly excludes environment-derived
preludeandextension(compile.rs:398). Applying the whole environment layer is a desirable behaviourchange and must be stated as one, not smuggled in as continuity. It is what GH #887 asks for.
--configand source selection are independent. The first draft said a run passing--configor
--project"is not a synthesis case". That is wrong about today, and the two-axes model abovereplaces it: those flags settle project origin, while
--inputsettles source selection. Thecompatibility case to preserve is a manifest exposing
AandBcompiled with--input A.elm,which keeps the configured package name and exposes only
A.Failure ordering is deliberate and must be decided, not assumed. The first draft wanted
failures "before any output directory is prepared". The single-file path calls
prepare_destbefore reading the source or selecting a provider, on purpose: it invalidates the previous
successful task record so
generatecannot consume stale IR after a failed compile(
compile.rs:710). Early capability failure is implementable but changes that. Either preservethe invalidation or separate task invalidation from destination preparation, and say which.
Package identity must be preserved exactly, per provider. Synthesis becomes provider-owned, so
the naming rules become a provider contract, and a contract can only cover inputs the provider
accepts. The rules split accordingly.
Reachable through every provider, and therefore contract tests: the declared module name, including
nested-comment and port-module handling; ASCII lowercasing with dots replaced by dashes; a trimmed
non-empty
--package-nameoverride; exactly one exposed module. Compatibility tests already pinthese at the CLI level, including
[["local"],["acme","widget"]]frommodule Acme.Widget.Provider-dependent, and therefore not a single contract: the filename fallback and
"Main".morphir-elm-nativerejects a source with no module declaration before any fallback is consulted(
cst_to_ast.rs:460-463), so for that provider the fallback does not exist and cannot be tested.It may still be live for the
morphir-elmprocess extension, which parses differently and whichthis repository cannot exercise. A provider that requires a module declaration has no fallback to
preserve; a provider that does not must preserve its own. Do not write one contract test and assume
it covers both.
Effect modules are out of scope entirely: the native provider has no effect-module concept, and
real Elm restricts
effect moduleto authorized core packages, so no user source can be one.A more sophisticated parser must not silently change historical identities for the inputs a
provider does accept.
Identity alone does not give identical IR, and the IR version is deliberately not preserved.
Standalone Elm forces IR v3 and rejects an explicit v4 (
compile.rs:722), while the project routedefaults to v4 (
compile/version.rs:24).An earlier draft of this section asked for that default to be "preserved or explicitly migrated",
which contradicts the capability decision above. The capability decision wins: the CLI stops
holding an opinion about IR versions and negotiates from the provider's advertised
irVersions,as the project route already does.
What that preserves is the outcome for a given provider, which is the compatibility that
actually matters:
morphir-elmprocess extensionmorphir-elm-native["3","4"]So no user's output changes unless their provider could always have produced more than the CLI
allowed. Storage behaviour moves with the version, since
v3_json_descriptor()is hardcoded besidethe check (
compile.rs:728); provider choice and the remaining options are preserved as statedelsewhere in this section.
Do not fabricate a manifest path.
ConfigContext.config_pathis mandatory(
morphir-devkit/src/config/loader.rs:34). Introduce a manifest-independent resolved-project typenow; it can hold the eventual layered
EffectiveConfigwithout a second redesign.Resolved questions
fallback. Transport it as
FileEntry::File, and reuse that captured text for compilation ratherthan reading the file twice.
ProjectOrigin::Synthesizedvariant. Absence of a manifest path is too weak to carry a policy decision, and the decision is
needed before file layers load.
FrontendCapability.fragments? No. Elm and Gleam both advertisefragments: falsetoday while single-file compilation works, which is direct evidence the twoare unrelated. Reserve fragment for an expression, declaration or incomplete module needing an
enclosing context, and document the flag as reserved until that contract exists. Removing the
unused flag is a separate protocol cleanup.
morphir gleam roundtripis not merely adjacent. Its generate half callsrun_generatewith no explicit IR input, and generate independently requires a manifest (
gleam.rs:100,generate.rs:45). A newly working standalone compile would then fail at generation, or discoverthe ambient configuration compilation deliberately ignored. Either pass the prepared project and
artifact into generation, or explicitly reject standalone roundtrip and say so before
compiling.
Resolution order
Synthesized values get explicit provenance below the environment and explicit CLI values. The
CLI overlay applies once, at precedence 700. Clap defaults must not masquerade as explicitly
supplied flags.
Alternatives rejected
Commands::configuration() -> ConfigRequirementRequired/Optional/NotNeededis too coarse for rules that distinguish explicit--config, discovery under--project, and no discoveryis_single_file_requestto a CLI language listmorphir.workspace.discoveris the right method; it needs a v2 contract, not a siblingDecisions taken
The three questions that blocked implementation are answered:
N = 1everywhere,N > 1only where declaredgeneraterefuses unless acknowledged. Explicitgenerate --inputis out of scope and documented as a limitWhat ships is language-agnostic synthesis: single-file and multi-file Gleam, and any future
language whose provider declares the capability, with no CLI hardcoding. What is deliberately not
built is subset-with-project-access semantics, which no current use case requires.
Settled contracts
Root, directory inputs and canonicalization
The root is the single common parent directory of the canonicalized inputs. Inputs that span
directories are rejected in this increment.
Spanning is where module identity goes wrong:
--input src/A.elm --input lib/B.elmputs the commonancestor at the project directory, so Gleam derives
src.Aandlib.B. That is deterministic andsurprising. Rejecting it keeps "one root per set" true rather than aspirational. Multi-directory
sets become a later capability, with an explicit
--source-rootif anything needs them.A directory stays a legal input:
--input src/is the set rooted atsrcwith its recursivecontents, which is what the project path does today. Inputs are canonicalized with symlinks
resolved, deduplicated by canonical path, rejected if they escape the root, and ordered by sorted
canonical path, matching the existing collector.
Provider precedence and ambiguity
Precedence mirrors what
frontend_extension::resolvealready implements:Language comes from
--languagewhen given, otherwise from matching the suffix against providers'declared
file_extensions. An ambiguous suffix is an error naming every provider that claimsit, and saying to pass
--languageor--extension. The CLI never picks. No provider for asuffix is an error naming the suffix and the languages that are available.
Old providers: nothing to gate against
Decided: no compatibility gate, no upstream release dependency, no shim.
Earlier drafts treated the
morphir-elmprocess extension as a shipped artifact whose installedbase had to be protected, and concluded that synthesis must be released upstream before this
repository could require it. That was wrong on the facts. The Elm MEP extension lives on the
vnextbranch offinos/morphir-elmand reaches this repository only through the submodule pin.It is pre-release iteration work with no users in the field.
So the sequencing collapses. Elm synthesis is implemented on
vnextand picked up by bumping thepin, in the same way any other submodule change is. There is no release to wait for and no minimum
version to name in an error.
The capability declaration still matters, and for its own reason rather than as a compatibility
device: a host must consult what a provider advertises rather than hardcode what providers used to
do, which is the rule recorded under "restrictions come from declared capabilities". A provider
that cannot synthesize says so, and the host reports that clearly. What is gone is the rollout
ceremony around it, not the negotiation.
The Elm-specific synthesis code in the CLI is deleted rather than kept on life support, which was
the point of the change.
Artifact policy
generateproceeds silently. Their scope cannot be recovered, and refusing would break existing workflows
to guard a hazard that already exists.
prepare_destalready writes a tombstone over theprevious record, so there is nothing to consume; provenance is written fresh on success.
generaterefuses, naming the selection and the override flag--from-partial-compile.project. Threading the prepared project and artifact into generation is the better answer and is
out of scope here.
Sequence
The contracts above are settled, so the work is sequenced rather than blocked. Note that step 4
crosses repositories and gates step 6.
exposure, IR version, provider selection and failure ordering.
vnextbranch offinos/morphir-elm, picked up by bumping the submodule pin. No release,so this is no longer the long pole it was drafted as.
value and the artifact-consumption guard. Step 4's review carried three things here, each
deliberately left undone rather than overlooked:
this specification requires. Both providers skip a project that already has a name, so
--package-namereaches compilation unchecked and fails there, exactly as it does today.The provider only becomes the natural place for that check once step 5 routes names through
discovery, which is why it waited.
one distinct source requires a name" is enforced in portable discovery
(
workspace.selection.name-required), not by the provider that owns what "distinct" meansfor its language. Correct for every language today; revisit when selection is capability-driven.
Released extension metadata will not carry the workspace capability.Struck: this wasrecorded as a step-5 concern and turned out to be a hard failure in step 4's own CI. The
daemon already validates initialization metadata against the published manifest and refuses
any disagreement in capability kinds, so an extension advertising
Workspaceat runtimewhile its bundle declared only frontend and backend could not negotiate a session at all.
Packaging could not express the capability; it now can, via a
workspace_discoverykey in.github/extensions.toml. The lesson worth keeping: the gap between what an extension isand what its published manifest says is enforced, not advisory.
from a single input, so a selection that supplies its own name and selects several sources
falls through with
exposedModulesunset, meaning "expose everything". Nothing can constructthat shape until this step wires source collection, which is why it waits — but this step is
also what makes it reachable, so enumerating a multi-source set's modules belongs here.
compatibility cases pass through the replacement. The gate is a capability check, not a release
boundary.
test:cli-releasedrives every freshly built bundle through the morphir CLI release pinned in
.config/morphir-cli-version— it is the declared compatibility check between the tworepositories — so a bundle that only speaks
sourcescannot pass until a release carries ahost that sends it. Step 4 therefore accepts a request that is wholly legacy (top-level
documents, optionally with a legacy root key) or wholly modern (sources), and rejectsany mixture. Once a released host speaks the new shape, bump the pinned version and remove
the legacy alternative. Until then the pin is the thing that says whether removal is safe.
Relationship to PR #894
Land #894 first, then this as a separate change. This
specification requires protocol evolution, provider rollout, configuration policy and compatibility
work; folding it into the lifecycle change makes both harder to review.
#894's interim
Readyshape will be replaced, and that does not waste the lifecycle boundary. Whatwould be wasted is further polishing its optional configuration pair or its command enumeration
as though either were the final abstraction.
All reactions