Repository: dagu-org/dagu

Issue:
JSON-schema relative path resolution
- Add check for whether JSON schema file actually exists
- Implement relative path resolution for JSON schema file
  1. From the current directory
  2. [From the value specified in the DAG’s workingDir field](https://github.com/thefishhat/dagu/blob/9dfe0194faa294e1feccf0cf94ddfd68ee34aa1e/internal/digraph/dag.go#L43)
  3. [From the location where the DAG file was originally loaded](https://github.com/thefishhat/dagu/blob/9dfe0194faa294e1feccf0cf94ddfd68ee34aa1e/internal/digraph/dag.go#L46)

Here's the code to for loading JSON schema:
https://github.com/dagu-org/dagu/blob/ffb332d1bb36def574586c6a9df3aaee0bc91c1b/internal/digraph/params.go#L198-L208

You can also find a similar piece of code here.
https://github.com/thefishhat/dagu/blob/9dfe0194faa294e1feccf0cf94ddfd68ee34aa1e/internal/persistence/filedag/store.go#L418-L441

Hints from issue comments:
@thefishhat: Raised a PR for this here: https://github.com/dagu-org/dagu/pull/1371@yottahmd: It's implemented now, thank you very much, @thefishhat !

Solution patch:
diff --git a/internal/core/spec/builder.go b/internal/core/spec/builder.go
index 1e71dfa3..4e7072c3 100644
--- a/internal/core/spec/builder.go
+++ b/internal/core/spec/builder.go
@@ -50,25 +50,38 @@ func (c BuildContext) WithFile(file string) BuildContext {
 	return copy
 }
 
+// BuildFlag represents a bitmask option that influences DAG building behaviour.
+type BuildFlag uint32
+
+const (
+	BuildFlagNone BuildFlag = 0
+
+	BuildFlagNoEval BuildFlag = 1 << iota
+	BuildFlagOnlyMetadata
+	BuildFlagAllowBuildErrors
+	BuildFlagSkipSchemaValidation
+)
+
 // BuildOpts is used to control the behavior of the builder.
 type BuildOpts struct {
 	// Base specifies the Base configuration file for the DAG.
 	Base string
-	// OnlyMetadata specifies whether to build only the metadata.
-	OnlyMetadata bool
 	// Parameters specifies the Parameters to the DAG.
 	// Parameters are used to override the default Parameters in the DAG.
 	Parameters string
 	// ParametersList specifies the parameters to the DAG.
 	ParametersList []string
-	// NoEval specifies whether to evaluate dynamic fields.
-	NoEval bool
 	// Name of the core.DAG if it's not defined in the spec
 	Name string
 	// DAGsDir is the directory containing the core.DAG files.
 	DAGsDir string
-	// AllowBuildErrors specifies whether to allow build errors.
-	AllowBuildErrors bool
+	// Flags stores all boolean options controlling build behaviour.
+	Flags BuildFlag
+}
+
+// Has reports whether the flag is enabled on the current BuildOpts.
+func (o BuildOpts) Has(flag BuildFlag) bool {
+	return o.Flags&flag != 0
 }
 
 var builderRegistry = []builderEntry{
@@ -149,7 +162,7 @@ func build(ctx BuildContext, spec *definition) (*core.DAG, error) {
 
 	var errs core.ErrorList
 	for _, builder := range builderRegistry {
-		if !builder.metadata && ctx.opts.OnlyMetadata {
+		if !builder.metadata && ctx.opts.Has(BuildFlagOnlyMetadata) {
 			continue
 		}
 		if err := builder.fn(ctx, spec, dag); err != nil {
@@ -168,7 +181,7 @@ func build(ctx BuildContext, spec *definition) (*core.DAG, error) {
 	}
 
 	if len(errs) > 0 {
-		if ctx.opts.AllowBuildErrors {
+		if ctx.opts.Has(BuildFlagAllowBuildErrors) {
 			// If we are allowing build errors, return the core.DAG with the errors.
 			dag.BuildErrors = errs
 		} else {
@@ -362,7 +375,7 @@ func buildRegistryAuths(ctx BuildContext, spec *definition, dag *core.DAG) error
 	case string:
 		// Handle as a JSON string (e.g., from environment variable)
 		expandedJSON := v
-		if !ctx.opts.NoEval {
+		if !ctx.opts.Has(BuildFlagNoEval) {
 			expandedJSON = os.ExpandEnv(v)
 		}
 
@@ -380,7 +393,7 @@ func buildRegistryAuths(ctx BuildContext, spec *definition, dag *core.DAG) error
 			switch auth := authData.(type) {
 			case string:
 				// Simple string value - treat as JSON auth config
-				if !ctx.opts.NoEval {
+				if !ctx.opts.Has(BuildFlagNoEval) {
 					auth = os.ExpandEnv(auth)
 				}
 				authConfig.Auth = auth
@@ -389,21 +402,21 @@ func buildRegistryAuths(ctx BuildContext, spec *definition, dag *core.DAG) error
 				// Auth config object with username/password fields
 				if username, ok := auth["username"].(string); ok {
 					authConfig.Username = username
-					if !ctx.opts.NoEval {
+					if !ctx.opts.Has(BuildFlagNoEval) {
 						authConfig.Username = os.ExpandEnv(authConfig.Username)
 					}
 				}
 
 				if password, ok := auth["password"].(string); ok {
 					authConfig.Password = password
-					if !ctx.opts.NoEval {
+					if !ctx.opts.Has(BuildFlagNoEval) {
 						authConfig.Password = os.ExpandEnv(authConfig.Password)
 					}
 				}
 
 				if authStr, ok := auth["auth"].(string); ok {
 					authConfig.Auth = authStr
-					if !ctx.opts.NoEval {
+					if !ctx.opts.Has(BuildFlagNoEval) {
 						authConfig.Auth = os.ExpandEnv(authConfig.Auth)
 					}
 				}
@@ -425,7 +438,7 @@ func buildRegistryAuths(ctx BuildContext, spec *definition, dag *core.DAG) error
 			switch auth := authData.(type) {
 			case string:
 				// Simple string value - treat as JSON auth config
-				if !ctx.opts.NoEval {
+				if !ctx.opts.Has(BuildFlagNoEval) {
 					auth = os.ExpandEnv(auth)
 				}
 				authConfig.Auth = auth
@@ -434,21 +447,21 @@ func buildRegistryAuths(ctx BuildContext, spec *definition, dag *core.DAG) error
 				// Auth config object with username/password fields
 				if username, ok := auth["username"].(string); ok {
 					authConfig.Username = username
-					if !ctx.opts.NoEval {
+					if !ctx.opts.Has(BuildFlagNoEval) {
 						authConfig.Username = os.ExpandEnv(authConfig.Username)
 					}
 				}
 
 				if password, ok := auth["password"].(string); ok {
 					authConfig.Password = password
-					if !ctx.opts.NoEval {
+					if !ctx.opts.Has(BuildFlagNoEval) {
 						authConfig.Password = os.ExpandEnv(authConfig.Password)
 					}
 				}
 
 				if authStr, ok := auth["auth"].(string); ok {
 					authConfig.Auth = authStr
-					if !ctx.opts.NoEval {
+					if !ctx.opts.Has(BuildFlagNoEval) {
 						authConfig.Auth = os.ExpandEnv(authConfig.Auth)
 					}
 				}
@@ -457,21 +470,21 @@ func buildRegistryAuths(ctx BuildContext, spec *definition, dag *core.DAG) error
 				// Handle nested YAML map type
 				if username, ok := auth["username"].(string); ok {
 					authConfig.Username = username
-					if !ctx.opts.NoEval {
+					if !ctx.opts.Has(BuildFlagNoEval) {
 						authConfig.Username = os.ExpandEnv(authConfig.Username)
 					}
 				}
 
 				if password, ok := auth["password"].(string); ok {
 					authConfig.Password = password
-					if !ctx.opts.NoEval {
+					if !ctx.opts.Has(BuildFlagNoEval) {
 						authConfig.Password = os.ExpandEnv(authConfig.Password)
 					}
 				}
 
 				if authStr, ok := auth["auth"].(string); ok {
 					authConfig.Auth = authStr
-					if !ctx.opts.NoEval {
+					if !ctx.opts.Has(BuildFlagNoEval) {
 						authConfig.Auth = os.ExpandEnv(authConfig.Auth)
 					}
 				}
@@ -508,7 +521,7 @@ func buildDotenv(ctx BuildContext, spec *definition, dag *core.DAG) error {
 		return core.NewValidationError("dotenv", v, ErrDotEnvMustBeStringOrArray)
 	}
 
-	if !ctx.opts.NoEval {
+	if !ctx.opts.Has(BuildFlagNoEval) {
 		dag.LoadDotEnv(ctx.ctx)
 	}
 
@@ -558,7 +571,7 @@ func buildWorkingDir(ctx BuildContext, spec *definition, dag *core.DAG) error {
 	switch {
 	case spec.WorkingDir != "":
 		wd := spec.WorkingDir
-		if !ctx.opts.NoEval {
+		if !ctx.opts.Has(BuildFlagNoEval) {
 			wd = os.ExpandEnv(wd)
 			_ = os.MkdirAll(wd, 0755)
 			if err := os.Chdir(wd); err != nil {
@@ -1421,9 +1434,8 @@ func buildStepEnvs(ctx StepBuildContext, def stepDef, step *core.Step) error {
 	if def.Env == nil {
 		return nil
 	}
-	// For step environment variables, we load them without evaluation. They will
-	// be evaluated later when the step is executed.
-	ctx.opts.NoEval = true
+	// For step environment variables, load without evaluation; runtime expands later.
+	ctx.opts.Flags |= BuildFlagNoEval
 	vars, err := loadVariables(ctx.BuildContext, def.Env)
 	if err != nil {
 		return err
@@ -1486,7 +1498,7 @@ func buildSubDAG(ctx StepBuildContext, def stepDef, step *core.Step) error {
 	if def.Params != nil {
 		// Parse the params to convert them to string format
 		ctxCopy := ctx
-		ctxCopy.opts.NoEval = true // Disable evaluation for params parsing
+		ctxCopy.opts.Flags |= BuildFlagNoEval // Disable evaluation for params parsing
 		paramPairs, err := parseParamValue(ctxCopy.BuildContext, def.Params)
 		if err != nil {
 			return core.NewValidationError("params", def.Params, err)
diff --git a/internal/core/spec/loader.go b/internal/core/spec/loader.go
index d7c53607..70d22768 100644
--- a/internal/core/spec/loader.go
+++ b/internal/core/spec/loader.go
@@ -27,14 +27,12 @@ var (
 
 // LoadOptions contains options for loading a DAG.
 type LoadOptions struct {
-	name             string   // Name of the DAG.
-	baseConfig       string   // Path to the base core.DAG configuration file.
-	params           string   // Parameters to override default parameters in the DAG.
-	paramsList       []string // List of parameters to override default parameters in the DAG.
-	noEval           bool     // Flag to disable evaluation of dynamic fields.
-	onlyMetadata     bool     // Flag to load only metadata without full core.DAG details.
-	dagsDir          string   // Directory containing the core.DAG files.
-	allowBuildErrors bool     // Flag to allow build errors.
+	name       string   // Name of the DAG.
+	baseConfig string   // Path to the base core.DAG configuration file.
+	params     string   // Parameters to override default parameters in the DAG.
+	paramsList []string // List of parameters to override default parameters in the DAG.
+	flags      BuildFlag
+	dagsDir    string // Directory containing the core.DAG files.
 }
 
 // LoadOption is a function type for setting LoadOptions.
@@ -64,14 +62,14 @@ func WithParams(params any) LoadOption {
 // WithoutEval disables the evaluation of dynamic fields.
 func WithoutEval() LoadOption {
 	return func(o *LoadOptions) {
-		o.noEval = true
+		o.flags |= BuildFlagNoEval
 	}
 }
 
 // OnlyMetadata sets the flag to load only metadata.
 func OnlyMetadata() LoadOption {
 	return func(o *LoadOptions) {
-		o.onlyMetadata = true
+		o.flags |= BuildFlagOnlyMetadata
 	}
 }
 
@@ -100,7 +98,14 @@ func WithDAGsDir(dagsDir string) LoadOption {
 // and will not fail the loading process.
 func WithAllowBuildErrors() LoadOption {
 	return func(o *LoadOptions) {
-		o.allowBuildErrors = true
+		o.flags |= BuildFlagAllowBuildErrors
+	}
+}
+
+// SkipSchemaValidation disables schema resolution/validation during build.
+func SkipSchemaValidation() LoadOption {
+	return func(o *LoadOptions) {
+		o.flags |= BuildFlagSkipSchemaValidation
 	}
 }
 
@@ -130,14 +135,12 @@ func Load(ctx context.Context, nameOrPath string, opts ...LoadOption) (*core.DAG
 	buildContext := BuildContext{
 		ctx: ctx,
 		opts: BuildOpts{
-			Base:             options.baseConfig,
-			Parameters:       options.params,
-			ParametersList:   options.paramsList,
-			OnlyMetadata:     options.onlyMetadata,
-			NoEval:           options.noEval,
-			Name:             options.name,
-			DAGsDir:          options.dagsDir,
-			AllowBuildErrors: options.allowBuildErrors,
+			Base:           options.baseConfig,
+			Parameters:     options.params,
+			ParametersList: options.paramsList,
+			Name:           options.name,
+			DAGsDir:        options.dagsDir,
+			Flags:          options.flags,
 		},
 	}
 	return loadDAG(buildContext, nameOrPath)
@@ -150,14 +153,12 @@ func LoadYAML(ctx context.Context, data []byte, opts ...LoadOption) (*core.DAG,
 		opt(&options)
 	}
 	return LoadYAMLWithOpts(ctx, data, BuildOpts{
-		Base:             options.baseConfig,
-		Parameters:       options.params,
-		ParametersList:   options.paramsList,
-		OnlyMetadata:     options.onlyMetadata,
-		NoEval:           options.noEval,
-		Name:             options.name,
-		DAGsDir:          options.dagsDir,
-		AllowBuildErrors: options.allowBuildErrors,
+		Base:           options.baseConfig,
+		Parameters:     options.params,
+		ParametersList: options.paramsList,
+		Name:           options.name,
+		DAGsDir:        options.dagsDir,
+		Flags:          options.flags,
 	})
 }
 
@@ -165,7 +166,7 @@ func LoadYAML(ctx context.Context, data []byte, opts ...LoadOption) (*core.DAG,
 func LoadYAMLWithOpts(ctx context.Context, data []byte, opts BuildOpts) (*core.DAG, error) {
 	raw, err := unmarshalData(data)
 	if err != nil {
-		if opts.AllowBuildErrors {
+		if opts.Has(BuildFlagAllowBuildErrors) {
 			// Return a minimal core.DAG with the error recorded
 			return &core.DAG{
 				Name:        opts.Name,
@@ -177,7 +178,7 @@ func LoadYAMLWithOpts(ctx context.Context, data []byte, opts BuildOpts) (*core.D
 
 	def, err := decode(raw)
 	if err != nil {
-		if opts.AllowBuildErrors {
+		if opts.Has(BuildFlagAllowBuildErrors) {
 			// Return a minimal core.DAG with the error recorded
 			return &core.DAG{
 				Name:        opts.Name,
@@ -210,7 +211,7 @@ func LoadBaseConfig(ctx BuildContext, file string) (*core.DAG, error) {
 		return nil, core.ErrorList{err}
 	}
 
-	ctx = ctx.WithOpts(BuildOpts{NoEval: ctx.opts.NoEval}).WithFile(file)
+	ctx = ctx.WithOpts(BuildOpts{Flags: ctx.opts.Flags}).WithFile(file)
 	dag, err := build(ctx, def)
 
 	if err != nil {
@@ -230,7 +231,7 @@ func loadDAG(ctx BuildContext, nameOrPath string) (*core.DAG, error) {
 
 	// Load base config definition if specified
 	var baseDef *definition
-	if !ctx.opts.OnlyMetadata && ctx.opts.Base != "" && fileutil.FileExists(ctx.opts.Base) {
+	if !ctx.opts.Has(BuildFlagOnlyMetadata) && ctx.opts.Base != "" && fileutil.FileExists(ctx.opts.Base) {
 		raw, err := readYAMLFile(ctx.opts.Base)
 		if err != nil {
 			return nil, fmt.Errorf("failed to read base config: %w", err)
@@ -244,7 +245,7 @@ func loadDAG(ctx BuildContext, nameOrPath string) (*core.DAG, error) {
 	// Load all DAGs from the file
 	dags, err := loadDAGsFromFile(ctx, filePath, baseDef)
 	if err != nil {
-		if ctx.opts.AllowBuildErrors {
+		if ctx.opts.Has(BuildFlagAllowBuildErrors) {
 			// Return a minimal core.DAG with the error recorded
 			dag := &core.DAG{
 				Name:        defaultName(filePath),
diff --git a/internal/core/spec/params.go b/internal/core/spec/params.go
index accd625f..8070f435 100644
--- a/internal/core/spec/params.go
+++ b/internal/core/spec/params.go
@@ -8,6 +8,7 @@ import (
 	"net/url"
 	"os"
 	"os/exec"
+	"path/filepath"
 	"regexp"
 	"sort"
 	"strconv"
@@ -62,14 +63,17 @@ func buildParams(ctx BuildContext, spec *definition, dag *core.DAG) error {
 		overrideParams(&paramPairs, overridePairs)
 	}
 
-	// Validate the parameters against the provided schema, if it exists
-	schemaRef := extractSchemaReference(spec.Params)
-	if schemaRef != "" {
-		updatedPairs, err := validateParams(paramPairs, schemaRef)
-		if err != nil {
-			return err
+	// Validate the parameters against a resolved schema (if declared)
+	if !ctx.opts.Has(BuildFlagSkipSchemaValidation) {
+		if resolvedSchema, err := resolveSchemaFromParams(spec.Params, spec.WorkingDir, dag.Location); err != nil {
+			return fmt.Errorf("failed to get JSON schema: %w", err)
+		} else if resolvedSchema != nil {
+			updatedPairs, err := validateParams(paramPairs, resolvedSchema)
+			if err != nil {
+				return err
+			}
+			paramPairs = updatedPairs
 		}
-		paramPairs = updatedPairs
 	}
 
 	for _, paramPair := range paramPairs {
@@ -81,12 +85,7 @@ func buildParams(ctx BuildContext, spec *definition, dag *core.DAG) error {
 	return nil
 }
 
-func validateParams(paramPairs []paramPair, schemaRef string) ([]paramPair, error) {
-	schema, err := getSchemaFromRef(schemaRef)
-	if err != nil {
-		return nil, fmt.Errorf("failed to get JSON schema: %w", err)
-	}
-
+func validateParams(paramPairs []paramPair, schema *jsonschema.Resolved) ([]paramPair, error) {
 	// Convert paramPairs to a map for validation
 	paramMap := make(map[string]any)
 	for _, pair := range paramPairs {
@@ -129,7 +128,7 @@ func validateParams(paramPairs []paramPair, schemaRef string) ([]paramPair, erro
 }
 
 // Schema Ref can be a local file (relative or absolute paths), or a remote URL
-func getSchemaFromRef(schemaRef string) (*jsonschema.Resolved, error) {
+func getSchemaFromRef(workingDir string, dagLocation string, schemaRef string) (*jsonschema.Resolved, error) {
 	var schemaData []byte
 	var err error
 
@@ -137,7 +136,7 @@ func getSchemaFromRef(schemaRef string) (*jsonschema.Resolved, error) {
 	if strings.HasPrefix(schemaRef, "http://") || strings.HasPrefix(schemaRef, "https://") {
 		schemaData, err = loadSchemaFromURL(schemaRef)
 	} else {
-		schemaData, err = loadSchemaFromFile(schemaRef)
+		schemaData, err = loadSchemaFromFile(workingDir, dagLocation, schemaRef)
 	}
 
 	if err != nil {
@@ -196,15 +195,64 @@ func loadSchemaFromURL(schemaURL string) (data []byte, err error) {
 }
 
 // loadSchemaFromFile loads a JSON schema from a file path.
-func loadSchemaFromFile(filePath string) ([]byte, error) {
-	// Resolve the path (handles relative paths, env vars, tilde expansion)
-	resolvedPath, err := fileutil.ResolvePath(filePath)
-	if err != nil {
-		return nil, fmt.Errorf("failed to resolve schema file path: %w", err)
+func loadSchemaFromFile(workingDir string, dagLocation string, filePath string) ([]byte, error) {
+	// Try to resolve the schema file path in the following order:
+	// 1) Current working directory (default ResolvePath behavior)
+	// 2) DAG's workingDir value
+	// 3) Directory of the DAG file (where it was loaded from)
+
+	var tried []string
+
+	// Attempts a candidate by joining base and filePath (if base provided),
+	// resolving env/tilde + absolute path, checking existence, and reading.
+	tryCandidate := func(label, base string) ([]byte, string, error) {
+		var candidate string
+		if strings.TrimSpace(base) == "" {
+			candidate = filePath
+		} else {
+			candidate = filepath.Join(base, filePath)
+		}
+		resolved, err := fileutil.ResolvePath(candidate)
+		if err != nil {
+			tried = append(tried, fmt.Sprintf("%s: resolve error: %v", label, err))
+			return nil, "", err
+		}
+		if !fileutil.FileExists(resolved) {
+			tried = append(tried, fmt.Sprintf("%s: %s", label, resolved))
+			return nil, resolved, os.ErrNotExist
+		}
+		data, err := os.ReadFile(resolved) // #nosec G304 - validated path
+		if err != nil {
+			tried = append(tried, fmt.Sprintf("%s: %s (read error: %v)", label, resolved, err))
+			return nil, resolved, err
+		}
+		return data, resolved, nil
+	}
+
+	// 1) As provided (CWD/env/tilde expansion handled by ResolvePath)
+	if data, _, err := tryCandidate("cwd", ""); err == nil {
+		return data, nil
+	}
+
+	// 2) From DAG's workingDir value if present
+	if wd := strings.TrimSpace(workingDir); wd != "" {
+		if data, _, err := tryCandidate(fmt.Sprintf("workingDir(%s)", wd), wd); err == nil {
+			return data, nil
+		}
+	}
+
+	// 3) From the directory of the DAG file used to build
+	if dagLocation != "" {
+		base := filepath.Dir(dagLocation)
+		if data, _, err := tryCandidate(fmt.Sprintf("dagDir(%s)", base), base); err == nil {
+			return data, nil
+		}
 	}
 
-	// #nosec G304 - File path is validated by ResolvePath
-	return os.ReadFile(resolvedPath)
+	if len(tried) == 0 {
+		return nil, fmt.Errorf("failed to resolve schema file path: %s (no candidates)", filePath)
+	}
+	return nil, fmt.Errorf("schema file not found for %q; tried %s", filePath, strings.Join(tried, ", "))
 }
 
 func overrideParams(paramPairs *[]paramPair, override []paramPair) {
@@ -247,7 +295,7 @@ func parseParams(ctx BuildContext, value any, params *[]paramPair, envs *[]strin
 	accumulatedVars := make(map[string]string)
 
 	for index, paramPair := range paramPairs {
-		if !ctx.opts.NoEval {
+		if !ctx.opts.Has(BuildFlagNoEval) {
 			evaluated, err := evalParamValue(ctx, paramPair.Value, accumulatedVars)
 			if err != nil {
 				return core.NewValidationError("params", paramPair.Value, fmt.Errorf("%w: %s", ErrInvalidParamValue, err))
@@ -267,7 +315,7 @@ func parseParams(ctx BuildContext, value any, params *[]paramPair, envs *[]strin
 			accumulatedVars[paramPair.Name] = paramPair.Value
 		}
 
-		if !ctx.opts.NoEval && paramPair.Name != "" {
+		if !ctx.opts.Has(BuildFlagNoEval) && paramPair.Name != "" {
 			*envs = append(*envs, paramString)
 		}
 
@@ -410,7 +458,7 @@ func parseStringParams(ctx BuildContext, input string) ([]paramPair, error) {
 				value = strings.ReplaceAll(value, `\"`, `"`)
 			}
 
-			if !ctx.opts.NoEval {
+			if !ctx.opts.Has(BuildFlagNoEval) {
 				// Perform backtick command substitution
 				backtickRegex := regexp.MustCompile("`[^`]*`")
 
@@ -488,6 +536,16 @@ func extractSchemaReference(params any) string {
 	return schemaRefStr
 }
 
+// resolveSchemaFromParams extracts a schema reference from params and resolves it.
+// Returns (nil, nil) if no schema is declared.
+func resolveSchemaFromParams(params any, workingDir, dagLocation string) (*jsonschema.Resolved, error) {
+	schemaRef := extractSchemaReference(params)
+	if schemaRef == "" {
+		return nil, nil
+	}
+	return getSchemaFromRef(workingDir, dagLocation, schemaRef)
+}
+
 // buildStepParams parses the params field in the step definition.
 // Params are converted to map[string]string and stored in step.Params
 func buildStepParams(ctx StepBuildContext, def stepDef, step *core.Step) error {
diff --git a/internal/core/spec/variables.go b/internal/core/spec/variables.go
index c8bcacf3..2899d53c 100644
--- a/internal/core/spec/variables.go
+++ b/internal/core/spec/variables.go
@@ -56,7 +56,7 @@ func loadVariables(ctx BuildContext, strVariables any) (
 	for _, pair := range pairs {
 		value := pair.val
 
-		if !ctx.opts.NoEval {
+		if !ctx.opts.Has(BuildFlagNoEval) {
 			// Evaluate the value of the environment variable.
 			// This also executes command substitution.
 			// Pass accumulated vars so ${VAR} can reference previously defined vars
diff --git a/internal/persistence/filedag/store.go b/internal/persistence/filedag/store.go
index 264226f6..dc29b3ac 100644
--- a/internal/persistence/filedag/store.go
+++ b/internal/persistence/filedag/store.go
@@ -112,10 +112,18 @@ func (store *Storage) GetMetadata(ctx context.Context, name string) (*core.DAG,
 		return nil, fmt.Errorf("failed to locate DAG %s in search paths (%v): %w", name, store.searchPaths, err)
 	}
 	if store.fileCache == nil {
-		return spec.Load(ctx, filePath, spec.OnlyMetadata(), spec.WithoutEval())
+		return spec.Load(ctx, filePath,
+			spec.OnlyMetadata(),
+			spec.WithoutEval(),
+			spec.SkipSchemaValidation(),
+		)
 	}
 	return store.fileCache.LoadLatest(filePath, func() (*core.DAG, error) {
-		return spec.Load(ctx, filePath, spec.OnlyMetadata(), spec.WithoutEval())
+		return spec.Load(ctx, filePath,
+			spec.OnlyMetadata(),
+			spec.WithoutEval(),
+			spec.SkipSchemaValidation(),
+		)
 	})
 }
 
@@ -276,7 +284,12 @@ func (store *Storage) List(ctx context.Context, opts execution.ListDAGsOptions)
 		// Read the file and parse the DAG.
 		// Use WithAllowBuildErrors to include DAGs with errors in the list
 		filePath := filepath.Join(store.baseDir, entry.Name())
-		dag, err := spec.Load(ctx, filePath, spec.OnlyMetadata(), spec.WithoutEval(), spec.WithAllowBuildErrors())
+		dag, err := spec.Load(ctx, filePath,
+			spec.OnlyMetadata(),
+			spec.WithoutEval(),
+			spec.SkipSchemaValidation(),
+			spec.WithAllowBuildErrors(),
+		)
 		if err != nil {
 			// If it completely fails to load, skip it
 			errList = append(errList, fmt.Sprintf("reading %s failed: %s", dagName, err))
@@ -406,7 +419,11 @@ func (store *Storage) Grep(ctx context.Context, pattern string) (
 				errs = append(errs, fmt.Sprintf("grep %s failed: %s", entry.Name(), err))
 				continue
 			}
-			dag, err := spec.Load(ctx, filePath, spec.OnlyMetadata(), spec.WithoutEval())
+			dag, err := spec.Load(ctx, filePath,
+				spec.OnlyMetadata(),
+				spec.WithoutEval(),
+				spec.SkipSchemaValidation(),
+			)
 			if err != nil {
 				errs = append(errs, fmt.Sprintf("check %s failed: %s", entry.Name(), err))
 				continue
diff --git a/internal/service/scheduler/entryreader.go b/internal/service/scheduler/entryreader.go
index c7935442..e30598b3 100644
--- a/internal/service/scheduler/entryreader.go
+++ b/internal/service/scheduler/entryreader.go
@@ -133,7 +133,13 @@ func (er *entryReaderImpl) initialize(ctx context.Context) error {
 	var dags []string
 	for _, fi := range fis {
 		if fileutil.IsYAMLFile(fi.Name()) {
-			dag, err := spec.Load(ctx, filepath.Join(er.targetDir, fi.Name()), spec.OnlyMetadata(), spec.WithoutEval())
+			dag, err := spec.Load(
+				ctx,
+				filepath.Join(er.targetDir, fi.Name()),
+				spec.OnlyMetadata(),
+				spec.WithoutEval(),
+				spec.SkipSchemaValidation(),
+			)
 			if err != nil {
 				logger.Error(ctx, "DAG load failed", "err", err, "name", fi.Name())
 				continue
@@ -177,7 +183,13 @@ func (er *entryReaderImpl) watchDags(ctx context.Context, done chan any) {
 			er.lock.Lock()
 			if event.Op == fsnotify.Create || event.Op == fsnotify.Write {
 				filePath := filepath.Join(er.targetDir, filepath.Base(event.Name))
-				dag, err := spec.Load(ctx, filePath, spec.OnlyMetadata(), spec.WithoutEval())
+				dag, err := spec.Load(
+					ctx,
+					filePath,
+					spec.OnlyMetadata(),
+					spec.WithoutEval(),
+					spec.SkipSchemaValidation(),
+				)
 				if err != nil {
 					logger.Error(ctx, "DAG load failed", "err", err, "file", event.Name)
 				} else {



Test patch:
diff --git a/internal/core/spec/builder_test.go b/internal/core/spec/builder_test.go
index 95a1a00d..112b494a 100644
--- a/internal/core/spec/builder_test.go
+++ b/internal/core/spec/builder_test.go
@@ -129,7 +129,7 @@ params:
   - BASE: ${SOURCE_ID}
   - PREFIX: ${BASE:0:5}
 `)
-		dag, err := spec.LoadYAMLWithOpts(context.Background(), data, spec.BuildOpts{NoEval: true})
+		dag, err := spec.LoadYAMLWithOpts(context.Background(), data, spec.BuildOpts{Flags: spec.BuildFlagNoEval})
 		require.NoError(t, err)
 
 		th := DAG{t: t, DAG: dag}
@@ -225,6 +225,140 @@ params:
 		require.Contains(t, th.Params, "batch_size=50")
 		require.Contains(t, th.Params, "environment=prod")
 	})
+
+	// Relative path resolution: workingDir, DAG dir, and CWD precedence
+	t.Run("ParamsSchemaResolutionFromWorkingDir", func(t *testing.T) {
+		// Schema with a default to prove it was loaded
+		schemaContent := `{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "type": "object",
+  "properties": {
+    "batch_size": {"type": "integer", "default": 42}
+  }
+}`
+
+		wd := t.TempDir()
+		wdSchema := filepath.Join(wd, "schema.json")
+		require.NoError(t, os.WriteFile(wdSchema, []byte(schemaContent), 0600))
+
+		origWD, err := os.Getwd()
+		require.NoError(t, err)
+		t.Cleanup(func() {
+			if err := os.Chdir(origWD); err != nil {
+				t.Fatalf("failed to restore working directory: %v", err)
+			}
+		})
+
+		data := []byte(fmt.Sprintf(`
+workingDir: %s
+params:
+  schema: "schema.json"
+  values:
+    environment: "dev"
+`, wd))
+
+		dag, err := spec.LoadYAML(context.Background(), data)
+		require.NoError(t, err)
+		th := DAG{t: t, DAG: dag}
+
+		// batch_size should be filled by schema default from workingDir schema
+		require.Contains(t, th.Params, "batch_size=42")
+	})
+
+	t.Run("ParamsSchemaResolutionFromDAGDir", func(t *testing.T) {
+		// Schema with different default to detect which file is used
+		schemaContent := `{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "type": "object",
+  "properties": {
+    "batch_size": {"type": "integer", "default": 7}
+  }
+}`
+
+		dir := t.TempDir()
+		// Write schema.json in same dir as DAG file
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "schema.json"), []byte(schemaContent), 0600))
+
+		dagYaml := []byte(`
+params:
+  schema: "schema.json"
+  values:
+    environment: "staging"
+`)
+		dagPath := filepath.Join(dir, "dag.yaml")
+		require.NoError(t, os.WriteFile(dagPath, dagYaml, 0600))
+
+		dag, err := spec.Load(context.Background(), dagPath)
+		require.NoError(t, err)
+		th := DAG{t: t, DAG: dag}
+
+		// batch_size default should come from schema.json next to DAG file
+		require.Contains(t, th.Params, "batch_size=7")
+	})
+
+	t.Run("ParamsSchemaResolutionPrefersCWDOverWorkingDir", func(t *testing.T) {
+		// Prepare two schemas with different defaults
+		cwdSchemaContent := `{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "type": "object",
+  "properties": {
+    "batch_size": {"type": "integer", "default": 99}
+  }
+}`
+		wdSchemaContent := `{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "type": "object",
+  "properties": {
+    "batch_size": {"type": "integer", "default": 11}
+  }
+}`
+
+		cwd := t.TempDir()
+		wd := t.TempDir()
+		// Write schema.json in both locations
+		require.NoError(t, os.WriteFile(filepath.Join(cwd, "schema.json"), []byte(cwdSchemaContent), 0600))
+		require.NoError(t, os.WriteFile(filepath.Join(wd, "schema.json"), []byte(wdSchemaContent), 0600))
+
+		// Change process CWD for this subtest and restore after
+		orig, err := os.Getwd()
+		require.NoError(t, err)
+		require.NoError(t, os.Chdir(cwd))
+		defer os.Chdir(orig)
+
+		data := []byte(fmt.Sprintf(`
+workingDir: %s
+params:
+  schema: "schema.json"
+  values:
+    environment: "dev"
+`, wd))
+
+		dag, err := spec.LoadYAML(context.Background(), data)
+		require.NoError(t, err)
+		th := DAG{t: t, DAG: dag}
+
+		// Should prefer schema.json from CWD (default 99) over workingDir (default 11)
+		require.Contains(t, th.Params, "batch_size=99")
+	})
+
+	t.Run("ParamsSkipSchemaValidationFlag", func(t *testing.T) {
+		data := []byte(`
+params:
+  schema: "missing-schema.json"
+  values:
+    foo: "bar"
+`)
+		_, err := spec.LoadYAML(context.Background(), data)
+		require.Error(t, err)
+
+		dag, err := spec.LoadYAMLWithOpts(context.Background(), data, spec.BuildOpts{
+			Flags: spec.BuildFlagSkipSchemaValidation,
+		})
+		require.NoError(t, err)
+
+		th := DAG{t: t, DAG: dag}
+		th.AssertParam(t, "foo=bar")
+	})
 	t.Run("ParamsWithSchemaAndOverrideValidation", func(t *testing.T) {
 		schemaContent := `{
   "$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -3264,7 +3398,7 @@ steps:
   - echo hello
 `, tempDir)
 
-		dag, err := spec.LoadYAMLWithOpts(context.Background(), []byte(yaml), spec.BuildOpts{NoEval: true})
+		dag, err := spec.LoadYAMLWithOpts(context.Background(), []byte(yaml), spec.BuildOpts{Flags: spec.BuildFlagNoEval})
 		require.NoError(t, err)
 		require.NotNil(t, dag)
 
@@ -3293,7 +3427,7 @@ env:
 steps:
   - echo hello
 `
-		dag, err := spec.LoadYAMLWithOpts(context.Background(), []byte(yaml), spec.BuildOpts{NoEval: true})
+		dag, err := spec.LoadYAMLWithOpts(context.Background(), []byte(yaml), spec.BuildOpts{Flags: spec.BuildFlagNoEval})
 		require.NoError(t, err)
 		require.NotNil(t, dag)