feat(builder): expose ConfigOptions.set/get as setOption / setOptions / getOption (#49)
diff --git a/core/src/main/java/org/apache/datafusion/SessionContext.java b/core/src/main/java/org/apache/datafusion/SessionContext.java
index 3cea058..1b2a075 100644
--- a/core/src/main/java/org/apache/datafusion/SessionContext.java
+++ b/core/src/main/java/org/apache/datafusion/SessionContext.java
@@ -115,6 +115,34 @@
     }
   }
 
+  /**
+   * Read the current value of a {@code datafusion.*} config key. The key must be one DataFusion
+   * recognises (see {@link SessionContextBuilder#setOption(String, String)} for examples and the
+   * upstream configuration reference for the full list).
+   *
+   * <p>{@code datafusion.runtime.*} keys (memory limit, temp directory, cache sizes, etc) are not
+   * yet supported by this getter and will throw. Use the typed {@link
+   * SessionContextBuilder#memoryLimit(long, double)} and {@link
+   * SessionContextBuilder#tempDirectory(String)} setters at construction time instead. Round-trip
+   * support for the runtime subtree is tracked as a follow-up.
+   *
+   * @return the current value as a string, or {@code null} if the key is recognised but has no
+   *     value set and no default.
+   * @throws IllegalArgumentException if {@code key} is {@code null}.
+   * @throws RuntimeException if the key is not recognised by DataFusion or is in the {@code
+   *     datafusion.runtime.*} subtree.
+   * @throws IllegalStateException if this context is closed.
+   */
+  public String getOption(String key) {
+    if (nativeHandle == 0) {
+      throw new IllegalStateException("SessionContext is closed");
+    }
+    if (key == null) {
+      throw new IllegalArgumentException("getOption key must be non-null");
+    }
+    return getOptionNative(nativeHandle, key);
+  }
+
   public void registerCsv(String name, String path) {
     registerCsv(name, path, new CsvReadOptions());
   }
@@ -235,6 +263,8 @@
 
   private static native byte[] tableSchemaIpc(long handle, String tableName);
 
+  private static native String getOptionNative(long handle, String key);
+
   private static native void registerParquetWithOptions(
       long handle, String name, String path, byte[] optionsBytes, byte[] schemaIpcBytes);
 
diff --git a/core/src/main/java/org/apache/datafusion/SessionContextBuilder.java b/core/src/main/java/org/apache/datafusion/SessionContextBuilder.java
index beabb5f..f34e24f 100644
--- a/core/src/main/java/org/apache/datafusion/SessionContextBuilder.java
+++ b/core/src/main/java/org/apache/datafusion/SessionContextBuilder.java
@@ -19,6 +19,10 @@
 
 package org.apache.datafusion;
 
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.datafusion.protobuf.ConfigOption;
 import org.apache.datafusion.protobuf.MemoryLimit;
 import org.apache.datafusion.protobuf.SessionOptions;
 
@@ -34,6 +38,7 @@
   private Long memoryLimitBytes;
   private Double memoryLimitFraction;
   private String tempDirectory;
+  private final LinkedHashMap<String, String> options = new LinkedHashMap<>();
 
   SessionContextBuilder() {}
 
@@ -86,6 +91,84 @@
   }
 
   /**
+   * Set an arbitrary {@code datafusion.*} config option by string key. Mirrors DataFusion's {@code
+   * ConfigOptions::set(key, value)} API — see the DataFusion configuration reference for the full
+   * set of keys. Entries set this way are applied <strong>after</strong> the typed setters on this
+   * builder, so an explicit {@code setOption} call overrides a typed setter for the same knob.
+   *
+   * <p>{@code datafusion.runtime.*} keys (memory limit, temp directory, cache sizes, etc) are not
+   * yet supported by this setter and will throw at {@link #build()}. Use the typed {@link
+   * #memoryLimit(long, double)} and {@link #tempDirectory(String)} setters instead. Round-trip
+   * support for the runtime subtree is tracked as a follow-up.
+   *
+   * <p>Unknown keys or unparseable values are not validated here; they surface as a {@link
+   * RuntimeException} from {@link #build()} carrying DataFusion's error message.
+   *
+   * @throws IllegalArgumentException if {@code key} or {@code value} is {@code null}.
+   */
+  public SessionContextBuilder setOption(String key, String value) {
+    if (key == null) {
+      throw new IllegalArgumentException("setOption key must be non-null");
+    }
+    if (value == null) {
+      throw new IllegalArgumentException("setOption value must be non-null");
+    }
+    // remove-then-put so a re-issued key moves to the end of the iteration
+    // order. LinkedHashMap.put on an existing key updates the value but keeps
+    // the original slot, which would re-order the wire-format relative to
+    // intervening keys. For overlapping side-effect keys (e.g.
+    // datafusion.optimizer.enable_dynamic_filter_pushdown rewrites the
+    // per-operator enable_*_dynamic_filter_pushdown flags) the umbrella key
+    // could otherwise apply *after* a later override of one of its
+    // sub-flags, silently undoing the override.
+    this.options.remove(key);
+    this.options.put(key, value);
+    return this;
+  }
+
+  /**
+   * Apply every entry of {@code entries} via {@link #setOption(String, String)}, in {@link
+   * LinkedHashMap} insertion order. Use this overload when you need the strict last-write-wins
+   * ordering guarantee for overlapping side-effect keys (e.g. {@code
+   * datafusion.optimizer.enable_dynamic_filter_pushdown} rewrites the per-operator {@code
+   * enable_*_dynamic_filter_pushdown} flags, so a per-operator override must come after the
+   * umbrella).
+   *
+   * @throws IllegalArgumentException if any key or value in {@code entries} is {@code null}.
+   */
+  public SessionContextBuilder setOptions(LinkedHashMap<String, String> entries) {
+    if (entries == null) {
+      throw new IllegalArgumentException("setOptions entries must be non-null");
+    }
+    for (Map.Entry<String, String> e : entries.entrySet()) {
+      setOption(e.getKey(), e.getValue());
+    }
+    return this;
+  }
+
+  /**
+   * Apply every entry of {@code entries} via {@link #setOption(String, String)}. Iterates in
+   * whatever order the supplied {@link Map} produces, which for {@link java.util.HashMap} or {@link
+   * java.util.Properties} is unspecified.
+   *
+   * <p>This is the right overload for the common case where the caller's keys don't overlap with
+   * any upstream setter's side effects. If you do need order — see the {@link
+   * #setOptions(LinkedHashMap)} overload, which the compiler will resolve to automatically when you
+   * pass a {@code LinkedHashMap}.
+   *
+   * @throws IllegalArgumentException if any key or value in {@code entries} is {@code null}.
+   */
+  public SessionContextBuilder setOptions(Map<String, String> entries) {
+    if (entries == null) {
+      throw new IllegalArgumentException("setOptions entries must be non-null");
+    }
+    for (Map.Entry<String, String> e : entries.entrySet()) {
+      setOption(e.getKey(), e.getValue());
+    }
+    return this;
+  }
+
+  /**
    * Construct a {@link SessionContext} with the configured options.
    *
    * @throws RuntimeException if the native side fails to construct the context.
@@ -118,6 +201,9 @@
     if (tempDirectory != null) {
       b.setTempDirectory(tempDirectory);
     }
+    for (Map.Entry<String, String> e : options.entrySet()) {
+      b.addOptions(ConfigOption.newBuilder().setKey(e.getKey()).setValue(e.getValue()).build());
+    }
     return b.build().toByteArray();
   }
 }
diff --git a/core/src/test/java/org/apache/datafusion/SessionContextBuilderTest.java b/core/src/test/java/org/apache/datafusion/SessionContextBuilderTest.java
index ed6cff2..27693b3 100644
--- a/core/src/test/java/org/apache/datafusion/SessionContextBuilderTest.java
+++ b/core/src/test/java/org/apache/datafusion/SessionContextBuilderTest.java
@@ -25,10 +25,14 @@
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
 
 import org.apache.arrow.memory.BufferAllocator;
 import org.apache.arrow.memory.RootAllocator;
 import org.apache.arrow.vector.ipc.ArrowReader;
+import org.apache.datafusion.protobuf.ConfigOption;
 import org.apache.datafusion.protobuf.SessionOptions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
@@ -113,4 +117,234 @@
       assertEquals(1, reader.getVectorSchemaRoot().getRowCount());
     }
   }
+
+  @Test
+  void setOptionRoundTripsThroughProto() throws Exception {
+    byte[] bytes =
+        SessionContext.builder()
+            .setOption("datafusion.execution.parquet.pushdown_filters", "true")
+            .setOption("datafusion.optimizer.prefer_hash_join", "true")
+            .toBytes();
+    SessionOptions parsed = SessionOptions.parseFrom(bytes);
+    List<ConfigOption> opts = parsed.getOptionsList();
+    assertEquals(2, opts.size());
+    assertEquals("datafusion.execution.parquet.pushdown_filters", opts.get(0).getKey());
+    assertEquals("true", opts.get(0).getValue());
+    assertEquals("datafusion.optimizer.prefer_hash_join", opts.get(1).getKey());
+    assertEquals("true", opts.get(1).getValue());
+  }
+
+  @Test
+  void setOptionsBulkRoundTripsThroughProto() throws Exception {
+    Map<String, String> entries = new LinkedHashMap<>();
+    entries.put("datafusion.execution.time_zone", "UTC");
+    entries.put("datafusion.optimizer.default_filter_selectivity", "10");
+    byte[] bytes = SessionContext.builder().setOptions(entries).toBytes();
+    SessionOptions parsed = SessionOptions.parseFrom(bytes);
+    List<ConfigOption> opts = parsed.getOptionsList();
+    assertEquals(2, opts.size());
+    assertEquals("datafusion.execution.time_zone", opts.get(0).getKey());
+    assertEquals("UTC", opts.get(0).getValue());
+    assertEquals("datafusion.optimizer.default_filter_selectivity", opts.get(1).getKey());
+    assertEquals("10", opts.get(1).getValue());
+  }
+
+  @Test
+  void setOptionRejectsNullKeyOrValue() {
+    SessionContextBuilder b = SessionContext.builder();
+    assertThrows(IllegalArgumentException.class, () -> b.setOption(null, "v"));
+    assertThrows(IllegalArgumentException.class, () -> b.setOption("k", null));
+    assertThrows(IllegalArgumentException.class, () -> b.setOptions(null));
+  }
+
+  @Test
+  void setOptionAffectsRunningSession() {
+    try (SessionContext ctx =
+        SessionContext.builder()
+            .setOption("datafusion.execution.parquet.pushdown_filters", "true")
+            .build()) {
+      assertEquals("true", ctx.getOption("datafusion.execution.parquet.pushdown_filters"));
+    }
+  }
+
+  @Test
+  void setOptionLastWriteWinsWithinMap() throws Exception {
+    byte[] bytes =
+        SessionContext.builder()
+            .setOption("datafusion.execution.batch_size", "1024")
+            .setOption("datafusion.execution.batch_size", "8192")
+            .toBytes();
+    SessionOptions parsed = SessionOptions.parseFrom(bytes);
+    // setOption dedups by key (remove-then-put), so only one entry reaches
+    // the wire — the most-recently-written value, at the position of the
+    // most recent setOption call.
+    List<ConfigOption> opts = parsed.getOptionsList();
+    assertEquals(1, opts.size());
+    assertEquals("datafusion.execution.batch_size", opts.get(0).getKey());
+    assertEquals("8192", opts.get(0).getValue());
+  }
+
+  @Test
+  void setOptionOverridesTypedSetterAtConstruction() {
+    // Typed setter says batch_size = 8192; setOption overrides to 1024.
+    // The override is a real semantic change, not just absence of a throw —
+    // assert that's what DataFusion is actually running with.
+    try (SessionContext ctx =
+        SessionContext.builder()
+            .batchSize(8192)
+            .setOption("datafusion.execution.batch_size", "1024")
+            .build()) {
+      assertEquals("1024", ctx.getOption("datafusion.execution.batch_size"));
+    }
+  }
+
+  @Test
+  void unknownOptionKeyThrowsAtBuild() {
+    SessionContextBuilder b =
+        SessionContext.builder().setOption("datafusion.execution.bogus_made_up_key", "x");
+    RuntimeException thrown = assertThrows(RuntimeException.class, b::build);
+    // The DataFusion error message includes the bad key text — assert it's
+    // surfaced rather than swallowed.
+    assertTrue(
+        thrown.getMessage() != null && thrown.getMessage().contains("bogus_made_up_key"),
+        "expected DataFusion error to mention bad key, got: " + thrown.getMessage());
+  }
+
+  @Test
+  void getOptionReadsTypedSetterValue() {
+    try (SessionContext ctx = SessionContext.builder().batchSize(4096).build()) {
+      assertEquals("4096", ctx.getOption("datafusion.execution.batch_size"));
+    }
+  }
+
+  @Test
+  void getOptionWithNoExplicitValueReturnsDefault() {
+    // batch_size has a non-null default in DataFusion, so the getter must
+    // return the default rather than null when the user never set it.
+    try (SessionContext ctx = SessionContext.builder().build()) {
+      String batchSize = ctx.getOption("datafusion.execution.batch_size");
+      assertTrue(
+          batchSize != null && !batchSize.isEmpty(),
+          "expected default batch_size, got: " + batchSize);
+    }
+  }
+
+  @Test
+  void getOptionRejectsNullKey() {
+    try (SessionContext ctx = SessionContext.builder().build()) {
+      assertThrows(IllegalArgumentException.class, () -> ctx.getOption(null));
+    }
+  }
+
+  @Test
+  void getOptionThrowsOnUnknownKey() {
+    try (SessionContext ctx = SessionContext.builder().build()) {
+      RuntimeException thrown =
+          assertThrows(RuntimeException.class, () -> ctx.getOption("datafusion.no.such.key"));
+      assertTrue(
+          thrown.getMessage() != null && thrown.getMessage().contains("no.such.key"),
+          "expected error to mention bad key, got: " + thrown.getMessage());
+    }
+  }
+
+  @Test
+  void getOptionThrowsAfterClose() {
+    SessionContext ctx = SessionContext.builder().build();
+    ctx.close();
+    assertThrows(
+        IllegalStateException.class, () -> ctx.getOption("datafusion.execution.batch_size"));
+  }
+
+  @Test
+  void setOptionAppliesInInsertionOrder() throws Exception {
+    // Some upstream setters have side effects on other keys. For example,
+    // setting `datafusion.optimizer.enable_dynamic_filter_pushdown` rewrites
+    // the per-operator `enable_*_dynamic_filter_pushdown` flags. The caller's
+    // last write therefore must win. Pin the on-the-wire order; the Rust side
+    // applies in this order and DataFusion's per-key setter sees the umbrella
+    // first, the override second.
+    byte[] bytes =
+        SessionContext.builder()
+            .setOption("datafusion.optimizer.enable_dynamic_filter_pushdown", "true")
+            .setOption("datafusion.optimizer.enable_topk_dynamic_filter_pushdown", "false")
+            .toBytes();
+    SessionOptions parsed = SessionOptions.parseFrom(bytes);
+    List<ConfigOption> opts = parsed.getOptionsList();
+    assertEquals(2, opts.size());
+    assertEquals("datafusion.optimizer.enable_dynamic_filter_pushdown", opts.get(0).getKey());
+    assertEquals("datafusion.optimizer.enable_topk_dynamic_filter_pushdown", opts.get(1).getKey());
+
+    // And on the running session, the topk override wins over the umbrella.
+    try (SessionContext ctx =
+        SessionContext.builder()
+            .setOption("datafusion.optimizer.enable_dynamic_filter_pushdown", "true")
+            .setOption("datafusion.optimizer.enable_topk_dynamic_filter_pushdown", "false")
+            .build()) {
+      assertEquals(
+          "false", ctx.getOption("datafusion.optimizer.enable_topk_dynamic_filter_pushdown"));
+      assertEquals("true", ctx.getOption("datafusion.optimizer.enable_dynamic_filter_pushdown"));
+    }
+  }
+
+  @Test
+  void setOptionRepeatedKeyMovesToEndOfIterationOrder() throws Exception {
+    // Codex regression: a LinkedHashMap.put on an existing key updates the
+    // value but keeps the *original* slot. If the caller writes
+    //   topk=false  →  umbrella=true  →  topk=false (re-write)
+    // the re-write of topk would otherwise stay at position 0, putting the
+    // umbrella *after* it on the wire. The umbrella's setter has the side
+    // effect of resetting topk back to true, silently undoing the override.
+    // Pin both the wire-level order and the running-session behaviour.
+    byte[] bytes =
+        SessionContext.builder()
+            .setOption("datafusion.optimizer.enable_topk_dynamic_filter_pushdown", "false")
+            .setOption("datafusion.optimizer.enable_dynamic_filter_pushdown", "true")
+            .setOption("datafusion.optimizer.enable_topk_dynamic_filter_pushdown", "false")
+            .toBytes();
+    SessionOptions parsed = SessionOptions.parseFrom(bytes);
+    List<ConfigOption> opts = parsed.getOptionsList();
+    assertEquals(2, opts.size());
+    // Umbrella applies first; the re-issued topk override is last.
+    assertEquals("datafusion.optimizer.enable_dynamic_filter_pushdown", opts.get(0).getKey());
+    assertEquals("datafusion.optimizer.enable_topk_dynamic_filter_pushdown", opts.get(1).getKey());
+
+    try (SessionContext ctx =
+        SessionContext.builder()
+            .setOption("datafusion.optimizer.enable_topk_dynamic_filter_pushdown", "false")
+            .setOption("datafusion.optimizer.enable_dynamic_filter_pushdown", "true")
+            .setOption("datafusion.optimizer.enable_topk_dynamic_filter_pushdown", "false")
+            .build()) {
+      assertEquals(
+          "false", ctx.getOption("datafusion.optimizer.enable_topk_dynamic_filter_pushdown"));
+    }
+  }
+
+  @Test
+  void setOptionRejectsRuntimeKeysAtBuild() {
+    // datafusion.runtime.* lives on a separate config object (RuntimeEnv) and
+    // round-tripping through getOption/setOption has subtle correctness
+    // pitfalls (lazy default-tempdir creation, integer K/M/G truncation,
+    // OS-specific path separators). Reject runtime keys with a clear pointer
+    // to the typed setters until a follow-up PR designs proper support.
+    SessionContextBuilder b =
+        SessionContext.builder().setOption("datafusion.runtime.memory_limit", "2G");
+    RuntimeException thrown = assertThrows(RuntimeException.class, b::build);
+    assertTrue(
+        thrown.getMessage() != null
+            && thrown.getMessage().contains("datafusion.runtime")
+            && thrown.getMessage().contains("memoryLimit"),
+        "expected runtime-key error to point at typed setters, got: " + thrown.getMessage());
+  }
+
+  @Test
+  void getOptionRejectsRuntimeKeys() {
+    try (SessionContext ctx = SessionContext.builder().build()) {
+      RuntimeException thrown =
+          assertThrows(
+              RuntimeException.class, () -> ctx.getOption("datafusion.runtime.memory_limit"));
+      assertTrue(
+          thrown.getMessage() != null && thrown.getMessage().contains("datafusion.runtime"),
+          "expected runtime-key error, got: " + thrown.getMessage());
+    }
+  }
 }
diff --git a/native/src/lib.rs b/native/src/lib.rs
index 08a919b..9041819 100644
--- a/native/src/lib.rs
+++ b/native/src/lib.rs
@@ -89,16 +89,46 @@
             config = config.with_information_schema(v);
         }
 
-        let mut runtime = RuntimeEnvBuilder::new();
+        let mut runtime_builder = RuntimeEnvBuilder::new();
         if let Some(mem) = opts.memory_limit {
-            runtime = runtime.with_memory_limit(mem.max_memory_bytes as usize, mem.memory_fraction);
+            runtime_builder = runtime_builder
+                .with_memory_limit(mem.max_memory_bytes as usize, mem.memory_fraction);
         }
         if let Some(dir) = opts.temp_directory {
-            runtime = runtime.with_temp_file_path(PathBuf::from(dir));
+            runtime_builder = runtime_builder.with_temp_file_path(PathBuf::from(dir));
         }
 
-        let runtime_env = runtime.build()?;
+        // datafusion.runtime.* keys live on RuntimeEnv (separate object from
+        // SessionConfig) and round-tripping them through getOption/setOption
+        // has subtle correctness pitfalls (lazy default-tempdir creation,
+        // upstream's K/M/G integer truncation, OS-specific path separators).
+        // Reject them here with a clear error so callers fall back to the
+        // typed memoryLimit() / tempDirectory() setters until a follow-up
+        // PR designs the side-cache needed to support them safely.
+        //
+        // Iteration order matters: some upstream setters have side effects on
+        // other keys (e.g. `datafusion.optimizer.enable_dynamic_filter_pushdown`
+        // also rewrites the per-operator `enable_*_dynamic_filter_pushdown`
+        // flags), so the caller's last write must win. The proto field is
+        // `repeated ConfigOption` for this reason -- prost's default
+        // `map<string,string>` decodes to a HashMap whose iteration order is
+        // randomized.
+        for opt in &opts.options {
+            if opt.key.starts_with("datafusion.runtime.") {
+                return Err(format!(
+                    "datafusion.runtime.* keys are not supported via setOption yet; \
+                     use SessionContextBuilder.memoryLimit() / .tempDirectory() instead. \
+                     Got: {} = {}",
+                    opt.key, opt.value
+                )
+                .into());
+            }
+            config.options_mut().set(&opt.key, &opt.value)?;
+        }
+
+        let runtime_env = runtime_builder.build()?;
         let ctx = SessionContext::new_with_config_rt(config, Arc::new(runtime_env));
+
         Ok(Box::into_raw(Box::new(ctx)) as jlong)
     })
 }
@@ -381,6 +411,50 @@
 }
 
 #[no_mangle]
+pub extern "system" fn Java_org_apache_datafusion_SessionContext_getOptionNative<'local>(
+    mut env: JNIEnv<'local>,
+    _class: JClass<'local>,
+    handle: jlong,
+    key: JString<'local>,
+) -> jni::sys::jstring {
+    try_unwrap_or_throw(
+        &mut env,
+        std::ptr::null_mut(),
+        |env| -> JniResult<jni::sys::jstring> {
+            if handle == 0 {
+                return Err("SessionContext handle is null".into());
+            }
+            let ctx = unsafe { &*(handle as *const SessionContext) };
+            let key: String = env.get_string(&key)?.into();
+
+            // datafusion.runtime.* keys live on RuntimeEnv and are not yet
+            // supported via this getter (see the matching restriction in
+            // createSessionContextWithOptions). Reject them with a clear
+            // pointer to the typed alternatives.
+            if key.starts_with("datafusion.runtime.") {
+                return Err(format!(
+                    "datafusion.runtime.* keys are not supported via getOption yet; \
+                     use SessionContextBuilder typed setters instead. Got: {key}"
+                )
+                .into());
+            }
+
+            let config = ctx.copied_config();
+            for entry in config.options().entries() {
+                if entry.key == key {
+                    return match entry.value {
+                        Some(v) => Ok(env.new_string(v)?.into_raw()),
+                        None => Ok(std::ptr::null_mut()),
+                    };
+                }
+            }
+
+            Err(format!("unknown DataFusion config key: {key}").into())
+        },
+    )
+}
+
+#[no_mangle]
 pub extern "system" fn Java_org_apache_datafusion_SessionContext_closeSessionContext<'local>(
     mut env: JNIEnv<'local>,
     _class: JClass<'local>,
diff --git a/proto/session_options.proto b/proto/session_options.proto
index 96342a6..2c9e629 100644
--- a/proto/session_options.proto
+++ b/proto/session_options.proto
@@ -24,6 +24,24 @@
 
 // Options used to construct a SessionContext. All fields are optional —
 // unset fields leave DataFusion's default behavior in place.
+//
+// `options` is a free-form escape hatch matching DataFusion's
+// `ConfigOptions::set(key, value)` API: any `datafusion.*` config key
+// can be set as a string. Entries are applied AFTER the typed fields
+// above (so an explicit `options` entry overrides a typed setter for
+// the same knob) and IN INSERTION ORDER (so for keys whose setters
+// have side effects on other keys -- e.g.
+// `datafusion.optimizer.enable_dynamic_filter_pushdown` rewrites the
+// specific `enable_*_dynamic_filter_pushdown` flags -- the caller's
+// last write wins deterministically).
+//
+// `repeated ConfigOption` is used instead of `map<string,string>`
+// because protobuf maps decode into `HashMap` on the Rust side, whose
+// iteration order is randomized and would otherwise break the
+// last-write-wins contract for overlapping keys.
+//
+// Unknown keys or unparseable values surface as a RuntimeException at
+// SessionContext construction.
 message SessionOptions {
   optional uint64 batch_size = 1;
   optional uint64 target_partitions = 2;
@@ -31,6 +49,12 @@
   optional bool information_schema = 4;
   optional MemoryLimit memory_limit = 5;
   optional string temp_directory = 6;
+  repeated ConfigOption options = 7;
+}
+
+message ConfigOption {
+  string key = 1;
+  string value = 2;
 }
 
 message MemoryLimit {