diff --git a/.claude/rules/workflow.md b/.claude/rules/workflow.md
index ed783c8..026e83c 100644
--- a/.claude/rules/workflow.md
+++ b/.claude/rules/workflow.md
@@ -10,4 +10,5 @@
- Merge by squash so each feature arrives as a single logical commit on the default branch, consistent with the "one logical change per commit" rule above; the squash commit message keeps the imperative, *why*-focused form.
- Versioning follows semantic versioning: each phase gets its own minor version (Phase 1 → `0.1.x`, Phase 2 → `0.2.x`, …; Phase 6 ships `1.0.0`).
- When a phase completes, tag it on the default branch with an annotated tag (e.g., `git tag -a v0.1.0 -m "..."`) and push the tag to GitHub for reference.
+- Each roadmap milestone has a matching GitHub milestone (created per phase); the milestone's PR is associated on creation and the milestone closed on merge.
- Update this file when a new convention or correction is established.
diff --git a/.editorconfig b/.editorconfig
index 95cb50f..577e68b 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -95,3 +95,8 @@ dotnet_naming_rule.locals_and_parameters_should_be_camel_case.style = camel_case
dotnet_naming_symbols.locals_and_parameters_symbol.applicable_kinds = parameter, local
dotnet_naming_style.camel_case_style.capitalization = camel_case
+
+# Test methods follow testing.md's Should_ExpectedOutcome_When_Scenario convention instead of
+# the Async suffix rule above, even when they are themselves async.
+[test/**/*.cs]
+dotnet_naming_rule.async_methods_should_be_async_suffixed.severity = none
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/src/SqlBound/SqlParameters.cs b/src/SqlBound/SqlParameters.cs
new file mode 100644
index 0000000..1573857
--- /dev/null
+++ b/src/SqlBound/SqlParameters.cs
@@ -0,0 +1,61 @@
+namespace SqlBound;
+
+///
+/// An immutable, named set of SQL parameter values. Null values are normalized to
+/// so callers can bind them to an ADO.NET parameter without a
+/// separate null check.
+///
+public sealed class SqlParameters
+{
+ private readonly KeyValuePair[] _items;
+
+ ///
+ /// Initializes a new instance with the given named parameter values. Names must be
+ /// non-empty and unique.
+ ///
+ ///
+ /// A parameter name is null, empty, whitespace, or duplicated.
+ ///
+ public SqlParameters(params (string Name, object? Value)[] parameters)
+ {
+ ArgumentNullException.ThrowIfNull(parameters);
+
+ var items = new KeyValuePair[parameters.Length];
+ var seenNames = new HashSet(StringComparer.Ordinal);
+
+ for (var i = 0; i < parameters.Length; i++)
+ {
+ var (name, value) = parameters[i];
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentException("Parameter name must not be null or whitespace.", nameof(parameters));
+ }
+
+ if (!seenNames.Add(name))
+ {
+ throw new ArgumentException($"Duplicate parameter name '{name}'.", nameof(parameters));
+ }
+
+ items[i] = new KeyValuePair(name, value ?? DBNull.Value);
+ }
+
+ _items = items;
+ }
+
+ ///
+ /// An empty parameter set.
+ ///
+ public static SqlParameters Empty { get; } = new();
+
+ ///
+ /// The number of parameters in this set.
+ ///
+ public int Count => _items.Length;
+
+ ///
+ /// The parameters, in the order they were given. Values are never null; a null
+ /// value provided at construction is normalized to .
+ ///
+ public IReadOnlyList> Items => _items;
+}
diff --git a/src/SqlBound/SqlSession.cs b/src/SqlBound/SqlSession.cs
new file mode 100644
index 0000000..f895a07
--- /dev/null
+++ b/src/SqlBound/SqlSession.cs
@@ -0,0 +1,125 @@
+using System.Data;
+using System.Data.Common;
+
+namespace SqlBound;
+
+///
+/// Executes SQL against an already-open . SqlSession never opens,
+/// closes, or otherwise owns the connection or its lifecycle — that responsibility always
+/// stays with the caller, so the same connection (and transaction) can be shared safely with
+/// other data-access libraries.
+///
+public sealed class SqlSession
+{
+ private readonly DbConnection _connection;
+ private readonly DbTransaction? _transaction;
+
+ ///
+ /// Initializes a new session over the given connection and, optionally, an existing
+ /// transaction to enlist every command in.
+ ///
+ public SqlSession(DbConnection connection, DbTransaction? transaction = null)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+
+ _connection = connection;
+ _transaction = transaction;
+ }
+
+ ///
+ /// Executes a non-query statement and returns the number of affected rows.
+ ///
+ /// is null or whitespace.
+ /// The connection is not open.
+ public async Task RunAsync(
+ string sql,
+ SqlParameters? parameters = null,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateAndPrepare(sql, cancellationToken);
+
+ using var command = CreateCommand(sql, parameters);
+ return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Executes a statement and returns its first column, first row as .
+ /// A database null converts to when can
+ /// represent it (a reference type or ); otherwise it throws.
+ ///
+ /// is null or whitespace.
+ ///
+ /// The connection is not open, or the result is a database null and
+ /// is a non-nullable value type.
+ ///
+ public async Task FetchScalarAsync(
+ string sql,
+ SqlParameters? parameters = null,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateAndPrepare(sql, cancellationToken);
+
+ using var command = CreateCommand(sql, parameters);
+ var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+
+ return ConvertScalar(result);
+ }
+
+ private static T? ConvertScalar(object? result)
+ {
+ if (result is null or DBNull)
+ {
+ if (default(T) is not null)
+ {
+ throw new InvalidOperationException(
+ $"The query returned no value, but '{typeof(T)}' is a non-nullable value type.");
+ }
+
+ return default;
+ }
+
+ if (result is T typed)
+ {
+ return typed;
+ }
+
+ var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
+ return (T)Convert.ChangeType(result, targetType);
+ }
+
+ private void ValidateAndPrepare(string sql, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(sql);
+ cancellationToken.ThrowIfCancellationRequested();
+ EnsureConnectionOpen();
+ }
+
+ private void EnsureConnectionOpen()
+ {
+ if (_connection.State != ConnectionState.Open)
+ {
+ throw new InvalidOperationException(
+ $"The connection must be open before executing a command. Current state: {_connection.State}.");
+ }
+ }
+
+ private DbCommand CreateCommand(string sql, SqlParameters? parameters)
+ {
+ var command = _connection.CreateCommand();
+ command.CommandText = sql;
+ command.Transaction = _transaction;
+
+ if (parameters is not null)
+ {
+ foreach (var (name, value) in parameters.Items)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = name;
+ parameter.Value = value;
+ command.Parameters.Add(parameter);
+ }
+ }
+
+ return command;
+ }
+}
diff --git a/test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs b/test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs
new file mode 100644
index 0000000..24675ed
--- /dev/null
+++ b/test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs
@@ -0,0 +1,102 @@
+using System.Data;
+using System.Data.Common;
+using System.Diagnostics.CodeAnalysis;
+
+namespace SqlBound.UnitTests.Fakes;
+
+internal sealed class FakeDbCommand : DbCommand
+{
+ private readonly FakeDbParameterCollection _parameters = new();
+
+ [AllowNull]
+ public override string CommandText { get; set; } = string.Empty;
+
+ public override int CommandTimeout { get; set; }
+
+ public override CommandType CommandType { get; set; } = CommandType.Text;
+
+ public override bool DesignTimeVisible { get; set; }
+
+ public override UpdateRowSource UpdatedRowSource { get; set; }
+
+ public int ExecuteNonQueryResult { get; set; }
+
+ public object? ExecuteScalarResult { get; set; }
+
+ public Exception? ExceptionToThrow { get; set; }
+
+ public CancellationToken ReceivedCancellationToken { get; private set; }
+
+ protected override DbConnection? DbConnection { get; set; }
+
+ protected override DbParameterCollection DbParameterCollection => _parameters;
+
+ protected override DbTransaction? DbTransaction { get; set; }
+
+ public override void Cancel()
+ {
+ }
+
+ public override int ExecuteNonQuery()
+ {
+ if (ExceptionToThrow is not null)
+ {
+ throw ExceptionToThrow;
+ }
+
+ return ExecuteNonQueryResult;
+ }
+
+ public override Task ExecuteNonQueryAsync(CancellationToken cancellationToken)
+ {
+ ReceivedCancellationToken = cancellationToken;
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return Task.FromCanceled(cancellationToken);
+ }
+
+ if (ExceptionToThrow is not null)
+ {
+ return Task.FromException(ExceptionToThrow);
+ }
+
+ return Task.FromResult(ExecuteNonQueryResult);
+ }
+
+ public override object? ExecuteScalar()
+ {
+ if (ExceptionToThrow is not null)
+ {
+ throw ExceptionToThrow;
+ }
+
+ return ExecuteScalarResult;
+ }
+
+ public override Task