Skip to content

chore: scaffold standalone stackable-odbc-core repository - #1

Merged
maltesander merged 390 commits into
mainfrom
scaffolding
Aug 4, 2026
Merged

chore: scaffold standalone stackable-odbc-core repository#1
maltesander merged 390 commits into
mainfrom
scaffolding

Conversation

@maltesander

Copy link
Copy Markdown
Member

Description

Extracts the database-independent ODBC framework from the stackable-odbc-rs
monorepo into this standalone, crates.io-releasable library

Layout

  • Core crate lives at the repo root (src/, Cargo.toml); fuzz/ is a
    subdir whose path dependency points back at ...
  • Root Cargo.toml is a plain [package] with inline [lints.clippy]
    (unwrap_used / unwrap_in_result / panic = "deny") — no workspace.

Releasable to crates.io

  • Version reset to 0.0.1 (core and fuzz).
  • Added required/recommended metadata: description, repository, readme,
    keywords, categories, rust-version = 1.95.0, authors, license.

Dependency decoupling

  • Core depends only on snafu, tracing, odbc-sys, tracing-subscriber,
    tracing-appender (+ dev criterion, proptest). No driver dependencies
    (rusqlite, trino-rust-client, tokio, …) leak in — verified.
  • deny.toml trimmed: dropped the Trino-only RUSTSEC-2024-0436 ignore, the
    ring license clarify, and the Windows target; fixed the stale header.

Tooling & CI

  • pre-commit: added cargo-sort and the check-yaml / check-toml /
    check-merge-conflict / mixed-line-ending hygiene hooks; dropped
    shellcheck (no shell scripts). Manifests sorted accordingly.
  • CI reduced to unit-tests + Miri + a new cargo-fuzz smoke job. Dropped the
    SQLite integration, Windows cross-compile, and driver-specific jobs. Windows
    cross-compile removed entirely (core builds no DLL).

Docs

  • AGENTS.md partitioned to be driver-agnostic (no Trino/SQLite names);
    driver integration/stress/profiling sections removed; kept core architecture,
    crate-layout, the odbc-sys/conversion guides, Miri/fuzz, and a generic
    "adding a driver" walkthrough.
  • README.md rewritten for the standalone crate.
  • Net-new CHANGELOG.md in Keep a Changelog format.

Verification

All green: cargo fmt, clippy -D warnings, cargo test (593), cargo doc -D warnings, cargo deny, cargo sort, markdownlint + pre-commit file hooks,
Miri (584 passed, 0 failed), cargo-fuzz build + smoke (both targets,
millions of runs, no crashes/leaks).

Deferred

  • crates.io publish workflow (pending crates.io ownership decision).

@maltesander
maltesander requested review from adwk67 and lfrancke July 24, 2026 18:17
@maltesander maltesander self-assigned this Jul 24, 2026
adwk67
adwk67 previously approved these changes Jul 24, 2026
maltesander and others added 26 commits July 28, 2026 21:29
… serve real rows

Three more defaulted Backend methods, with core owning the column layout and
the sort. SQLTablePrivileges orders by PRIVILEGE before GRANTEE, so its keys
are not in ascending column order -- that is the spec, and a comment on the
constant says so.

The four rewrites left set_empty_result with no callers, so it is deleted.
Each rewritten function now logs its parsed arguments at debug, matching
SQLTablesW and SQLColumnsW rather than the bare entry log the stubs had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… functions

Every string argument of all four is an identifier under SQL_TRUE -- no
TableType-style exemption in this family -- so core normalises all of them.

The (DM) markers are not uniform, and that is the trap: all four state the
METADATA_ID null-catalog clause unmarked, so all four check it, but only
SQLColumnPrivileges states an unmarked, unconditional null-TableName sentence.
SQLTablePrivileges, SQLProcedures and SQLProcedureColumns must not check one.
Tests pin the difference in both directions, verified by mutation.

Two existing tests passed a null TableName to SQLColumnPrivilegesW and now
supply one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records what a driver author needs for the four new ones: why they default to
an empty result set rather than NotImplemented, and why only
SQLColumnPrivileges checks a null TableName.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SQLColumnPrivileges' null-TableName HY009 is the only part of the new group an
existing driver test suite can notice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SQLPrimaryKeysW, SQLForeignKeysW, SQLStatisticsW and SQLSpecialColumnsW logged
only the statement handle, so a log could show that a catalog call happened but
not what it asked for -- the one thing worth knowing when a client's metadata
query returns nothing. All ten catalog functions now follow AGENTS.md's shape:
trace! the raw inputs at entry, debug! the parsed values after.

Also corrects two comments that the new SQLColumnPrivileges check falsified:
the unmarked null-TableName HY009 clause now belongs to three catalog
functions, not two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule read as being about raw integers becoming enums, so a function whose
only parsing was UTF-16 pointers to strings looked compliant with a bare entry
log. Four catalog functions were, until the commit before this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SqlState::operation_canceled, the defaulted Backend::is_cancelled hook, and
src/cancel.rs holding the single reclassification helper the ~23 backend call
sites will use.

Only the error half is ever examined. The spec allows a cancelled execution to
finish anyway -- "it is possible for the execution to succeed and return
SQL_SUCCESS while the cancel is also successful" -- so Ok is returned untouched
and a test pins that.

All twelve mocks that implement Backend::cancel now implement is_cancelled too.
Leaving them unpaired would have made the crate's own test doubles violate the
rule the trait's doc comment states.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backend::cancel marks the token, so a token reused by the next execution stayed
marked and every later call on that statement observed a cancellation that was
not its own. The spec requires the opposite: "After the statement has been
canceled, the application can call SQLExecute or SQLExecDirect again."

The create-once rule this replaces was defending against a SQLCancel that
reaches an already-finished execution and cancels nothing. The spec states that
outcome is correct, not a bug -- "a call to SQLCancel when no processing is
being done on the statement ... has is [sic] no effect at all" -- so it was
defending the wrong property at the cost of a real one.

Two existing tests asserted create-once and are inverted rather than deleted,
each carrying the spec sentence that overturned it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tests

Models "another thread cancelled this while it was in flight" by having the
backend signal its own token from inside the call, immediately before failing.
Core sees the same state a real cross-thread cancel produces -- a failed call
whose token reads cancelled -- with none of the timing nondeterminism. The
genuinely concurrent path is proved separately.

Smoke-tested here rather than left for the entry-point tests to discover: a
broken mock should fail in one place, not twenty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SQLExecDirect, SQLPrepare, SQLExecute and SQLParamData ask the execution's
cancel token on the error path and relabel the failure as HY008. The spec's
HY008 row carries no (DM) marker on any of these pages, and its second clause
is exactly this crate's cross-thread cancel.

The Ok path is deliberately untouched: "it is possible for the execution to
succeed and return SQL_SUCCESS while the cancel is also successful."

The token comments these sites carry still described create-once, which Task 3
replaced; corrected here rather than left to mislead the next reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each catalog call asks its execution's cancel token on the error path and
relabels the failure, as the execution entry points already do.

SQLStatistics and SQLSpecialColumns needed care the other eight did not: they
convert the backend's error before matching so a NotImplemented can become the
spec's empty result set. NotImplemented there means "this backend exposes no
index metadata" -- an answer, not a failure -- so only the genuine-error arm is
reclassified. Reclassifying the whole Result would turn a spec-mandated empty
result set into HY008, and four existing tests catch that.

SQLDescribeCol and SQLColAttribute are deliberately NOT wired; see the report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These consume a cursor an earlier execution opened, so they observe that
execution's token rather than minting one. StatementBackend::fetch takes no
token and deliberately still does not: coupling that trait to
Backend::CancelToken would be a worse trade than resolving the token from the
handle registry, which is what SQLCancel already does.

Adds reclassify_cancelled_opt for the case these three have and the
statement-producing calls do not -- no backend call has run yet, so there is no
token and nothing that could have been cancelled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thread A blocks inside the backend holding the connection's group lock, thread
B calls SQLCancel, A returns HY008. Every other HY008 test simulates the
interleaving on one thread, which pins the reclassification but not the lock
behaviour -- this one needs SQLCancel to actually take its try_lock-failed
branch.

The backend's wait is bounded rather than unconditional, which is what makes
the mutation useful: replacing try_lock with lock makes SQLCancel wait for the
call it was asked to cancel, and the test then fails in 10s with a clear
message instead of hanging CI forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every one claimed the state could not arise, on one of two false grounds: that
"the Backend trait is synchronous", which says nothing about another thread
cancelling, or that it was (DM)-handled, which the spec contradicts -- the
HY008 row carries no (DM) marker on any of these pages.

Each now names which of the row's two clauses applies, in one of three shapes:
the call reclassifies (18), it is connection-level and has no token to observe
(6), or it makes no fallible backend call for a cancellation to be reported
through (8). SQLDescribeCol and SQLColAttribute get their own wording: they do
reach a fallible call, but its map_err replaces any error with 07009, so a
cancellation is indistinguishable from a bad column number by the time core
sees it.

Classifying each function rather than pattern-matching turned up one the design
missed: SQLDescribeParam makes a fallible backend call and has a token in
reach, so it is wired here rather than documented as exempt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Minting per execution made this interleaving reachable for the first time:
set_cancel used to run at most once per statement, so nothing could race a
replacement. Now every statement-producing call writes while sql_cancel reads
with no group lock, serialised only by the registry's own RwLock.

Which of the two tokens the canceller observes is deliberately not asserted --
both are correct, and the spec says so. What the model earns is that neither
thread panics, the downcast always succeeds, and the read never yields None for
a slot that has held a token throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects in the parameter path, fixed in this order because making an
unbound marker an error before hardening the scanner would have turned
working statements into errors.

`count_params` tracked single-quoted string literals and nothing else, so a
`?` inside a delimited identifier or a comment counted as a parameter marker:
`SELECT "a?b" FROM t` reported one parameter and `SELECT 1 -- huh?` reported
one too. `escape.rs` already scanned the same text correctly, so the fix is to
stop having a second scanner: its `copy_*` helpers split into `skip_*` plus a
copying wrapper, and `count_params` calls the same `skip_*` functions with the
identifier delimiters taken from the backend's `EscapeDialect`.

A marker with no binding was padded with `ColumnValue::Null`, so `WHERE x = ?`
with nothing bound ran as `WHERE x = NULL` — no rows, and `SQL_SUCCESS`. Both
execution paths now report 07002, the first clause of that row on the
`SQLExecute` and `SQLExecDirect` tables, neither of them `(DM)`-marked. The
data-at-execution scan rejects the same gap so it is not a second route to the
old behaviour. A `SQL_PARAM_OUTPUT` binding still yields `Null`: it has no
input value, and reading its uninitialised buffer would be unsound.

`read_param_value` matched on the C type alone, so `ParameterBinding::sql_type`
— `SQLBindParameter`'s `ParameterType` — was recorded and never read. For every
C type but the two character ones that lost nothing; for `SQL_C_CHAR` and
`SQL_C_WCHAR` it discarded the only statement of what the text was, and
`SQL_C_CHAR` + `SQL_NUMERIC` reached the backend as a string. The new
`param_convert` module is the spec's "C to SQL: Character" table transcribed,
with the SQLSTATE its third column gives for each outcome. Decimal literals are
carried as digits and a scale rather than through `f64`, so scale survives.

Driver-visible: a backend now receives `Decimal`, `I32`, `Timestamp` and so on
where a character binding previously produced `String`.

Verified with `pre-commit run --all-files` (15 hooks), 937 unit tests, and
Miri (927 passed, no leaks).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`text_to_sql_type` checked that character parameter data denoted the declared
type but never measured it against `SQLBindParameter`'s `ColumnSize` and
`DecimalDigits`, so `12.345` bound as `DECIMAL(10,2)` reached the backend whole
and so did a thirty-digit value. Both are 22001 on the "C to SQL: Character"
table — truncation of fractional digits, and loss of whole digits — and both
are now returned.

Two readings the spec does not supply, and the reasoning for each:

A `ColumnSize` of 0 reads as "no size declared". The spec defines no sentinel
for an unknown size, but no decimal has zero digits of precision, so 0 cannot
be a literal declaration; taking it literally would reject every value an
application that omits it ever binds. A negative `DecimalDigits` disables the
check rather than reading as 0, because it asks for a rounding to tens or
hundreds that core has none to apply.

The check validates without reshaping: trailing zeros beyond the declared scale
lose nothing and are not truncation, and the value is passed on as the
application wrote it.

Only `SQL_DECIMAL` and `SQL_NUMERIC` are checked. `ColumnSize` is a count of
mantissa bits for the approximate numerics, whose row tests range instead and
already does; for everything else the spec says plainly that "for other data
types, the ColumnSize argument is ignored", which is why the integer targets
keep the C type's own range check. Tests pin both of those directions.

The declared size for character and binary targets is still unenforced.
`text_to_sql_type`'s "Declared size" note records what is missing and why the
character row cannot be answered without a `Backend` hook: its test is in bytes
of the data source's own encoding, which core does not know.

Verified with `pre-commit run --all-files` (15 hooks), 951 unit tests, and
Miri (941 passed, no leaks).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…location

SQLSetStmtAttr's Comments make SQL_ATTR_METADATA_ID one of exactly two
attributes an application may set at the connection level. SQLSetConnectAttr
stored it and SQLGetConnectAttr read it back, but nothing else looked:
metadata_id_enabled consults the statement's own map, and a statement was
allocated with an empty one. The connection-level route therefore returned
SQL_SUCCESS, echoed the value back, and left every catalog call treating its
arguments as search patterns rather than identifiers.

A statement now starts from its connection's value, seeded at the one site
that decides a statement's initial state. Per the ODBC 2.x rule this route
inherits, it is the default for statements allocated afterwards; existing
statements are untouched and a later SQLSetStmtAttr still overrides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hem all back

The spec's 01S02 row closes the set of statement attributes a driver may
substitute for at eight: SQL_ATTR_CONCURRENCY, SQL_ATTR_CURSOR_TYPE,
SQL_ATTR_KEYSET_SIZE, SQL_ATTR_MAX_LENGTH, SQL_ATTR_MAX_ROWS,
SQL_ATTR_QUERY_TIMEOUT, SQL_ATTR_ROW_ARRAY_SIZE and SQL_ATTR_SIMULATE_CURSOR.
Each now stores the value core actually uses and posts 01S02, routed through
one substitute_stmt_attr helper so the rule has a single place to read.

Attributes off that list take HYC00 instead, since there is no substitution to
report: SQL_ATTR_USE_BOOKMARKS above SQL_UB_OFF, SQL_ATTR_RETRIEVE_DATA =
SQL_RD_OFF, SQL_ATTR_CURSOR_SENSITIVITY = SQL_SENSITIVE,
SQL_ATTR_ENABLE_AUTO_IPD = SQL_TRUE, and SQL_ATTR_ASYNC_ENABLE =
SQL_ASYNC_ENABLE_ON.

SQLGetStmtAttr gains arms for the nine attributes SQLSetStmtAttr stores but
could not report, and a test drives readability off statement_attribute_from_raw
rather than a hand-written list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SQL_ATTR_PACKET_SIZE reports HY011 once the connection is open, which its spec
entry states directly. SQL_ATTR_ASYNC_ENABLE = SQL_ASYNC_ENABLE_ON and
SQL_ATTR_ENLIST_IN_DTC report HYC00: core is synchronous and reports
SQL_AM_NONE for SQL_ASYNC_MODE, and it enlists in no distributed transaction.
Unrecognized attributes are still accepted silently for DM and tool
compatibility.

SQLGetConnectAttr gains arms for SQL_ATTR_ASYNC_ENABLE and
SQL_ATTR_TRANSLATE_OPTION, so every attribute the setter stores can be read
back, with a test enumerating the pairs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SQLSetStmtAttr stores SQL_ATTR_PARAMS_PROCESSED_PTR and
SQL_ATTR_PARAM_STATUS_PTR, and SQLExecDirect, SQLExecute and the SQLParamData
data-at-execution completion now write through them. The processed count is 1,
since SQL_ATTR_PARAMSET_SIZE is pinned at 1, and the first status element is
SQL_PARAM_SUCCESS, or SQL_PARAM_ERROR when the execution failed — written
before the error propagates.

This is the parameter-side counterpart of what SQLFetch writes through
SQL_ATTR_ROWS_FETCHED_PTR and SQL_ATTR_ROW_STATUS_PTR, and shares its
unaligned-write discipline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The classification test asks one question of every info type — does the answer
move when the backend does? — but only over default_get_info. The raw path was
unpoliced, and it is the only place several info types are ever answered:
SQL_CURSOR_ROLLBACK_BEHAVIOR has no odbc_sys::InfoType variant at all, and
SQL_TABLE_TERM, SQL_PROCEDURES and their neighbours have one but no arm in
default_get_info.

The sibling test scans the whole u16 range against the same two mocks and
sorts what it finds into two lists. RAW_PATH_CORE_FACTS holds the five core is
entitled to decide, each with its reason. RAW_PATH_GAPS holds the three that
are claims about the data source — SQL_QUOTED_IDENTIFIER_CASE,
SQL_MULTIPLE_ACTIVE_TXN and SQL_DATABASE_NAME — each naming the hook or
derivation it still needs. Those three are outstanding work, and the list can
only shrink: a new hard-coded data-source claim on this path fails the test
rather than joining them silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Core owns these column layouts, so a column added to a spec result set should
be a core-only change. #[non_exhaustive] is what makes that true: without it a
new field breaks every struct literal in every driver.

Rust rejects a struct expression for a non_exhaustive type outside its own
crate, including ..Default::default() (E0639), so each type gains one consuming
setter per column. The catalog_rows! macro generates the struct, the marker and
the setters from a single field list, so the three cannot drift apart, and
adding a column later adds a setter — an additive change.

Setters take impl Into<T> and are named after their field: an Option<String>
column accepts a bare String, a String column accepts a &str, a nullable
numeric column accepts the bare number. No positional new(): ColumnRow has
eighteen columns and ProcedureColumnRow nineteen, and an argument list would
reintroduce the ordering mistake named fields exist to prevent.

The module doctest is compiled as a separate crate, so it proves the idiom
works from outside core rather than only inside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…self

Each is a falsifiable statement about the data source that core had hard-coded:
quoted_identifier_case, txn_capable, integrity, multiple_active_txn,
special_characters, accessible_procedures, and the identity four driver_name,
driver_version, dbms_name and dbms_version.

SQL_TXN_CAPABLE is the sharpest of them. It had no arm anywhere, so it took the
shape-aware default of U16(0) — SQL_TC_NONE, "transactions not supported" —
even for a backend declaring an isolation level and implementing end_tran.
Backend::set_autocommit's own doc already referred to "a backend that reports
SQL_TC_NONE for SQL_TXN_CAPABLE", a declaration no hook exposed. A test now
pins it against txn_isolation_options in both directions.

driver_name and driver_version take no connection, so core answers the whole
pre-connect group the Windows Driver Manager asks for: SQL_DRIVER_NAME,
SQL_DRIVER_VER, SQL_DRIVER_ODBC_VER, SQL_ASYNC_DBC_FUNCTIONS and
SQL_MAX_CONCURRENT_ACTIVITIES. The AGENTS.md checklist item about overriding
get_info_pre_connect for them is replaced by two declarations the compiler asks
for.

The guard lists shrink accordingly: three entries leave CORE_FACTS, five leave
SHAPE_DEFAULT_IS_THE_ANSWER, and two of the three RAW_PATH_GAPS are closed,
leaving only SQL_DATABASE_NAME's derivation from SQL_ATTR_CURRENT_CATALOG.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec makes SQL_DATABASE_NAME a second name for SQL_ATTR_CURRENT_CATALOG,
so SQLGetInfo reads the attribute the connection already stores rather than
answering "" from a second place. It cannot live in common_get_info_raw with
the other raw-path answers, because the attribute is on the connection handle
rather than on B::Connection, so sql_get_info_w threads it through. A backend
that knows the real current database still wins: get_info_raw runs first. This
closes the last RAW_PATH_GAPS entry.

SQL_CURSOR_SENSITIVITY reports SQL_UNSPECIFIED. Insensitivity is a promise
that no other cursor's changes ever become visible, and core's fetch streams
rows as the application asks for them, so it can promise nothing about rows it
has not read. The spec puts the two either side of a conformance line, but this
describes core's cursor rather than the backend's SQL grammar, so it does not
follow Backend::sql_conformance. SQLSetStmtAttr accepts only that value now,
and SQLGetStmtAttr reports it, so the attribute and the info type agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
conformance::info_group_inconsistencies states the invariants once, in the
shared harness each driver's suite already runs against its real backend, and
returns one message per violation. Core cannot police a backend's get_info at
runtime — that method runs first and is entitled to answer anything — so this
is what makes the vendor-terminology group safe without Backend hooks: a driver
that answers SQL_CATALOG_TERM but leaves SQL_CATALOG_NAME saying "N" fails its
own tests.

Two invariants are one-directional on purpose. SQL_PROCEDURES = "Y" implies a
non-empty SQL_PROCEDURE_TERM but not the converse, because the info type is a
conjunction that includes the driver supporting {call}. An empty
SQL_SCHEMA_TERM implies SQL_SCHEMA_USAGE = 0 but not the converse, because the
SQL_SCHEMA_TERM page names an SQL_SCHEMA_NAME info type that does not exist in
sqlext.h, leaving the term as its own support signal.

observe_string_value and observe_u16_value join observe_u32_value so all three
value shapes can be read through the real entry point. Core's own answers are
asserted group-consistent, with four value assertions alongside so the
implications cannot pass vacuously on a reader that returns "" and 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adwk67 and others added 27 commits August 3, 2026 20:17
…ented

write_utf16 collected a Vec<u16> before checking whether the caller had
anywhere to write, so a pure length query built the units only to discard
them. Counted instead, with the Vec built after that branch.

translate_escapes returns brace-free input unchanged without allocating a
Vec<char> as wide as the statement or running the scanner. Setup alone goes
from 86 ns to 6 ns, before counting the scan now skipped, and it runs on
every SQLExecDirect and SQLPrepare.
text_without_braces_is_returned_unchanged pins that the fast path and the
scanner agree, which is the property worth testing.

The ten catalog row types gain a consuming into_values, measured at 0.40x
the borrowing form for a 50 000-row result set, 3.28 ms against 1.33 ms.
Implemented by inverting rather than adding: into_values is primary and
to_values delegates to self.clone().into_values(), so the ten spec column
orders are still written once. Ten hand-written into_values would have been
a second copy of an ordering an application binds by number.

logging.rs records the threat model the audit asked for, with each claim
checked: a log holds connection parameters other than credentials, which
ConnectParams redacts by hand under test, plus catalog filter arguments,
and no statement text or parameter or column values — schema-revealing
rather than data-revealing. ODBC_LOG_FILE is not a privilege boundary,
since setting it requires already running as that user. Symlinks are
followed, which leaves the world-writable-directory case operational rather
than something core can decide. Mode 0o600 applies at creation only.

CRLF-stripping for backend-originated text is left undecided and recorded
as a question in the plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 7.1's deferred Miri pass, which found three defects rather than
confirming none.

Miri runs at all only because cargo-miri's sysroot cache can be redirected
with XDG_CACHE_HOME; the development sandbox denies ~/.cache/miri, so every
earlier attempt on this branch died in "failed to build sysroot" before
interpreting anything. Two task entries that said their pointer work was
unverified under Miri are corrected: it is verified now.

First, the job had been red since 814167b.
connect_refuses_an_nts_credential_that_runs_to_the_scan_cap reaches
read_dsn_keys, and Miri cannot call the foreign SQLGetPrivateProfileStringW.
Its ServerName sibling is exempt because that scan overruns before the DSN
read; this one parses the server name first. Three neighbouring SQLConnectW
tests already carried the same ignore.

Second, the budget, which answers the question raising MAX_NTS_SCAN left
open. Eighteen boundary tests allocate a buffer of exactly the cap, and one
of them takes 392 seconds interpreted, so eighteen is about two hours
against timeout-minutes: 30. The cap is 1 << 12 under cfg(miri) rather than
those tests being ignored, which keeps what they exist for: the buffer has
no terminator, so a read one unit past it is a heap overflow Miri sees, and
that holds at any cap. All eighteen now run in 37 seconds. Two tests assert
an absolute 100 000-character input is accepted, which is false at 4096, and
are ignored with the reason recorded at the constant — grepping for symbolic
uses of MAX_NTS_SCAN missed them, and only the full run found them.

Third, a real handle leak. Six leaked allocations, all from cursor.rs's
cleanup_env_conn_stmt_for, which freed stmt, conn and env without
disconnecting. free_connection refuses a still-open connection with HY010
and frees nothing, so every test there that connected leaked its connection
handle and the HY010 message its diagnostic queue held. It predates this
work and went unnoticed because Miri was deferred.

Also fifteen MD012 violations, every one introduced by this plan's own
outcome entries plus one in the CHANGELOG. markdownlint needs a Node
environment the sandbox cannot install, so it never ran during the work —
but the rule is ten lines to implement, which was the right move at any
point instead of reporting the hook as unavailable.

Both Miri passes are now clean: 1514 pass, no leaks, no undefined
behaviour, and no alignment errors under -Zmiri-symbolic-alignment-check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prose says "Backend has 4 associated types" and the example directly
above it showed three: `CancelToken` was missing, so a driver author
following the sketch hit a compile error the surrounding text said not to
expect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec makes HY091 and HYC00 different claims, and neither row carries a
Driver-Manager marker, so both are the driver's to return:

  HY091  "was not one of the defined values and was not an
          implementation-defined value"
  HYC00  "was not supported by the driver"

Every unrecognised identifier answered HYC00, so an application could not tell
a garbage value from a valid extension core has not implemented.
SQLGetDescFieldW and SQLSetDescFieldW already drew this line through
field_from_raw; SQLColAttributeW was the outlier.

The three ODBC 2.x spellings -- SQL_COLUMN_LENGTH, SQL_COLUMN_PRECISION and
SQL_COLUMN_SCALE -- keep HYC00, because they are defined identifiers. They are
deliberately absent from desc_from_raw, which is shared with the descriptor
functions, where an ODBC 2.x column identifier names no field at all.
Supporting them outright is what the Backward Compatibility section asks of a
3.x driver and is tracked separately: their ODBC 2.x semantics differ from
their 3.x counterparts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
07009's clause naming "a column number greater than the number of columns in
the result set" carries no (DM) marker, so it is the driver's. It was
delegated to the backend, which answered whatever its error mapping produced --
usually HY000.

The recorded reason was that "a precise check would require an extra
round-trip to obtain the column count". That was false.
StatementBackend::column_count is a local accessor with no I/O behind it, and
describe_col has been range-checked against that same call all along.

get_data_with_a_huge_ordinal_is_the_backends_hy000_not_07009 existed to pin
the old behaviour "so that changing it is a decision rather than an accident".
This is that decision; the test is renamed and inverted, and a boundary test
covers the last valid column so the comparison cannot drift to >=.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SQLBindParameter's StrLen_or_IndPtr defines five negative values: SQL_NTS,
SQL_NULL_DATA, SQL_DEFAULT_PARAM, SQL_DATA_AT_EXEC and
SQL_LEN_DATA_AT_EXEC(n). Both character arms of read_param_value folded every
negative into SQL_NTS, so SQL_NO_TOTAL, -6 and -42 bound the whole
null-terminated string and answered SUCCESS -- a value the application never
asked to send, with no diagnostic.

SQLExecDirect's and SQLExecute's HY090 rows state the condition themselves and
carry no (DM) marker for it, and SQLPutData already refused the same class.

SQL_DEFAULT_PARAM is handled beside SQL_NULL_DATA at the top of the function
rather than per-arm, so the character arms can treat every remaining negative
as undefined. It resolves to NULL, which is the ruling sql_put_data's doc
comment already recorded: it names a procedure parameter's default, and
crate::escape refuses {call ...} with HYC00, so no statement core executes has
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Display Size appendix does not just give a number, it says what the number
is made of: SQL_FLOAT/SQL_DOUBLE is "24 (a sign, 15 digits, a decimal point,
the letter E, a sign, and 3 digits)" and SQL_REAL is "14 (a sign, 7 digits, a
decimal point, the letter E, a sign, and 2 digits)". Both describe an exponent
rendering, and col_attr::display_size_for already reported 24 and 14 quoting
those very sentences.

Rust's Display for floats never emits an exponent, so core promised a
24-character exponent form and produced a positional one up to 326 characters
long. Two outcomes, the second worse than the first:

  f64::MAX  309 positional digits trip the SQL to C: Numeric whole-digit
            rule, so a display-size buffer got a hard 22003 -- loud.
  4.9e-324  326 characters whose first 24 are 0.00000000000000000000000, so
            the application read zero under 01004 "truncated" -- silent, and
            not the claim 01004 makes.

The switch is conditional, so 1.5 is still "1.5": the spec fixes the size, not
the notation, and rendering every float in exponent form would satisfy the
size while changing every value an application actually sees.

Neither neighbouring driver could be read end-to-end -- MySQL Connector/ODBC
formats through my_gcvt and psqlODBC passes PostgreSQL's own float8 text
through -- but both are consistent with a conditional switch. The deciding
argument is the spec's own definition of the display size, not the survey.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It had no arm in write_column_value at all, so every value answered 07006
through both SQLGetData and SQLBindCol+SQLFetch, while the same C type worked
as an input parameter. SQL_C_NUMERIC shares the SQL to C: Numeric table's
exact-integer row with SQL_C_SLONG and SQL_C_SBIGINT -- n/a, 01S07, 22003 --
and the overview page states drivers "are required to support conversions to
all ODBC C data types from the ODBC SQL data types that they support". It is
the natural C type for a DECIMAL column.

The conversion is a rescale and a base change on the digit string, not a
numeric conversion: SQL_NUMERIC_STRUCT is +/- val x 10^-scale with val an
unsigned little-endian magnitude, which is the shape DecimalLiteral already
has, so the digits never pass through f64.

BREAKING: write_column_value and write_column_value_at take a final
NumericTarget carrying the ARD's SQL_DESC_PRECISION and SQL_DESC_SCALE. The
spec makes those the application's way of declaring the struct's layout
("SQLSetDescField is required to perform manual binding with SQL_C_NUMERIC
values"), so the conversion cannot be done from the ColumnValue alone.
NumericTarget::UNSPECIFIED means the application declared nothing and the
value describes itself; zero is not a legal precision, which is what makes it
usable as that marker. A struct rather than two i16 parameters, because two
adjacent same-typed arguments can be crossed at a call site and still compile.

fetch.rs's Binding becomes a struct for the same reason, now that it carries
seven fields.

Tests cover to_numeric_struct directly -- the sign convention (odbc-sys
documents it as "1 if positive, 0 if negative", the opposite of a sign bit),
the rescale in both directions, the 01S07 flag and its zero-digit exemption,
and the u128 boundary from both sides. Writing them found that 39 nines does
*not* fit a u128 despite u128::MAX having 39 digits, so MAX_U128_DIGITS is
only a pre-expansion guard against a pathological exponent and the real bound
is the parse.

The usual misalignment test could not be written and the reason is recorded
instead: SQL_NUMERIC_STRUCT is u8/i8/[u8; 16], so it has alignment 1 and every
address is aligned for it. The test asserts that, and fails if odbc-sys ever
widens a field.

The fuzz target threads precision and scale through as fuzzed input rather
than constants; 12.3M executions clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ColumnValue::Guid converted to SQL_C_BINARY and SQL_C_CHAR but its own C type
answered 07006. The SQL to C: GUID table gives it one row -- test "None", data
written, indicator 16, no SQLSTATE -- and there is no failure case.

The reported half of this did not survive checking. The claim was that "ODBC
defines SQL_CHAR -> SQL_C_GUID, which should be 22018 on a bad parse rather
than a blanket refusal". It does not: SQL_C_GUID appears in exactly one
conversion table, whose only source type is SQL_GUID, and the SQL to C:
Character table has no SQL_C_GUID row at all. The overview page prescribes
07006 for "an identifier for an ODBC C data type not shown in the table for a
given ODBC SQL data type", so the existing refusal is correct and a 22018
would be inventing a cell the spec does not have. A test pins that direction
too.

SQLGUID's first three groups are integers whose textual form is the big-endian
reading of the bytes, which is the order column_value_to_string already
renders. Reading them natively would byte-swap the GUID on every
little-endian machine, silently; a mutation of that line fails two tests.

Unlike SQL_NUMERIC_STRUCT, SQLGUID leads with a u32 and so has alignment 4 --
it can genuinely be misaligned, and has the usual offset-by-one test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OutConnectionString carries credentials in cleartext, and that is deliberate
rather than an oversight. The spec's own description is a "completed
connection string" and applications persist it to reconnect without prompting;
redacting it would hand back a string that no longer connects -- a silent
failure at the next startup instead of a visible one now.

Backend::sensitive_connect_keywords governs Debug redaction only, and the
split is a threat-model difference rather than an inconsistency: a log file is
written to a path the application did not choose and read by people who never
held the credential, while the echo goes back to the caller that supplied the
string. A driver author must still declare credentials there so they stay out
of logs, and they will still appear in the echo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
StatementBackend::take_value_warning, defaulted to None and drained by core
immediately after every get_data.

Core already raises 01S07 where it drops precision -- a non-zero
ColumnValue::Time fraction written to SQL_C_TYPE_TIME, or a fraction lost
reaching an exact-integer C type. A driver that loses precision inside its own
type conversion does so before a ColumnValue exists, so core never saw it: a
driver reporting decimal_digits = 12 for a timestamp(12) column and delivering
nine answered SQL_SUCCESS with no diagnostic, and could not fix it
driver-side.

Both call sites that read a value drain it -- SQLGetData and SQLFetch's
bound-column loop -- because a warning visible through only one of them is
invisible to every application that uses SQLBindCol. Deleting either drain
fails its own test.

Three points of shape:

  - Option, not Vec: get_data is the hottest path in the crate and a
    collection would allocate per column per row to carry nothing.
  - A separate method, not a widened get_data return: the Cow return exists so
    caching backends hand back a borrow without cloning.
  - take_, not get_: core calls it once per value and expects it cleared. The
    mock clears on read so a test can tell "drained per value" from "reported
    forever", and warns on one column of two so the warning is provably
    attached to the value rather than the statement.

StatementData forwards it; a synthetic result set has none, because core
builds those rows from values it already holds.

This is the first mechanism by which a backend attaches a diagnostic to a
value it produced successfully. It is not an error channel -- a returned
warning does not make the call fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/superpowers/ was checked in by mistake; plans and specs live in the
gitignored .superpowers/ tree.

scripts/ held one hook, no-duplicate-test-names.sh, wired in through
.pre-commit-config.yaml and so also run by the pre-commit CI job. Both the
script and the hook entry are removed together, so nothing references a path
that no longer exists.

Note this retires a real guard: a test function carrying two #[test]
attributes is registered twice and its neighbour, whose attribute was
absorbed, silently stops running. Both outcomes leave cargo test green, so the
registered names were the only place either showed up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ver reports

Nine changes from twelve reported items. Three reports did not survive
checking and are recorded as such rather than acted on:

  - The f64 rendering claim named the wrong symptom. Core already answered a
    hard 22003 for f64::MAX; the real defect was subnormals delivering zero
    under 01004, and the justification is the Display Size appendix defining
    SQL_DOUBLE's 24 characters as an exponent form.
  - SQL_CHAR -> SQL_C_GUID is not a defined conversion. The SQL to C:
    Character table has no SQL_C_GUID row, so the existing 07006 is correct
    and the proposed 22018 would have invented a spec cell.
  - SQLGetTypeInfo's empty result set for an invalid type is deliberate and
    left alone; changing it needs a driver survey first.

Two defects found that were not reported: SQL_COLUMN_LENGTH/PRECISION/SCALE
are absent from desc_from_raw though the spec requires a 3.x driver to
support them (now correctly HYC00, still unimplemented), and SQL_DEFAULT_PARAM
was unhandled on the bound-parameter path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ease

The three documents read as a development log rather than as documentation.
This rewrites them for a reader who has not been following the work.

CHANGELOG.md collapses from 252 KB to 8 KB. Every one of its 292 entries
measured a delta from an unpublished monorepo that no consumer ever had, and
the five migration sections described breaking changes against that same
baseline. It becomes an initial capability statement instead.

README.md addresses driver authors throughout, keeping a short ODBC primer for
a reader evaluating the crate. The testing commands and the trait sketch move
to CONTRIBUTING.md and AGENTS.md, which removes three command blocks that were
duplicated byte for byte. Its links to AGENTS.md and fuzz/README.md become
absolute, because Cargo.toml excludes both from the published tarball.

AGENTS.md is reordered so Architecture comes first rather than last, and so
Descriptors and Concurrency are top-level sections rather than filing under
Testing. The narration goes: incident reports, sentences about what an earlier
revision did, and measured runtimes that had already gone stale once.

Counts that grow with the code are replaced by pointers to the authoritative
list. Counts the ODBC spec fixes stay.

CONTRIBUTING.md and SECURITY.md are new. The issue-template config no longer
says a security policy is missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The doc comments carried about 3,150 em-dashes, and a habit of narrating the
code's own history. Both render on docs.rs, and neither helps a reader decide
anything. Every one of the em-dashes is gone except three in
types/diagnostics_table.rs, where the character is load-bearing: one is the
bullet parser's own terminator set, and two document it.

The narration goes with them. A doc comment now states the rule and the failure
it prevents, rather than which revision introduced it or what the comment used
to say. Where the history was carrying an argument, that argument is restated
as a counterfactual: a token minted once per statement WOULD leave a cancelled
statement unusable, rather than an account of the revision where it did.

Counts that grow with the code are replaced by pointers to the authoritative
list. Counts the ODBC spec fixes stay. Two of the removed counts were already
wrong: ffi/fetch.rs described CORE_UNEXPORTED_FUNCTIONS as seventeen entries
where it holds nineteen, of which only fifteen are Appendix G mappings, and a
struct described as carrying seven fields has six.

Dangling references to gitignored plan documents are gone: a
docs/superpowers/plans/ path in column_value.rs, and the B3, C2, C5 and
Task 2.10 prefixes that pointed at trackers a reader cannot open.

Two structural fixes: ffi/stmt_attr.rs had the doc block for
offer_to_data_source attached to struct Substitution, so the two rendered as
one run-on item; and param_convert.rs's section dividers now match the style
its two sibling conversion modules use.

Comments only. The 40 changed non-comment lines are all continuation lines of
multi-line assertion messages or trailing comments on code lines.

The diagnostics-table guard in types/diagnostics_table.rs parses these doc
comments, so its four verdict phrasings, its SQLSTATE bullet shapes and its
DM-attribution vocabulary were preserved exactly. pre-commit run --all-files
passes, including cargo test and cargo doc with warnings denied. The Windows
target and the detached bench/ and fuzz/ workspaces build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…th (DM)

sql_get_type_info and sql_execute described their 24000 row as having a clause
belonging to the Driver Manager. Neither row carries a (DM) marker anywhere.

The three pages concerned, SQLExecDirect, SQLExecute and SQLGetTypeInfo, share
a row shape the transcription cannot express: a first condition, then a
sentence splitting that same condition by outcome, "returned by the Driver
Manager if SQLFetch or SQLFetchScroll has not returned SQL_NO_DATA, and is
returned by the driver if SQLFetch or SQLFetchScroll has returned
SQL_NO_DATA", then further unattributed conditions. Both sides own the first
condition, at different moments, and the driver owns the rest outright. Calling
it the Driver Manager's clause was wrong in both directions: it gave away a
case core does handle, and it counted the attribution sentence as a condition.

SQLPrepare is the contrast and was already right. Its 24000 prints an actual
(DM) on the first of two sentences, which is why that one is transcribed Split
while these three are None.

All four transcriptions were correct and are unchanged. Only the doc comments
moved, plus sql_exec_direct_w's, which was accurate but silent about the split
that its two neighbours now describe.

types/diagnostics_table.rs records the shape and the limit it implies: the
guard reads (DM) markers, so it cannot check a boundary the spec draws in
English, and a row like this must not be promoted to Split to make the prose
match, because Split names a marked clause and there is none.

Verified by mutation: attributing the row to the Driver Manager in a phrasing
the guard recognises makes every_doc_comment_matches_the_spec_diagnostics_table
fail and name the function and the state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…SQLSTATEs

Three changes to the test suite, none of which touches production code. Every
diff hunk in all fifteen files is inside a `mod tests` block or in
`test_utils.rs`.

Eleven private re-implementations of helpers `test_utils.rs` already exported
are gone: five byte-identical copies of `connect_handle`, six of a cleanup that
was `cleanup_connected_env_conn_stmt` spelled out, and two shapes of the
allocator. `test_utils.rs` gains the three the survey found missing, an
unconnected generic triple, an environment-and-connection pair and its teardown,
plus a shared `connect_handle`.

The four cleanup copies had to keep disconnecting before freeing, and nothing
stopped them drifting apart on that step. Getting it wrong leaks the connection
and the environment, which fails as a red Miri job rather than as a failing
assertion, so one copy is worth more than four.

Twenty tests are removed, each proven redundant first by breaking the line it
covered and confirming the surviving test failed. A byte-identical pair in
`column_value.rs` and one in `cursor.rs`; two pairs in `metadata.rs` written
twice, keeping the ones that pass named constants where the others passed bare
0; and sixteen in `col_attr.rs`, twelve display-size and octet-length
assertions with a `constants_*` twin that additionally ties the result to its
named constant, and four subsumed by parameterised tests already driving the
same inputs through `get_column_attribute`.

Four `col_attr.rs` tests the survey's table paired with nothing were kept.
Deleting them would have dropped the SMALLINT and TINYINT octet-length branches
and the VARCHAR display-size branch entirely.

About forty tests now assert which SQLSTATE came back rather than only that the
call failed. A test asserting `SqlReturn::ERROR` alone passes whether the driver
returns the spec's state or a generic HY000, which for this crate is the
assertion worth having. `metadata.rs`'s `first_sqlstate` now reads through
`sql_get_diag_rec_w` instead of the diagnostic queue directly, so its twelve
existing call sites assert what an application can observe too.

Tests whose state comes from a backend were left alone, because `MockBackend`
collapses everything to `NotImplemented` and asserting that pins the mock.

Two weaknesses found while working, both closed: `display_size_bit` used a
descriptor whose precision equalled the answer, so it passed whether the code
returned `DISPLAY_SIZE_BIT` or fell back to the precision; and two of the
`free_stmt` option tests carried comments claiming they checked the bindings
were cleared while asserting only the return code. `SQLBindParameter` writes two
descriptors, and the mutation showed the IPD half was genuinely unpinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SQLBindCol rejected SQL_C_DATE with HY021, "inconsistent descriptor
information", for a bind that had nothing wrong with it. SQL_C_TIME and
SQL_C_TIMESTAMP were accepted, so the three siblings behaved three different
ways and none of it was chosen.

The cause is a numeric collision. SQL_DATETIME is itself 9 and SQL_INTERVAL is
10, so verbose_type passes the 2.x codes through unmapped and they then compare
equal to a verbose constant by identity rather than by mapping. 9 looks like a
datetime whose subcode is missing and fails the consistency check, 10 looks like
an interval and skips the check entirely, and 11 is an ordinary type that
passes. verbose_type's comment anticipates the collision without following it
through to what the check then does.

10 and 11 had a second problem behind the first: they passed the bind and then
reached a fetch, where column_value has arms for the 3.x spellings only.

set_concise_type now promotes 9, 10 and 11 to 91, 92 and 93. That is the Driver
Manager's own mapping, from the two tables in "Datetime Data Type Changes",
which give the same pair of values for the C type in SQLBindCol, SQLGetData and
SQLBindParameter's ValueType and for the SQL type in its ParameterType. One
translation therefore serves the ARD, the APD and the IPD.

This is not core second-guessing the Driver Manager. Behind one, the deprecated
codes never reach core at all, because that table maps them for an ODBC 3.x
driver in both the "2.x app" and the "3.x app" column. Doing the same here
extends the guarantee to an application linked directly against this crate,
which it can be, and which core's own tests are.

set_concise_type is the single writer of the type trio, so SQLBindCol,
SQLBindParameter, SQLSetDescField and SQLSetDescRec all inherit it.

Two tests. One pins the promotion and the consistency check for each of the
three codes. The other sweeps every C data type c_data_type_from_raw accepts
through a record shaped the way SQLBindCol shapes one and requires that none
fails the check, which is the guard that would have caught this. It carries a
miri ignore: it scans the whole i16 space and the module holds no unsafe.

sql_bind_col's doc comment said HY021 was returned by this driver. The check
does run there, but no argument the function accepts can now fail it, and the
comment says so and names the two functions that can reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README's dependency snippet says `cargo add stackable-odbc-core` rather
than pinning a version, so the rule matched nothing and `exactly = 1` turned
that into a hard failure. A release aborted before it reached the commit.

`Cargo.toml` now holds the only version string in the repository, so nothing
outside it needs rewriting at release time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`setup.rs` was the one module missing from AGENTS.md's crate-layout table,
which otherwise lists every file down to `ffi/mod.rs`. It is driver-facing
API, `ConfigRequest`, `InstallerError` and `config_request_from_raw`, and it
is what a driver reads before implementing `Backend::configure_dsn`.

`release.toml` and `release/release.sh` both still described a release as
rewriting README.md. The README version replacement was removed in 32e72d2,
so only CHANGELOG.md is rewritten now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec's Default C Data Types table pairs SQL_GUID with SQL_C_GUID. Core
inferred SQL_C_BINARY from the `ColumnValue::Guid` variant, and the two are
not interchangeable even though both write sixteen bytes: SQL_C_GUID
reassembles the first three groups as integers, so the SQL_C_BINARY reading
byte-swaps them on every little-endian machine.

An application binding SQL_C_DEFAULT on a GUID column allocates a SQLGUID
and reads `d1` from it, so it received 0x33221100 where 0x00112233 was
stored, under SQL_SUCCESS with nothing in the diagnostic queue.

`default_target_width` gains the SQL_C_GUID row it now needs: that arm calls
`write_fixed`, which does not consult BufferLength, because for an explicitly
named fixed C type the spec has the driver ignore it. SQL_C_DEFAULT inverts
that, so without the row the inference could write sixteen bytes into eight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
04b8428 made an undefined negative *StrLen_or_Ind* HY090 for SQL_C_CHAR and
SQL_C_WCHAR and did not reach SQL_C_BINARY, which mapped every negative onto
its no-indicator case and bound NULL. An application that passed
SQL_NO_TOTAL, or a stale -42, sent a NULL to the data source in place of the
value it bound, under SQL_SUCCESS and with an empty diagnostic queue.

By the time control reaches these arms every negative names none of the
values SQLBindParameter defines: SQL_NULL_DATA and SQL_DEFAULT_PARAM return
at the top of `read_param_value`, and the data-at-execution values are
diverted by `find_data_at_exec_params` before it is called.

SQL_NTS is where the three arms legitimately part company, and the new test
states the difference rather than one half of it: the character arms take it
as "scan for the terminator", and a binary value has no terminator to find,
so it names no length for SQL_C_BINARY and is refused with the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…NULL

`read_param_value`'s terminal arm answered every C type it did not handle
with `ColumnValue::Null` and a `tracing::warn!`. A log line is not a
diagnostic, so the application's value was replaced by a NULL, sent to the
data source and reported as SQL_SUCCESS with an empty diagnostic queue.

Two families reached it. The thirteen SQL_C_INTERVAL_* types and the
SQL_ARD_TYPE / SQL_APD_TYPE sentinels are genuinely unmarshallable and are
now refused with 07006, at bind where the pairing is already fixed and again
at execute, which is where a binding assembled through SQLSetDescField
arrives without passing the bind gate.

SQL_C_DEFAULT is the larger half and is not unmarshallable at all: the spec
has it name its C type through the declared ParameterType. That table, the
Default C Data Types page, is transcribed as
`param_convert::default_c_type_for_parameter` and resolved before any arm
runs. Two of its rows are easy to get wrong and are commented as such: the
character rows say SQL_C_CHAR and not SQL_C_WCHAR even in a W driver, since
the W governs a function's own string arguments rather than a bound buffer;
and SQL_FLOAT resolves to SQL_C_DOUBLE, only SQL_REAL being single
precision.

The data-at-execution path had the same gap from the other side:
`dae_buffer_to_value` matched the raw concise type, so a SQL_C_DEFAULT
parameter declared SQL_LONGVARBINARY reached the text arm and had every
non-UTF-8 byte replaced, where the same parameter sent in one piece was read
as binary.

The allowlist is positive so a C type absent from both it and
`read_param_value` fails closed, costing a spurious 07006 rather than a
silent NULL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every SQL_C_INTERVAL_* target and every exact-numeric target for an interval
column answered 07006; only the character rows worked. The gap was
undocumented, and `sql_fetch`'s doc comment claimed `write_column_value`
returned 22015 while `sql_get_data`'s said the opposite about the same
function.

The two pages, SQL to C: Year-Month Intervals and SQL to C: Day-Time
Intervals, are now transcribed in `column_value.rs`: the C interval targets
(data / 01S07 for a truncated trailing-fields portion / 22015 for a leading
precision too small), footnote [b]'s exact-numeric row, and the character
and binary rows, which both pages word differently from every other source,
giving 22003 where the others truncate with 01004.

The two pages disagree in one cell. "Interval precision was not a single
field" against an exact numeric is 22015 on the year-month page and 07006 on
the day-time page. Each is answered with its own page's state, because a
caller reads the page for the type it asked about.

`ColumnValue`'s interval variants gain the precision the tables turn on:
neither "is the precision a single field?" nor "which trailing fields did
the source have?" can be answered from a magnitude. `IntervalDayTime` is now
a signed i128 nanosecond total rather than i64 milliseconds. It stays one
signed magnitude, for the reason the variant already documented, that split
fields admit mixed signs and cannot express a negative sub-day interval;
nanoseconds because SQL_INTERVAL_STRUCT's fraction counts billionths, and
i128 because u32::MAX days of nanoseconds overflows i64.

Rendering is now per-precision, from the spec's Interval Literals page: the
leading field unpadded, trailing fields two digits, the fraction trimmed.
An hour interval renders as "163" and not "0 05:00:00.000", which is what
puts the character rows' whole-digit test on digits the value actually has.

`NumericTarget` carries the ARD's SQL_DESC_DATETIME_INTERVAL_PRECISION
alongside the precision and scale it already held, since all three are read
from one record at one moment. A leading precision of 0 reads as "the
application declared none", the convention `numeric_convert` already applied
to this field in the C-to-SQL direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SQL_YEAR_MONTH_STRUCT` is two SQLUINTEGERs where `SQL_DAY_SECOND_STRUCT` is
five, so constructing the union through its year-month arm alone left the
last twelve bytes uninitialised. The whole struct is then copied into the
application's buffer, so those twelve bytes were driver stack handed to the
caller: uninitialised memory an application can read back, and a disclosure
however dull its contents.

The union is now initialised through its widest arm first and only then
overwritten, so every byte of the value is defined whichever target was
asked for.

Miri found it, as an uninitialised `DaySecond::minute` on the year-month
tests, reading through a test helper whose SAFETY comment claimed it read
only the arm the target wrote. It did not: building that struct reads both
arms eagerly however few the caller goes on to inspect. The comment now
records what the soundness actually rests on.

The accompanying test asserts the post-condition but is explicitly not the
guard: the skipped bytes come from the driver's own stack rather than from
the destination buffer, so nothing the test controls decides their value and
reverting the fix does not reliably fail it. Miri is what sees this, which is
why it runs on every pull request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@adwk67 adwk67 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LVGTM!

@maltesander
maltesander merged commit d02f5bd into main Aug 4, 2026
8 checks passed
@maltesander
maltesander deleted the scaffolding branch August 4, 2026 12:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants