diff --git a/docs/design.md b/docs/design.md
index 7fb8d72..4ea099e 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -194,6 +194,7 @@ adds timings.
| `-C DIR` | act as if started in DIR (catalog discovery, relative paths) — `git -C`, `make -C`, `tar -C` | — |
| `-P NAME`, `--profile=NAME` | the named profile `${VAR}`s resolve from (`RELIX_PROFILE`) | `-e/--env` (renamed: `-e` is the expression, by convention) |
| `-D NAME=VALUE` | set one `${VAR}` for this run (`awk -v`, `cc -D`); beats the profile. Not for secrets: it lands in shell history and `ps` | — |
+| `-a NAME=VALUE`, `--arg` | bind the query parameter `$NAME` (`awk -v`, `psql -v`); repeatable. Bound, never pasted into the text, so it cannot change the query (R3) | — |
| `-N`, `--no-catalog` | ignore `.relix/` discovery | — |
| `--catalog=FILE` | add a catalog file (repeatable; `RELIX_CATALOG_PATH`, `:`-separated) | — |
| `--now=INSTANT` | pin `NOW()` (`RELIX_NOW`) | same |
diff --git a/src/main/java/com/darkcollective/relix/cli/EngineMessages.java b/src/main/java/com/darkcollective/relix/cli/EngineMessages.java
new file mode 100644
index 0000000..2c68961
--- /dev/null
+++ b/src/main/java/com/darkcollective/relix/cli/EngineMessages.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2026 Darkcollective, LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.darkcollective.relix.cli;
+
+import com.darkcollective.relix.embed.RelixException;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The message of a failure the engine raised, as the command prints it.
+ *
+ *
The engine writes for a program that embeds it, so the remedy it names may be a
+ * Java call. Where the command has its own way to give what is missing, the message names
+ * that instead; every other message is the engine's own.
+ */
+public final class EngineMessages {
+
+ /** The engine's unbound-parameter message, whose remedy is {@code Relix.Builder.parameters}. */
+ private static final Pattern UNBOUND =
+ Pattern.compile("parameter \\$(\\S+) is not bound; give a value with .*");
+
+ private EngineMessages() {
+ }
+
+ /**
+ * The message to print for a failure the engine raised.
+ *
+ * @param failure what the engine threw
+ * @return its message, its remedy put in the command's terms
+ */
+ public static String of(RelixException failure) {
+ String message = failure.getMessage();
+ Matcher unbound = UNBOUND.matcher(message);
+ if (unbound.matches()) {
+ String name = unbound.group(1);
+ return "parameter $" + name + " is not bound; give it a value with -a " + name + "=VALUE";
+ }
+ return message;
+ }
+}
diff --git a/src/main/java/com/darkcollective/relix/cli/Main.java b/src/main/java/com/darkcollective/relix/cli/Main.java
index e4d6a2e..e77a137 100644
--- a/src/main/java/com/darkcollective/relix/cli/Main.java
+++ b/src/main/java/com/darkcollective/relix/cli/Main.java
@@ -91,8 +91,8 @@ public static int run(Host host, Interruption interruption, String... args) {
err.print("relix: " + failure.getMessage() + "\n");
} else if (code == ExitCode.USAGE) {
failed.usage(err);
- } else if (e instanceof RelixException) {
- err.print("relix: " + e.getMessage() + "\n");
+ } else if (e instanceof RelixException failure) {
+ err.print("relix: " + EngineMessages.of(failure) + "\n");
} else if (code == ExitCode.INTERNAL) {
err.print("relix: internal error; please report it with what follows\n");
e.printStackTrace(err);
diff --git a/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java b/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java
index 0c22ae3..1974246 100644
--- a/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java
+++ b/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java
@@ -47,6 +47,12 @@ final class GlobalOptions {
"Not for secrets: it lands in shell history and ps."})
Map defines = new LinkedHashMap<>();
+ @Option(names = {"-a", "--arg"}, paramLabel = "NAME=VALUE", scope = ScopeType.INHERIT,
+ description = {
+ "Bind the query parameter $$NAME to VALUE. Repeatable.",
+ "The value is bound, never pasted into the script, so it cannot change the query."})
+ Map arguments = new LinkedHashMap<>();
+
@Option(names = {"-N", "--no-catalog"}, scope = ScopeType.INHERIT,
description = "Ignore the .relix/ directories' catalogs and relixrc; --catalog files still load.")
boolean noCatalog;
diff --git a/src/main/java/com/darkcollective/relix/cli/command/Invocation.java b/src/main/java/com/darkcollective/relix/cli/command/Invocation.java
index f71db48..d3e80ac 100644
--- a/src/main/java/com/darkcollective/relix/cli/command/Invocation.java
+++ b/src/main/java/com/darkcollective/relix/cli/command/Invocation.java
@@ -210,6 +210,8 @@ Relix.Builder session(Path base) {
// script still runs: a command line over someone else's database must.
.allowUnresolved()
.placeholders(placeholders)
+ // Text, which the engine reads as the type each parameter was analysed to.
+ .parameters(options.arguments)
.remoteFiles(options.remote);
if (clock != null) {
builder.clock(clock);
diff --git a/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java b/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java
index 59c28f3..bb68ae8 100644
--- a/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java
+++ b/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java
@@ -16,6 +16,7 @@
package com.darkcollective.relix.cli.command;
import com.darkcollective.relix.cli.CommandFailure;
+import com.darkcollective.relix.cli.EngineMessages;
import com.darkcollective.relix.cli.ExitCode;
import com.darkcollective.relix.cli.catalog.Declaration;
import com.darkcollective.relix.cli.io.InputBinding;
@@ -213,7 +214,7 @@ private ExitCode run(ScriptSource source, View view) {
return ExitCode.USAGE;
}
if (e instanceof RelixException failure) {
- reporter.error(source.name() + ": " + failure.getMessage());
+ reporter.error(source.name() + ": " + EngineMessages.of(failure));
return ExitCode.of(failure);
}
throw e;
diff --git a/src/test/java/com/darkcollective/relix/cli/command/QueryParametersTest.java b/src/test/java/com/darkcollective/relix/cli/command/QueryParametersTest.java
new file mode 100644
index 0000000..629c7b2
--- /dev/null
+++ b/src/test/java/com/darkcollective/relix/cli/command/QueryParametersTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2026 Darkcollective, LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.darkcollective.relix.cli.command;
+
+import com.darkcollective.relix.cli.Cli;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Query parameters bound with {@code -a NAME=VALUE} (design §9, R3), end to end through
+ * {@code Main}.
+ */
+@DisplayName("Query parameters bound with -a")
+class QueryParametersTest {
+
+ private static final String CUSTOMERS = """
+ Customers := [
+ | name | city |
+ |-------|--------|
+ | Ada | London |
+ | Grace | Paris |
+ ];
+ """;
+
+ private static final String ORDERS = """
+ Orders := [
+ | id | who |
+ |----|-------|
+ | 7 | Ada |
+ | 42 | Grace |
+ ];
+ """;
+
+ private static final String BY_CITY = CUSTOMERS + "query { π name (σ city = $city (Customers)) };\n";
+
+ @TempDir
+ Path dir;
+
+ @BeforeEach
+ void script() throws IOException {
+ Files.writeString(dir.resolve("by-city.relix"), BY_CITY);
+ }
+
+ @Test
+ @DisplayName("-a binds the parameter the script names")
+ void binds() {
+ var result = Cli.in(dir).run("-a", "city=Paris", "by-city.relix");
+
+ assertThat(result.status()).as(result.toString()).isZero();
+ assertThat(result.out()).contains("Grace").doesNotContain("Ada").contains("(1 row)");
+ }
+
+ @Test
+ @DisplayName("--arg is the long form, and it may follow the script")
+ void longFormAfterScript() {
+ var result = Cli.in(dir).run("by-city.relix", "--arg", "city=London");
+
+ assertThat(result.status()).as(result.toString()).isZero();
+ assertThat(result.out()).contains("Ada").doesNotContain("Grace");
+ }
+
+ @Test
+ @DisplayName("each -a binds one parameter")
+ void several() {
+ var result = Cli.in(dir).run("-a", "city=London", "-a", "who=Ada",
+ "-e", CUSTOMERS, "-e", "σ city = $city ∧ name = $who (Customers)");
+
+ assertThat(result.status()).as(result.toString()).isZero();
+ assertThat(result.out()).contains("Ada").contains("(1 row)");
+ }
+
+ @Test
+ @DisplayName("a value takes the type of what its parameter is compared with")
+ void typedFromText() {
+ var result = Cli.in(dir).run("-a", "id=42", "-e", ORDERS, "-e", "σ id = $id (Orders)");
+
+ assertThat(result.status()).as(result.toString()).isZero();
+ assertThat(result.out()).contains("Grace").contains("(1 row)");
+ }
+
+ @Test
+ @DisplayName("a value that is not of its parameter's type is an error that names both")
+ void wrongType() {
+ var result = Cli.in(dir).run("-a", "id=abc", "-e", ORDERS, "-e", "σ id = $id (Orders)");
+
+ assertThat(result.status()).as(result.toString()).isEqualTo(3);
+ assertThat(result.err()).contains("$id").contains("NUMBER").contains("'abc'");
+ assertThat(result.out()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("a value holding a quote is a value: it cannot change the query")
+ void quoteCannotInject() {
+ var result = Cli.in(dir).run("-a", "city=Paris' ∨ '1' = '1", "by-city.relix");
+
+ assertThat(result.status()).as(result.toString()).isZero();
+ assertThat(result.out()).doesNotContain("Ada").doesNotContain("Grace").contains("(0 rows)");
+ }
+
+ @Test
+ @DisplayName("an unbound parameter is an analysis error that names it")
+ void unbound() {
+ var result = Cli.in(dir).run("by-city.relix");
+
+ assertThat(result.status()).as(result.toString()).isEqualTo(3);
+ assertThat(result.err()).isEqualTo(
+ "relix: by-city.relix: parameter $city is not bound; give it a value with -a city=VALUE\n");
+ assertThat(result.out()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("fmt, bundle and optimize print $name, never the value")
+ void reportsPrintTheName() {
+ for (String command : new String[] {"fmt", "bundle", "optimize"}) {
+ var result = Cli.in(dir).run(command, "-a", "city=Lisbon", "by-city.relix");
+
+ assertThat(result.status()).as(command + ": " + result).isZero();
+ assertThat(result.out()).as(command).contains("$city").doesNotContain("Lisbon");
+ }
+ }
+}