diff --git a/linux/README.md b/linux/README.md index 95e9310fac..c4fdf5e5ad 100644 --- a/linux/README.md +++ b/linux/README.md @@ -261,7 +261,7 @@ says where the realm, the KDC and the tickets come from there. | Testing conventions, suites and CI jobs | [docs/testing.md](docs/testing.md) | | Saved passwords, SSH, Kerberos, logs and crash reports, for users | [docs/troubleshooting.md](docs/troubleshooting.md) | | Release setup, releases and packaging fixes | [docs/releasing.md](docs/releasing.md) | -| Every guide, driver page and dated audit | [docs/README.md](docs/README.md) | +| Every guide and driver page | [docs/README.md](docs/README.md) | | Architecture decision records | [docs/decisions/](docs/decisions/) | ## License diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index 104fe41af8..74e18b8527 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -174,7 +174,7 @@ Not committed: server monitoring (sessions, locks, slow queries), maintenance co ## Release criteria -From the [production-readiness audit](docs/production-audit.md#what-production-ready-means-here), for GNOME 50. A user on Ubuntu 26.04 or Fedora 44, with where each stands on 2026-10-18: +What has to hold before the first release, for a user on GNOME 50 with Ubuntu 26.04 or Fedora 44, and where each stands on 2026-10-18: | # | The user | Holds today | Still needed | |---|---|---|---| diff --git a/linux/crates/session/src/session/mod.rs b/linux/crates/session/src/session/mod.rs index c0d6522934..0b177179c1 100644 --- a/linux/crates/session/src/session/mod.rs +++ b/linux/crates/session/src/session/mod.rs @@ -6,7 +6,7 @@ mod object_list_snapshot; mod schema_catalog; #[expect( clippy::module_inception, - reason = "the Session type is the module's subject, as the plan names its file" + reason = "the Session type is the module's subject" )] mod session; mod session_deps; diff --git a/linux/docs/README.md b/linux/docs/README.md index 5610db4caa..96a226c7e8 100644 --- a/linux/docs/README.md +++ b/linux/docs/README.md @@ -29,13 +29,3 @@ The reference for working on the Linux app. Start with [ARCHITECTURE.md](../ARCH ## Decisions The architecture decision records, with a summary of each, are indexed in [decisions/README.md](decisions/README.md). - -## Dated audits and plans - -Snapshots, kept as they were written. The code has moved on since each date. - -| Date | Document | -|---|---| -| 2026-04-26 | [production-audit.md](production-audit.md): the production-readiness audit the roadmap's release criteria come from | -| 2026-09-14 | [plan-2026-09-14-audit-remediation.md](plan-2026-09-14-audit-remediation.md): the plan that fixed the 2026-09-13 audit's findings, with its [details](plan-2026-09-14-audit-remediation-details.md) | -| 2026-09-14 | [dependency-review-2026-09-14.md](dependency-review-2026-09-14.md): the dependency review whose amendments override that plan | diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 412fd1519e..3cf5065f9f 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -61,7 +61,7 @@ ADRs are short on purpose. If yours is more than a page, you are probably arguin | [0017](0017-distribution-channels.md) | Distribution channels | Superseded by 0018 | Flathub, a .deb for Ubuntu 26.04 on GitHub Releases built with rustup, and an .rpm for Fedora 44 on COPR, all from `meson install` and one vendor tarball; no AppImage. | | [0018](0018-ppa-copr-and-flathub-later.md) | A PPA and COPR first, Flathub later | Accepted | A Launchpad PPA built with `rustc-1.97` and a COPR project built on each release are the primary channels; Flathub follows as a submission a maintainer writes; Rust 1.97 is the MSRV. | -0011 is not used. The 2026-09-14 remediation plan reserved it for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. +0011 is not used. It was reserved for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. ## Adding an ADR diff --git a/linux/docs/dependency-review-2026-09-14.md b/linux/docs/dependency-review-2026-09-14.md deleted file mode 100644 index ad2b3d44f5..0000000000 --- a/linux/docs/dependency-review-2026-09-14.md +++ /dev/null @@ -1,979 +0,0 @@ -# TablePro Linux dependency review - -**Date**: 2026-09-14 -**Question**: are the current and planned dependencies standard, well-known and maintained, so the project does not build up tech debt? -**Inputs**: the workspace manifests and Cargo.lock after step 1 of the remediation plan, and the dependency decisions in [plan-2026-09-14-audit-remediation.md](plan-2026-09-14-audit-remediation.md) - -Each domain was researched against crates.io, GitHub and upstream source, then fact-checked by an independent reviewer that re-queried the numbers and challenged every verdict. - -## Verdict - -Most of the dependency set already meets the owner's bar: gtk4/libadwaita/glib/gio/sourceview5, gettext-rs 0.8, oo7, sqlx 0.9 (MySQL, SQLite), the official clickhouse crate, rustls with aws-lc-rs, deadpool, sqlparser 0.63, chrono, serde, thiserror, async-trait, tracing, Meson + cargo, cargo-deny, cargo-about and flatpak-builder-tools are all de facto, org-maintained and active. The plan fails the bar in four places. First, it carries two private forks (TableProApp/rust-postgres and TableProApp/tiberius). Second, it keeps Relm4, a niche framework that no GNOME Core or Circle Rust app uses (0 of 31) and that already forces wrapper code and clippy bans. Third, it rebuilds OpenSSH on top of russh (single owner, 8 breaking minors in 9 months) while still spawning ssh and ssh-keygen. Fourth, it adds roughly 20 custom Python scripts and support crates that standard tools already cover. Both forks can go. rust-postgres needs only upstream contributions (#1358 for type OIDs, a new PR for ReadyForQuery status), a SQL-cursor row limit, and exact transaction status tracked per statement with a SAVEPOINT probe. tiberius should be pinned to tiberius-rs main c1d2e741 now, which also clears RUSTSEC-2026-0098/0099/0104 and the archived rustls-pemfile, then moved to upstream main as #440-#449 merge, with money_as_numeric and tls_client_config sent upstream. SSH should run through the system OpenSSH client (ControlMaster, Unix-socket forwards, an askpass bridge). That removes about 25 planned files, the MySQL relay and the ProxyCommand refusal, at the cost of documented Flatpak limits. Relm4 should be replaced with plain gtk-rs subclasses and CompositeTemplate .ui files inside tasks that already rewrite those files. Hand-rolled code that has a mainstream replacement should be replaced: csv::Writer for CSV, deadpool-postgres, tokio-postgres-rustls (after a small upstream OID PR), rustls-platform-verifier where a ClientConfig can be injected, num-bigint, postgres-types/postgres-protocol decoders, and nextest/lychee/cargo-about templates/committed debian and spec files for tooling. Custom code stays only where a hard requirement has no mainstream answer: SqlDecimal, client-side script directives, per-engine SQL dialects, the health state machine, the OpenSSH bridge, and small interim shims each tied to a named upstream PR. Several verification corrections change the plan text itself. The flatpak-builder-lint call needs --exceptions. Accessibility tests need GTK_A11Y=test. GLib keeps an existing file's mode, so an explicit chmod 0600 is needed. /reconcile/conflicts[21], [29], [35] and [39] must be rewritten, or the fork, Relm4 and russh decisions survive. Finally, add Dependabot or Renovate and a dated watch list, so pins and interim shims do not quietly turn into permanent debt. - -## Decisions by area - -| Area | Current | Planned | Recommended | Verdict | Why | -|---|---|---|---|---|---| -| GUI toolkit | gtk4 0.11.4, libadwaita 0.9.2, glib/gio 0.22.9, sourceview5 0.11.2 (features set via relm4) | Same, floor gnome_50 via relm4 | Keep. Declare gtk4 features [gnome_50], libadwaita [v1_9], sourceview5 [v5_18], glib/gio [v2_88] directly. Enable gtk4-macros xml_validation for templates. | keep | Official bindings used by every GNOME Rust app. Without relm4 the crates must carry the feature floor themselves. AdwShortcutsDialog exists since libadwaita 1.8. | -| UI architecture layer | relm4 0.11.0 (28 of 43 app files; 9 components, 11 spawn, 3 spawn_local, 20 Sender, 2 FactoryVecDeque) | Keep relm4, bypass RelmApp, wrap RELM_THREADS, ban 6 APIs, new Relm4 components (W9-10, W9-12) and a Relm4 test harness | Replace with glib::Object subclasses + #[derive(gtk::CompositeTemplate)] with XML .ui files in the GResource, gio actions/signals/properties, gio::ListStore + bind_model, and an app-owned tokio Runtime with glib::spawn_future_local. Blueprint deferred. | replace | Niche, with concentrated maintainership (not strictly bus factor 1: 5 other contributors in 6 months) and a 47-48 day lag behind each gtk-rs release. It forces custom spawner, clippy-ban and harness code. RelmApp::run launches only a single root component. | -| i18n | gettext-rs 0.7.7 (gettext-system), custom tr! macro and LOCALEDIR probing | gettext-rs 0.8.0, build-time LOCALEDIR, gettext_f/ngettext_f helpers | Keep gettext-rs 0.8 with gettext-system. Keep the ~20-line Fractal-style named-placeholder helper. Avoid formatx, i18n-format, i18n-embed and justinas/gettext. | keep | GNOME standard. 0.8 makes setlocale unsafe, which the single-threaded init contract covers. locale_config 0.3.0 (2019) is transitive and goes on the watch list. | -| secrets | oo7 0.6.0 (tokio, native_crypto), secrecy 0.10.3 | Same | Keep oo7 0.6 and move to 0.7 when stable; keep secrecy; add zeroize (already transitive) for decoded keyring bytes and askpass or keyboard-interactive replies; ashpd stays transitive. | keep | GNOME-maintained Secret Service and portal client. Avoid keyring and secret-service: cross-platform layers not used by GNOME apps. | -| PostgreSQL driver | sqlx-postgres 0.8.6 | tokio-postgres 0.7.18 + 3-commit TableProApp/rust-postgres fork (type OIDs, transaction status, text portal) | tokio-postgres 0.7.18, postgres-types 0.2.14 and postgres-protocol 0.6.12 from crates.io. No fork. Exact transaction status from per-statement tracking plus a SAVEPOINT probe (25P01 when idle) until an upstream status PR ships. Editor columns stay text-typed until rust-postgres#1358 is released. Row limit uses DECLARE NO SCROLL CURSOR + FETCH, wrapped in a SAVEPOINT inside open transactions and sent as separate messages. COPY FROM STDIN refused. Never Client::prepare. | replace | sqlx-postgres has no cancel key, no public transaction status and no injected stream. The fork duplicates upstream #1358 and a text portal that upstream would not accept (#882). Exact status is required because read-only enforcement depends on it. The review's 'Unknown' interim was rejected as a security regression. prepare-based typing breaks the PgBouncer max_prepared_statements=0 rule. | -| pooling | sqlx Pool (MySQL/SQLite/PG), Mutex for SQL Server | deadpool 0.13.1 with hand-written PgManager, MysqlManager, MssqlManager | deadpool 0.13.1. PostgreSQL through deadpool-postgres 0.14.2 (Manager::from_connect + custom Connect, RecyclingMethod::Verified, Object::take on unfinished flights). Custom managers only for sqlx-mysql and tiberius. Avoid bb8, bb8-tiberius (stale) and deadpool-tiberius (niche). | keep | tokio-rs core team co-owns deadpool, and deadpool-postgres exposes the exact hook needed. The planned MySQL/MSSQL managers are about 30 lines on the documented Manager trait. | -| MySQL/MariaDB driver | sqlx-mysql 0.8.6 | sqlx-mysql 0.9 through a private Unix-socket relay | sqlx-mysql 0.9.x (the first patch containing the TCP_NODELAY fix #4336) with mysql-rsa. SSH path: MySqlConnectOptions.host(service).socket(OpenSSH-owned forward socket). Direct path: sqlx TCP with health probe. No relay. Contribute to transact-rs/sqlx #4281 (stream injection), #4130, #4355 and #4045. If OpenSSH transport is rejected, keep the relay as a dated interim tied to #4281. | keep | De facto async MySQL crate. sqlx upgrades TLS over a Unix socket against options.host and uses RSA (not cleartext) without TLS, so an ssh-owned socket works. mysql_async silently skips TLS on sockets and has a single owner. | -| SQL Server driver | tiberius 0.12.3 via git pin prisma/tiberius a6b4fcd (rustls 0.21, rustls-webpki 0.101.7) | 5-commit TableProApp/tiberius fork on c1d2e741 (reconcile conflicts[21]) | tiberius pinned to https://github.com/tiberius-rs/tiberius at main c1d2e741 now, moved to main after #440-#443/#449 merge, then crates.io 0.13. Use stack head 9870b07 only as a short CI-gated bridge. Upstream PRs for Config::money_as_numeric and tls_client_config. Interim: CAST money projections, trust_cert_ca plus system roots, upstream Client::cancel_query with poisoned-connection discard. default-features=false. Avoid mssql-tds (OpenSSL, no public transport), ODBC, tiberius-ng, mssql, mssql-client. | keep | The only pure-Rust TDS client that accepts any stream, uses rustls with aws-lc-rs and supports GSSAPI. The upstream revival carries panic hardening, sql_variant/UDT and Attention cancel, so a fork would conflict with the pending 0.13 rewrite. Release risk: MattJackson is not a crates.io owner. | -| SQLite driver | sqlx-sqlite 0.8.6 (driver + history DB) | sqlx-sqlite 0.9.0 sqlite-unbundled + direct libsqlite3-sys for one unsafe sqlite3_get_autocommit | Keep sqlx-sqlite 0.9 for driver and storage. Keep the direct libsqlite3-sys range as a tracked interim, widened in lockstep with sqlx patch releases (not a ~0.9.0 pin). Upstream a LockedSqliteHandle accessor PR. Cancel via the progress handler only. Record rusqlite 0.40 + deadpool/deadpool-sync as the documented fallback, not 'avoid'. Avoid libsql, turso, tokio-rusqlite and async-sqlite. | keep | One SQLite binding per graph (links=sqlite3). atuin uses the same direct range pattern. lock_handle waits behind a running statement, so sqlite3_interrupt through it can never work. unbundled forces bindgen/libclang for every build. | -| ClickHouse driver | clickhouse 0.15.2 with escape_bind_markers, LineReader, string type parsing | clickhouse 0.15.2 over Client::with_http_client, clickhouse-types 0.1 | Keep clickhouse 0.15.2 and add clickhouse-types 0.1.3. Client::query_raw for editor SQL. Keep Client::query binding (or a server param) for KILL QUERY. BytesCursor AsyncBufRead::read_until. system.columns key flags. Network errors classified by source() downcast. Explicit lz4 decision. Upstream PR for a structured error code. Over SSH, a ~15-line Service returning TokioIo. Avoid klickhouse, suharev7/clickhouse-rs, clickhouse-arrow, ch and hyperlocal. | keep | Vendor-maintained, with a public transport injection API. fetch_native lacks Decimal, the Date family, Enum and Int256, so JSONCompactEachRowWithNamesAndTypes stays. default-features=false silently disables lz4. | -| SSH | russh 0.55.0 (RUSTSEC-2026-0153/0154), TOFU writes, loopback TCP forwarder | russh 0.63.3 + ssh -G resolver + custom known_hosts verifier, auth order, jump chains, KRL (~25 files) | System OpenSSH client via tokio::process. One ControlMaster per destination. `-O forward -L <0700 dir>/n.sock:host:port`. SSH_ASKPASS_REQUIRE=force with an app askpass helper bridged to AdwAlertDialog over a peer_cred-checked socket. Command-line -o overrides (ExitOnForwardFailure=yes, ForwardAgent=no, PermitLocalCommand=no, RequestTTY=no, ClearAllForwardings=no, a LogLevel that keeps error lines). Exit 255 + stderr classification. `ssh-keygen -R` for replace. Bump russh to 0.63.3 only as an interim security fix, then remove it. Fallback ADR: russh with narrowed requirements. Avoid the openssh crate, openssh-mux-client, ssh2/libssh2, libssh-rs, async-ssh2-tokio, russh-config and ssh2-config. | replace | Only OpenSSH meets Include/Match/ProxyJump/ProxyCommand, markers, KRL and agent semantics, and the plan already depends on the binary. The re-implementation is security-critical debt on a single-owner crate. Flatpak limits must be documented: ProxyCommand and Match exec see only sandbox tools, host /etc/ssh is hidden, FIDO file keys fail (ssh-sk-helper is vm-only). The host-key prompt must be detected by text, and askpass gets no echo flag. | -| TLS and trust store | rustls 0.23.44 + 0.21.12 (tiberius), ring and aws-lc-rs both present, rustls-native-certs 0.8.4 + 0.6.3 | aws-lc-rs only, rustls-native-certs + direct webpki-roots union for every engine, custom PG TlsConnect with x509-cert 0.3 channel binding | rustls 0.23 + aws-lc-rs only (ban ring, openssl-sys, native-tls). rustls-platform-verifier 0.7 (new_with_extra_roots) wherever a ClientConfig is injectable (PostgreSQL, ClickHouse), plus a small no-hostname wrapper for verify-ca. tokio-postgres-rustls 0.14 for PG TLS and channel binding, after an upstream PR adds md5WithRSA, ecdsa-with-SHA1 and ecdsa-with-SHA512. SQL Server: trust_cert_ca plus system roots until tls_client_config lands upstream. MySQL: sqlx built-in roots plus the CA file. A system-CA PEM bundle (rustls-native-certs + pem-rfc7468) only if corporate system CAs must work, tied to sqlx #4045. Drop direct webpki-roots and x509-cert. | replace | The rustls project recommends platform-verifier over native-certs, and on Linux it is the same crate stack. A single identical union across engines is not achievable until sqlx #4045 and tiberius tls_client_config land, so the requirement is narrowed and documented per engine. tokio-postgres-rustls is a single-maintainer exception with ~400 lines and a low exit cost. | -| networking | 127.0.0.1 TCP forwarder in ssh crate | tablepro-net with RFC 8305 Happy Eyeballs race, SocketTuning, PrivateSocketRelay | Sequential tokio::net::lookup_host + TcpStream::connect with per-address timeout and socket2 0.6 SockRef keepalive, TCP_USER_TIMEOUT and nodelay (the pattern tokio-postgres uses internally). Drop the Happy Eyeballs requirement. Keep hyper-util only transitive via clickhouse. SSH transport is the OpenSSH socket forward. Record GIO GSocketClient (RFC 8305 since GLib 2.60) as the native option if Happy Eyeballs is ever required. | replace | Happy Eyeballs is not required for a DB client (libpq and tokio connect sequentially). The hyper-util legacy HTTP connector needs a fabricated Uri, is RFC 6555 only and splits the timeout across addresses. The happy-eyeballs and tokio-happy-eyeballs crates are stale or niche. | -| SQL parsing/splitting | naive split_sql_statements in editor.rs | sqlparser 0.63 for DDL validation plus a from-scratch ScriptLexer, a separate PG statement_shape lexer and a sqlparser top-level-semicolon split for history classification | sqlparser 0.63 isolated behind a core module. ONE shared ScriptLexer/ScriptPlan for execution, the format guard, PG statement_shape, completion scope and history classification. Time-boxed spike to build it on sqlparser's Tokenizer, with raw-text DELIMITER/GO pre-pass, ClickHouse '#' comments and Dialect::dialect() forwarding. If the fixture corpus fails, keep a custom lexer as a documented exception, differentially tested against sqlparser and sqlite3_complete. Avoid pg_query (bindgen/C build, PG only) and name it in the ADR. | add | No mainstream crate does client-side splitting (GO [n], DELIMITER, BEGIN ATOMIC, trigger bodies). The sqlparser tokenizer gives line/col only, errors on unterminated input and treats MySQL '$$' as a dollar string. Three lexers would be avoidable debt. | -| SQL formatting | sqlformat 0.5.0 | sqlformat with W6-14 significant-token guard | Keep sqlformat 0.5 behind the W6-14 guard using the shared ScriptLexer/sqlparser tokens. File mis-lexes upstream. Exit criterion: if releases stop, hide Format SQL rather than fork. Avoid sqruff-lib and polyglot-sql. | keep | Only popular Rust SQL formatter. Single owner and unreleased fixes since 0.5.0, but one call site makes it cheap to swap. sqlparser's pretty-printer drops comments. | -| query/DDL building | sql_dialect.rs plus five quote_ident copies; ClickHouse escape set copied from crate | Per-engine SqlDialect/DdlDialect (FB-D9, FB-D10, W3-06, W3-07, W3-12) | Keep per-engine dialects. Use postgres_protocol::escape::{escape_identifier, escape_literal} for PostgreSQL. Use clickhouse::sql::Identifier/Query::param where app-built, or a drift test against the crate. sqlparser MsSqlDialect parse_expr for SQL Server defaults. insta snapshot tests for golden SQL. Avoid sea-query. | keep | sea-query covers only MySQL, Postgres and SQLite (SQL Server only in commercial SeaORM X, no ClickHouse). Dialect SQL is core domain logic. Vendor escapers exist for PG and ClickHouse. | -| CSV | custom quoting in core export.rs plus a second csv_field in storage/query_history.rs without neutralisation | Custom streaming CsvEncoder/TsvEncoder (W5-14) plus ported history csv_field (W1-05) | csv 1.4 as a normal dependency: csv::WriterBuilder (delimiter, Terminator, QuoteStyle) for all CSV/TSV output including history export. QuoteStyle::Always whenever formula sanitising is on, otherwise the user's Always/Necessary/Never. Neutralisation and decimal comma stay as pre-write value transforms. Extend neutralisation to LF and full-width = + - @ per OWASP. Note the csv-core "" empty-single-field record. | replace | The per-field forced-quoting requirement came from a misreading: OWASP's quoting advice applies to every field, which QuoteStyle::Always gives. Keeping quoting on neutralised cells preserves the security measure, so no custom writer is left. | -| decimals and wide integers | rust_decimal 1.43.0 | String-backed SqlDecimal and WideInteger; rust_decimal removed at contract switch (reconcile conflicts[43]) | Custom SqlDecimal (validation, compare, fits, exact text; no arithmetic). WideInteger backed by num-bigint BigInt (version matching the lockfile after the SSH decision: 0.4 via oo7 if russh leaves, 0.5 if it stays), with an ASCII ^-?[0-9]+$ check and byte cap before FromStr. Evaluate text-form NUMERIC before writing a binary codec. Drop rust_decimal; avoid bigdecimal, ruint, primitive-types, ethnum, bnum. | replace | No crate covers PG NUMERIC NaN, ±Infinity and 131072 digits. bigdecimal has no NaN and a single idle maintainer. num-bigint is de facto and removes hand-written magnitude logic, but FromStr accepts '+' and '_', so a grammar check is needed for lossless edit text. | -| date/time | chrono 0.4.45 | chrono inside Temporal, custom temporal_wire for date/timestamp/time/interval | Keep chrono. On PostgreSQL use postgres_types::{Date, Timestamp} (infinity) and postgres-protocol decoders. Custom code only for time 24:00:00, timetz and interval. Avoid time and jiff as direct deps via review/clippy disallowed-types, not deny wrapper lists. | keep | Already used everywhere, and every driver supports it. Correction: time 0.3 is also supported by all four drivers, and chrono's range is 262145 BCE-262143 CE, so the reason is consistency, not exclusivity. | -| errors | thiserror 2.0.20; anyhow already removed in 5bd1716e9 | thiserror everywhere | Keep thiserror. No anyhow action needed. Add wildcard arms where upstream errors are #[non_exhaustive] (clickhouse Error; tiberius only if #449's change lands). | keep | De facto. The claim that tiberius Error is non_exhaustive appears only in the #449 description, not in code at 9870b07. | -| async traits | async-trait 0.1.92 | async-trait for dyn Connection/Transport/ResultSink; async_channel named in contracts but absent from Cargo.lock | Keep async-trait. Avoid dynosaur and trait-variant. Pick one channel family: tokio::sync::mpsc/oneshot (already a dependency) or async-channel 2.5 (gtk-rs book), not both. | keep | Async fn in traits is still not dyn-compatible. Three channel families would be drift. | -| logging | tracing 0.1.44, tracing-subscriber 0.3.23 | tracing + tracing-journald 0.3 with journald detection and RUST_LOG > G_MESSAGES_DEBUG precedence | Keep tracing + tracing-subscriber (fmt to stderr, EnvFilter), the pattern Fractal, Loupe, Snapshot, Authenticator and Pika Backup use. Add tracing-journald only if structured journal fields are a stated requirement, with minimal detection. Drop the custom precedence logic. | keep | None of the 31 GNOME Rust apps use tracing-journald. The session already routes stderr to the journal. The crate is first-party but slow and low adoption. | -| retry/backoff | connection_monitor.rs next_delay | Pure HealthPolicy state machine with saturating doubling | Keep the custom HealthPolicy. Avoid backoff (RUSTSEC-2025-0012, pulls instant RUSTSEC-2024-0384), backon (slow) and tokio-retry. | keep | Probe/Reconnect/Wait/Park with generations is a state machine that retry combinators do not model. The doubling is 3 lines. | -| build system | cargo only | Meson + cargo custom_target, glib-build-tools, tablepro-build-support crate (8 modules) rendering config.rs, msgfmt and schemas in build.rs | Meson (>=1.4) with cargo custom_target and meson.add_devenv(cargo_env). build.rs does only glib_build_tools::compile_resources. config.rs uses option_env! with dev defaults (Loupe) or configure_file (Fractal). Translations via po/meson.build. Drop tablepro-build-support. XML .ui in the GResource. | keep | This is the GNOME Rust app shape. A build-support crate with its own tests is custom infrastructure no reference app needs. | -| license notices | none | cargo-about 0.9.2 JSON + license_metadata.py for SPDX and DEP-5 | cargo-about 0.9.2 with handlebars templates for THIRD-PARTY-NOTICE, debian/copyright and About-dialog JSON, generated in upstream CI and committed (distros do not package cargo-about). RPM License from %cargo_license_summary and %cargo_vendor_manifest. Drop license_metadata.py. Avoid cargo-license. | keep | The tool already templates arbitrary formats. Fedora macros are the distro-standard license source. | -| supply-chain checks | none | cargo-deny (deny-action v2), cargo-shear 1.13.4, allow-git for two TableProApp forks, drift jobs for committed generated files | cargo-deny with bans on ring, openssl-sys, native-tls, rustls-pemfile, rust_decimal, bigdecimal, backoff, instant and duplicate libsqlite3-sys. allow-git only tiberius-rs/tiberius (plus rust-postgres upstream only if a PR pin is chosen). cargo-shear kept (cargo-machete is the more adopted fallback). Add Dependabot or Renovate for crates, actions, container tags and tool pins; zizmor and actionlint for workflows; taiki-e/install-action for tools. A dated watch list ADR. Avoid cargo-audit and cargo-udeps. Evaluate cargo-auditable. | keep | cargo-deny is the GNOME Rust standard. Without an update bot the new SHA and version pins become stale debt. Wrapper allowlists for time and hex would break on every bump. | -| packaging | none | Flatpak with committed cargo-sources.json and render_flathub_manifest.py; .deb via assemble_debian.py source package; .rpm via render_spec.py + copr-cli | Flatpak: Devel manifest in app repo (build-args --share=network), flatpak/flatpak-github-actions v6 gnome-50, flatpak-builder-lint with --exceptions --user-exceptions using the plan's {app-id: [rules]} format. Release manifest and generated cargo-sources.json in flathub/flathub new-pr branch then flathub/app.tablepro.TablePro, with flatpak-external-data-checker and flatpak-cargo-generator at a pinned commit. .deb: committed build-aux/debian, dch --create, dpkg-buildpackage -b with rustup (Ubuntu rustc 1.93 < sqlx 0.9 MSRV 1.94). .rpm: committed spec with cargo-rpm-macros, COPR via Packit or rpmdev-bumpspec. Runtime Depends openssh-client / Requires openssh-clients. Avoid cargo-deb, cargo-generate-rpm, cargo-rpm (archived), nfpm, dh-cargo and cargo-vendor-filterer. | replace | Custom Python renderers duplicate distro and Flathub tooling. The missing --exceptions flag means the user exceptions file is never read. | -| test tooling | cargo test, testcontainers 0.27.3 + testcontainers-modules 0.15.0 | Custom integration_matrix.py, Relm4 test harness, whole-tree a11y audit with libadwaita 1.9.3 internals allowlist, TestSessionBus, GTK_A11Y=none | Keep testcontainers 0.27 + testcontainers-modules 0.15 (default-features=false, aws-lc-rs) for now; move to GenericImage only if module release lag blocks 0.28 or a fix. cargo-nextest in GitHub CI only (test groups, JUnit; meson test and distro %check keep cargo test; libtest-mimic only for harness=false under nextest). clippy ignore_without_reason. proptest, insta, cargo-fuzz/libfuzzer-sys. #[gtk::test] with targeted test_accessible_* assertions on app widgets under GTK_A11Y=test. xvfb-run + dbus-run-session. Drop stock_internals and TestSessionBus. | replace | The modules-crate drop reasons were mostly wrong: ring is already avoided and h2 0.4.19 is patched. nextest is not packaged in Fedora 44 or Ubuntu 26.04. Under GTK_A11Y=none every test_accessible_* returns FALSE. Libadwaita-internal allowlists break on Ubuntu 1.9.0/1.9.1 and on future updates. | -| future engines | ROADMAP names fred, oracle, and others | W9-17 ROADMAP rewrite | Redis via redis 1.x (redis-rs) instead of fred. MongoDB via mongodb 3.x default-features=false + rustls-tls-aws-lc, conditional on a trust-store solution (it uses webpki_roots unless ca_file_path is set). DuckDB via duckdb (bundled, ~pinned, interrupt_handle, AccessMode::ReadOnly). Cassandra/Scylla via scylla 1.x (rustls-023, aws-lc default). Oracle blocked: out of the committed plan, at most a non-default feature outside Flathub. Re-evaluate microsoft mssql-tds each release. | avoid | Verdict applies to fred and oracle; the other engine crates are vendor or org maintained and active. The oracle crate is stale with a single maintainer and needs the proprietary client. | - -### Evidence - -- **GUI toolkit**: gtk4 793,494 recent downloads, 250 reverse deps, 0.11.4 2026-06-29 (crates.io/api/v1/crates/gtk4); libadwaita 428,198 recent, owners are GNOME developers; sourceview5 locked by Pods, Citations, NewsFlash, Text Pieces (gui survey). -- **UI architecture layer**: 0 of 31 GNOME Core+Circle Rust apps lock relm4 (gui-fc survey.json); 19 reverse deps vs 250 for gtk4; gtk4 0.11.0 2026-02-20 vs relm4 0.11.0 2026-04-08; AaronErhardt 1,307 commits; gtk-rs book and GNOME Builder template use CompositeTemplate; Blueprint docs: 'still experimental', Ubuntu 26.04 ships 0.19.0 vs upstream 0.22.2. -- **i18n**: gettext-rs 0.8.0 2026-08-05, 537,802 recent; used by Fractal, Loupe, Snapshot, Shortwave, Citations, Tour; formatx is positional only (single owner clitic); Fractal src/i18n.rs gettext_f. -- **secrets**: oo7 1,000,458 recent downloads; locked by Papers (Core), Authenticator, Pika Backup, Pods; Fractal on 0.7.0-alpha; zeroize used directly by Fractal and Pika Backup. -- **PostgreSQL driver**: tokio-postgres 16.6M recent, 748 reverse deps, 0.7.18 2026-06-12; paolobarbolini 109/135 commits since 2025-09; sqlx-postgres v0.9.0 connection/mod.rs:49-56,143; PR #1358 unreviewed since 2026-05-29; psql REL_16 ExecQueryUsingCursor; xact.c EndTransactionBlock; utility.c:663 RequireTransactionBlock; reconcile conflicts[12]. -- **pooling**: deadpool 31.9M recent, owners Darksonn, bikeshedder, github:tokio-rs:core, 0.13.1 2026-08-26; deadpool-postgres 0.14.2 lib.rs Connect trait verified in the released crate; bb8-tiberius last push 2025-01-08. -- **MySQL/MariaDB driver**: sqlx-mysql 33.1M recent, 18 direct reverse deps (3,725 on facade); v0.9.0 tls.rs:62, auth.rs:134, establish.rs connect_uds; mysql_async rustls_io.rs make_secure Ok for Socket; sqlx #4187 changes requested 2026-03-26, #4281 open since 2026-05-25. -- **SQL Server driver**: tiberius 672,168 recent, 111 reverse deps; issue #427 org move 2026-09-01; open stack #440-#444, #446, #447, #449 (+23,452 lines, mergeable); #439 merged 2026-09-04 (rustls 0.23); RUSTSEC-2026-0098/0099/0104 on rustls-webpki 0.101.7 in Cargo.lock; money.rs:216-236 still F64 at 9870b07. -- **SQLite driver**: sqlx-sqlite 33.2M recent; v0.9.0 handle.rs:85-89 pub(crate) in_transaction; worker.rs:328,473-478 UnlockDb queued; sqlx #4402 merged 2026-09-09 (libsqlite3-sys <0.39); rusqlite 105.9M downloads, Fractal via matrix-sdk-sqlite; freedesktop-sdk 25.08 sqlite.bst --fts5. -- **ClickHouse driver**: clickhouse 3,728,306 recent, 0.15.2 2026-08-28; lib.rs:152 with_http_client; #461 query_raw; response.rs:85-145 BadResponse(String); native/mod.md coverage; StorageSystemColumns.cpp:57-58. -- **SSH**: russh single crates.io owner, 0.55.0 2025-12-02 to 0.63.0 2026-08-21; known_hosts.rs:99; russh issue #699; readpass.c:130-133; sshconnect.c:615,1646; readconf.c:2880 StreamLocalBindMask 0177; Zed ssh.rs:191-204; freedesktop-sdk 25.08 openssh.bst V_10_5_P1 client not vm-only; ssh-sk-client.c:58-60. -- **TLS and trust store**: rustls-platform-verifier 69.2M recent, rustls org, reqwest 0.13 default; sqlx-core 0.9.0 Cargo.toml:31 and tls_rustls.rs:140-150,212-214; tokio-postgres-rustls 14.28M downloads, lib.rs:109-126 digest map, 19-month gap before 0.14.0; pem-rfc7468 1.0.0 RustCrypto 76.5M recent; freedesktop-sdk issue #1905 open (Flatpak sees only runtime CA bundle). -- **networking**: tokio-postgres connect_socket.rs:34,40 SockRef; libpq.sgml:1441 connect_timeout per address; hyper-util http.rs:754; happy-eyeballs 0.2.1 2023-05-08; tokio-happy-eyeballs 1 release, 1 author; gsocketclient.c:61-66. -- **SQL parsing/splitting**: sqlparser 11,984,869 recent, Apache DataFusion, 0.61.0 2026-02-10, 0.62.0 2026-05-07, 0.63.0 2026-09-13; tokenizer.rs:552-560, 1719, 1912-1950; dialect/mod.rs:209,1117; ClickHouse Lexer.cpp:383-389. -- **SQL formatting**: sqlformat 4,033,921 recent, 54 reverse deps, last release 2025-10-09, single crates.io owner shssoichiro; sqruff-lib 0.40.0 203,534 recent. -- **query/DDL building**: sea-query README and src/backend; postgres-protocol escape/mod.rs:16,21; clickhouse src/sql/mod.rs pub use bind::{Bind, Identifier}; insta 1.48.0 98.9M downloads. -- **CSV**: csv 47.1M recent, 3,321 reverse deps, 1.4.0 2025-10-17; csv-core writer.rs should_quote and 371-373; owasp.org/www-community/attacks/CSV_Injection formula characters incl. LF and full-width; storage/query_history.rs:441-476. -- **decimals and wide integers**: num-bigint 132M recent, rust-num org; biguint/convert.rs:213,223,235; russh 0.63.3 needs ^0.5, oo7 0.6 num ^0.4; bigdecimal 0 commits since 2026-03-14; rust_decimal 28-digit scale. -- **date/time**: chrono 171.9M recent; postgres-types special.rs and chrono_04.rs:139 (NaiveTime wraps); sqlx 0.9 time feature, tokio-postgres with-time-0_3; jiff has no sqlx/clickhouse/tiberius feature. -- **errors**: thiserror 356M recent; git show 5bd1716e9; tiberius 9870b07 src/error.rs:9-10. -- **async traits**: Rust Reference dyn-compatibility; async-trait 143M recent; async-channel 61.6M recent, smol-rs, in 21 of 31 GNOME Rust lockfiles. -- **logging**: tracing-journald 0.3.2 2025-11-26, 62 reverse deps; survey.json 0/31; glib log.rs:1024 log_writer_journald exists as a platform fallback. -- **retry/backoff**: backoff 0.4.0 2021-12-14; backon 0 commits since 2026-03-14, last release 2025-10-18. -- **build system**: Snapshot meson.build:101 add_devenv; Loupe src/config.rs option_env!; Fractal src/meson.build:76 configure_file; glib-build-tools 0.22.8 gtk-rs org (0/31 GNOME apps use it but it is the gtk-rs book tool). -- **license notices**: cargo-about 243,339 recent, MIT OR Apache-2.0, embark-studios; not in Fedora 44 mdapi or Launchpad; loupe/papers/glycin f44 specs use %cargo_license_summary. -- **supply-chain checks**: cargo-deny 1.6M recent, used by Loupe, Snapshot, Shortwave; zizmor 1.30.1 2026-09-09; Renovate 44.83.2 2026-09-14; cargo-shear 51,294 vs cargo-machete 519,592 recent. -- **packaging**: flatpak-builder-lint cli.py:94-105 and enable_exceptions gate; Fractal test.yml:18; modules.py:65 dir-source rule only in Flathub pipeline; cargo-rpm-macros 28.5-1.fc44 (codeberg rust2rpm); Launchpad rustc-1.93 resolute; sqlx 0.9.0 rust_version 1.94. -- **test tooling**: testcontainers 12.4M recent; testcontainers-modules 8.5M recent, issue #497; RUSTSEC-2026-0258 patched >=0.4.16; gtkatcontext.c:760,780-785; gtktestatcontext.c:256-258; nextest custom-test-harnesses doc; proptest 47M recent; Fedora 44 libadwaita 1.9.3. -- **future engines**: fred last release and push 2025-02-27, issue #377 unanswered; oracle 0.6.3 2025-01-02, ChangeLog 0.7.0 '2025-XX-XX'; redis 25M recent, 1.7.0 2026-09-05; mongodb tls_rustls.rs:21,72-83; scylla 1.8.0 MIT OR Apache-2.0; duckdb 1.10505.0 2026-07-22. - -## Forks - -No TableProApp forks. - -rust-postgres: -- Depend on tokio-postgres 0.7.18, postgres-types 0.2.14 and postgres-protocol 0.6.12 from crates.io. -- Commit 1 (SimpleColumn OIDs): drop it. Help land rust-postgres#1358 by reviewing it and adding PG 14-18 tests. Until it ships, editor result columns are text-typed. Do not use Client::prepare as an interim, because the PgBouncer max_prepared_statements=0 contract forbids it. Nothing consumes table_oid or column_id. -- Commit 2 (transaction status): open an upstream issue, then an additive PR exposing the ReadyForQuery status on SimpleQueryStream and Client. Meanwhile, get exact status without a fork: track state per statement from the statement class and success or failure, and run a SAVEPOINT probe after Undetermined or multi-statement messages (25P01 when idle, 25P02 when aborted). Test CALL/DO with transaction control and COMMIT AND CHAIN. An 'Unknown' interim is rejected because read-only enforcement depends on exact status. -- Commit 3 (text portal): drop it. Use DECLARE NO SCROLL CURSOR + FETCH FORWARD limit+1 in a driver-owned transaction when idle, or inside SAVEPOINT/RELEASE when a user transaction is open. Send DECLARE and FETCH as separate messages. Correct error positions. Fall back to simple_query_raw on 0A000 and 42601. Refuse COPY FROM STDIN, and file an upstream issue for CopyData on simple query streams. -- Pin fallback: only if exact type OIDs are mandatory for the first release, stack the status PR on #1358's branch and pin all three crates to one upstream 40-hex commit in the rust-postgres/rust-postgres repo, with a removal gate at the first release containing it. Never a TableProApp repository. - -tiberius: -- Replace the prisma a6b4fcd pin with tiberius-rs/tiberius main c1d2e741 now. That removes rustls 0.21, rustls-webpki 0.101.7 (RUSTSEC-2026-0098/0099/0104) and rustls-pemfile 1.0.4. -- Gate the sql_variant/UDT and cancel work on #442/#443/#449 reaching main. Use stack head 9870b07 only as a short CI-gated bridge, because the author offered to force-push and reorder the stack. -- Send Config::money_as_numeric and Config::tls_client_config upstream as additive PRs. Until they land, use CAST money projections plus an approximate label for editor money, and trust_cert_ca plus system roots with coarser TLS classification. -- Use upstream Client::cancel_query and discard poisoned connections. -- Contribute TablePro's codec, PRELOGIN-matrix and attention tests upstream. -- Keep default-features=false. Rewrite /reconcile/conflicts[21] and deny.toml allow-git to list only tiberius-rs/tiberius. -- Drop the patch when crates.io 0.13 ships. Its date depends on Prisma-era crates.io owners and is on the watch list. - -## Hand-rolled code to replace with standard crates - -- `linux/crates/app/src (Relm4 components, factories, relm4::spawn/spawn_local/Sender; planned W4-04 relm4_spawner.rs, runtime_threads.rs, app clippy.toml relm4 bans; W9-05 component_launch.rs, output_log.rs)`: use gtk-rs subclasses + CompositeTemplate .ui in the GResource, gio actions/signals, gio::ListStore + bind_model, app-owned tokio Runtime + glib::spawn_future_local. This is the GNOME standard (0/31 apps use Relm4), and it removes wrapper code that exists only to tame Relm4. -- `planned crates/ssh W1-01/W1-02/W1-04/W1-13 (ssh_g_output, token_expansion, known_hosts matcher/verifier/store, revocation_list, auth_sequence, route*), current ssh/src/lib.rs forwarder and TOFU`: use System OpenSSH (ControlMaster, -O forward Unix sockets, askpass bridge, ssh-keygen -R). Reference implementation for config, known_hosts, KRL, certificates, agent and jump chains. Removes F-122 loopback exposure and silent TOFU. -- `planned crates/net relay.rs, relay_directory.rs, relay_error.rs (PrivateSocketRelay)`: use OpenSSH-owned 0600 forward socket for SSH; sqlx TCP for direct; upstream sqlx #4281. sqlx upgrades TLS over sockets against options.host, so the relay's only remaining purpose (keepalive) is narrowed to the health probe. -- `planned crates/net happy_eyeballs.rs, race_failure.rs, socket_tuning.rs`: use tokio lookup_host + TcpStream::connect loop with socket2 SockRef options; no Happy Eyeballs race. Happy Eyeballs is not a standard requirement for DB clients. tokio-postgres uses the same socket2 pattern. -- `planned crates/net tls system_trust.rs, TrustAnchors, webpki-roots union; pem_bundle encoder`: use rustls-platform-verifier new_with_extra_roots; pem-rfc7468 only if a MySQL system-CA bundle is kept. The rustls-recommended verifier replaces the hand-assembled union. RustCrypto PEM encoder instead of a base64 loop. -- `planned crates/net tls postgres_connector.rs, channel_binding.rs (x509-cert 0.3)`: use tokio-postgres-rustls 0.14 (after upstream OID PR). Same RFC 5929 mapping over any stream, already maintained. -- `planned crates/drivers/postgres pg_manager.rs, pg_pooled.rs`: use deadpool-postgres 0.14.2 Manager::from_connect + Object::take. Provides the custom Connect hook, recycling and cancel_token through ClientWrapper. -- `planned TableProApp/rust-postgres text_portal.rs, SimpleColumn OID and status commits`: use Upstream #1358, an upstream status PR, SQL cursors and per-statement status tracking. Avoids a private fork of a crate that ships security releases. -- `planned TableProApp/tiberius 5-commit series (W2-04..W2-08)`: use tiberius-rs main pin plus upstream PRs. Upstream already carries panic hardening, sql_variant/UDT and Attention cancel. -- `crates/core/src/export.rs:135-235 quoting core; crates/storage/src/query_history.rs:441-476 csv_field; planned W5-14 csv_encoder.rs, tsv_encoder.rs`: use csv 1.4 WriterBuilder (QuoteStyle::Always when sanitising). De facto RFC 4180 writer. The second writer skipped formula neutralisation. -- `planned crates/core/src/value/wide_integer.rs magnitude parsing and bounds`: use num-bigint BigInt with ASCII grammar check and byte cap. Mainstream parsing, ordering and conversion instead of custom sign and magnitude logic. -- `planned crates/drivers/postgres temporal_wire.rs date/timestamp sentinels, array_decode.rs, network_text.rs`: use postgres_types::{Date,Timestamp} special types; postgres-protocol *_from_sql decoders (array, range, inet, macaddr, varbit, geometric, hstore, ltree, lsn). Upstream decoders with checked arithmetic. -- `crates/drivers/clickhouse/src/lib.rs:747-755 escape_bind_markers, 332-374 LineReader, 452-474 base_type, 549-575 split_key_expression, 757 hex_encode; core sql_dialect.rs:28 copied escape set`: use Client::query_raw; AsyncBufReadExt::read_until; clickhouse-types DataTypeNode; system.columns key flags + sqlparser; one core hex helper; clickhouse::sql::Identifier/Query::param or a drift test. Vendor APIs instead of copies of the crate's private grammar. -- `planned W2-14 crates/drivers/sqlite/src/index_sql.rs; crates/drivers/mssql/src/lib.rs:601-639 normalize_mssql_default`: use sqlparser 0.63 CreateIndex columns/predicate with Spanned source slices; MsSqlDialect parse_expr. A real parser handles quoting. Spans keep the original text. -- `crates/drivers/{postgres,mysql,sqlite,mssql}/src/lib.rs quote_ident copies`: use postgres_protocol::escape for PG; single SqlDialect functions for the others. Vendor escaping where it exists, one copy otherwise. -- `crates/app/src/services/config_io.rs, preferences.rs, window_state.rs, single_instance.rs (flock); storage/ssh manual XDG paths; inline CssProvider; ShortcutsWindow`: use gio::Settings, glib user dirs, glib::file_set_contents_full + explicit chmod 0600, GApplication uniqueness, GResource style.css, AdwShortcutsDialog. Platform APIs, already planned. Adds the GLib mode-preservation fix. -- `planned crates/build-support (8 modules) and build.rs msgfmt/schema compile`: use option_env! config.rs or meson configure_file, meson devenv, po/meson.build. GNOME Rust app convention. -- `build-aux/packaging/license_metadata.py, rpm/render_spec.py, packaging/assemble_debian.py, flatpak/render_flathub_manifest.py (with tests)`: use cargo-about templates; committed spec with cargo-rpm-macros + Packit; committed debian/ + dch + dpkg-buildpackage -b; Flathub repo manifest + flatpak-external-data-checker. Distro and Flathub tooling already covers these renderings. -- `build-aux/ci/integration_matrix.py; check-doc-references.py; check-source-size.py; check-regression-map.py; check-hig-capitalization.py; check-protected-msgids.py; check-screenshots.py; check-icon-names.py; check-app-icons.py; check-clippy-shared-keys.py; committed cargo-sources.json drift job`: use cargo-nextest + clippy ignore_without_reason; lychee; clippy too_many_lines; test naming; appstreamcli + flatpak-builder-lint; a gtk::IconTheme test; drop the rest (keep crates/app/clippy.toml, drop the sync script); generate cargo-sources.json at release. Standard tools or conventions replace bespoke gates with their own fixtures. -- `planned crates/test-support-gtk accessibility_audit.rs, stock_internals.rs, test_session_bus.rs`: use Targeted gtk::test_accessible_has_property/relation assertions under GTK_A11Y=test; dbus-run-session. Allowlists keyed to libadwaita private .ui ids break on updates. -- `crates/app/src/ui/browse_tab.rs format_thousands; history_dialog.rs:1007 format_relative_time`: use icu_decimal or no grouping; ngettext_f. Hard-coded ',' and replace() without plural forms are wrong in a translated app. - -## Custom code that stays, and why - -- `crates/core value SqlDecimal (FB-C2) and PG numeric_wire/interval_wire FromSql impls, SqlTime (24:00:00), timetz`: PG NUMERIC needs NaN, ±Infinity and 131072 digits. rust_decimal caps scale at 28 and bigdecimal has no NaN. postgres-types has no numeric or interval type, and its NaiveTime decode wraps 24:00:00. -- `crates/core/src/script client-directive planner (GO [n], DELIMITER, psql routine bodies, SQLite trigger bodies); the full lexer only if the sqlparser spike fails`: No Rust crate implements client-side splitting. sqlparser has no GO or DELIMITER tokens, no byte offsets, and errors on unterminated input. pg_query is PostgreSQL-only with a C/bindgen build. -- `Per-engine SqlDialect/DdlDialect for MySQL, SQLite, SQL Server (FB-D9, FB-D10, W3-12)`: sea-query has no open-source SQL Server or ClickHouse backend and cannot express MySQL MODIFY, SQLite rebuilds or MSSQL default-constraint handling. -- `HealthPolicy reconnect state machine (W4-05)`: backoff is unmaintained (RUSTSEC-2025-0012). Retry combinators do not model Probe/Reconnect/Wait/Park with generations. -- `crates/app i18n gettext_f/ngettext_f/pgettext_f`: formatx is positional only and i18n-format is niche. Fractal uses the same ~20-line helper. -- `deadpool Manager impls for sqlx-mysql and tiberius`: No maintained deadpool manager exists for sqlx-mysql. deadpool-tiberius is niche and bb8-tiberius is stale. Each impl is ~30 lines on the documented trait. -- `OpenSSH transport glue: askpass helper and peer_cred socket bridge, ControlMaster lifecycle, exit/stderr classification, host-key prompt text detection`: The openssh crate supports password-less auth only and has no ask path. openssh-mux-client has no stdio forward. The askpass protocol is the OpenSSH-provided integration point, and Zed uses the same pattern. -- `ClickHouse Unix-socket connector (tower Service -> TokioIo) for SSH forwards`: hyper-util has no Unix connector. hyperlocal 0.9.1 (2024-07-22) is stale with a single owner. ~15 lines. -- `PostgreSQL transaction status tracker + SAVEPOINT probe; cursor row-limit flight; COPY FROM STDIN refusal (interim until upstream status PR)`: No maintained Rust PG crate exposes ReadyForQuery status publicly (tokio-postgres drops it; sqlx keeps in_transaction pub(crate)). Upstream has no text portal. -- `Direct TCP dialer on tokio + socket2 (sequential, per-address timeout, keepalive/TCP_USER_TIMEOUT) for engines that take injected streams`: Direct use of standard tokio and socket2 APIs, the same as tokio-postgres internals. hyper-util's legacy HTTP connector is not a DB dialer. -- `TLS: no-hostname ServerCertVerifier wrapper for verify-ca / TrustServerCertificate semantics, plus the observed-certificate verifier`: rustls-platform-verifier has no chain-only mode. sqlx keeps an equivalent NoHostnameTlsVerifier internally. -- `crates/drivers/sqlite autocommit.rs single unsafe sqlite3_get_autocommit (tracked interim)`: sqlx keeps in_transaction pub(crate), and is_in_transaction misses raw BEGIN. Remove when an upstream LockedSqliteHandle accessor ships. -- `ClickHouse 'Code: N' prefix parse`: clickhouse 0.15.2 exposes only Error::BadResponse(String). Replace when an upstream structured code lands. -- `CSV formula neutralisation transform; one core hex helper; PersistDebouncer on glib timeout; file_set_contents_full wrapper with explicit chmod 0600`: Small value transforms on platform primitives. The hex crate has not released since 2021 and const-hex is single-owner. GLib preserves an existing file's mode. -- `Temporary MySQL system-CA PEM bundle (only if system-store corporate CAs must work)`: sqlx-core 0.9 aws-lc-rs TLS always seeds webpki roots and accepts CA only as PEM. Remove when sqlx #4045 lands. - -## Security advisories in the current lockfile - -From `cargo deny --all-features check advisories` on the tree after step 1. - -| Kind | Advisory | Crate | Title | Pulled in by | -|---|---|---|---|---| -| unsound | [RUSTSEC-2026-0244](https://rustsec.org/advisories/RUSTSEC-2026-0244) | gettext-rs 0.7.7 | `setlocale` and `TextDomain::init` are unsound as they access environment with no synchronization | gettext-rs (from tablepro-app) | -| vulnerability | [RUSTSEC-2026-0212](https://rustsec.org/advisories/RUSTSEC-2026-0212) | libcrux-secrets 0.0.4 | Potentially Incorrect Output of Constant-Time Swap/Select on Aarch64 | russh (from tablepro-ssh) | -| vulnerability | [RUSTSEC-2026-0074](https://rustsec.org/advisories/RUSTSEC-2026-0074) | libcrux-sha3 0.0.4 | Incorrect Output of Incremental Portable SHAKE API | russh (from tablepro-ssh) | -| vulnerability | [RUSTSEC-2026-0207](https://rustsec.org/advisories/RUSTSEC-2026-0207) | libcrux-sha3 0.0.4 | Incorrect Output of Incremental Portable SHAKE API on Multiple Squeeze Calls | russh (from tablepro-ssh) | -| vulnerability | [RUSTSEC-2026-0208](https://rustsec.org/advisories/RUSTSEC-2026-0208) | libcrux-sha3 0.0.4 | Potential Panic in AVX2 SHAKE-256 | russh (from tablepro-ssh) | -| unmaintained | [RUSTSEC-2026-0173](https://rustsec.org/advisories/RUSTSEC-2026-0173) | proc-macro-error2 2.0.1 | proc-macro-error2 is unmaintained | russh (from tablepro-ssh) | -| vulnerability | [RUSTSEC-2023-0071](https://rustsec.org/advisories/RUSTSEC-2023-0071) | rsa 0.9.10 | Marvin Attack: potential key recovery through timing sidechannels | russh (from tablepro-ssh), sqlx (from tablepro-driver-mysql), sqlx (from tablepro-driver-postgres), sqlx (from tablepro-driver-sqlite), sqlx (from tablepro-storage) | -| vulnerability | [RUSTSEC-2026-0154](https://rustsec.org/advisories/RUSTSEC-2026-0154) | russh 0.55.0 | Unbounded 32-bit allocation | russh (from tablepro-ssh) | -| vulnerability | [RUSTSEC-2026-0153](https://rustsec.org/advisories/RUSTSEC-2026-0153) | russh-cryptovec 0.52.0 | Unchecked `CryptoVec` allocation and growth handling | russh (from tablepro-ssh) | -| unmaintained | [RUSTSEC-2025-0134](https://rustsec.org/advisories/RUSTSEC-2025-0134) | rustls-pemfile 1.0.4 | rustls-pemfile is unmaintained | tiberius (from tablepro-driver-mssql) | -| vulnerability | [RUSTSEC-2026-0098](https://rustsec.org/advisories/RUSTSEC-2026-0098) | rustls-webpki 0.101.7 | Name constraints for URI names were incorrectly accepted | tiberius (from tablepro-driver-mssql) | -| vulnerability | [RUSTSEC-2026-0099](https://rustsec.org/advisories/RUSTSEC-2026-0099) | rustls-webpki 0.101.7 | Name constraints were accepted for certificates asserting a wildcard name | tiberius (from tablepro-driver-mssql) | -| vulnerability | [RUSTSEC-2026-0104](https://rustsec.org/advisories/RUSTSEC-2026-0104) | rustls-webpki 0.101.7 | Reachable panic in certificate revocation list parsing | tiberius (from tablepro-driver-mssql) | - -## Plan amendments - -- **/reconcile/conflicts[21], FB-D16, FB-C20, FC-D4, FC-C19, FD-C19, FC-C20**: Rewrite as: no fork; tiberius pinned to tiberius-rs/tiberius main c1d2e741, moved to main after #440-#443/#449 merge, crates.io 0.13 when released. allow-git lists only tiberius-rs/tiberius. The contract uses upstream Client::cancel_query, hostname_in_certificate, trust_cert_ca and upstream UDT/sql_variant mapping. The authoritative resolution currently mandates a TableProApp fork. The upstream revival covers four of the five commits. -- **W2-04, W2-05, W2-07**: Delete the fork commits. Contribute the PRELOGIN matrix, codec panic, sql_variant/geography/hierarchyid and attention tests upstream, and run them in CI against the pinned rev. W2-15 discards poisoned connections after cancel_query errors. Upstream #440/#442/#443/#449 carries this work. -- **W2-06, W2-08, FB-D16**: Turn them into upstream PRs (money_as_numeric, tls_client_config). Interim: CAST money to decimal(19,4)/decimal(10,4) in projections, approximate label for editor money, and trust_cert_ca plus system roots with coarser TLS classification. Additive and upstreamable. Upstream no longer panics, which removes the reason CAST was rejected. -- **W8-10, W8-11**: Pin tiberius to the upstream rev with default-features=false. The libgssapi direct edge follows the version tiberius resolves (0.8 at c1d2e741, 0.11 at the stack), checked with cargo tree -d. Fix the stale 'sqlx 0.8.6' text to 0.9.0. Avoids two GSSAPI bindings. The stack changed default features to native-tls. -- **FC-D1, FC-C24, W2-01, W2-02, W2-03, FC-D19, FC-D25**: crates.io tokio-postgres/postgres-types/postgres-protocol, no fork. Contribute to #1358 and open an upstream status PR. Implement exact status by per-statement tracking plus a SAVEPOINT probe. Editor columns text-typed until #1358 ships. Delete query_text_portal and replace it with the cursor flight (SAVEPOINT-wrapped inside open transactions, separate messages, error-position correction, 0A000/42601 fallback). Refuse COPY FROM STDIN. No Client::prepare anywhere. Allow-git has no rust-postgres entry unless the single-commit upstream pin fallback is chosen. Removes the fork while keeping exact status for read-only enforcement and the PgBouncer rule. -- **FC-D18, W2-12**: PostgreSQL pool via deadpool-postgres 0.14.2 Manager::from_connect with a Connect over the transport + connect_raw, RecyclingMethod::Verified, and Object::take for flights without a COMMIT/ROLLBACK acknowledgement. Delete PgPooled. Keep the 10,000-row enum/citext flight test as a read_form guard. No prepare_typed mitigation. A maintained manager exists. conflicts[12] already avoids #1348. -- **FC-D23, W2-10**: Use tokio-postgres-rustls 0.14 wrapped to record handshake_started. File an upstream PR adding md5WithRSA, ecdsa-with-SHA1 and ecdsa-with-SHA512 and adopt it after release. Delete channel_binding.rs and postgres_connector.rs and the direct x509-cert. Record it as a bus-factor-1 exception with an exit plan. Same algorithm, minus three OIDs, in a maintained crate. -- **W3-09, FB-D5**: Decode date/timestamp/timestamptz with postgres_types special types. Declare postgres-protocol directly for array, range, inet, macaddr, varbit, geometric, hstore, ltree and lsn. Keep custom only for time, timetz, interval and numeric (evaluate text-form numeric first). Rewrite the rationale that cites sqlx-postgres. Upstream decoders with checked arithmetic. -- **W8-12, /reconcile/conflicts[22]**: Do not keep the PG crate on sqlx 0.8.6 with workspace tls-rustls. Either port in the same switch or give it a sqlx entry without TLS features, so the ring ban holds. sqlx 0.8.6 tls-rustls resolves to ring (cargo tree -i ring). -- **FC-D17, /reconcile/conflicts[35], W1-01, W1-02, W1-03, W1-04, W1-13**: SSH transport becomes system OpenSSH via tokio::process: ControlMaster per destination under $XDG_RUNTIME_DIR, -O forward Unix sockets, SSH_ASKPASS_REQUIRE=force with an app askpass helper bridged to CredentialPrompter/AdwAlertDialog, command-line -o overrides, exit 255 + stderr classification, ssh-keygen -R for replace. Delete the token expansion, known_hosts matcher/verifier/store, revocation_list, auth_sequence, route* and nix dependencies. Keep SshDestination validation and the '--' separator. Detect host-key confirmation by prompt text. Treat all keyboard-interactive fields as secret. Only OpenSSH meets the config and known_hosts requirements. The re-implementation on single-owner russh is security-critical debt. -- **W8-04**: Limit to the interim russh 0.63.3 security bump (check_server_key signature, no TOFU writes). Do not build HostKeyVerifier/KnownHostsStore. Remove russh, ssh-key and internal-russh-forked-ssh-key when FC-D17 lands. RUSTSEC-2026-0153/0154 need a fix now. The infrastructure would be thrown away. -- **FC-D11, FD-D15, FD-D20, FD-D23, W9-02**: Map SSH deadlines to ConnectTimeout, ServerAliveInterval/CountMax, `ssh -O check` and `-O forward` exits, pausing while askpass is open. Document ssh/ssh-keygen from org.gnome.Platform//50 and the Flatpak limits (ProxyCommand/Match exec tools, hidden /etc/ssh, FIDO file keys). Add Depends openssh-client / Requires openssh-clients. Rescope the RSA advisory to sqlx-mysql public-key encryption. Add sshd fixtures for keyboard-interactive, @cert-authority and KRL, and a scripted askpass helper. The OpenSSH dependency becomes load-bearing and its limits must be explicit. -- **FC-D2, FC-C14, FC-D6, W2-13**: Remove PrivateSocketRelay. SSH uses MySqlConnectOptions.host(service).socket(ssh socket). Direct uses sqlx TCP with the health probe. Correct the mysql_async rejection text (it silently skips TLS on sockets). Point the tests disabled_mode_uses_rsa_not_cleartext and tls_upgrade_happens_over_socket at the ssh socket. Watch sqlx #4281, #4130, #4355 and #4045. If OpenSSH is rejected, keep the relay as a dated interim tied to #4281. OpenSSH owns a private socket, and the relay's remaining purpose is narrowed. -- **FC-D3**: Take the first sqlx 0.9.x patch containing #4336 (TCP_NODELAY). 0.9.0 does not set nodelay. -- **FC-D6, W2-10**: DirectTcpTransport: sequential tokio lookup_host + TcpStream::connect with per-address timeout and socket2 SockRef keepalive/TCP_USER_TIMEOUT/nodelay. Drop happy_eyeballs.rs, race_failure.rs and socket_tuning.rs, and the Happy Eyeballs requirement. Record GSocketClient as the native option. tablepro-net shrinks to dialer, TLS config builder and error classification. Standard API usage instead of a custom race or an HTTP legacy connector. -- **FC-D8, /reconcile/conflicts[14]**: rustls-platform-verifier new_with_extra_roots + a no-hostname wrapper for PostgreSQL and ClickHouse. SQL Server uses trust_cert_ca plus system roots until tls_client_config. MySQL uses sqlx roots plus the CA file (temporary PEM bundle via rustls-native-certs + pem-rfc7468 only if system CAs are required, tied to sqlx #4045). Drop direct webpki-roots. Document the per-engine trust sets and the Flatpak runtime-bundle limit (freedesktop-sdk #1905). rustls-recommended verifier. A single identical union is not reachable until upstream sqlx and tiberius changes land. -- **FA-D1, FA-D11, /reconcile/conflicts[29], W4-04, W4-13, W4-14, W9-10, W9-11, W9-12, W9-05, W8-14, FD-D2, FD-D4, FD-D9, W8-20**: Replace Relm4: SessionWindow and new widgets as gtk-rs subclasses with CompositeTemplate .ui in the GResource. Output enums become signals or gio actions. FactoryVecDeque becomes gio::ListStore. TokioSpawner over an app-owned Runtime with explicit worker_threads replaces RELM_THREADS. Remove relm4 clippy bans and the Relm4 harness (signal_log and action helpers instead). No RelmApp::from_app intermediate step. Declare gtk4 gnome_50, libadwaita v1_9, sourceview5 v5_18, glib/gio v2_88 directly. List every .ui in POTFILES. Final commit deletes relm4. 0/31 GNOME Rust apps use Relm4. Folding the migration into tasks that already rewrite these files avoids double work. -- **new (W4/W9)**: Add the task 'Migrate remaining Relm4 components to gtk-rs subclasses', ordered so every commit builds: 9 component impls, 11 spawn + 3 spawn_local + 20 Sender uses, 2 FactoryVecDeque sites. Enable gtk4-macros xml_validation. Gives the Relm4 removal an explicit atomic end state. -- **FD-D4, W1-05**: write_private_blocking calls set_permissions(0o600) on an existing file before file_set_contents_full. Storage history export uses the core CSV encoder with neutralisation. gfileutils.c preserves the old mode. The history csv_field is an injection path. -- **W5-14, W5-15, FB-D17**: csv 1.4 as a normal dependency. CsvEncoder/TsvEncoder over csv::WriterBuilder, with QuoteStyle::Always when sanitize_formulas is on. Neutralise LF and full-width = + - @ as well. Delete the custom quoting and storage csv_field. De facto writer, and it keeps the OWASP quoting measure. -- **W6-05, W6-14, W6-17, W1-16, FC-C23**: One core ScriptLexer/ScriptPlan used by execution, the format guard, completion scope, PG statement_shape and history classification. Time-boxed spike on the sqlparser Tokenizer with a raw-text DELIMITER/GO pre-pass, ClickHouse '#' comments and Dialect::dialect() forwarding. If it fails, a custom lexer with differential tests against sqlparser and sqlite3_complete (excluding BEGIN ATOMIC and trigger bodies). proptest for properties. Name pg_query as rejected. Removes duplicate lexers and anchors the rest to mainstream oracles. -- **FB-D3, W3-03**: Back WideInteger with num-bigint (version matching the lockfile after the SSH decision), validated by ^-?[0-9]+$ and a byte cap before FromStr. De facto crate; FromStr accepts '+' and '_'. -- **W2-14, FC-D16, FD-D11, FA foundation cancel text**: Use sqlparser CreateIndex with Spanned slices (fallback to raw text on empty spans). Keep the direct libsqlite3-sys range and widen it in lockstep with sqlx (no ~0.9.0 pin). File the upstream accessor PR. Record rusqlite + deadpool-sync as the fallback. Delete 'sqlite3_interrupt through lock_handle'. Correct FD-D11: all builds of storage and sqlite need libclang. Verified sqlx internals and build dependencies. -- **W2-16, FC-D5, D12**: Client::query_raw for editor SQL; keep Client::query binding for KILL QUERY. read_until on BytesCursor. clickhouse-types 0.1.3 direct. query_id via with_setting. Explicit lz4 feature decision. Network errors classified by source() downcast. Upstream PR for a structured code. Record fetch_native as rejected for now. Add a Unix-socket connector for SSH forwards. Vendor APIs replace custom escaping and buffering. -- **W3-07, W3-11**: ClickHouse identifiers and values through clickhouse::sql::Identifier/Query::param where app-built, or a drift test against the crate. PostgreSQL through postgres_protocol::escape. One core hex helper; no hex crate. Avoids silent drift from copied escape sets. -- **FD-D7, W8-21**: Default to tracing-subscriber fmt on stderr. Add tracing-journald only if structured journal fields are required. Drop the custom precedence rule. None of the GNOME reference apps use it. -- **FD-D1, W8-14**: Drop crates/build-support. config.rs via option_env! defaults or meson configure_file. build.rs only compiles resources. Translations and schemas via meson and meson devenv. GNOME Rust convention. -- **FD-D13, W8-23**: cargo-about handlebars templates for notices and debian/copyright (committed, regenerated in CI). RPM License from %cargo_license_summary. Delete license_metadata.py. The tool already templates. cargo-about is not packaged by distros. -- **FD-D14, W8-24**: Devel manifest in the app repo with --share=network. Release manifest and generated cargo-sources.json in the flathub/flathub new-pr branch, then flathub/app.tablepro.TablePro with x-checker-data. Delete render_flathub_manifest.py, check-screenshots.py and the committed cargo-sources.json. Flathub convention (Fractal, Loupe). -- **FD-D15**: Invoke flatpak-builder-lint with --exceptions --user-exceptions and keep the {app-id: [rules]} file format. cli.py reads user exceptions only when --exceptions is set. The list form is what the loader expects. -- **FD-D20, W8-28**: Committed build-aux/debian + dch --create + dpkg-buildpackage -b (rustup toolchain). Committed spec with cargo-rpm-macros; COPR via Packit or rpmdev-bumpspec. Delete assemble_debian.py, render_spec.py and control.in. Distro-standard tools; Fedora dist-git shows the macro path. -- **W9-03, FA-D18, /reconcile/conflicts[39], W8-15**: cargo-nextest in GitHub CI only: host job uses --workspace --exclude tablepro --exclude tablepro-test-support-gtk; test groups; JUnit. meson test and %check keep cargo test. clippy ignore_without_reason=deny. Set GTK_A11Y=test (not none) in gtk_test_env, Flatpak run-tests, dh_auto_test and %check. Delete integration_matrix.py. nextest is not packaged in distros, and test_accessible_* returns FALSE under none. -- **W9-05, W9-14**: Replace the whole-tree audit and stock_internals allowlist with targeted test_accessible_has_property/relation assertions on app-owned widgets. Remove TestSessionBus in favour of dbus-run-session. The allowlist is keyed to libadwaita private ids. -- **W9-04, W9-15, W9-16, W7-13, W8-15, W8-17, W8-20, W8-26, FD-D5, FD-D6**: lychee offline instead of check-doc-references.py. Drop check-source-size.py (clippy too_many_lines), check-regression-map.py (test naming), check-hig-capitalization.py (keep an em-dash grep), check-protected-msgids.py, check-icon-names.py (gtk::IconTheme test) and check-app-icons.py (appstreamcli/linter). Delete check-clippy-shared-keys.py but keep crates/app/clippy.toml. POTFILES via a git ls-files + git diff check. Custom gates without upstream equivalents. A root clippy.toml would ban tokio::spawn in the ssh and mssql crates. -- **FD-D12, FD-D10, W9-02**: Keep testcontainers 0.27 + testcontainers-modules 0.15 (default-features=false, aws-lc-rs). Re-evaluate when issue #497 ships or 0.28 is needed; switch to GenericImage then. The ring and h2 reasons for dropping it do not apply to our graph. -- **FD-D19, W8-10**: Add bans: ring, openssl-sys, native-tls, rustls-pemfile (after the tiberius pin), rust_decimal (after the switch), bigdecimal, backoff, instant, duplicate libsqlite3-sys. No wrapper allowlists for time or hex. Install tools with taiki-e/install-action. Remove drift diffs for generated files. Turns value-model and TLS decisions into CI gates without churn. -- **new (supply chain)**: Add Dependabot or Renovate (cargo, github-actions, container tags, tool pins), zizmor and actionlint, and a watch-list ADR reviewed each release. The list: tiberius 0.13/#427/advisory-db#3148; rust-postgres #1358, status PR, #1348/#1349; tokio-postgres-rustls OID PR; sqlx #4281/#4130/#4355/#4045/#3054 and the SQLite accessor; mssql-tds; testcontainers-modules #497; oo7 0.7; sqlformat; locale_config; connection-string; enum_dispatch; libgssapi; bnum; rsa/ssh-key release candidates. Evaluate cargo-auditable and typos. Pins and interim shims need a dated owner or they become permanent debt. -- **W9-18**: Add ADRs: gtk-rs subclasses (no Relm4); XML .ui (Blueprint deferred); SSH via system OpenSSH with the russh fallback; SQLite binding sqlx-sqlite with rusqlite fallback; no forks with upstream pins and removal gates; per-engine TLS trust; CSV via csv crate. Records the rejected alternatives so they are not reintroduced. -- **FD-D12 (oo7), W1-04, W1-07**: Stay on oo7 0.6 until 0.7 is stable; ashpd stays transitive. Hold askpass replies and decoded keyring bytes in zeroize::Zeroizing until converted to SecretString. GNOME-maintained client. zeroize pattern from Fractal and Pika Backup. -- **new (runtime channels)**: Choose one channel family for WorkspaceStore and ResumeDetector contracts: tokio::sync (already a dependency) or async-channel 2.5. Do not use three. async_channel appears in contracts but not in Cargo.lock. -- **W9-17**: ROADMAP: redis-rs instead of fred; mongodb with rustls-tls-aws-lc conditional on native-roots support or ca_file_path; duckdb official; scylla; Oracle blocked; watch mssql-tds. fred and oracle are stale. mongodb ignores the system trust store. -- **W5-13**: Format counts with icu_decimal or without grouping, instead of a hard-coded ','. Relative time uses ngettext_f. Locale correctness in a translated app. - -## Open risks - -- OpenSSH transport: stderr and prompt-text classification is not a stable API (LogLevel, locale, future wording). It needs pinned format tests against the OpenSSH shipped in CI and in GNOME 50. In Flatpak, ProxyCommand and Match exec tools, host /etc/ssh and FIDO file keys do not work; agent-held FIDO keys are unverified at runtime. -- tiberius 0.13 release depends on Prisma-era crates.io owners working in spare time; the stack is 23.5k lines, partly agent-authored and unreviewed. Once RUSTSEC advisory-db#3148 / GHSA-vch9-whpx-g727 is published, cargo-deny fails on any pin before #443 unless a time-limited ignore is documented. -- rust-postgres review latency (#1358 unanswered since 2026-05-29) may keep editor columns text-typed for a long time. The SAVEPOINT probe writes an ERROR line to server logs on each idle probe, and statement-class tracking must be proven for CALL/DO with transaction control. -- The PostgreSQL cursor path changes planner behaviour (cursor_tuple_fraction 0.1), falls back under PgBouncer statement pooling, and loses COPY TO STDOUT result display until upstream exposes CopyData. -- sqlparser breaks its API every 3-4 months, and the shared ScriptLexer spike may fail. The fallback is a documented custom lexer that must then be maintained against five dialects. -- Relm4 removal touches 28 of 43 app files. If not folded into W4/W9 rewrites it could stall mid-migration with two UI paradigms coexisting. -- Trust sets differ per engine (MySQL seeds webpki roots, SQL Server uses tiberius roots) until sqlx #4045 and tiberius tls_client_config land; inside Flatpak only the runtime CA bundle is visible (freedesktop-sdk #1905). -- tokio-postgres-rustls, sqlformat, cargo-shear, libgssapi and gettext-rs have concentrated maintainership. Exit costs are low but must be watched. -- sqlx-mysql direct TCP has no keepalive until #4355, so dead peers are detected only by the health probe and login deadlines. -- sqlx-sqlite unbundled forces libclang at build time for every build, and the direct libsqlite3-sys range can hold the graph back if it is not widened with sqlx patch releases. -- Ubuntu 26.04 rustc 1.93 is below sqlx 0.9's MSRV 1.94, so the .deb must be built with rustup and cannot enter a PPA or the Debian archive. -- Several claims remain unverified: FIDO via host agent in Flatpak, the exact merge timing of tiberius #442/#443, whether KILL QUERY accepts server-side parameters, and compression behaviour of fetch_bytes with lz4 disabled. - -## Appendix: dependency facts by domain - -### GUI stack and GNOME integration crates - -GUI stack and GNOME integration review for TablePro Linux, as of 2026-09-14. Evidence comes from crates.io API, GitHub and GNOME GitLab APIs, gnome-build-meta, Fedora mdapi, Launchpad, and blob-less git clones of 100 app repos linked from apps.gnome.org (27 Core, 73 Circle). - -Keep, all mainstream and active: -- gtk4 0.11.4 -- libadwaita 0.9.2 -- glib/gio 0.22.9 -- sourceview5 0.11.2, the only GtkSourceView binding, maintained by gtk-rs/GNOME people -- glib-build-tools 0.22.8 -- gettext-rs, moving to 0.8.0 as planned -- oo7 0.6.0 -- ashpd 0.13.13, pulled in by oo7 -- sqlformat 0.5.0, kept with the plan's W6-14 guard - -Main finding: Relm4 is the one tech-debt dependency in this area. -- **GNOME adoption:** 0 of the 30 Rust apps in GNOME Core and Circle use it. All 30 use plain gtk-rs subclassing with composite templates in GResource. -- **Its only Circle showcase is gone:** Relm4's README lists Exercise Timer, which moved to gitlab.gnome.org/World/exercise-timer and is now Vala. The GitHub repo is archived. -- **Adoption numbers:** 19 reverse dependencies on crates.io (gtk4 has 250, libadwaita 66). 142,743 downloads in 90 days (gtk4 793,494). Code-search hits in Cargo.toml: 566 for relm4 against 3,640 for gtk4. -- **Maintainers:** one main maintainer (AaronErhardt, 1,307 commits; crates.io owners are AaronErhardt and euclio). -- **Upgrade lag:** each gtk-rs release waits about 47 days for Relm4 (gtk4 0.10.0 on 2025-07-15, relm4 0.10.0 on 2025-09-01; gtk4 0.11.0 on 2026-02-20, relm4 0.11.0 on 2026-04-08). -- **The plan already works against it:** it bypasses RelmApp, subclasses adw::Application, bans six relm4 APIs in clippy.toml, wraps relm4's hidden tokio runtime (RELM_THREADS, relm4::spawn) in a TaskSpawner, and then builds new Relm4 components (W9-10, W9-12) and a Relm4-based test harness (W9-05). -- **Recommendation:** replace Relm4 with the GNOME standard: glib::Object subclasses, CompositeTemplate with .ui files in the embedded GResource, gio actions, signals and properties, and gio::ListStore models. This is the pattern in the gtk-rs book and in GNOME Builder's Rust template. -- **Timing:** fold the migration into the tasks that already rewrite those files (W4-13, W4-14, W9-10, W9-11, W9-12), then delete relm4. -- **Current usage:** 28 of 43 app source files import relm4, with 9 component impls, 11 relm4::spawn calls and RelmApp in main.rs. The grid is already built on its own GObject subclasses. - -Blueprint: use GtkBuilder XML .ui files for now. -- Blueprint is used by 12 of the 30 apps (XML by 17) and is popular. -- Its own docs say "Blueprint is still experimental. Future versions may have breaking changes." -- The plan's build targets ship different compiler versions: GNOME 50 SDK 0.20.4, Fedora 44 0.20.4, Ubuntu 26.04 0.19.0, upstream 0.22.2. - -gettext-rs 0.8.0 is confirmed in Fractal, Loupe, Snapshot, Newsflash, Shortwave, Citations and GNOME Tour. The small named-placeholder helper (gettext_f) is the Fractal pattern; formatx only does positional placeholders, so the helper is the lower-debt choice. - -oo7 is the GNOME-maintained Secret Service client, used by Authenticator, Pika Backup, Pods and Fractal. Plan to move to 0.7 when it goes stable. - -No forks in this area. - -| Crate | Role | Status | Latest | Released | Downloads (all / 90d) | Maintenance | Standing | Verdict | -|---|---|---|---|---|---|---|---|---| -| gtk4 | GTK 4 bindings, core widget toolkit | current | 0.11.4 (2026-06-29) | 2026-06-29 | 3,905,805 / 793,494 (90 days) | active | de_facto | keep | -| libadwaita | libadwaita bindings (AdwApplication, ApplicationWindow, dialogs, rows, toasts, AboutDialog, ShortcutsDialog) | current | 0.9.2 (2026-07-07) | 2026-07-07 | 2,243,097 / 428,198 | active | de_facto | keep | -| glib | GLib bindings: GObject subclassing, main context, spawn_future_local, user dirs, logging | current | 0.22.9 (2026-08-30) | 2026-08-30 | 50,638,897 / 14,830,297 | active | de_facto | keep | -| gio | GIO bindings: GApplication, GSettings, GResource, actions, ListStore, Notification, FileLauncher | planned | 0.22.9 (2026-08-30) | 2026-08-30 | 38,577,684 / 11,716,893 | active | de_facto | add | -| sourceview5 | GtkSourceView 5 bindings for the SQL editor (highlighting, completion, search, style schemes) | current | 0.11.2 (2026-07-23) | 2026-07-23 | 199,055 / 35,031 | active | de_facto | keep | -| relm4 | Elm-style component framework over gtk4-rs (Component, FactoryVecDeque, view! macro, internal tokio runtime) | current | 0.11.0 (2026-04-08) | 2026-04-08 | 685,848 / 142,743 | active | niche | replace | -| relm4-macros | Proc macros for relm4 (view!, #[relm4::component]) | current | 0.11.0 (2026-04-08) | 2026-04-08 | 686,386 / 143,717 | active | niche | drop | -| relm4-components / relm4-icons | Relm4 add-on component library and icon bundling (not used) | candidate | relm4-components 0.11.0 (2026-04-08); relm4-icons 0.11.0 (2026-04-06) | 2026-04-08 | 272,616 / 102,566 / 24,472 / 9,375 | active | niche | avoid | -| glib-build-tools | build.rs helper that runs glib-compile-resources for the embedded GResource | planned | 0.22.8 (2026-06-29) | 2026-06-29 | 446,368 / 74,461 | active | popular | add | -| gettext-rs | gettext bindings (setlocale, bindtextdomain, gettext, ngettext, pgettext) | current | 0.8.0 (2026-08-05) | 2026-08-05 | 4,681,336 / 537,802 | active | de_facto | keep | -| gettext-sys | FFI layer under gettext-rs | current | 0.27.0 (2026-08-05) | 2026-08-05 | 4,674,832 / 540,582 | active | de_facto | keep | -| gettext (justinas) | Pure-Rust .mo catalog reader (alternative to gettext-rs) | candidate | 0.4.0 (2019-06-04) | 2019-06-04 | 1,076,236 / 200,795 | stale | niche | avoid | -| i18n-embed / fluent | Fluent-based localisation (alternative pipeline) | candidate | i18n-embed 0.16.0 (2025-07-09) | 2025-07-09 | 5,931,830 / 2,517,682 | slow | popular | avoid | -| formatx / i18n-format | Runtime format-string substitution for translated strings (candidate for gettext_f) | candidate | formatx 0.4.2 (2026-07-20); i18n-format 0.4.0 (2025-12-25) | 2026-07-20 | formatx 1,020,221; i18n-format 137,179 / formatx 270,231; i18n-format 25,457 | active | niche | avoid | -| oo7 | Secret Service / keyring client with sandboxed file backend (saved passwords) | current | 0.6.0 stable (2026-02-21); 0.7.0-alpha (2026-07-17, MSRV 1.95) | 2026-07-17 | 1,911,972 / 995,644 | active | popular | keep | -| ashpd | XDG desktop portal client (transitive via oo7) | current | 0.13.13 (2026-07-17) | 2026-07-17 | 14,485,601 / 4,352,745 | active | de_facto | keep | -| keyring / secret-service | Cross-platform keyring alternative to oo7 | candidate | keyring 4.2.0 (2026-08-29); secret-service 5.2.0 (2026-08-29) | 2026-08-29 | keyring 24,822,890; secret-service 18,080,431 / keyring 10,402,447; secret-service 5,999,350 | active | popular | avoid | -| sqlformat | SQL reformatting in the editor (Ctrl+Shift+F) | current | 0.5.0 (2025-10-09) | 2025-10-09 | 63,574,692 / 4,033,921 | active | popular | keep | -| sqlparser | SQL tokenizer/parser (planned FB-D14); relevant here as the token-equality oracle for editor formatting | planned | 0.63.0 (2026-09-13) | 2026-09-13 | 77,314,066 / 11,984,869 | active | de_facto | keep | -| blueprint-compiler | Build-time compiler from .blp markup to GtkBuilder XML (candidate UI definition format) | candidate | v0.22.2 (2026-07-10) | 2026-07-10 | n/a (not a crate) / n/a | active | popular | avoid | -| gvdb / gvdb-macros | Pure-Rust GResource/GVariant database compiler (alternative to glib-build-tools) | candidate | gvdb 0.10.1 (2026-09-03); gvdb-macros 0.1.16 (2026-03-25) | 2026-09-03 | 215,502 / 130,492 / 21,452 / 10,305 | active | niche | avoid | -| tracing-journald | journald output layer for tracing (FD-D7) | planned | 0.3.2 (2025-11-26) | 2025-11-26 | 8,995,811 / 2,302,846 | slow | de_facto | add | -| cargo-about | Generates third-party license data for AdwAboutDialog legal sections and package metadata (FD-D13) | planned | 0.9.2 (2026-08-18) | 2026-08-18 | 1,175,829 / 242,897 | active | popular | add | -| libc | flock(2) for the hand-written single-instance gate | current | unverified | | / | unverified | de_facto | drop | - -Fact-check corrections: - -- relm4 risks (bus factor): claimed Bus factor about 1: AaronErhardt has 1,307 commits, crates.io owners are AaronErhardt and euclio., actual Too strong. Work is concentrated in one person, but others contribute. Commits since 2026-03-14 by author: AaronErhardt 41, dependabot 26, Lynnesbian 12, zekefast 9, bahrom04 7, dottorblaster 5, plus 5 people with 1 commit each. The Relm4 GitHub org has 6 public members: AaronErhardt, edfloreshz, euclio, hwittenborn, mmstick, pentamassiv. The crates.io owners (euclio, AaronErhardt) and the 19 reverse deps are correct. Fair wording: concentrated maintainership, not bus factor 1. (gh api repos/Relm4/Relm4/commits?since=2026-03-14; gh api orgs/Relm4/members; https://crates.io/api/v1/crates/relm4/owners) -- relm4 risks (RelmApp multi-window): claimed RelmApp cannot model multi-window GApplication; RelmApp has no open or handle-local-options hook., actual RelmApp::from_app(app: impl IsA) accepts any application object, including an adw::Application subclass, so the app can connect open and handle-local-options itself. The real limit is in RelmApp::run: it launches exactly one root Component in connect_startup, and connect_activate only re-shows app.active_window(). The conclusion (the plan must bypass RelmApp) holds, but the wording is wrong. (~/.cargo/registry/src/*/relm4-0.11.0/src/app.rs:47-56, 148-200) -- relm4 notable_users / GNOME survey counts: claimed 100 apps.gnome.org repos (27 Core, 73 Circle); 0 of the 30 Rust apps use Relm4., actual On 2026-09-14 apps.gnome.org lists 28 Core and 71 Circle apps (99). I resolved 90 repos and found 31 Rust apps: Core Disk Utility, Loupe, Papers, Snapshot, Tour; Circle Amberol, Audio Sharing, Authenticator, Bobby, Bustle, Citations, Converter, Decoder, Emblem, Eyedropper, Fragments, Fretboard, Hieroglyphic, Identity, Impression, Lorem, Mousai, NewsFlash, Obfuscate, Pika Backup, Resources, Share Preview, Shortwave, Text Pieces, Video Trimmer, Warp. None has relm4 in Cargo.lock or Cargo.toml. 9 pages gave no repo link (Console, Bazaar, Commit, Deja Dup, DieBahn, Hiit, Junction, Tangram, Workbench), so the true Rust count is 31 or more. The headline (no GNOME Core or Circle Rust app uses Relm4) stands. (https://apps.gnome.org/ plus each app page's issue link, Cargo.lock/Cargo.toml fetched at HEAD (scratchpad deps-review/gui-fc/survey.json)) -- relm4 risks (upgrade lag): claimed each gtk-rs release waits about 47 days for Relm4, actual 47 days for 0.11 (gtk4 0.11.0 on 2026-02-20, relm4 0.11.0 on 2026-04-08) and 48 days for 0.10 (gtk4 0.10.0 on 2025-07-15, relm4 0.10.0 on 2025-09-01; relm4 0.10.0-beta.4 came out 2025-08-23). Substance correct. (https://crates.io/api/v1/crates/gtk4 and https://crates.io/api/v1/crates/relm4 (versions[].created_at)) -- relm4 hand_rolled_to_replace location counts: claimed 11 relm4::spawn calls, actual 11 relm4::spawn calls confirmed, plus 3 relm4::spawn_local calls the review left out. 20 uses of relm4::Sender and 2 FactoryVecDeque sites also need migrating. 28 of 43 files, 5 SimpleComponent, 2 Component, 2 FactoryComponent, 5 view! and 3 #[relm4::component] are confirmed. (grep -rn over linux/crates/app/src) -- libadwaita (AdwShortcutsDialog) hand_rolled_to_replace / FD-D9 note: claimed AdwShortcutsDialog defined in shortcuts-dialog.ui (libadwaita 1.9), actual AdwShortcutsDialog has been available since libadwaita 1.8: libadwaita-rs 0.9.2 gates mod shortcuts_dialog on feature v1_8. The gnome_50/v1_9 floor still covers it. GtkShortcutsWindow being deprecated since GTK 4.18 is confirmed (gtk4 0.11.4 shortcuts_window.rs:57). (~/.cargo/registry/src/*/libadwaita-0.9.2/src/auto/mod.rs:279-280; gtk4-0.11.4/src/auto/shortcuts_window.rs:57) -- glib-build-tools notable_users: claimed GNOME Core and Circle apps mostly compile resources in meson (gnome.compile_resources) instead., actual Not 'mostly': 0 of 31 surveyed Rust Core/Circle apps have glib-build-tools in Cargo.lock, so all of them compile resources through meson. The gtk-rs book does use it (book/listings/Cargo.toml: glib-build-tools = "0.22"). 23 crates.io reverse deps. compile_resources returns () and uses unwrap/assert!, so the panic claim is confirmed. (survey.json; https://raw.githubusercontent.com/gtk-rs/gtk4-rs/main/book/listings/Cargo.toml; https://raw.githubusercontent.com/gtk-rs/gtk-rs-core/main/glib-build-tools/src/lib.rs) -- tracing-journald notable_users / maintenance: claimed notable users unverified; tokio-rs/tracing repo activity not checked, actual 0 of 31 surveyed Rust GNOME Core/Circle apps use it. Owners: hawkw, Ralith, github:tokio-rs:publish-tracing. 62 reverse deps. MSRV 1.65. Releases: 0.3.1 on 2024-11-29, 0.3.2 on 2025-11-26. tokio-rs/tracing last pushed 2026-05-30; tracing-subscriber's latest is 0.3.23 (2026-03-13). 'slow' is fair. (https://crates.io/api/v1/crates/tracing-journald; gh api repos/tokio-rs/tracing; survey.json) -- sourceview5 notable_users: claimed Pods (sourceview5 0.11); 8 crates.io reverse deps, actual Three GNOME Circle apps also lock sourceview5: Citations, NewsFlash, Text Pieces. Pods is confirmed (sourceview5 = 0.11). Stronger evidence than the review gave. (survey.json; https://raw.githubusercontent.com/marhkb/pods/main/Cargo.toml) -- oo7 notable_users: claimed Authenticator (0.6), Pika Backup (0.6), Pods (0.6), Health, Fractal (0.7.0-alpha), Key Rack (0.7.0-alpha), actual Papers, a GNOME Core app, locks oo7 0.6.0 and is missing from the list. Fragments locks 0.4.3. Confirmed: Authenticator 0.6.0, Pika Backup 0.6.0, Pods 0.6, Fractal 0.7.0-alpha. Health and Key Rack not verified. Latest figures: 1,916,767 downloads, 1,000,458 recent. 0.7.0-alpha has MSRV 1.95 (0.6.0 has 1.92). (survey.json; https://gitlab.gnome.org/World/fractal/-/raw/main/Cargo.toml; https://crates.io/api/v1/crates/oo7) -- cargo-about license: claimed Apache-2.0 per GitHub API (crate may be dual-licensed; unverified), actual MIT OR Apache-2.0 on crates.io for 0.9.2. The only owner is the embark-studios team. MSRV 1.88.0. (https://crates.io/api/v1/crates/cargo-about) -- gvdb / gvdb-macros repository / risks: claimed user-owned, 10 stars; Bus factor one., actual Not bus factor one. crates.io owners are felinira, sophie-h, gnome-rust-associated-admin and the github:gnome-rust:associated-maintainers team. Still niche (4 and 0 reverse deps), so 'avoid' holds. (https://crates.io/api/v1/crates/gvdb/owners) -- formatx / i18n-format repository / license: claimed repository unverified, license unverified, actual formatx: github.com/clitic/formatx, single owner clitic, MIT OR Apache-2.0, 11 reverse deps. i18n-format: github.com/hfiguiere/i18n-format, single owner hfiguiere, MIT, 0 reverse deps. (https://crates.io/api/v1/crates/formatx; https://crates.io/api/v1/crates/i18n-format) -- i18n-embed / fluent license / owners: claimed license unverified, actual MIT, single crates.io owner kellpossible, 47 reverse deps. No release since 0.16.0 (2025-07-09), though the repo was pushed 2026-09-07. (https://crates.io/api/v1/crates/i18n-embed; gh api repos/kellpossible/cargo-i18n) -- keyring / secret-service license / repository: claimed license unverified; repository https://github.com/hwchen/secret-service-rs, actual Both MIT OR Apache-2.0. The secret-service repo now lives at open-source-cooperative/secret-service-rs (GitHub redirect, pushed 2026-08-30). keyring has 711 reverse deps; secret-service has 33. (https://crates.io/api/v1/crates/keyring; https://crates.io/api/v1/crates/secret-service; gh api repos/hwchen/secret-service-rs) -- gettext (justinas) license: claimed unverified, actual MIT, single owner justinas, repo last pushed 2023-10-27, 13 reverse deps. (https://crates.io/api/v1/crates/gettext; gh api repos/justinas/gettext) -- libc latest_version / maintenance: claimed unverified, actual Latest stable 0.2.189 (2026-07-21); 1.0.0-alpha.4 is also out. Owned by rust-lang-owner. Active. Workspace lock has 0.2.189. (https://crates.io/api/v1/crates/libc) -- sqlformat notable_users: claimed Historically sqlx-core 0.7.x (^0.2.0) ... sqlx-core 0.8.6 and 0.9.0 no longer depend on it., actual sqlx-core 0.8.0 also depended on sqlformat ^0.2.0; the dependency was dropped later in 0.8.x. Recent main-branch fixes (2026-06-18 to 2026-07-02) are by Huliiiiii, not the owner, and none has been released since 0.5.0 (2025-10-09). Single crates.io owner: shssoichiro. (https://crates.io/api/v1/crates/sqlx-core/0.8.0/dependencies; gh api repos/shssoichiro/sqlformat-rs/commits) -- relm4-icons license: claimed Apache-2.0 OR MIT (unverified for icons), actual (Apache-2.0 OR MIT) AND CC0-1.0 AND MIT; single owner AaronErhardt. (https://crates.io/api/v1/crates/relm4-icons) -- GNOME Builder Rust template relm4 rationale evidence: claimed GNOME Builder's Rust template generates window-gtk4.rs with CompositeTemplate plus window-gtk4.ui, actual Structure confirmed. But the template's Cargo-gtk4.toml still pins gtk4 0.9 (gnome_47), libadwaita 0.7 (v1_6) and gettext-rs 0.7, so it shows the pattern, not current versions. (https://gitlab.gnome.org/GNOME/gnome-builder/-/raw/main/src/plugins/meson-templates/resources/src/Cargo-gtk4.toml) - -Verdict challenges: - -- tracing-journald: add to optional: default to tracing-subscriber fmt on stderr; add tracing-journald only if structured journal fields are a stated requirement. None of the 31 surveyed GNOME Core/Circle Rust apps use tracing-journald. Fractal, Loupe, Snapshot, Authenticator and Pika Backup all use plain tracing-subscriber. The plan (FD-D7) adds journald detection plus a RUST_LOG > G_MESSAGES_DEBUG > profile precedence rule, which partly re-implements GLib's own log-writer selection. Least debt is what the reference apps do. The crate is mainstream (tokio-rs, 2.3M recent downloads) but releases slowly (0.3.1 in 2024-11, 0.3.2 in 2025-11). If journald fields are needed, keep it, and keep the detection code minimal. -- blueprint-compiler: avoid to defer (XML .ui now, revisit later). The supporting facts check out: 0.22.2 released 2026-07-10, Ubuntu 26.04 ships 0.19.0-2, Fedora 44 and the GNOME 50 SDK ship 0.20.4, and the docs say 'Blueprint is still experimental.' But Blueprint is a GNOME-namespace project used by many GNOME Rust apps (Fractal has 148 .blp files and 1 .ui). gtk4 0.11.4 even ships a `blueprint` feature in gtk4-macros. 'Avoid' marks it as debt, which overstates the case. The review's own rationale already says 'revisit', so the verdict should say defer. -- sqlformat: keep to keep, with an explicit exit criterion. Keep is right because no mainstream alternative exists. I checked sqlparser 0.63's `{:#}` pretty-printer (README): it rebuilds from the AST, so it drops comments and fails on vendor syntax it cannot parse, which rules it out for an editor formatter. The debt should still be recorded: sqlformat has one crates.io owner, and fixes merged in 2026-06/07 are unreleased (last release 0.5.0, 2025-10-09). The ADR should say to send guard-found mis-lexes upstream, and to hide or drop Format SQL rather than fork if the crate stops releasing. - -### PostgreSQL driver and connection pooling - -PostgreSQL stack review, 2026-09-14. You can meet this domain's requirements without a TableProApp fork of rust-postgres. - -**Recommended stack, all from crates.io** -- tokio-postgres 0.7.18, postgres-types 0.2.14 and postgres-protocol 0.6.12. -- deadpool-postgres 0.14.2 over deadpool 0.13.1. It already takes a custom `Connect`, so `connect_raw` over the tablepro-net stream plugs in. This replaces the planned PgManager written directly on deadpool. -- tokio-postgres-rustls 0.14.0 with the aws-lc-rs feature. It already does tls-server-end-point channel binding over any stream. This replaces the planned hand-written connector and the x509-cert 0.3 code. -- Replacing sqlx-postgres with tokio-postgres is right. In sqlx-postgres v0.9.0, process_id and secret_key are private (connection/mod.rs:49-56) and `in_transaction` is `pub(crate)` (line 143). So it has no CancelRequest and no transaction status. sqlx stays for MySQL and SQLite. - -**The three fork commits, checked against upstream** -1. **Column type OIDs.** Upstream already has this in flight: issue #1329, PR #1338 (OID only) and PR #1358 (OID plus type modifier). #1358 has been waiting on maintainer review since 2026-05-29. Nothing in the plan uses table_oid or column_id except the fork's own tests. Help land #1358 instead of writing a competing commit. Until it ships, single statements get types from upstream `Client::prepare`, which describes without executing. Scripts show text columns. -2. **Transaction status.** No maintained Rust PostgreSQL crate exposes this publicly. Upstream `simple_query.rs` drops the ReadyForQuery status byte, `check_connection` and `query::sync` discard it too, and no issue or PR asks for it. This is the one real hard gap. Least-debt path: file a small additive PR upstream. Meanwhile, either report a conservative Idle/Unknown state, or pin to the upstream PR ref (`rev = "refs/pull/N/head"`, which Cargo documents) with a removal gate. No TableProApp repository either way. -3. **Row-limited text portal.** Drop it. The standard SQL cursor path does the same job: `DECLARE ... NO SCROLL CURSOR` plus `FETCH FORWARD n` over upstream `simple_query_raw`, inside a BEGIN/COMMIT the driver owns. psql up to 16 used this exact pattern for FETCH_COUNT. Upstream is also least likely to accept a text portal: the library is designed around binary results, and sfackler said a text API would be awkward (#882). - -**Maintenance and precedent** -- rust-postgres ships about every quarter: 0.7.14 on 2025-09-25 through 0.7.18 on 2026-06-12. It had 56 merged PRs in the last year. Effectively one active maintainer (paolobarbolini wrote 109 of 135 commits since 2025-09-01), and review is slow. -- Real open risk: an upstream deadlock in `query_typed` when a result has non-built-in types (#1348, #1349, open since 2026-05-11). The plan's pipelined workspace reads can trigger it on tables with enum, citext or PostGIS columns. It needs a workaround and a test. -- Precedent without a fork: Tabularis (4.9k stars, Tauri) uses tokio-postgres, deadpool-postgres and tokio-postgres-rustls. rsql uses tokio-postgres 0.7.18. diesel-async uses tokio-postgres. rainfrog and SeaORM use sqlx. Neon, Materialize, Prisma and ReadySet do carry forks, but each has staff to maintain them. - -| Crate | Role | Status | Latest | Released | Downloads (all / 90d) | Maintenance | Standing | Verdict | -|---|---|---|---|---|---|---|---|---| -| tokio-postgres | PostgreSQL wire client: connect_raw over injected streams, CancelToken::cancel_query_raw, simple_query_raw for user SQL, query_typed/execute_typed for app SQL, pipelining | planned | 0.7.18 (2026-06-12) | 2026-06-12 | 66,773,558 / 16,598,527 (90 days) | active | de_facto | add | -| TableProApp/rust-postgres fork (tokio-postgres, postgres-protocol, postgres-types patched at one rev on tokio-postgres-v0.7.18) | Planned carrier for SimpleColumn type_oid/table_oid/column_id, TransactionStatus from ReadyForQuery, and Client::query_text_portal with CopyFail/CopyData handling (FC-D1, FC-C24, W2-01..W2-03) | planned | n/a (does not exist yet) | | / | unverified | niche | avoid | -| postgres-types | FromSql/ToSql and Type/OID mapping for tokio-postgres; custom FromSql impls (numeric_wire, interval_wire) and the text-format PgParam ToSql | planned | 0.2.14 (2026-06-12) | 2026-06-12 | 70,797,750 / 17,652,391 (90 days) | active | de_facto | add | -| postgres-protocol | Binary value decoders for W3-09 (array_from_sql, range_from_sql, inet_from_sql, macaddr_from_sql, varbit_from_sql, point/box/path_from_sql, date/time/timestamp_from_sql raw values) and SCRAM | candidate | 0.6.12 (2026-06-12) | 2026-06-12 | 71,684,917 / 17,713,795 (90 days) | active | de_facto | add | -| sqlx (sqlx-postgres backend) | Current PostgreSQL driver backend (PgPoolOptions, sqlx::query) in crates/drivers/postgres | current | 0.9.0 (2026-05-21, MSRV 1.94.0) | 2026-05-21 | sqlx 145,371,371; sqlx-postgres 121,386,864 / sqlx 36,185,734; sqlx-postgres 35,227,582 (90 days) | active | de_facto | replace | -| deadpool | Generic async pool (managed) used by the PostgreSQL, MySQL and SQL Server workspace pools (FC-D18) | planned | 0.13.1 (2026-08-26) | 2026-08-26 | 130,067,203 / 31,884,341 (90 days) | active | de_facto | keep | -| deadpool-postgres | tokio-postgres pool manager: Manager::from_connect with a custom Connect (tablepro-net stream + Config::connect_raw + TLS), RecyclingMethod, ClientWrapper deref to Client (cancel_token, is_closed) | candidate | 0.14.2 (2026-08-26) | 2026-08-26 | 22,241,763 / 5,689,263 (90 days) | active | popular | add | -| tokio-postgres-rustls | rustls TlsConnect/MakeTlsConnect for tokio-postgres with tls-server-end-point channel binding (FC-D23) | candidate | 0.14.0 (2026-05-21) | 2026-05-21 | 14,269,341 / 4,673,348 (90 days) | active | popular | add | -| x509-cert | Planned direct dependency of tablepro-net used only to read the certificate signature algorithm for channel binding (FC-D23) | planned | 0.3.0 (2026-07-09) | 2026-07-09 | 38,343,542 / 13,802,213 (90 days) | active | de_facto | drop | -| rust_decimal (db-tokio-postgres feature) | Listed in FC-D1 for the PostgreSQL crate | planned | 1.43.0 (2026-09-02) | 2026-09-02 | 143,157,270 / 33,937,317 (90 days) | active | de_facto | drop | -| bb8 / bb8-postgres | Alternative async pool and tokio-postgres manager | candidate | bb8 0.9.1 (2025-11-24); bb8-postgres 0.9.0 (2024-12-09) | bb8 2025-11-24; bb8-postgres 2024-12-09 | bb8 36,100,076; bb8-postgres 5,746,583 / bb8 6,722,991; bb8-postgres 955,853 (90 days) | slow | popular | avoid | -| postgres-openssl / postgres-native-tls | Upstream TLS connectors for tokio-postgres | candidate | 0.5.3 both (2026-03-30) | 2026-03-30 | postgres-native-tls 6,744,922; postgres-openssl 4,638,420 / postgres-native-tls 2,372,008; postgres-openssl 543,562 (90 days) | active | popular | avoid | -| pq-sys (libpq) | Reference C client that natively provides PQtransactionStatus, PQftype on every result, PQsetChunkedRowsMode (PG17 libpq) and PQcancel | candidate | 0.7.5 (2025-10-15) | 2025-10-15 | 20,044,972 / 2,766,760 (90 days) | slow | popular | avoid | -| pg_query | Candidate for exact PostgreSQL statement splitting/classification (FC-C23) using libpg_query | candidate | 6.2.0 (2026-08-03) | 2026-08-03 | 2,359,712 / 780,901 (90 days) | active | niche | avoid | - -Fact-check corrections: - -- tokio-postgres rationale (API version history): claimed query_typed/execute_typed (0.7.17), actual query_typed shipped in 0.7.11 (2024-07-21). 0.7.17 (2026-03-30) added execute_typed, query_typed_one and query_typed_opt. The other dates are right: simple_query_raw and check_connection in 0.7.15, Column::type_modifier in 0.7.16. (CHANGELOG.md in https://static.crates.io/crates/tokio-postgres/tokio-postgres-0.7.18.crate (lines 14-16, 111)) -- tokio-postgres (open deadlock #1348/#1349) risks / forks_and_hard_requirements mitigation: claimed Mitigation: call prepare_typed before executing, because prepare resolves types after Describe with no row backlog, actual The #1348 author says the deadlock hits 'Client::query_typed_raw (and the streaming-prepare code path)'. prepare.rs also resolves non-builtin OIDs through get_type, which prepares named, cached typeinfo statements. Separately, the reconciled plan contract PG-DRIVER says 'Client::prepare is never called'. So prepare is neither shown safe nor allowed. (https://github.com/rust-postgres/rust-postgres/pull/1348 (body); tokio-postgres-0.7.18/src/prepare.rs:66,89,97,135-144; plan.json /reconcile/canonical_contracts[18]) -- tokio-postgres (open deadlock #1348/#1349) risks: claimed The plan's pipelined workspace reads (FC-D21) can trigger it on tables with enum, citext or PostGIS columns, actual The reconciled plan already rules this out. conflicts[12] sets PgDialect read_form = ServerText (::text) for every column whose OID is not a postgres_types built-in: enum, range, composite, spatial, other, and arrays of those. Domains send their base OID. PG-DRIVER guarantees built-in-only result OIDs, tested behind PgBouncer with max_prepared_statements=0. The review read FC-D21 without the reconcile section. (plan.json /reconcile/conflicts[12]/resolution, /reconcile/canonical_contracts[18], /reconcile/global_decisions[8]) -- TableProApp/rust-postgres fork (interim for type OIDs) forks_and_hard_requirements R1: claimed A describe-only named statement from Client::prepare is harmless under every PgBouncer mode, actual PgBouncer tracks protocol-level named statements only when max_prepared_statements > 0. Before 1.24.0 (2025-01-10) the default was 0. The plan's own PgBouncer fixture sets max_prepared_statements = 0. The prepare name s{n} comes from a process-global counter, and typeinfo statements are cached per Client. Under transaction pooling those names can end up missing on, or colliding with, another server connection. (https://raw.githubusercontent.com/pgbouncer/pgbouncer/master/NEWS.md (lines 161-163, 334-343); plan.json /workstreams[8]/plan/tasks[1]/change; tokio-postgres-0.7.18/src/prepare.rs:59-66,195-208) -- rust_decimal (db-tokio-postgres feature) rationale / FC-D1 amendment: claimed FC-D1 adds rust_decimal db-tokio-postgres only for the contract switch to delete it, actual The reconciled PG-DRIVER contract already says 'Neither rust_decimal nor any sqlx-postgres'. The drop verdict is right, but the amendment is redundant. Only the older FC-D1 decision text still names it. (plan.json /reconcile/canonical_contracts[18]/final_definition) -- bb8 / bb8-postgres rationale: claimed bb8 commits since 2025 are mostly dependabot bumps, actual bb8 has 56 commits since 2025-01-01: 25 by djc, 25 by dependabot[bot], 6 by others. Last push 2026-08-28, 953 stars, 27 open issues. bb8 0.9.1 released 2025-11-24. bb8-postgres 0.9.0 released 2024-12-09 (21 months), so the stale label fits bb8-postgres releases only. (gh api repos/djc/bb8/commits?since=2025-01-01; https://crates.io/api/v1/crates/bb8/versions; https://crates.io/api/v1/crates/bb8-postgres/versions) -- tokio-postgres-rustls maintenance: claimed active, actual Active by the 6-month rule only because 0.14.0 came out 2026-05-21. History is bursty: 0.13.0 (2024-10-19) to 0.14.0 was a 19-month gap, and nothing was committed between 2024-11-03 and 2025-10-31. Channel-binding fix PR #32 (conradludgate) merged 2025-10-31 and went unreleased for about 7 months. Personal repo, 42 stars, sole crates.io owner jbg. Downloads: 14,281,231 all-time, 4,685,299 recent. (https://crates.io/api/v1/crates/tokio-postgres-rustls/versions; gh api repos/jbg/tokio-postgres-rustls/commits; gh api repos/jbg/tokio-postgres-rustls) -- tokio-postgres-rustls rationale: claimed Feature aws-lc-rs selects the provider, actual Features aws-lc-rs and ring only switch on rustls/aws_lc_rs or rustls/ring (and the tokio-rustls equivalents). MakeRustlsConnect::new(ClientConfig) and the TlsConnect impls are not feature-gated, so the provider is whatever the app's ClientConfig was built with. The crate sets no ALPN protocols (no 'alpn' anywhere in lib.rs). (tokio-postgres-rustls-0.14.0/Cargo.toml [features]; tokio-postgres-rustls-0.14.0/src/lib.rs:61,248-262) -- sqlx (sqlx-postgres backend) notable_users: claimed Tabularis uses sqlx for MySQL and SQLite, actual Tabularis enables sqlx 0.8.6 with sqlite, mysql and postgres features (tls-rustls-ring-native-roots). Its tokio-postgres 0.7.13 + deadpool-postgres 0.14.1 + tokio-postgres-rustls 0.13 path uses rustls with ring plus rustls-platform-verifier 0.6, not aws-lc-rs. Stars 4,929, verified. (https://raw.githubusercontent.com/TabularisDB/tabularis/main/src-tauri/Cargo.toml (lines 31, 69-86)) -- sqlx (W8-12 amendment) risks: claimed Leaving crates/drivers/postgres on sqlx 0.8.6 only adds a second sqlx-core; there is no conflict, actual The workspace sqlx entry enables tls-rustls. In sqlx 0.8.6 that feature is tls-rustls-ring, then tls-rustls-ring-webpki. Today's lockfile already carries ring 0.17.14 through sqlx-core 0.8.6 (cargo tree -i ring). The plan bans ring, so the interim PostgreSQL crate must not inherit the workspace sqlx features, or the ban must land after the switch. (~/.cargo/registry/src/.../sqlx-0.8.6/Cargo.toml:501-511; cargo tree --offline -i ring; linux/Cargo.toml:32; plan.json foundations[2]/design/summary ('ring, rustls<0.23 and openssl-sys are banned')) -- pq-sys (libpq) repository / owners: claimed https://github.com/sgrif/pq-sys (personal), actual crates.io owners are sgrif and the github:diesel-rs:core team. Repo last pushed 2026-03-02, 51 stars. 0.7.5 released 2025-10-15. The avoid verdict is unaffected. (https://crates.io/api/v1/crates/pq-sys/owners; gh api repos/sgrif/pq-sys) -- rust-postgres PR #1358 forks_and_hard_requirements R1: claimed Waiting on maintainer review since 2026-05-29, actual Confirmed. The only review is the author's own COMMENTED. The 2026-08-20 ping to paolobarbolini has no reply. mergeable_state is clean. Head commit 42cd88328a583118a2f599dc68faa732308645d5 is on tristan957/rust-postgres. Issue #1329 got a 'Bump!' on 2026-08-20. (gh api repos/rust-postgres/rust-postgres/pulls/1358, /pulls/1358/reviews, /issues/1358/comments, /issues/1329/comments) - -Verdict challenges: - -- TableProApp/rust-postgres fork: commit 2 (transaction status) and the review's option (a) 'report Idle/Unknown': avoid the fork; default interim is option (a): Idle when no top-level BEGIN, otherwise Unknown to avoid the fork, but reject option (a). Exact status is a hard requirement. Get it either with upstream crates.io tokio-postgres plus deterministic tracking and a SAVEPOINT probe, or with a pin to one upstream PR commit.. The review missed that W2-12 read-only enforcement depends on exact status. -- Its routing: Idle plus statement becomes START TRANSACTION READ ONLY / statement / COMMIT; Idle plus TransactionStart becomes statement then SET TRANSACTION READ ONLY; InTransaction or Failed is sent as-is (plan.json /workstreams[1]/plan/tasks[11]/change). -- With Unknown, wrapping a session that is really InTransaction commits the user's open transaction, because START TRANSACTION inside a block only warns. -- Sending as-is a session that is really Idle lets writes through read-only mode, which is a security regression. - -Fork-free exact option: -- Editor sessions already send one statement per message, so status follows from the statement class plus success or failure under PostgreSQL rules. This is my inference and needs tests: COMMIT in a failed block rolls back (xact.c TBLOCK_ABORT becomes TBLOCK_ABORT_END); COMMIT AND CHAIN and ROLLBACK TO SAVEPOINT re-enter the block. -- Probe only after an Undetermined or multi-statement message: SAVEPOINT uses RequireTransactionBlock, so it raises ERROR 25P01 when Idle (utility.c:663, xact.c:3701). It succeeds inside a block (then RELEASE) and fails with 25P02 in an aborted block. -- Cost: one round trip and a server ERROR log line per Idle probe. - -If protocol-exact status is preferred: -- Cargo [patch] allows one source per crate, so #1358 and a status PR cannot be pinned as two refs. -- Stack the status PR on #1358's branch and pin tokio-postgres, postgres-protocol and postgres-types to the same 40-hex commit. refs/pull/N/head moves on force-push; Cargo documents the ref form, but a commit hash is reproducible. -- TableProApp/rust-postgres fork: commit 1 (type OIDs), interim Client::prepare describe (FC-D1 and W2-01 amendments): avoid the fork; use a pipelined Client::prepare for Single statements until #1358 ships to avoid the fork; do not use prepare. Interim is text-typed editor columns, or the single upstream-commit pin described above.. prepare breaks the reconciled PG-DRIVER pooler rule: no named prepared statements, 'Client::prepare is never called', and a PgBouncer max_prepared_statements=0 conformance test that asserts no named Parse is ever sent. -- prepare names statements s{n} (prepare.rs:66). -- It resolves non-builtin OIDs through named, cached typeinfo statements. -- PR #1348 names the prepare path as deadlock-prone. - -The plan's editor decoder consumes type_oid ('Editor sessions use the text portal or simple protocol with the fork's type_oid and a per-session pg_type cache'), so the gap is user-visible. Until #1358 is released, text-only typing is the honest interim. - -The review is right that no consumer of table_oid or column_id exists, and that #1358 is the upstream vehicle. -- TableProApp/rust-postgres fork: commit 3 (text portal) replaced by the SQL cursor path (W2-03 deletion, FC-D19 amendment): drop the portal; use DECLARE NO SCROLL CURSOR + FETCH FORWARD only when the session is known Idle, otherwise simple_query_raw reading past the limit to replace with the cursor path, conditionally: also cover InTransaction through a savepoint wrapper, and keep an exact-status source. 1. It regresses a fix the plan already accepted. After review issue [15] (F-012), the plan uses the row-limited portal 'when the status is Idle or InTransaction' (plan.json /workstreams[1]/plan/coverage[3]). Idle-only brings back full-result transfer inside open transactions. - -2. Inside a block, a refused DECLARE aborts the user's transaction for a statement that would have succeeded. Refusals: data-modifying WITH is 0A000, SELECT INTO is 42601 (analyze.c transformDeclareCursorStmt, analyze.c:1354-1357). The flight must be SAVEPOINT; DECLARE; FETCH, then ROLLBACK TO SAVEPOINT on refusal or RELEASE after the fetch. - -3. If DECLARE ... FOR and FETCH share one Query string, a trailing -- comment in swallows the FETCH. psql sends DECLARE as its own PQexec with a newline after FOR (psql REL_16 common.c:1843). Use separate pipelined messages or add a newline. - -4. The cursor path needs exact Idle or InTransaction status, so it depends on the challenge above. - -5. COPY TO STDOUT output regresses: upstream simple_query yields Err for every CopyData (simple_query.rs:112), while the plan showed it as a text result set. - -Neutral point: both DECLARE CURSOR and an Execute with a non-zero fetch count disable parallel plans (parallel.sgml:172-173,232), so the plan and the review do not differ there. - -The psql REL_16 precedent is verified (ExecQueryUsingCursor, BEGIN when PQTRANS_IDLE, _psql_cursor, FETCH FORWARD). psql 17 does use PQsetChunkedRowsMode. -- W2-12 deadlock mitigation amendment (rust-postgres #1348/#1349): add a ::text cast or prepare_typed mitigation plus an enum/citext integration test to no plan change beyond keeping the regression test; delete the prepare_typed alternative. reconcile/conflicts[12] already projects every non-builtin column as ::text for app SQL, for PgBouncer reasons, which also avoids #1348. The prepare_typed alternative breaks the PG-DRIVER rule and is named in #1348 as affected. The 10,000-row enum/citext flight test is still worth keeping as a guard for the read_form invariant. -- FC-C23 statement_shape over sqlparser 0.63 tokenizer: replace the hand-written lexer with the sqlparser Tokenizer (PostgreSqlDialect) to add sqlparser tokenizer only if it passes the plan's existing script_split test list; otherwise keep the psqlscan-equivalent splitter as a documented exception and share it with the editor workstream. Verified in sqlparser 0.63.0: PostgreSqlDialect supports nested comments and E'' strings, emits DollarQuotedString tokens, and allows $ in identifiers. - -The plan's W2-12 script_split still needs work that tokens do not provide: -- BEGIN/CASE...END depth inside CREATE [OR REPLACE] FUNCTION|PROCEDURE, so that 'CREATE FUNCTION ... BEGIN ATOMIC ... END' is one statement. The review's 'split on SemiColon at paren depth 0' breaks this. -- Plain backslash strings with standard_conforming_strings off. sqlparser has no such mode, so the review turns them into Undetermined, a regression. - -The tokenizer also builds a token Vec for the whole buffer. Memory on multi-MB dumps is unmeasured. - -pg_query::split_with_parser and split_with_scanner (pganalyze, PostgreSQL's own grammar) are the exact alternative. Rejecting them for bindgen 0.66, cc and prost-build at build time is defensible, but they should be named. - -workstreams[5] already plans psqlscan.l compound tracking for the editor, so there should be one shared splitter, not two. -- tokio-postgres-rustls: add to add, with bus-factor-1 noted as an accepted exception and an exit plan. No more standard option exists. The rust-postgres org ships only postgres-openssl and postgres-native-tls, both OpenSSL-based. A crates.io search for 'postgres rustls' finds no other connector with meaningful downloads. - -The code matches FC-D23 except for ecdsa-with-SHA512. Verified in 0.14.0: channel_binding_digest maps SHA-1/256 RSA and ECDSA-SHA256 to SHA-256, SHA-384 to SHA-384, SHA-512 RSA to SHA-512, and Ed25519 to none. The TlsConnect impl is generic over the stream. - -It is a personal repo with bursty releases (a 19-month gap before 0.14.0). The low exit cost (lib.rs is 416 lines including tests, MIT) is what makes it acceptable. Record that explicitly in the supply-chain ADR. -- deadpool-postgres: add to add (agree). Verified in the 0.14.2 crate, not just main: -- pub trait Connect { fn connect(&self, &PgConfig) -> BoxFuture), Error>> } and Manager::from_connect. -- recycle checks is_closed(), then simple_query(sql). -- Dependencies: deadpool 0.13.1 (managed) and tokio-postgres 0.7.14+. Default feature rt_tokio_1, so deadpool recycle and create timeouts are available. -- deadpool 0.13.1 Object::take exists (object.rs:54). Owners include github:tokio-rs:core. - -Implementation note: ClientWrapper's Drop aborts the connection task (lib.rs:309-312). A taken object therefore closes without a Terminate message. -- sqlx (sqlx-postgres) replace: replace for PostgreSQL only to replace (agree), but fix the W8-12 interim. Verified in sqlx-postgres 0.9.0: -- process_id (line 51) and secret_key (line 56) are private. -- in_transaction is pub(crate) (line 143). -- The stream is only ever opened through net::connect_tcp or connect_uds from options (stream.rs:46-47). There is no injected-stream connect. -- Issue #183 has been open since 2020-03-30. - -The W8-12 interim (PostgreSQL crate stays on sqlx 0.8.6) keeps ring in the graph through the workspace tls-rustls feature, which conflicts with the plan's ring ban. The crate would need its own sqlx entry without TLS features, or the ban has to be ordered after the switch. - -### MySQL/MariaDB and SQL Server drivers - -MySQL/MariaDB: keep sqlx-mysql. Move to 0.9.0 as planned, and take the next 0.9.x patch because TCP_NODELAY was fixed on main after 0.9.0 (transact-rs/sqlx#4336, merged 2026-08-17). I found no mainstream MySQL crate that both accepts an injected stream and verifies TLS against a name different from the dial host, so the planned private Unix-socket relay is the least-debt way to close F-122 today. - -- **sqlx 0.9.0 gaps (verified in source):** the TLS name is always options.host (sqlx-mysql/src/connection/tls.rs:62). It does upgrade TLS over a Unix socket, which is why the relay works. It sends the password in clear text only when is_tls is set (auth.rs:134), so mysql-rsa is required in Disabled mode. The connection id stays pub(crate) (protocol/connect/handshake.rs:20), and no connection attributes are sent. -- **Fixes are all open upstream:** TLS server name (#4130, open since 2025-12-19), custom transport plus pool connector (#4187 got changes requested; its follow-up #4281 has waited for review since 2026-05-25), and connection attributes (#3054, open since 2024-02). -- **mysql_async 0.37.1 is not a better base.** It has tls_hostname_override, Conn::id() and connection attributes, but it cannot take an injected stream. Its rustls make_secure also returns Ok without encrypting on a Unix socket (src/io/tls/rustls_io.rs), so pointing it at a relay would silently send plain text. Over SSH it would need a loopback listener, which is exactly F-122. It also has a single crates.io owner. -- **Treat the relay as transitional:** give it a removal condition and push #4281 and #4130 upstream. - -SQL Server: drop the planned TableProApp/tiberius fork. Upstream is being revived right now: -- prisma/tiberius moved to the new tiberius-rs org on 2026-09-01. A crates.io owner (aqrln) did the move, gave a new maintainer (MattJackson) maintain access and is setting up trusted publishing. -- An upstream stacked series (#440 to #449, all mergeable, updated 2026-09-08) already fixes the server-reachable panics and adds sql_variant and UDT decoding, `Client::cancel_query` over TDS Attention, hostname_in_certificate, client certificates, CryptoProvider::get_default and additive trust_cert_ca. -- A RustSec advisory PR (rustsec/advisory-db#3148) is putting pressure on a release. -- The crates.io release is still 0.12.3 from 2024-07-19. In the 12 months before September only infrastructure and security PRs were merged, and community feature PRs sat unanswered. - -The least-debt path: -- Pin `[patch.crates-io]` to a tiberius-rs/tiberius commit: the stack head 9870b07 now, upstream main once the stack merges, then crates.io 0.13 when it is released. -- Contribute TablePro's planned tests upstream. -- Open additive upstream PRs for the two gaps the stack leaves: exact MONEY (stack money.rs still decodes to f64) and an injected rustls ClientConfig. -- Narrow the rest: accept upstream cancel semantics, where an interrupted send poisons the connection and we discard it, and use CAST projections for MONEY until upstream merges. - -Other SQL Server options fail a hard requirement: -- **Microsoft's new mssql-tds 0.1.0 (2026-09-10):** its TLS goes through native-tls, which is OpenSSL on Linux, and the crate also depends on openssl directly. Custom transport exists only under cfg(fuzzing), and the repo does not accept external PRs. -- **odbc-api:** needs msodbcsql18 (proprietary, redistribution requires end-user terms and indemnifying Microsoft) or FreeTDS (C with GnuTLS or OpenSSL). odbc-api has no safe cancel wrapper and no stream injection. -- **tiberius-ng, mssql, mssql-client and mssql-tiberius-bridge:** single-owner niche crates. - -The plan also contradicts itself on the tiberius pin: FD-C18/FD-C19 pin tiberius-rs c1d2e74, while FC-C19/FC-C20/W8-10/D16 pin a TableProApp fork. The amendments below resolve that. - -| Crate | Role | Status | Latest | Released | Downloads (all / 90d) | Maintenance | Standing | Verdict | -|---|---|---|---|---|---|---|---|---| -| sqlx-mysql (via sqlx, features mysql, mysql-rsa, tls-rustls-aws-lc-rs) | MySQL 8.x and MariaDB 10.6+/11.x wire protocol, TLS upgrade, COM_QUERY text protocol for editor scripts, bound queries for app SQL | current | 0.9.0 | 2026-05-21 | sqlx-mysql 117,066,601; sqlx 145,371,425 / sqlx-mysql 33,111,385; sqlx 36,185,734 | active | de_facto | keep | -| mysql_async | Alternative async MySQL/MariaDB driver | candidate | 0.37.1 (0.37.0 yanked) | 2026-09-01 | 7,383,841 / 1,254,125 | active | popular | avoid | -| mysql (rust-mysql-simple) | Synchronous MySQL driver | candidate | 28.0.2 (28.0.0 and 28.0.1 yanked) | 2026-09-01 | 4,081,316 / 681,424 | active | popular | avoid | -| rsa (via sqlx feature mysql-rsa) | RSA public-key encryption of the MySQL password for caching_sha2_password / sha256_password when the connection is not TLS | planned | 0.10.0-rc.18 (stable 0.9.10) | 2026-04-27 | 219,245,932 / 50,498,984 | active | de_facto | keep | -| deadpool (managed, rt_tokio_1) | Pool managers for MySQL (MysqlManager) and SQL Server (MssqlManager) that carry per-connection state (connection id, flavour, abandon flag) | planned | 0.13.1 | 2026-08-26 | 130,068,412 / 31,884,341 | active | de_facto | add | -| tiberius (upstream tiberius-rs/tiberius) | SQL Server TDS client: login, TLS, Kerberos via GSSAPI, queries, streaming results | current | crates.io 0.12.3; upstream main c1d2e74 (2026-09-03); upstream stack head 9870b07 (branch stack/s9, 2026-09-07, version 0.13.0) | 2024-07-19 | 5,146,726 / 672,168 | active | de_facto | keep | -| TableProApp/tiberius fork (planned 5-commit patch series) | Planned private fork carrying panic removal, sql_variant/UDT, MONEY as numeric, cancel_pending, injected rustls ClientConfig | planned | not created | | / | unverified | niche | avoid | -| tiberius-ng | Continuation fork of tiberius published under a new crate name | candidate | 0.13.1 | 2026-08-30 | 2,980 / 2,980 | active | niche | avoid | -| mssql (mssql-rust/mssql-rust) | Another tiberius fork renamed to mssql | candidate | 1.0.2 | 2026-09-01 | 231 / 226 | active | niche | avoid | -| mssql-tds (Microsoft, microsoft/mssql-rs) | Microsoft's Rust TDS implementation (also backs mssql-python and an msodbcsql-compatible ODBC driver) | candidate | 0.1.0 (earlier published as mssql-tds-preview 0.1.0-preview.9, 31,398 downloads) | 2026-09-10 | 1,834 / 1,834 | active | niche | avoid | -| mssql-tiberius-bridge | tiberius-like API over mssql-tds | candidate | 0.1.0 | 2026-09-11 | 32,967 / 24,978 | active | niche | avoid | -| mssql-client (praxiomlabs/rust-mssql-driver) | Independent async TDS 8.0 driver | candidate | 0.20.2 | 2026-07-07 | 3,998 / 2,710 | active | niche | avoid | -| odbc-api (+ msodbcsql18 or FreeTDS + unixODBC) | ODBC route to SQL Server | candidate | 29.0.0 (odbc-sys 0.31.0) | 2026-07-19 | 1,643,349 / 251,877 | active | popular | avoid | -| libgssapi | MIT Kerberos GSSAPI binding used by tiberius integrated-auth-gssapi, plus a planned direct dev dependency for the krb5_profile test | current | 0.11.0 | 2026-05-30 | 1,244,811 / 531,478 | active | niche | keep | -| tokio-util (compat) | TokioAsyncWriteCompatExt to hand tokio streams to tiberius's futures-io API | current | 0.7.19 | 2026-07-21 | 773,811,749 / 163,822,127 | active | de_facto | keep | -| deadpool-tiberius / bb8-tiberius | Ready-made tiberius pool managers | candidate | deadpool-tiberius 0.1.9; bb8-tiberius 0.16.0 | deadpool-tiberius 2025-04-11; bb8-tiberius 2025-01-08 | deadpool-tiberius 42,569; bb8-tiberius 382,840 / deadpool-tiberius 7,537; bb8-tiberius 114,414 | slow | niche | avoid | - -Fact-check corrections: - -- tiberius (upstream tiberius-rs/tiberius) risks: stack size: claimed ~30k added lines across the stack, actual 23,452 additions and 2,088 deletions across the open stack PRs: #440 (+1768), #441 (+1265), #442 (+8927), #443 (+6748), #444 (+175), #446 (+434), #447 (+61), #449 (+4074). #439 (+154) is already merged. (gh api repos/tiberius-rs/tiberius/pulls/{440..449} (.additions/.deletions)) -- tiberius (upstream tiberius-rs/tiberius) stack PR numbering: claimed upstream stacked series (#440 to #449, all mergeable), actual The open PRs are #440-#444, #446, #447 and #449, all mergeable=true. #445 is the gh-stack grouping, not a pull request (the pulls API returns 404). #448 is a separate CI PR, merged 2026-09-04. #439 (rustls 0.23) is already on main. (https://api.github.com/repos/tiberius-rs/tiberius/pulls/445 (404); /pulls/448 merged_at 2026-09-04T20:10:05Z) -- tiberius (upstream tiberius-rs/tiberius) risks: Error #[non_exhaustive]: claimed the stack makes Error #[non_exhaustive], actual Only PR #449's description says Error is now #[non_exhaustive], and offers to drop that commit. src/error.rs at the stack head 9870b07 declares `#[derive(Debug, Clone, Error, PartialEq, Eq)] pub enum Error` (lines 9-10) with no #[non_exhaustive]. The source does not back the claim, so the FC-C19 wildcard-arm amendment rests on an unverified premise (harmless either way). (https://raw.githubusercontent.com/tiberius-rs/tiberius/9870b07/src/error.rs; gh api repos/tiberius-rs/tiberius/pulls/449 .body) -- sqlx PR #4051 plan_amendments W2-08 / forks_and_hard_requirements: claimed sqlx's maintainer accepted the same design shape for Postgres in #4051 (2026-04-29), actual #4051 is still open. abonander's only review, on 2026-04-29, is CHANGES_REQUESTED: he agrees a custom ClientConfig can make sslmode a no-op and asks for URL-lossiness guards plus reconciliation with #4242. It is not Postgres-only: it also changes sqlx-mysql/src/connection/tls.rs and sqlx-core tls_rustls.rs. Last activity is a 2026-09-03 user ping. (gh api repos/transact-rs/sqlx/pulls/4051/reviews and /files) -- Plan tiberius contradiction summary / FD-C19 amendment: claimed The plan contradicts itself: FD-C18/FD-C19 pin tiberius-rs c1d2e74 while FC-C19/FC-C20/W8-10/D16 pin a TableProApp fork, actual The individual items do disagree: FD-C18 has a plain tiberius-rs c1d2e741 pin, C20 bases the fork on prisma a6b4fcd, and FC-D4/FC-C19/W8-10 fork c1d2e74. But plan.json already has an authoritative reconciliation at /reconcile/conflicts[21] (between FB-D16, FB-C20, FC-D4, FC-C19, FD-D10, FD-C19): 'There is one fork, TableProApp/tiberius on branch tablepro, based on c1d2e741... five commits... allow-git lists exactly TableProApp/tiberius and TableProApp/rust-postgres'. The amendments must rewrite that resolution entry. Editing only FD-C19/FC-C20 leaves the fork in place. (plan.json /reconcile/conflicts[21]; /foundations[3]/design/contracts[17] (FD-C18); /foundations[1]/design/contracts[19] (C20)) -- deadpool-tiberius / bb8-tiberius maintenance: claimed slow, actual bb8-tiberius is stale: its last release and last push were both 2025-01-08, over 20 months ago, which is past the 18-month threshold. deadpool-tiberius is slow: last release 2025-04-11 and last push the same day, about 17 months ago. GitHub detects no license on either repo. (https://crates.io/api/v1/crates/bb8-tiberius/versions; gh api repos/kardeiz/bb8-tiberius (pushed_at 2025-01-08T15:38:09Z); gh api repos/Geo-W/deadpool-tiberius) -- mssql-tds (Microsoft) downloads_all_time / downloads_recent: claimed 1,834 / 1,834, actual 3,005 / 3,005 on 2026-09-14. The crate was published 2026-09-10, so counts are moving fast. mssql-tiberius-bridge is now 34,137 / 26,156. (https://crates.io/api/v1/crates/mssql-tds; https://crates.io/api/v1/crates/mssql-tiberius-bridge) -- mssql-tds (Microsoft) sources: no external PRs: claimed README says 'not currently accepting external pull requests' (cited as mssql-tds/README.md), actual The sentence is in the repo root README.md, line 62, not mssql-tds/README.md. The openssl dependency is gated to cfg(all(not(windows), not(target_os = "macos"))) and serves Always Encrypted crypto, so it is unconditional on Linux. TLS on Linux uses native-tls (transport/tls/native_tls_engine.rs is the only non-Windows engine). The review's conclusion stands. (https://raw.githubusercontent.com/microsoft/mssql-rs/main/README.md; https://raw.githubusercontent.com/microsoft/mssql-rs/main/mssql-tds/Cargo.toml) -- sqlx-mysql notable_users: reverse dependencies: claimed 3,725 reverse dependencies on crates.io, actual 3,725 is the count for the sqlx facade crate. sqlx-mysql itself has 18 direct reverse dependencies. (https://crates.io/api/v1/crates/sqlx/reverse_dependencies?per_page=1 (meta.total 3725); .../sqlx-mysql/reverse_dependencies (18)) -- tiberius (upstream tiberius-rs/tiberius) risks: merge history window: claimed in the 12 months to 2026-09-14 only 5 PRs merged: #406, #409, #438, #439, #448 ... 'in the 12 months before September only infrastructure and security PRs were merged', actual #438, #439 and #448 were merged 2026-09-02 and 2026-09-04, inside September. Before September the only merges in that window were #406 (2026-02-11) and #409 (2026-03-06), both repo-policy and CI changes. #439 took 2 days (created 09-02, merged 09-04) and is on main at c1d2e74. (gh api repos/tiberius-rs/tiberius/pulls/{406,409,438,439,448}) -- tiberius (upstream tiberius-rs/tiberius) governance: publishing: claimed A crates.io owner (aqrln) did the move, gave MattJackson maintain access and is setting up trusted publishing, actual Correct, but incomplete. The crates.io owners are still steffengy, pimeys, tomhoule, aqrln, Weakky and miguelff, and MattJackson is not one. On 2026-09-01 aqrln wrote that he would merge security fixes 'in my personal capacity in my spare time'. The release shape (a patch release vs the whole stack as 0.13) was still waiting on his decision per MattJackson's 2026-09-07 comment. A crates.io 0.13 release therefore depends on a Prisma-era owner with limited time. (https://crates.io/api/v1/crates/tiberius/owners; https://github.com/tiberius-rs/tiberius/issues/427 (aqrln 2026-09-01T19:30:01Z, MattJackson 2026-09-07T23:28:20Z)) - -Verdict challenges: - -- tiberius (upstream tiberius-rs/tiberius): keep; pin [patch.crates-io] to stack head 9870b07 (branch stack/s9) now to keep. Pin tiberius-rs main c1d2e741 now, and move the pin to main after #440-#443 merge. Use the stack head 9870b07 only as a short, CI-gated bridge if W2-15 cannot wait.. Pinning an unmerged stack branch is its own kind of debt: -- On 2026-09-07 MattJackson offered to reorder and force-push the stack branches to suit aqrln's release-shape decision. A rev on stack/s9 could then survive only through refs/pull/449/head. -- The open stack is 23.5k added lines, includes the breaking async-std drop in #442, and has no maintainer review yet. aqrln suspected agent-automated PR handling in #427 (2026-09-02). -- main c1d2e741 already carries #439 (rustls 0.23, closing RUSTSEC-2026-0098, -0099 and -0104). The current pin a6b4fcd still ships rustls-webpki 0.101.7, which is affected (Cargo.lock), so moving to main is an immediate security fix with no behaviour dependency on the stack. - -The sql_variant, UDT and cancel work (W2-05, W2-07) should depend on #442/#443 reaching main, not on a stack branch head. The fork verdict (avoid) still holds. -- TableProApp/tiberius fork (planned 5-commit patch series): avoid (amend FC-D4, D16, FC-C19, FD-C19, FC-C20, W8-10) to avoid, and also amend /reconcile/conflicts[21]. The review agrees with the owner requirement, but it targets the wrong plan entries. The plan already resolves the tiberius conflict at /reconcile/conflicts[21] in favour of the fork, as one TableProApp/tiberius branch on c1d2e741 with five commits and allow-git set to TableProApp/tiberius. Unless that resolution is rewritten, the fork survives the amendments. -- libgssapi: keep (only maintained Rust GSSAPI binding) to keep, with the note corrected. Kerberos through the system ccache still needs libgssapi: the stack's auth.rs says Kerberos on Unix comes from integrated-auth-gssapi. The review misses that the upstream stack also adds a `sspi-rs` feature built on Devolutions' pure-Rust sspi crate, which gives NTLM on Unix without libkrb5 (auth.rs:58, 'on Unix it uses NTLM (no Kerberos)'). sspi is org-backed: 4 owners including a github:devolutions team, 1,665,058 downloads. The stack pins sspi 0.18 while the latest is 0.21.3 (2026-07-16). So 'Requirement 6: only mssql-tds offers it' is true for Kerberos, but not for integrated auth in general. -- net::PrivateSocketRelay (hand-rolled, for sqlx-mysql): justified interim; removal trigger = first sqlx release with #4281 or #4130 to justified interim, with a time-boxed trigger and a fallback. The code facts are verified: -- sqlx 0.9.0 tls.rs has no socket skip in maybe_upgrade and passes hostname: &options.host at line 62. -- establish.rs dials connect_uds when socket is set. -- auth.rs sends a clear-text password only when stream.is_tls. - -The removal trigger is weak, though. #4130 has zero comments and zero reviews in 9 months. #4281 has had no maintainer response since 2026-05-25, and sqlx's two crates.io owners are the bottleneck. Record a review date (for example the next sqlx minor) at which the team either actively carries #4281 through review or accepts the relay as a documented permanent component, so it is not an open-ended 'transitional' item. #4051, which also touches sqlx-mysql TLS, belongs on the watch list. - -### SQLite and ClickHouse drivers - -SQLite and ClickHouse are both on mainstream, actively maintained, upstream-released crates. Neither domain needs a fork. The planned choices hold up: sqlx-sqlite 0.9.0 for the SQLite driver and the history DB, and ClickHouse Inc's official clickhouse 0.15.2 over Client::with_http_client. Every serious alternative is weaker on adoption or maintenance: klickhouse (one maintainer, 4 reverse deps), suharev7/clickhouse-rs (last push 2024-07-16), clickhouse-arrow, libsql/turso (a SQLite fork and a beta rewrite), and tokio-rusqlite (a personal repo). rusqlite is as mainstream as sqlx and has better APIs for this job: safe InterruptHandle, is_autocommit and txn_state. It cannot join the graph today, though. sqlx-sqlite 0.9.0 requires libsqlite3-sys <0.38 and rusqlite 0.40 requires ^0.38.2, and both link sqlite3. The fix (#4402) was merged to sqlx main on 2026-09-09 but is not released. The plan still carries real debt in this domain: (1) one unsafe sqlite3_get_autocommit call plus a direct libsqlite3-sys pin, because sqlx keeps its own in_transaction() crate-private and its public is_in_transaction() only counts sqlx-managed BEGINs; (2) a hand-rolled CREATE INDEX key splitter where the already-planned sqlparser 0.63 parses CreateIndex; (3) keeping escape_bind_markers and a custom line reader after clickhouse 0.15.2 added Client::query_raw() and BytesCursor implements AsyncBufRead; (4) sqlite-unbundled turning on bindgen, which makes libclang a build dependency even with Kerberos off. Also: clickhouse 0.15.2's new fetch_native cannot yet replace JSON decoding. It lacks Decimal, the Date/DateTime family, Enum, Int256, Variant, Dynamic and JSON, and it rejects invalid UTF-8. For future engines: use redis-rs, not fred (fred's last release and push were 2025-02-27, and an open issue asks if the repo is dead); use mongodb with the rustls-tls-aws-lc feature (the default feature uses ring, which FD-D10 bans); use duckdb from the duckdb org and scylla from the ScyllaDB org. Oracle has no mainstream option: the oracle crate has one maintainer, last released 2025-01-02, and needs the proprietary Oracle client. - -| Crate | Role | Status | Latest | Released | Downloads (all / 90d) | Maintenance | Standing | Verdict | -|---|---|---|---|---|---|---|---|---| -| sqlx-sqlite | SQLite driver (drivers/sqlite) and query history DB with FTS5 (storage) through sqlx | current | 0.9.0 | 2026-05-21 | 117,801,243 / 33,188,928 | active | de_facto | keep | -| sqlx | Umbrella crate: MySQL driver, SQLite driver, storage migrations (migrate!, macros) | current | 0.9.0 | 2026-05-21 | 145,371,322 / 36,185,734 | active | de_facto | keep | -| libsqlite3-sys (direct dependency) | Planned direct dep for the single unsafe sqlite3_get_autocommit call in drivers/sqlite autocommit.rs | planned | 0.38.2 | 2026-08-08 | 231,408,681 / 64,187,303 | active | de_facto | drop | -| rusqlite | Candidate alternative SQLite binding (sync) for driver and storage | candidate | 0.40.2 | 2026-08-08 | 105,891,462 / 33,496,608 | active | de_facto | avoid | -| tokio-rusqlite | Candidate async bridge for rusqlite | candidate | 0.8.0 | 2026-09-06 | 1,948,472 / 607,609 | active | niche | avoid | -| deadpool-sqlite | Candidate rusqlite pool/async bridge (fallback path only) | candidate | 0.14.0 | 2026-08-26 | 727,369 / 206,903 | active | niche | avoid | -| async-sqlite | Candidate rusqlite async wrapper | candidate | 0.6.0 | 2026-05-27 | 364,030 / 103,614 | active | niche | avoid | -| libsql | Candidate SQLite-compatible client (Turso) | candidate | 0.9.30 stable (0.10.0-pre.4 on 2026-06-02) | 2026-03-19 | 2,052,978 / 853,990 | active | popular | avoid | -| turso | Candidate SQLite-compatible engine rewrite | candidate | 0.7.2 stable (0.8.0-pre.11 on 2026-09-11) | unverified (0.7.2 date not fetched) | 900,529 / 531,440 | active | niche | avoid | -| clickhouse | ClickHouse driver over HTTP (drivers/clickhouse) | current | 0.15.2 | 2026-08-28 (crates.io publish; CHANGELOG heading 2026-08-24) | 12,629,429 / 3,728,306 | active | de_facto | keep | -| clickhouse-types | Parse ClickHouse type strings into DataTypeNode (replaces hand-rolled base_type/type_is_nullable) | planned | 0.1.3 | 2026-08-28 | 4,475,123 / 1,837,522 | active | popular | add | -| klickhouse | Candidate ClickHouse native-protocol client | candidate | 0.15.3 | 2026-05-26 | 258,395 / 108,814 | active | niche | avoid | -| clickhouse-rs (suharev7) | Candidate ClickHouse native-protocol client | candidate | 1.1.0-alpha.1 (stable 0.1.21) | 2023-09-10 | 197,428 / 17,572 | stale | niche | avoid | -| clickhouse-arrow | Candidate ClickHouse native/Arrow client | candidate | 0.2.1 | 2025-12-14 | 878,834 / 389,966 | active | niche | avoid | -| ch | Candidate ClickHouse crate named in brief | candidate | 0.2.0 | 2026-06-19 | 71 / 71 | active | niche | avoid | -| sqlparser | Parse SQLite CREATE INDEX text (and ClickHouse key expressions) instead of hand-rolled splitters | planned | 0.63.0 | 2026-09-13 | 77,279,458 / 11,949,762 | active | de_facto | keep | -| duckdb | Future DuckDB driver (ROADMAP Phase 4) | candidate | 1.10505.0 | 2026-07-22 | 3,912,975 / 1,481,593 | active | de_facto | add | -| mongodb | Future MongoDB driver (ROADMAP Phase 4) | candidate | 3.9.1 | 2026-09-10 | 17,279,040 / 3,839,417 | active | de_facto | add | -| redis | Future Redis/Valkey driver | candidate | 1.7.0 | 2026-09-05 | 102,151,277 / 25,042,066 | active | de_facto | add | -| fred | ROADMAP-named Redis client | candidate | 10.1.0 | 2025-02-27 | 8,794,212 / 1,858,727 | stale | popular | avoid | -| oracle | ROADMAP-named Oracle driver (ODPI-C) | candidate | 0.6.3 | 2025-01-02 | 2,873,603 / 1,028,718 | slow | niche | avoid | -| scylla | ROADMAP-named Cassandra/ScyllaDB driver | candidate | 1.8.0 | 2026-08-13 | 8,758,028 / 1,463,647 | active | de_facto | add | - -Fact-check corrections: - -- fred license: claimed MIT OR Apache-2.0 (crates.io); GitHub reports Apache-2.0, actual crates.io 10.1.0 declares MIT. GitHub reports Apache-2.0. (https://crates.io/api/v1/crates/fred/versions (10.1.0 license field); https://api.github.com/repos/aembke/fred.rs) -- fred risks: claimed Community fix PRs (#379-#381) sit unmerged, actual #379 is closed without merging. #380 (2026-09-07) and #381 (2026-09-08) are open. The last commit is still 2025-02-27T14:50:53Z. Issue #377 is open with 0 comments. (gh api repos/aembke/fred.rs/pulls/379, /380, /381; repos/aembke/fred.rs/commits) -- scylla license / notable_users: claimed unverified (GitHub reports Apache-2.0); notable users unverified, actual crates.io 1.8.0 declares MIT OR Apache-2.0. 73 reverse dependencies. (https://crates.io/api/v1/crates/scylla/1.8.0; https://crates.io/api/v1/crates/scylla/reverse_dependencies) -- scylla risks (TLS provider): claimed TLS feature and crypto provider not checked against FD-D10 (unverified), actual Feature rustls-023 enables rustls ^0.23 and tokio-rustls ^0.26 with default features. rustls 0.23.44's default is [aws_lc_rs, logging, prefer-post-quantum, std, tls12], so the provider is aws-lc-rs and fits FD-D10. No ring. (https://crates.io/api/v1/crates/scylla/1.8.0/dependencies; https://crates.io/api/v1/crates/rustls/versions (0.23.44)) -- turso latest_release_date: claimed unverified (0.7.2 date not fetched), actual 0.7.2 was published 2026-07-30. The newest pre-release is 0.8.0-pre.11 (2026-09-11). (https://crates.io/api/v1/crates/turso/versions) -- oracle maintenance: claimed slow, actual The last release, 0.6.3 on 2025-01-02, is about 20 months old, so the crate is stale under the owner's rule (over 18 months). The last commit is 2025-03-23. ChangeLog.md still reads '0.7.0 (2025-XX-XX)'. The verdict (avoid) stands. (https://crates.io/api/v1/crates/oracle/versions; gh api repos/kubo/rust-oracle/commits; https://raw.githubusercontent.com/kubo/rust-oracle/master/ChangeLog.md) -- rusqlite rationale (API names): claimed safe Connection::get_interrupt_handle/InterruptHandle::interrupt, is_autocommit, txn_state, actual The public API is Connection::transaction_state(db_name) (src/transaction.rs:525). txn_state is the internal InnerConnection method it calls. is_autocommit (src/lib.rs:1068), get_interrupt_handle (src/lib.rs:1036) and InterruptHandle::interrupt (src/lib.rs:1358) are confirmed. (https://raw.githubusercontent.com/rusqlite/rusqlite/master/src/transaction.rs; https://raw.githubusercontent.com/rusqlite/rusqlite/master/src/lib.rs) -- clickhouse-types standing: claimed popular, actual 3 reverse dependencies. Its 4.48M downloads come almost entirely through the clickhouse crate. It is a vendor sub-crate released in lockstep with clickhouse (published by abonander on the same day), and niche on its own. The add verdict is still right. DataTypeNode::new(&str) is public (data_types.rs:118 in 0.1.3). (https://crates.io/api/v1/crates/clickhouse-types/reverse_dependencies; ~/.cargo/registry/src/*/clickhouse-types-0.1.3/src/data_types.rs) -- clickhouse plan_amendments W2-16 ('Use Client::query() only where client-side binding is wanted, and none is planned'): claimed No client-side binding is planned, actual The plan does bind client-side. FC-D10 cancel and the row-limit stop both use control client query("KILL QUERY WHERE query_id = ? ASYNC").bind(query_id.to_string()).execute(). Client::query stays for that call, unless it moves to a server-side {id:String} param. Whether KILL QUERY accepts query parameters is unverified. (plan.json FC-D10 engine stops text; clickhouse-0.15.2 src/sql/mod.rs:51-70 (SqlBuilder::new vs raw)) -- clickhouse hand_rolled (clickhouse::sql::Bind can render a literal: unverified): claimed check whether clickhouse::sql::Bind (public) can render a literal (unverified), actual Bind is a sealed public trait. Its only method, write(), is #[doc(hidden)], so it is not a supported API for rendering literals. Identifier is the only documented public Bind type. Either keep a small dialect literal writer or use server-side Query::param. (clickhouse-0.15.2 src/sql/bind.rs:6-9 and src/sql/mod.rs:8) -- sqlx-sqlite forks_and_hard_requirements #3 and plan_amendments FD-D12 ('The plan lists libclang-dev for .deb and CI only because of Kerberos'; add clang to Flatpak and rpm): claimed The plan justifies libclang only by Kerberos, so it must be added to Flatpak, rpm BuildRequires and .deb, actual The plan already has all of this. Migration step 6 reads 'build(deps): migrate to sqlx 0.9 with system SQLite ... README and CI deps gain libsqlite3-dev and libclang-dev'. The rpm spec BuildRequires clang-devel unconditionally. debian Build-Depends and the CI apt list include libclang-dev. The Flatpak llvm22 extension stays permanently. The only wrong text is FD-D11's rationale 'Contributors, docker integration jobs and cargo test -p tablepro-driver-mssql need no libkrb5-dev/libclang'. Any build of storage or drivers/sqlite needs libclang through sqlx-sqlite unbundled = libsqlite3-sys/buildtime_bindgen. (plan.json FD-D11 rationale, FD-D12, migration step 6, rpm spec and debian control text; https://crates.io/api/v1/crates/sqlx-sqlite/0.9.0 (features)) -- sqlx-sqlite forks_and_hard_requirements #3 (FTS5 in system SQLite unverified): claimed FTS5 in freedesktop 25.08, Fedora 44 and Ubuntu 26.04 system SQLite not re-checked (unverified), actual Verified. freedesktop-sdk release/25.08 elements/components/sqlite.bst builds sqlite 3.50.4 with --fts5. Ubuntu 26.04 libsqlite3-0 3.46.1-9ubuntu0.2 reports ENABLE_FTS5 in PRAGMA compile_options (checked on this machine). Fedora's f44 sqlite.spec configures with --enable-fts5 (version not read). (https://gitlab.com/freedesktop-sdk/freedesktop-sdk/-/raw/release/25.08/elements/components/sqlite.bst; local apt-cache policy libsqlite3-0 and python3 sqlite3 PRAGMA compile_options; https://src.fedoraproject.org/rpms/sqlite/raw/f44/f/sqlite.spec) -- sqlx plan_amendments FC-D16 ('Pin sqlx to ~0.9.0'): claimed Pinning sqlx ~0.9.0 satisfies the as_raw_handle docs, actual In Cargo, ~0.9.0 means >=0.9.0,<0.10.0, the same as the default caret requirement '0.9.0', so it pins nothing. An exact =0.9.0 would block patch fixes. It would also block the release that carries #4402: sqlx-sqlite/src/lib.rs lets the libsqlite3-sys range widen in patch releases, and main already reads '>=0.30.1, <0.39.0'. Only one libsqlite3-sys version can be linked (links = sqlite3), and the direct range dependency already forces unification. What must be tracked is widening the direct range in lockstep with sqlx, not pinning sqlx. (https://raw.githubusercontent.com/transact-rs/sqlx/v0.9.0/sqlx-sqlite/src/lib.rs (lines 3-30); https://raw.githubusercontent.com/transact-rs/sqlx/main/sqlx-sqlite/Cargo.toml; Cargo reference, tilde requirements) -- sqlx / sqlx-sqlite downloads: claimed sqlx 145,371,322 / 36,185,734; sqlx-sqlite 117,801,243 / 33,188,928, actual Figures re-queried the same day: sqlx 145,469,528 / 36,284,208 and sqlx-sqlite 117,891,181 / 33,279,114. The difference is ordinary growth, not an error. Every other count, version and date re-queried (clickhouse 0.15.2 on 2026-08-28, sqlx 0.9.0 on 2026-05-21, libsqlite3-sys and rusqlite on 2026-08-08, sqlparser 0.63.0 on 2026-09-13, reverse-dependency counts, pushed_at dates) matched. (https://crates.io/api/v1/crates/sqlx; https://crates.io/api/v1/crates/sqlx-sqlite) - -Verdict challenges: - -- libsqlite3-sys (direct dependency): drop to keep (tracked interim). The replacement the review names does not exist. sqlx 0.9.0 and main both keep ConnectionHandle::in_transaction pub(crate) (handle.rs:85). A search of transact-rs/sqlx issues and PRs for get_autocommit, autocommit or LockedSqliteHandle finds no request for an accessor. A mainstream sqlx 0.9 user already does exactly what the plan does: atuin's workspace Cargo.toml declares libsqlite3-sys = ">=0.30.1, <0.38.0" to mirror sqlx-sqlite's range. The honest verdict is keep, with a tracked upstream PR. sqlx has taken a similar small accessor before (#3707, LockedSqliteHandle::last_error). Drop the direct dependency when the accessor ships, and widen the range in lockstep with sqlx patch releases. -- rusqlite: avoid to documented fallback (not avoid). 'Avoid' is meant for niche or stale crates. rusqlite is de facto: 105.99M downloads, 4,827 reverse deps, org-owned, released 2026-08-08. It also has two advantages the review did not weigh. First, system linking needs no bindgen or libclang: libsqlite3-sys's default min_sqlite_version_3_34_1 feature uses prebuilt bindings, while sqlx-sqlite 'unbundled' forces buildtime_bindgen. Second, it is the GNOME precedent: Fractal uses it through matrix-sdk-sqlite (rusqlite 0.40.1 with deadpool 0.13 and deadpool-sync 0.2, the same deadpool line as FC-D18), and none of the GNOME apps checked use sqlx. Keeping sqlx-sqlite now is still right, because storage relies on migrate! and the links rule forbids a split. Record rusqlite as the named fallback, with the triggers the review lists. -- mongodb: add (default-features=false with rustls-tls-aws-lc) to add, conditional on trust-store handling. rustls-tls-aws-lc fixes the provider but not the roots. driver/src/runtime/tls_rustls.rs at v3.9.1 imports webpki_roots::TLS_SERVER_ROOTS and fills the root store from it unless ca_file_path is set (lines 21, 72-83). The system trust store that FD-D10 requires through rustls-native-certs is never used, and openssl-tls is banned. The ROADMAP entry must name the least-debt path: an upstream option to use native roots or a caller-supplied rustls ClientConfig, or ca_file_path set to the platform bundle. -- deadpool-sqlite: avoid to avoid; in the rusqlite fallback use deadpool + deadpool-sync directly. deadpool-sqlite has 26 reverse deps. The larger precedent, matrix-sdk-sqlite, uses deadpool 0.13 with deadpool-sync 0.2 over rusqlite, not deadpool-sqlite. The review's FC-D16 fallback text should name that pairing, not deadpool-sqlite. - -### SSH tunnels, TLS, networking - -Domain: SSH tunnels, TLS, networking. The TLS base is sound: rustls 0.23, aws-lc-rs as the only provider, tokio-rustls 0.26 and socket2 are all de facto, rustls-org or rust-lang crates. The SSH design is the main tech-debt risk in the plan. - -The plan already makes the system OpenSSH client a hard runtime dependency (`ssh -G` in W1-01; `ssh-keygen -F` and `-Q` in W1-02). It then re-implements OpenSSH on top of russh anyway, in about 25 new files: -- ssh_config token expansion; -- the pubkey_prepare auth order from sshconnect2.c; -- jump-chain building from ssh.c; -- host-certificate checks (sshkey_cert_check_host); -- KRL and known_hosts decisions; -- a known_hosts writer. -It still refuses ProxyCommand. That is the worst of both options: an external binary dependency plus a large custom security surface that has to follow OpenSSH semantics forever. - -Checked evidence: -- **OpenSSH is in the Flatpak runtime.** GNOME 50 junctions freedesktop-sdk-25.08.16. That release's openssh.bst moves only sshd, sftp-server, sshd-*, ssh-sk-helper and ssh-copy-id to vm-only, so ssh and ssh-keygen ship in the platform. -- **All prompts can reach the app.** With SSH_ASKPASS_REQUIRE=force, OpenSSH sends every read_passphrase call to SSH_ASKPASS, even without DISPLAY (readpass.c). That includes the host-key confirmation (sshconnect.c:615), passwords, passphrases and keyboard-interactive prompts (sshconnect2.c:2124). -- **Precedents.** GNOME gvfs's sftp backend spawns ssh with ControlMaster auto and shows its prompts through GMountOperation. Zed runs ControlMaster=yes with SSH_ASKPASS_REQUIRE=force and an askpass helper that talks back over a Unix socket. -- **Unix-socket forwards are private by default.** OpenSSH's default StreamLocalBindMask is 0177, so a forwarded socket is created 0600. -- **ConnectTimeout** bounds the TCP connect and the identification exchange (sshconnect.c passes timeout_ms to kex_exchange_identification). - -The Rust SSH crates cannot meet the stated requirements: -- **russh 0.63.3:** active and popular, but one crates.io owner, a repo on a personal account, and 8 breaking minor releases from 0.55.0 (2025-12-02) to 0.63.0 (2026-08-21). Its known_hosts module matches only exact and |1| hashed names, with no wildcards, negation, @cert-authority or @revoked. One malformed key line makes the whole check return an error (the parse error is propagated with ?). -- **russh-config:** no Include and no Match (issue #699, open). -- **ssh2-config:** no Match and no tokens. -- **ssh-key:** its known_hosts module only parses (and 0.7 is still a release candidate); it has no host matcher. -- **libssh2:** last release 1.11.1 on 2024-10-16, a blocking C API, and knownhost.c has no marker handling. -- **openssh crate:** slow (last release 2025-12-03, only dependabot commits since March), documented as password-less authentication only, and its mux client has no stdio forward. - -Recommendation: make system OpenSSH the SSH transport, driven directly with tokio::process the way Zed does it. Use one ControlMaster per destination, Unix-socket forwards for streams, and an askpass bridge into the existing CredentialPrompter and AdwAlertDialog. Then delete the re-implementation. Keep russh 0.63.3 only as the interim fix for RUSTSEC-2026-0153/0154 until the switch. - -The same change removes the custom MySQL relay for SSH, because ssh owns the private socket. For direct MySQL, sqlx-mysql 0.9.0 keeps do_handshake private and has no keepalive; transact-rs/sqlx PRs #3559 and #4355 (keepalive for MySQL and PostgreSQL) are still open. Narrow the requirement to the health probe, contribute to #4355, and do not keep a relay. - -Networking: -- Happy Eyeballs is not standard for database clients. libpq tries addresses in order with connect_timeout per address, and tokio's TcpStream::connect is sequential. -- If it stays, hyper-util's HttpConnector (already in the graph through clickhouse) provides it, plus keepalive, TCP_USER_TIMEOUT, nodelay and connect timeout, with enforce_http(false). That replaces the hand-rolled race and SocketTuning. -- tokio-happy-eyeballs and happy-eyeballs are niche or stale. - -TLS: -- The rustls project tells users to prefer rustls-platform-verifier over rustls-native-certs. On Linux it is rustls-native-certs 0.8 plus rustls-webpki, adds a CA file with `new_with_extra_roots`, and reqwest 0.13 uses it by default. Use it wherever a ClientConfig can be injected (PostgreSQL, ClickHouse, SQL Server). Keep rustls-native-certs only to build the PEM bundle sqlx-mysql needs; sqlx 0.9 has no aws-lc native-roots feature and always seeds webpki-roots. -- Drop the planned direct union with webpki-roots. -- Replace the hand-rolled PostgreSQL TlsConnect and tls-server-end-point binding with tokio-postgres-rustls 0.14. It is generic over the stream and computes SHA-256/384/512 bindings. -- Replace the hand-rolled PEM encoder with the pem crate. -- Flatpak caveat that no crate fixes: file-based trust readers see only the runtime's CA bundle. Host CAs exposed through p11-kit are invisible to them (freedesktop-sdk issue #1087, open). Document it and rely on the CA file row. - -| Crate | Role | Status | Latest | Released | Downloads (all / 90d) | Maintenance | Standing | Verdict | -|---|---|---|---|---|---|---|---|---| -| OpenSSH client (system ssh, ssh-keygen) | SSH transport: config evaluation (Include, Match, ProxyJump, ProxyCommand), agent, identities, certificates, known_hosts/KRL, keepalive, Unix-socket forwarding, ControlMaster multiplexing | planned | 10.5p1 (as tracked by freedesktop-sdk release/25.08) | unverified | n/a (system package) / n/a | active | de_facto | add | -| russh | In-process SSH client (tunnel, host key check, public-key and password auth) | current | 0.63.3 | 2026-09-09 | 6,895,117 / 2,755,167 | active | popular | replace | -| internal-russh-forked-ssh-key | russh 0.55's private fork of RustCrypto ssh-key | current | 0.6.18+upstream-0.6.7 | 2026-02-28 | 2,479,570 / 636,585 | slow | niche | drop | -| ssh-key | SSH key, certificate and known_hosts parsing (transitive through russh 0.63.3; planned direct use for the FD-D23 RSA identity hint and W1-02 certificate checks) | planned | 0.7.0-rc.11 (stable 0.6.7) | 2026-06-29 (stable 0.6.7: 2024-10-15) | 11,230,792 / 4,625,186 | active | de_facto | drop | -| openssh | Candidate: Rust wrapper that drives the system ssh with ControlMaster | candidate | 0.11.6 | 2025-12-03 | 5,272,036 / 413,549 | slow | popular | avoid | -| openssh-mux-client | Candidate: native ControlMaster mux protocol client | candidate | 0.17.9 | 2025-10-21 | 3,862,419 / 142,119 | slow | niche | avoid | -| ssh2 | Candidate: libssh2 binding | candidate | 0.9.6 (libssh2-sys 0.3.3, 2026-09-05) | 2026-06-30 | 7,741,196 / 1,096,188 | active | popular | avoid | -| async-ssh2-tokio | Candidate: convenience wrapper | candidate | 0.13.0 | 2026-07-12 | 232,197 / 26,597 | active | niche | avoid | -| russh-config | Candidate: ssh_config parser | candidate | 0.58.0 | 2026-03-18 | 199,636 / 129,125 | active | niche | avoid | -| ssh2-config | Candidate: ssh_config parser | candidate | 0.8.0 | 2026-08-31 | 162,570 / 40,541 | active | niche | avoid | -| nix | Planned in W1-01 for LocalIdentity (getuid, User::from_uid, gethostname) used by token expansion | planned | 0.31.3 | 2026-05-11 | 790,585,178 / 173,317,064 | active | de_facto | drop | -| gcr4-ssh-askpass | Candidate: GNOME's stock SSH_ASKPASS program | candidate | part of gcr 4 (installed to libexecdir) | unverified | n/a / n/a | active | niche | avoid | -| rustls | TLS for all network drivers | current | 0.23.44 (0.24.0-dev.1 pre-release) | 2026-09-07 | 908,603,329 / 196,868,215 | active | de_facto | keep | -| aws-lc-rs | Single rustls crypto provider | current | 1.18.1 | 2026-09-01 | 221,487,753 / 81,190,091 | active | de_facto | keep | -| ring | Second crypto provider pulled in transitively | current | 0.17.14 | 2025-03-11 | 731,537,489 / 154,603,539 | slow | de_facto | drop | -| tokio-rustls | Async TLS streams | current | 0.26.5 | 2026-09-04 | 761,086,692 / 163,318,369 | active | de_facto | keep | -| rustls-platform-verifier | Candidate: server certificate verifier using the OS trust store (plan FC-D8 rejected it) | candidate | 0.7.0 | 2026-04-12 | 139,812,176 / 69,220,683 | active | de_facto | add | -| rustls-native-certs | Loads system CA certificates | current | 0.8.4 | 2026-06-01 | 543,323,591 / 125,309,191 | active | de_facto | keep | -| webpki-roots | Bundled Mozilla roots (plan: direct TLS_SERVER_ROOTS added to every RootCertStore) | planned | 1.0.9 | 2026-07-18 | 734,693,302 / 181,476,536 | active | de_facto | drop | -| webpki-root-certs | Candidate (rejected in FC-D8) | candidate | 1.0.9 | 2026-07-18 | 92,441,940 / 45,312,438 | active | popular | avoid | -| rustls-pki-types | PEM and DER decoding for CA files and client identities | planned | 1.15.1 | 2026-07-23 | 505,778,088 / 155,268,683 | active | de_facto | keep | -| pem | PEM encoding of the certificate bundle handed to sqlx-mysql | candidate | 4.0.0 | 2026-07-28 | 305,097,444 / 69,513,225 | active | de_facto | add | -| tokio-postgres-rustls | rustls TlsConnect for tokio-postgres, including tls-server-end-point channel binding | candidate | 0.14.0 | 2026-05-21 | 14,269,380 / 4,673,348 | active | popular | add | -| x509-cert | Planned: reads the certificate signature algorithm for PostgreSQL channel binding | planned | 0.3.0 | 2026-07-09 | 38,343,540 / 13,802,213 | active | popular | drop | -| rcgen | Test PKI for the TLS conformance suite (W9-02) | planned | 0.14.10 | 2026-08-28 | 99,339,367 / 27,608,125 | active | de_facto | keep | -| socket2 | TCP keepalive, TCP_USER_TIMEOUT and nodelay on direct sockets | planned | 0.6.5 | 2026-07-13 | 1,207,807,149 / 288,488,588 | active | de_facto | keep | -| hyper-util | HttpConnector: DNS, Happy Eyeballs, keepalive, TCP_USER_TIMEOUT, connect timeout | current | 0.1.20 | 2026-02-02 | 471,094,267 / 139,433,828 | active | de_facto | keep | -| tokio-happy-eyeballs | Candidate: Happy Eyeballs for tokio | candidate | 0.1.0 | 2026-04-15 | 186,453 / 185,807 | slow | niche | avoid | -| happy-eyeballs | Candidate: Happy Eyeballs | candidate | 0.2.1 | 2023-05-08 | 10,924 / 190 | stale | niche | avoid | - -Fact-check corrections: - -- rustls-native-certs / Flatpak trust caveat sources / forks_and_hard_requirements item 6 ('freedesktop-sdk issue #1087, open'): claimed freedesktop-sdk issue #1087 is open and tracks OpenSSL-file consumers not seeing host p11-kit CAs, actual #1087 ('libcurl OpenSSL backend not using host certs through p11-kit') was closed on 2020-07-25. The open issue is #1905, 'OpenSSL is not working with local root certificate' (opened 2025-09-18, updated 2026-09-07). Cite #1905. The caveat itself stands: GnuTLS works in the sandbox, OpenSSL file readers do not. (https://gitlab.com/api/v4/projects/freedesktop-sdk%2Ffreedesktop-sdk/issues/1087 ; https://gitlab.com/api/v4/projects/freedesktop-sdk%2Ffreedesktop-sdk/issues/1905) -- rustls-native-certs / Flatpak trust caveat sources (flatpak PR #1757): claimed flatpak PR #1757 is the evidence that host CAs are shared through the p11-kit server, actual PR #1757 was closed without merging on 2018-06-07. The feature shipped another way: flatpak main common/flatpak-run.c:1858-1871 overrides /etc/pkcs11/modules/p11-kit-trust.module with p11-kit-client.so, which talks to /run/flatpak/p11-kit/pkcs11. Cite flatpak-run.c, not the PR. (gh api repos/flatpak/flatpak/pulls/1757 ; https://raw.githubusercontent.com/flatpak/flatpak/main/common/flatpak-run.c) -- OpenSSH client (system ssh, ssh-keygen) notable_users (GNOME gvfs precedent): claimed gvfs shows password, passphrase, 'Verification code' and host-authenticity prompts through GMountOperation, cited as a precedent next to the Zed askpass model, actual gvfs does not use SSH_ASKPASS. It opens a pty (pty_open, USE_PTY) and matches prompt strings in the pty output (gvfsbackendsftp.c:563, 856, 1289, 1338). -oControlMaster auto is added only for SFTP_VENDOR_OPENSSH (line 498), and -oBatchMode yes only when USE_PTY is not defined. It is a precedent for spawning ssh with ControlMaster, not for an askpass bridge. Zed is the only verified askpass precedent: ssh.rs:191-204 sets ControlPersist=no, ControlMaster=yes, SSH_ASKPASS_REQUIRE=force and SSH_ASKPASS, and askpass.rs uses a UnixListener. (https://gitlab.gnome.org/GNOME/gvfs/-/raw/master/daemon/gvfsbackendsftp.c ; https://raw.githubusercontent.com/zed-industries/zed/main/crates/remote/src/transport/ssh.rs) -- OpenSSH client (system ssh, ssh-keygen) plan_amendments W1-03 (detecting the host-key prompt): claimed The host-key prompt can be recognised as 'an askpass confirm prompt (SSH_ASKPASS_PROMPT=confirm or a yes/no prompt text)', actual Host-key confirmation never sets SSH_ASKPASS_PROMPT. confirm() calls read_passphrase(msg, RP_ECHO) (sshconnect.c:615). readpass.c:177-178 sets the hint 'confirm' only for RP_ASK_PERMISSION, and readpass.c:27 exports SSH_ASKPASS_PROMPT only when a hint exists. The helper must classify the prompt from its text ('The authenticity of host', 'Are you sure you want to continue connecting (yes/no/[fingerprint])?'). The helper also gets no echo flag for this prompt. (https://raw.githubusercontent.com/openssh/openssh-portable/master/readpass.c ; https://raw.githubusercontent.com/openssh/openssh-portable/master/sshconnect.c) -- OpenSSH client (system ssh, ssh-keygen) risks (FIDO security keys in Flatpak): claimed FIDO security keys inside Flatpak are unverified, actual Checkable, and file-based FIDO keys will likely fail. ssh-sk-client.c:58-60 and :104 exec SSH_SK_HELPER or _PATH_SSH_SK_HELPER for FIDO operations. freedesktop-sdk openssh.bst puts ssh-sk-helper in vm-only, and GNOME 50's flatpak/platform/filesystem.bst excludes vm-only. So sk-* keys loaded from files by the bundled ssh have no helper. Keys held by the host agent through --socket=ssh-auth are signed on the host and should still work (unverified at runtime). (https://raw.githubusercontent.com/openssh/openssh-portable/master/ssh-sk-client.c ; https://gitlab.com/freedesktop-sdk/freedesktop-sdk/-/raw/release/25.08/elements/components/openssh.bst ; https://gitlab.gnome.org/GNOME/gnome-build-meta/-/raw/gnome-50/elements/flatpak/platform/filesystem.bst) -- aws-lc-rs risks: claimed C/assembly build (cc/cmake); build-time tooling needs in the Flatpak SDK unverified, actual The aws-lc-rs Linux requirements page says a non-FIPS aws-lc-sys build needs only a C/C++ compiler; CMake, bindgen and Go are 'Never required'. Only FIPS builds need CMake and Go. The Flatpak SDK's gcc is enough. (https://raw.githubusercontent.com/aws/aws-lc-rs/main/book/src/requirements/linux.md) -- russh risks (commit share): claimed 85 of about 140 commits since 2026-03-14 are by Eugeny, actual 85 of 159 commits since 2026-03-14 (GitHub commits API: 100 + 59). The next largest authors are allcontributors[bot] (17), mjc (15), kpcyrd (6) and rogkne (5). Other bus-factor facts confirmed: the only crates.io owner is Eugeny; the repo is owner_type=User (warp-tech/russh redirects to Eugeny/russh); Eugeny has 478 all-time commits. (gh api 'repos/Eugeny/russh/commits?since=2026-03-14T00:00:00Z' ; https://crates.io/api/v1/crates/russh/owners) -- rustls-native-certs / sqlx-mysql TLS rationale ('sqlx 0.9 has no aws-lc native-roots feature and always seeds webpki-roots'): claimed No aws-lc plus native-roots path exists, so sqlx-mysql always seeds webpki-roots, actual True at the sqlx facade: the only native-roots feature, tls-rustls-ring-native-roots, pulls in ring (sqlx v0.9.0 Cargo.toml:104-108). But sqlx-core 0.9.0 declares rustls-native-certs as an optional dependency without the dep: prefix, so an implicit sqlx-core feature 'rustls-native-certs' exists. tls_rustls.rs:211-217 then switches import_root_certs to native certs while the provider stays aws-lc (lines 94-99). sqlx-core describes itself as 'Not intended to be used directly', so this is not a clean path. Also, a CA given via ssl_ca or ssl_ca_from_pem is added to that base store (tls_rustls.rs:140-150), so the planned PEM bundle adds system roots on top of Mozilla roots and does not replace them. Upstream issues #4044 and #4045, 'Add support for rustls-platform-verifier', have been open since 2025-09-26. (https://raw.githubusercontent.com/transact-rs/sqlx/v0.9.0/sqlx-core/Cargo.toml ; https://raw.githubusercontent.com/transact-rs/sqlx/v0.9.0/sqlx-core/src/net/tls/tls_rustls.rs ; gh search issues repo:transact-rs/sqlx aws-lc native) -- hyper-util / Happy Eyeballs summary and forks_and_hard_requirements item 7 ('Happy Eyeballs is not standard for database clients'): claimed Database clients do not use Happy Eyeballs: libpq and tokio connect sequentially, actual Only partly true. libpq does apply connect_timeout separately to each host name or IP address (libpq.sgml:1441), and tokio TcpStream::connect is sequential. But Go's net.Dialer enables RFC 6555 Fast Fallback by default, with a 300 ms FallbackDelay (dial.go:153-173; dualStack() is FallbackDelay >= 0). Go database drivers built on net.Dialer therefore get it by default. The conclusion (do not hand-roll it) still holds; the claim that it is not standard is overstated. (https://raw.githubusercontent.com/postgres/postgres/master/doc/src/sgml/libpq.sgml ; https://raw.githubusercontent.com/golang/go/master/src/net/dial.go) -- OpenSSH client (system ssh, ssh-keygen) rationale (Flatpak presence evidence chain): claimed The element ships in the GNOME 50 platform because platform.bst depends on components/openssh.bst and vm-only covers only the server parts, actual Mostly confirmed; the evidence chain needs one more link. freedesktop-sdk release/25.08 platform.bst:162 depends on components/openssh.bst, ref V_10_5_P1. gnome-50 junctions freedesktop-sdk-25.08.16. GNOME's own image excludes vm-only in elements/flatpak/platform/filesystem.bst, while platform.bst excludes only debug, docs, locale, tests and shells. That GNOME's sdk-platform.bst pulls in freedesktop-sdk platform.bst was not traced (unverified), but nothing contradicts it. (https://gitlab.gnome.org/GNOME/gnome-build-meta/-/raw/gnome-50/elements/flatpak/platform/filesystem.bst ; https://gitlab.gnome.org/GNOME/gnome-build-meta/-/raw/gnome-50/elements/flatpak/platform/platform.bst) - -Verdict challenges: - -- hyper-util (HttpConnector as the DirectTcpTransport dialer, FC-D6 amendment): keep, and adopt HttpConnector as the generic database dialer to keep only as a transitive dependency of clickhouse; avoid it as a database dialer. Use tokio::net::TcpStream::connect plus socket2::SockRef for keepalive and TCP_USER_TIMEOUT, and use tokio-postgres's own Config for direct PostgreSQL TCP.. No Rust database driver dials through hyper's HTTP connector. It lives in client::legacy and needs a fabricated `http://host:port` Uri (with its own IPv6 bracket and scheme handling). It resolves through GaiResolver, implements RFC 6555 only, and splits set_connect_timeout evenly across addresses (http.rs:343-347). The standard pattern is the one tokio-postgres 0.7 uses internally: connect_socket.rs:4,34,40 calls SockRef::set_tcp_user_timeout and set_tcp_keepalive, and Config offers connect_timeout, keepalives_idle/interval/retries, tcp_user_timeout, hostaddr and load_balance_hosts (config.rs:413-557). For tiberius, which takes a caller-supplied stream, the same pattern is about 10 lines on socket2, a rust-lang org crate with feature "all" (unix.rs:2544-2552, lib.rs:558-580). Drop the Happy Eyeballs requirement rather than borrowing an HTTP-client component to meet it. -- pem: add to avoid. Prefer pem-rfc7468 1.0.0 if an encoder is still needed; better, remove the need by contributing to transact-rs/sqlx #4044/#4045.. pem has a single owner (jcreekmore, personal repo, 58 stars), and 4.0.0 is a new major from 2026-07-28. pem-rfc7468 1.0.0 (2025-11-08) is RustCrypto-org owned (tarcieri plus the github:rustcrypto:formats team), has 317,199,017 all-time and 76,488,129 recent downloads, and exports encode_string. It is already in the lock file at 0.7.0. The PEM bundle is also weak on its own merits: sqlx adds any CA PEM on top of webpki roots (tls_rustls.rs:140-150), so it never yields platform-only trust. The least-debt fix is upstream platform-verifier support in sqlx (issues #4044/#4045, open since 2025-09-26). -- rustls-native-certs (narrowed to the sqlx-mysql PEM bundle): keep (direct, to build the MySQL PEM bundle) to keep as a transitive dependency of rustls-platform-verifier only. For MySQL, accept sqlx's built-in roots plus the user CA file and record the gap in the ADR. Then contribute to sqlx #4044/#4045 instead of keeping a bundle builder.. The review drops the webpki-roots union for the other engines because it widens trust. The MySQL bundle recreates the same union (webpki plus system plus CA), because sqlx-core 0.9 seeds webpki roots under tls-rustls-aws-lc-rs and adds the CA to them. A direct native-certs loader plus a PEM encoder is custom glue that exists only to work around sqlx. The implicit sqlx-core 'rustls-native-certs' feature would do the same without glue, but sqlx-core is 'Not intended to be used directly'. The upstream issue is the non-debt exit. If system-store corporate CAs must work for MySQL before then, keep the bundle as an explicitly temporary item tied to #4044. -- OpenSSH client (system ssh, ssh-keygen): add (the only option meeting the full requirement set, including ProxyCommand and Match exec) to add, but state the Flatpak limits in the ADR and do not count ProxyCommand, Match exec, host /etc/ssh or FIDO file keys as delivered in the Flatpak build. The core argument is sound and verified: readpass.c:130-133 (force sets use_askpass and allow_askpass), sshconnect.c:615 and 1646, sshconnect2.c:1051, 1547 and 2124, readconf.c:2880, russh known_hosts.rs:99 plus exact or |1| matching only, russh-config with no Include or Match (#699 open), and ssh-key without a matcher. But in the sandbox, ProxyCommand and Match exec run in the runtime, where host tools such as cloudflared, aws ssm or tsh are absent. Host /etc/ssh is hidden. FIDO sk keys from files lose ssh-sk-helper (vm-only). flatpak-spawn --host would need org.freedesktop.Flatpak, a sandbox escape. The .deb and .rpm builds get the full benefit. The custom code that remains is also not trivial: askpass helper and socket bridge, stderr classification, master lifecycle, and a Unix-socket connector for ClickHouse HTTP. It is still far less debt than re-implementing sshconnect2.c and known_hosts on russh. -- rustls-platform-verifier: add (replaces the whole trust assembly for PostgreSQL, ClickHouse and SQL Server) to add, with an explicit small wrapper for no-hostname modes (PostgreSQL sslmode=verify-ca, SQL Server TrustServerCertificate semantics) and the plan's observed-certificate verifier. Verified: the Linux target depends on rustls-native-certs 0.8 and rustls-webpki 0.103 (Cargo.toml:34-36), and new_with_extra_roots(extra_roots, Arc) exists (others.rs:35-39). The README confirms Linux has no revocation checking and loads the store once. But Verifier has no 'chain only, skip hostname' mode. verify-ca still needs a ServerCertVerifier wrapper that accepts CertificateError::NotValidForName, the same shape sqlx keeps internally as NoHostnameTlsVerifier (tls_rustls.rs:153-170). Say so, so the plan does not assume the crate removes every custom verifier. - -### Core data, parsing, formatting and utility crates - -I reviewed core data, parsing, formatting and utility crates against the plan in plan.json and the current code, using the crates.io API, the GitHub API, upstream source files and reference Cargo.toml files (Fractal, Pika Backup, Snapshot, rainfrog). All data was fetched 2026-09-14. - -**Keep:** chrono, serde, serde_json (drop preserve_order, add raw_value, as planned), uuid, thiserror, async-trait, tracing, tracing-subscriber, secrecy, tempfile (dev) and sqlformat. -**Add:** sqlparser 0.63, tracing-journald 0.3, num-bigint (new; already in the dependency graph through russh 0.63.3 and oo7), csv 1.4 as a dev-dependency test oracle, and zeroize in narrow secret-handling spots. -**Drop:** rust_decimal after the value-model switch, and anyhow (unused). -**Avoid:** bigdecimal, jiff, time as a direct dependency, sea-query, dynosaur, trait-variant, backoff (RUSTSEC-2025-0012, unmaintained), backon and tokio-retry (not needed), atomicwrites, atomic-write-file, hex (no release since 2021-03-03), ruint, primitive-types, ethnum and bnum. - -**Custom code that is justified by a hard requirement (evidence below):** -- SqlDecimal: PostgreSQL NUMERIC with NaN, Infinity and 131072 digits. -- The client-side script lexer: psql and mysql client splitting, DELIMITER, and sqlcmd GO. -- The small CSV quoting core: OWASP-style per-field forced quoting, which csv/csv-core cannot express. -- Per-engine SQL generation: sea-query has no open-source SQL Server backend and no ClickHouse backend. - -**Custom code that is not justified:** -- WideInteger as a string magnitude: use num_bigint::BigInt. -- The planned hand-written date/timestamp infinity decoding: postgres-types 0.2.14 already has Date and Timestamp with PosInfinity/NegInfinity, and its chrono conversions use checked_add_signed. -- A second CSV writer in storage/query_history.rs that skips formula neutralisation. -- Duplicated hex_encode helpers. -- A hand-rolled floor_char_boundary, now in std since Rust 1.91.0. - -**Plan defects found:** -1. GLib's g_file_set_contents_full keeps an existing file's mode: gfileutils.c calls fchmod(fd, old_stat.st_mode). The planned W1-05 test rewrite_of_0644_file_is_0600 therefore contradicts GLib unless the store chmods explicitly. -2. rust_decimal is still listed in FC-D1 (db-tokio-postgres), FC-D2 and FD-D12 (sqlx feature), FC-D4/W8-10 (tiberius feature) and the "rust_decimal unchanged" line, although FB-D2 makes SqlDecimal the only decimal type. -3. The FB-D5 and FB-D2 rationales cite sqlx-postgres behaviour, but PostgreSQL moved to tokio-postgres (FC-D1). -4. The W1-16 history credential classifier splits statements with sqlparser's tokenizer while execution splits with ScriptPlan. The two disagree on DELIMITER, GO, MySQL "-- " comments and ClickHouse "//" comments. - -| Crate | Role | Status | Latest | Released | Downloads (all / 90d) | Maintenance | Standing | Verdict | -|---|---|---|---|---|---|---|---|---| -| csv | CSV writing and reading (export encoder oracle; future CSV import) | candidate | 1.4.0 | 2025-10-17 | 243,668,921 / 47,053,626 | active | de_facto | add | -| rust_decimal | Fixed-precision decimal for Value::Decimal and driver decode (sqlx, tiberius and planned tokio-postgres features) | current | 1.43.0 | 2026-09-02 | 143,157,270 / 33,937,317 | active | de_facto | drop | -| bigdecimal | Arbitrary-precision decimal (alternative to SqlDecimal) | candidate | 0.4.10 | 2025-12-27 | 116,668,668 / 25,727,443 | slow | popular | avoid | -| num-bigint | Arbitrary-precision integer to back WideInteger (ClickHouse Int128, UInt128, Int256, UInt256) | candidate | 0.5.1 | 2026-07-05 | 581,892,747 / 132,018,295 | active | de_facto | add | -| ruint | Fixed-width 256-bit integers (alternative for WideInteger) | candidate | 1.20.0 | 2026-07-30 | 28,074,894 / 7,083,173 | active | niche | avoid | -| primitive-types | U256 fixed-width integer (alternative for WideInteger) | candidate | 0.14.0 | 2025-09-19 | 61,697,826 / 10,817,300 | active | popular | avoid | -| ethnum | I256 and U256 (alternative for WideInteger) | candidate | 1.5.3 | 2026-04-20 | 29,875,772 / 6,356,711 | active | niche | avoid | -| bnum | Const-generic fixed-width integers (alternative for WideInteger) | candidate | 0.14.4 | 2026-03-24 | 15,363,151 / 6,072,154 | active | niche | avoid | -| chrono | Date, time and offset types inside Temporal, OffsetTimestamp and driver decode | current | 0.4.45 | 2026-06-04 | 800,783,505 / 171,972,320 | active | de_facto | keep | -| time | Alternative date and time crate | candidate | 0.3.55 | 2026-08-01 | 892,745,042 / 173,085,283 | active | de_facto | avoid | -| jiff | Alternative date, time and time-zone crate | candidate | 0.2.37 | 2026-09-12 | 187,765,044 / 63,772,528 | active | popular | avoid | -| postgres-types | PostgreSQL FromSql/ToSql, including the special Date/Timestamp infinity types (planned with tokio-postgres) | planned | 0.2.14 | 2026-06-12 | / | active | de_facto | keep | -| sqlparser | Validating user-typed types and defaults, classifying catalog defaults and MySQL COLUMN_TYPE, history credential tokenizing (FB-D14) | planned | 0.63.0 | 2026-09-13 | 77,279,442 / 11,949,762 | active | de_facto | add | -| sqlformat | SQL formatting in the editor (W6-14) | current | 0.5.0 | 2025-10-09 | 63,574,692 / 4,033,921 | active | popular | keep | -| sqruff-lib | SQL linter and formatter (alternative to sqlformat) | candidate | 0.40.0 | 2026-08-14 | 694,860 / 203,534 | active | niche | avoid | -| sea-query | Dynamic DML/DDL builder (alternative to sql_ddl.rs/sql_dialect.rs and planned per-engine dialects) | candidate | 1.0.2 | 2026-08-12 | 35,560,033 / 4,903,545 | active | popular | avoid | -| thiserror | Typed error enums in every crate | current | 2.0.20 | 2026-08-08 | 1,453,174,943 / 356,042,650 | active | de_facto | keep | -| anyhow | Ad-hoc application errors | current | 1.0.104 | 2026-07-18 | 952,561,962 / 208,730,276 | active | de_facto | drop | -| async-trait | Dyn-dispatched async traits (Connection, DatabaseDriver, Transport, ResultSink) | current | 0.1.92 | 2026-08-08 | 656,554,940 / 143,094,336 | active | de_facto | keep | -| dynosaur | Dyn dispatch for native async fn in traits | candidate | 0.3.1 | 2026-07-03 | 1,684,471 / 491,602 | active | niche | avoid | -| trait-variant | Send-bound variants of async traits | candidate | 0.1.3 | 2026-07-22 | 13,163,191 / 3,465,357 | active | niche | avoid | -| tracing | Structured logging facade | current | 0.1.44 | 2025-12-18 | 843,041,830 / 187,603,230 | active | de_facto | keep | -| tracing-subscriber | EnvFilter, registry and fmt layer | current | 0.3.23 | 2026-03-13 | 602,250,349 / 140,098,197 | active | de_facto | keep | -| tracing-journald | Native journald output layer (FD-D7, W8-21) | planned | 0.3.2 | 2025-11-26 | 8,995,811 / 2,302,846 | active | niche | add | -| secrecy | SecretString for passwords and passphrases | current | 0.10.3 | 2024-10-09 | 159,741,238 / 38,838,501 | active | de_facto | keep | -| zeroize | Wiping intermediate secret buffers (keyring bytes, keyboard-interactive answers) | candidate | 1.9.0 | 2026-06-12 | 685,940,057 / 172,487,587 | active | de_facto | add | -| serde | Serialization of settings, workspace documents and connection store | current | 1.0.229 | 2026-07-18 | 1,388,305,966 / 303,174,127 | active | de_facto | keep | -| serde_json | JSON documents, JsonText validation, RawValue number tokens in export | current | 1.0.151 | 2026-07-20 | 1,291,828,674 / 306,813,968 | active | de_facto | keep | -| uuid | Connection ids and the UUID value type | current | 1.26.1 | 2026-09-10 | 796,778,464 / 181,754,553 | active | de_facto | keep | -| backoff | Exponential backoff for reconnect | candidate | 0.4.0 | 2021-12-14 | 102,384,051 / 14,394,622 | stale | popular | avoid | -| backon | Retry and backoff runner | candidate | 1.6.0 | 2025-10-18 | 81,663,943 / 28,564,413 | active | popular | avoid | -| tokio-retry | Retry strategies for tokio | candidate | 0.3.2 | 2026-06-09 | 98,691,136 / 8,789,463 | active | popular | avoid | -| tempfile | Test fixtures (TempDir, SQLite temp files) | current | 3.27.0 | 2026-03-11 | 805,503,057 / 178,809,790 | active | de_facto | keep | -| atomicwrites | Atomic file replacement | candidate | 0.4.4 | 2024-09-19 | 19,177,571 / 2,879,092 | slow | niche | avoid | -| atomic-write-file | Atomic file replacement | candidate | 0.3.1 | 2026-08-11 | 5,425,525 / 713,438 | active | niche | avoid | -| hex | Hex encoding for byte display, export and dialect literals | candidate | 0.4.3 | 2021-03-03 | 644,664,300 / 141,617,979 | stale | de_facto | avoid | - -Fact-check corrections: - -- chrono rationale: claimed It is the only date crate that sqlx 0.9 (chrono feature), tokio-postgres 0.7.18 (with-chrono-0_4), tiberius main (chrono) and clickhouse 0.15.2 (chrono) all support. It also has the widest range., actual time 0.3 is supported by all four as well: sqlx 0.9.0 has a `time` feature, tokio-postgres 0.7.18 has `with-time-0_3`, tiberius main has `time`, and clickhouse 0.15.2 has `time`. The range claim is also false: time's `large-dates` feature covers years up to ±999,999, which is wider than chrono. chrono stays the right choice because it is already used everywhere, not because it is the only option. (https://crates.io/api/v1/crates/sqlx/0.9.0 ; https://crates.io/api/v1/crates/tokio-postgres/0.7.18 ; https://crates.io/api/v1/crates/clickhouse ; https://raw.githubusercontent.com/prisma/tiberius/main/Cargo.toml ; https://docs.rs/time/latest/time/struct.Date.html) -- chrono range: claimed January 1, 262144 BCE to December 31, 262142 CE, actual "Allows for every proleptic Gregorian date from Jan 1, 262145 BCE to Dec 31, 262143 CE" (https://docs.rs/chrono/latest/chrono/naive/struct.NaiveDate.html) -- postgres-types repository / downloads: claimed https://github.com/sfackler/rust-postgres (user sfackler); downloads not given, actual crates.io now lists https://github.com/rust-postgres/rust-postgres, an Organization repo (not archived, pushed 2026-07-27, 183 open issues). Downloads are 70,846,836 all-time and 17,701,687 in the last 90 days. (https://crates.io/api/v1/crates/postgres-types ; gh api repos/rust-postgres/rust-postgres) -- num-bigint version / notable_users: claimed Already compiled in through russh 0.63.3 and oo7; 0.4 and 0.5 may coexist, match the russh version (not checked), actual russh 0.63.3 needs num-bigint ^0.5 (normal dependency). oo7 0.6.0 needs num ^0.4, which brings num-bigint 0.4.8. Today the lockfile has only 0.4.8, pulled by russh 0.55.0 and oo7. After the russh upgrade both 0.4.8 and 0.5.x will be built, so the direct dependency should be num-bigint 0.5. Versions 0.4.8 and 0.5.1 were both published on 2026-07-05. (https://crates.io/api/v1/crates/russh/0.63.3/dependencies ; https://crates.io/api/v1/crates/oo7/0.6.0/dependencies ; cargo tree --offline -i num-bigint) -- num-bigint risks: claimed FromStr has no length cap; cap the input before parsing, actual FromStr is also lenient. It accepts a leading '+' (biguint/convert.rs:213) and skips '_' between digits (lines 223 and 235), so "1_000" parses as 1000. FB-D15 requires lossless edit text, so an ASCII grammar check ^-?[0-9]+$ must run before BigInt::from_str, not just a length cap. (~/.cargo/registry/src/*/num-bigint-0.4.8/src/biguint/convert.rs ; https://raw.githubusercontent.com/rust-num/num-bigint/master/src/biguint/convert.rs) -- anyhow status / verdict: claimed status current, version_in_use 1.0.104, verdict drop, actual Commit 5bd1716e9 (2026-09-14, the W8-01 commit) already removed anyhow from every workspace manifest. The only copy left is a transitive one through prost-derive on the testcontainers dev chain. No action is needed. (git show --stat 5bd1716e9 ; crates/*/Cargo.toml ; cargo tree --offline -i anyhow) -- hex license / maintenance / version_in_use: claimed license unverified; maintenance stale; transitive via sqlx 0.8, russh 0.55, bollard, actual The license is MIT OR Apache-2.0. The repository had commits on 2025-11-09 (merged perf PR #64) and 2026-01-23, and advisory-db has no entry for hex. By the rubric that is 'slow' (no release since 2021-03-03), not 'stale'. It is also pulled in normally through zbus 5.19.0 (via oo7) and through hyperlocal. (https://crates.io/api/v1/crates/hex ; gh api repos/KokaKiwi/rust-hex/commits ; gh api repos/rustsec/advisory-db/contents/crates/hex (404) ; cargo tree --offline -i hex) -- const-hex standing (as the suggested fallback): claimed const-hex 1.19.1 (active, 57M downloads) if a crate is required, actual The repository is DaniPopes/const-hex, owned by one user account with 67 stars. By the review's own bus-factor rule it is a single-maintainer crate. Its crates.io stats are accurate: 1.19.1 released 2026-05-23, 57,402,501 downloads. (gh api repos/danipopes/const-hex ; https://crates.io/api/v1/crates/const-hex) -- sqruff-lib license: claimed unverified, actual Apache-2.0 (https://crates.io/api/v1/crates/sqruff-lib/versions) -- csv (CSV quoting core) risks / forks_and_hard_requirements: claimed OWASP says to wrap each neutralised cell in double quotes while still honouring IfNeeded, so per-field forced quoting is a hard requirement csv cannot express, actual OWASP's text is: 'apply the following sanitization to each field of the CSV: Wrap each cell field in double quotes; Prepend each cell field with a single quote; Escape every double quote'. That applies to every field, which is exactly csv's QuoteStyle::Always. The same page warns 'The above techniques are not reliable in Microsoft Excel after saving and re-opening' and recommends a tab prefix for Excel. Nothing requires per-field quoting. RFC 4180 gives a leading apostrophe no special meaning, so an unquoted neutralised field is still valid CSV. Every current option maps onto csv: delimiters Comma/Semicolon/Tab/Pipe are single bytes; line breaks Lf/CrLf/Cr map to Terminator::Any(b'\n')/CRLF/Any(b'\r'); quoting Always/IfNeeded/Never maps to QuoteStyle Always/Necessary/Never. csv-core also quotes CR and LF for CRLF or LF terminators. (https://owasp.org/www-community/attacks/CSV_Injection ; crates/core/src/export.rs:20-74,202-226 ; https://github.com/BurntSushi/rust-csv/blob/master/csv-core/src/writer.rs (build requires_quotes, should_quote) ; csv-core/src/lib.rs Terminator, QuoteStyle) -- plan defect #2 / amendments FC-D1, FD-D12 (rust_decimal) summary claim: claimed The plan still lists rust_decimal in FC-D1, FC-D2 and FD-D12 (sqlx feature) and in FC-D4/W8-10 (tiberius feature), and says 'rust_decimal unchanged', although FB-D2 makes SqlDecimal the only decimal type, actual The plan already resolves this. Reconcile conflict 43 (details line 5582) says 'The contract switch removes rust_decimal and the sqlx and tiberius rust_decimal features ... and FC's rust_decimal db-tokio-postgres'. Tasks W2-12, W2-13, W2-14, W2-15 and W2-16 each say 'rust_decimal removed', W8-12 says 'The contract switch drops rust_decimal', and details lines 5907 and 5990 say 'Neither rust_decimal nor any sqlx-postgres' and 'No rust_decimal after the switch'. W8-10 in plan.json does not mention rust_decimal at all. What remains is stale foundation and contract text at details lines 2605, 2645, 3808, 3847, 3861, 5206-5207 and 5217. That needs an editorial fix, not a design change. The FB-D5 sqlx-postgres rationale (defect #3) is superseded the same way by the reconcile at details line 5529. (/tmp/claude-1000/-home-ngoquocdat-Workspaces-TablePro-linux/f564f546-0075-4f9a-9d3e-20b24faa3f40/scratchpad/plan.json (reconcile/conflicts/43, W2-12..W2-16, W8-12) ; /home/ngoquocdat/Workspaces/TablePro/linux/docs/plan-2026-09-14-audit-remediation-details.md) -- floor_char_boundary / truncate_for_display (W4-11 amendment) hand_rolled_to_replace / plan_amendments: claimed W4-11 rewrites workspace persistence, so replace workspace_state.rs:238 floor_char_boundary with std, and use s.char_indices().nth(n) in grid truncate_for_display, actual W4-11's file list already contains 'linux/crates/app/src/services/workspace_state.rs (deleted)', and its change removes MAX_QUERY_BYTES and the truncation branch. W5-08 says '`value_to_display_text` and `truncate_for_display` are deleted'. Both amendments target code the plan already removes. The std claim itself is right: str::floor_char_boundary is listed under Rust 1.91.0 (2025-10-30). (plan.json W4-11 files, W5-08 change ; https://raw.githubusercontent.com/rust-lang/rust/main/RELEASES.md) -- plan MSRV rationale (floor_char_boundary): claimed plan MSRV 1.98, actual The plan contradicts itself. FD-D8 sets rust-version 1.98, while FC-D3 says 'workspace rust-version 1.94; clippy.toml msrv 1.94'. The workspace today has rust-version 1.93. All three are above 1.91, so the std method is usable either way. Rust 1.98.1 was released 2026-09-03. (plan.json FD-D8, FC-D3 ; /home/ngoquocdat/Workspaces/TablePro/linux/Cargo.toml ; rust-lang/rust RELEASES.md) -- jiff version_in_use: claimed not used, actual Cargo.lock does contain jiff 0.2.37, recorded as an optional dependency of serde_with 3.23.0, but it is not in the build graph (cargo tree --offline --target all -i jiff prints nothing). 'Not used' is effectively correct. (/home/ngoquocdat/Workspaces/TablePro/linux/Cargo.lock ; cargo tree --offline --target all -i jiff) -- atomicwrites maintenance: claimed slow; two years without a release, actual The release claim is correct (0.4.4 on 2024-09-19), but the repository had a commit on 2026-08-08 (a README and CI cleanup). 'Slow' is still reasonable, since that commit is not maintenance of the code. (gh api repos/untitaker/rust-atomicwrites/commits) - -Verdict challenges: - -- csv: add (core dev-dependency test oracle only; normal dependency later for import) to add as a normal dependency now and write all CSV output with csv::Writer (data export and history export). The owner wants standard crates over hand-rolled code. The only reason given for keeping a custom writer is per-field forced quoting, and OWASP's guidance does not require it: its quoting advice applies to every field (QuoteStyle::Always) and is marked unreliable in Excel. Every CsvOptions value maps to csv's WriterBuilder (delimiter u8, Terminator CRLF/Any, QuoteStyle Always/Necessary/Never). Formula neutralisation is a value transform before write_field, so it does not need writer internals. The product rule of quoting tabs, converted multi-line cells and neutralised cells under IfNeeded should be narrowed: either accept Necessary semantics, or switch to QuoteStyle::Always when sanitize_formulas is on. That leaves zero custom CSV writers, and csv 1.4.0 (3,321 reverse dependencies, used by rainfrog) owns quoting correctness. -- CsvEncoder quoting core (crates/core/src/export.rs:135-235): keep custom (justified by a hard requirement) to replace with csv::Writer; keep only neutralisation, decimal-comma and NULL rendering as pre-write value logic. The same evidence as for csv. The claimed hard requirement comes from a misreading of OWASP, and the remaining behaviour differences are UX preferences that can be narrowed. The review's own rule says to switch to csv::Writer if per-field forced quoting is dropped, and nothing forces keeping it. -- num-bigint: add (version not pinned; 'match the version russh uses (not checked)') to add num-bigint 0.5 and validate text against ^-?[0-9]+$ with a byte cap before FromStr. russh 0.63.3 requires ^0.5, so 0.5 costs nothing extra after the upgrade; 0.4.8 stays only through oo7's num. BigInt::from_str accepts '+' and '_' separators, which would silently turn edit text like "1_000" into 1000 and break FB-D15's lossless edit text. The swap from a string-backed WideInteger is still correct. -- anyhow: drop to no action (already removed). Commit 5bd1716e9 (W8-01) removed it from every manifest; it exists only transitively on the testcontainers dev chain. -- hex: avoid; one core helper, const-hex 1.19.1 if a crate is required to avoid a crate (one shared helper is fine); if a crate is ever wanted, prefer hex 0.4.3 over const-hex. hex has no RustSec advisory, 645M downloads, commits into 2026, and is used directly by GNOME Authenticator. const-hex is a single-maintainer user repository (DaniPopes, 67 stars), which fails the review's own bus-factor rule. Either way a 5-line encoder is not meaningful debt. -- W4-11 / grid truncate_for_display amendment: amend (use std floor_char_boundary and char_indices().nth(n)) to drop the amendment; the plan deletes both helpers (W4-11, W5-08). workspace_state.rs is deleted in W4-11 and truncate_for_display in W5-08. If a display cap comes back, char_indices().nth(n) splits grapheme clusters. The native choice is Pango ellipsization (the plan already uses ellipsize End in W7-04), or unicode-segmentation for a character cap. -- plan amendments FC-D1 and FD-D12 (rust_decimal removal): plan defect needing new decisions to editorial cleanup of stale foundation text only. Reconcile conflict 43, W2-12..W2-16 and W8-12 already remove rust_decimal and the sqlx and tiberius rust_decimal features in the contract switch. Only details lines 2605, 2645, 3808, 3847, 3861, 5206-5207 and 5217 still show the old text. The proposed cargo tree check (-i rust_decimal reports no package) is still a useful gate. -- W8-10 deny.toml bans for time and hex with wrapper lists: add bans (time only via bollard and bollard-stubs; hex only via existing transitive wrappers) to ban rust_decimal, bigdecimal, backoff and instant; do not ban time or hex through wrapper allowlists. hex currently arrives through bollard, hyperlocal, internal-russh-forked-ssh-key (russh 0.55), sqlx-mysql 0.8.6, sqlx-postgres 0.8.6 and zbus 5.19.0. The russh 0.63.3, sqlx 0.9 and PostgreSQL moves in this plan all change that set, so a wrapper allowlist would break CI on every dependency bump and turn into maintenance debt of its own. A direct-dependency review rule, or clippy disallowed-types for time::Date and time::OffsetDateTime in workspace code, keeps one date vocabulary with no churn. -- tracing-journald: add to add, but record it as a low-adoption choice. None of the six GNOME Rust apps checked (Fractal, Pika Backup, Snapshot, Loupe, Authenticator, glycin) uses it; all log through tracing-subscriber to stderr, which the GNOME session already sends to the journal. The crate is first-party in tokio-rs/tracing, 0.3.2 depends only on libc, tracing-core and tracing-subscriber, and FD-D7 falls back to stderr, so the risk is small. glib 0.22.9 also exposes log_writer_journald (log.rs:1024) if the crate is ever dropped. - -### Build, packaging, CI and test tooling - -Scope: build, packaging, CI and test tooling for TablePro Linux. The plan's main tool choices are the standard ones, and I recommend keeping them: Meson with a cargo custom_target (the Snapshot, Loupe, Fractal, Amberol and gnome-tour shape), glib-build-tools, flatpak-cargo-generator, flatpak/flatpak-github-actions with the gnome-50 image, flatpak-builder-lint, cargo-deny (through cargo-deny-action), cargo-about, testcontainers, the gtk4 test API (#[gtk::test] and test_accessible_*), xvfb-run plus dbus-run-session, and dpkg-buildpackage, rpmbuild and COPR. - -The tech debt in this domain is not a fork. It is roughly 16 custom Python scripts and 3 custom Rust support crates, most with their own unittest suites, that re-implement what existing tools or distro conventions already do: -- **Flathub manifest:** render_flathub_manifest.py. Standard flow: the release manifest lives in the flathub/ repo, as it does for Loupe and Fractal. -- **Package metadata:** license_metadata.py, assemble_debian.py and render_spec.py. Standard replacements: a cargo-about handlebars template, Fedora cargo-rpm-macros (verified in the Fedora 44 specs for loupe, snapshot, papers and glycin), and a committed debian/ directory plus dch. -- **Integration matrix:** integration_matrix.py. Standard replacement: cargo-nextest with `--run-ignored only`, filtersets and test groups, plus clippy ignore_without_reason (Rust 1.88). -- **Doc and process gates:** check-doc-references.py (lychee), check-source-size.py, check-regression-map.py, check-protected-msgids.py and check-clippy-shared-keys.py (one root clippy.toml). -- **Build config:** the tablepro-build-support crate. Standard replacement: Loupe's option_env! config.rs plus meson devenv. -- **Accessibility audit:** stock-internals rules in test-support-gtk pinned to the libadwaita 1.9.3 .ui files. They will break under Fedora 44 (1.9.0) and Ubuntu 26.04 (1.9.1) package %check. - -One real dependency problem: testcontainers-modules 0.15.0 is the last release (2026-02-21). Its main branch already depends on testcontainers 0.28, but nothing has shipped for about 7 months. Issue #497 (2026-08-24) asks for a release, and a commenter says the pin blocks the h2 fix for RUSTSEC-2026-0258 (not checked against our graph). Default features also pull ring. W9-02 already builds each engine container by hand, so drop the modules crate and use testcontainers::GenericImage from the same org, on 0.28.0. - -Two plan rejections no longer hold: -- **Fedora %cargo_prep vendoring macros "not verifiable":** they are verified in the f44 and rawhide specs for loupe, snapshot, papers, glycin and gnome-tour. -- **Linting the Devel build "tests nothing real":** flatpak-builder-lint flags dir sources only inside the Flathub pipeline (checks/modules.py:65), and mirror-screenshots-url works on any build. - -Also: the plan's lint command omits `--exceptions`, which the README example pairs with `--user-exceptions`. The format of the local exceptions file is unverified. - -Hard constraints that justify custom paths, with evidence: -- rust-version 1.98 against Ubuntu 26.04's rustc 1.93 (plan-verified) rules out dh-cargo and Launchpad PPAs, so the .deb stays a rustup build on GitHub Releases. -- Flathub allows no network at build time, so flatpak-cargo-generator is required. - -| Crate | Role | Status | Latest | Released | Downloads (all / 90d) | Maintenance | Standing | Verdict | -|---|---|---|---|---|---|---|---|---| -| meson (with cargo custom_target pattern) | Primary build system: data files, i18n, install, tests, dist tarball; cargo invoked from a custom_target | planned | 1.12.0 (2026-08-10); Ubuntu 26.04 ships 1.10.1, Fedora 44 ships 1.11.2 | 2026-08-10 | n/a (PyPI/distro) / n/a | active | de_facto | keep | -| glib-build-tools | build.rs compiles the GResource (style.css, shortcuts dialog, icons, license JSON) for both meson and plain cargo builds | planned | 0.22.8 | 2026-06-29 | 446,672 / 74,783 | active | popular | keep | -| flatpak-cargo-generator (flatpak/flatpak-builder-tools) | Generates cargo-sources.json from Cargo.lock so Flathub's offline flatpak-builder can fetch crates | planned | no git tags; last cargo/ commit f03a673abe (2025-08-16); PyPI flatpak-cargo-generator 0.1.4 (2026-06-13) | 2025-08-16 (last cargo/ commit) | unverified / unverified | slow | de_facto | keep | -| flatpak-builder-lint | Flathub linter for manifest and built repo, run in CI with local exceptions | planned | rolling (commit 77d19b14e8, 2026-09-13) | 2026-09-13 | n/a (not on PyPI; ghcr.io/flathub/flatpak-builder-lint image or org.flatpak.Builder) / n/a | active | de_facto | keep | -| flatpak/flatpak-github-actions (flatpak-builder@v6) | GitHub Actions Flatpak build with run-tests (xvfb-run), cache, arch matrix, mirror-screenshots-url | planned | v6.8 | 2026-08-30 | n/a / n/a | active | de_facto | keep | -| cargo-deny (+ EmbarkStudios/cargo-deny-action) | Advisories, license allow-list, bans (ring/openssl/native-tls), sources gate in CI | planned | cargo-deny 0.20.2; action v2.1.1 (2026-07-13) | 2026-07-09 | 5,676,819 / 1,597,680 | active | de_facto | keep | -| cargo-audit | RustSec advisory scan | candidate | 0.22.2 | 2026-06-05 | 11,791,983 / 3,333,225 | active | de_facto | avoid | -| cargo-about | Generates third-party license notices (JSON for About dialog, notice file for share/licenses) | planned | 0.9.2 | 2026-08-18 | 1,176,233 / 243,339 | active | popular | keep | -| cargo-license | License listing | candidate | 0.7.0 | 2025-07-29 | 1,935,247 / 210,249 | slow | popular | avoid | -| cargo-shear | Detect unused/misplaced Cargo dependencies in CI | planned | 1.13.4 | 2026-08-11 | 488,406 / 51,294 | active | popular | keep | -| cargo-machete | Alternative unused-dependency detector | candidate | 0.9.2 | 2026-04-15 | 2,861,503 / 519,592 | active | popular | avoid | -| cargo-udeps | Unused dependency detection | candidate | 0.1.61 | 2026-04-29 | 1,464,555 / 138,813 | active | popular | avoid | -| cargo-nextest | Test runner: run ignored docker suites, filter by package/rdeps, serialize via test groups, JUnit output, partitioning | candidate | 0.9.144 | 2026-09-10 | 12,723,536 / 1,866,796 | active | de_facto | add | -| testcontainers | Docker containers for driver integration tests and TLS/SSH fixtures | current | 0.28.0 (bollard 0.20 -> 0.21, russh 0.61.2) | 2026-08-06 | 39,795,031 / 12,411,830 | active | de_facto | keep | -| testcontainers-modules | Prebuilt images (postgres, mysql, mariadb, mssql_server) for integration tests | current | 0.15.0 (main already depends on testcontainers 0.28.0, unreleased) | 2026-02-21 | 21,108,054 / 8,544,066 | slow | popular | drop | -| gtk4 test utilities (#[gtk::test], gtk::test_synced, test_accessible_has_role/property/relation) | Run GTK tests on the main thread; assert accessible roles, labels and relations | current | gtk4 0.11.4 | 2026-06-29 | 3,911,069 / 798,848 | active | de_facto | keep | -| xvfb-run (Xvfb) + dbus-run-session | Headless display and private session bus for GTK/GApplication tests in CI and package %check | planned | unverified (distro-provided) | unverified | n/a / n/a | active | de_facto | keep | -| weston (headless backend) | Alternative headless Wayland compositor for GTK tests | candidate | 14.0.2 in Ubuntu 26.04 | unverified | n/a / n/a | active | popular | avoid | -| Fedora cargo-rpm-macros / rust-toolset (%cargo_prep -v vendor, %cargo_license_summary, %cargo_vendor_manifest) | RPM spec macros for vendored Rust builds with meson, license summary and bundled-crate manifest | candidate | unverified (Fedora 44 package present, used by f44 specs) | unverified | n/a / n/a | active | de_facto | add | -| Packit | Upstream GitHub release to COPR builds (and optional Fedora dist-git PRs) with spec version handling | candidate | 1.16.3 | 2026-09-04 | unverified / unverified | active | popular | add | -| dpkg-buildpackage + debhelper (dh --buildsystem=meson) + devscripts dch + lintian | Build the Ubuntu 26.04 .deb from meson install | planned | unverified (distro-provided) | unverified | n/a / n/a | active | de_facto | keep | -| dh-cargo / debcargo | Debian archive Rust packaging (librust-*-dev crates) | candidate | dh-cargo 33ubuntu3 (Ubuntu 26.04); debcargo 2.8.4 (2026-07-22) | 2026-07-22 | debcargo 90,094 / debcargo 2,502 | active | niche | avoid | -| cargo-deb | Build .deb directly from Cargo metadata | candidate | 3.8.0 | 2026-09-01 | 2,196,196 / 222,328 | active | popular | avoid | -| cargo-generate-rpm | Build .rpm directly from Cargo metadata | candidate | 0.21.0 | 2026-05-04 | 340,102 / 66,232 | active | niche | avoid | -| cargo-rpm | Legacy RPM builder | candidate | 0.8.0 | 2020-07-09 | 82,339 / 1,591 | stale | niche | avoid | -| nfpm | Package a staged file tree into .deb/.rpm from YAML | candidate | v2.47.0 | 2026-06-20 | unverified / unverified | active | popular | avoid | -| cargo vendor (--versioned-dirs) | Vendor tarball for offline .deb/.rpm builds | planned | part of cargo | n/a | n/a / n/a | active | de_facto | keep | -| cargo-vendor-filterer | Vendor only target-platform crates (smaller tarball) | candidate | 0.5.18 | 2025-07-11 | 87,710 / 4,776 | slow | niche | avoid | -| lychee | Markdown link checker (relative file links and anchors, offline mode) | candidate | lychee-v0.24.2 | 2026-05-01 | unverified / unverified | active | popular | add | -| taiki-e/install-action | Install pinned prebuilt cargo tools (cargo-about, cargo-shear, cargo-nextest) in CI | candidate | v2.87.12 | 2026-09-12 | n/a / n/a | active | popular | add | -| Swatinem/rust-cache and dtolnay/rust-toolchain | CI toolchain setup and cargo cache | current | rolling | 2026-09 (pushed 2026-09-07 / 2026-09-12) | n/a / n/a | active | de_facto | keep | -| appstreamcli / desktop-file-validate / glib-compile-schemas --strict | Meson data validation tests | planned | unverified upstream | unverified | n/a / n/a | active | de_facto | keep | - -Fact-check corrections: - -- flatpak-builder-lint risks / FD-D15 amendment (format of the local exceptions file): claimed The format of the local file is unverified. The amendment says to prefer the upstream exceptions.json shape {app-id: {repo_key: {rule: reason}}}., actual The format is verified. get_user_exceptions (cli.py:94-105) returns set(exceptions.get(appid, [])). The plan's list form {app-id: [rules]} is therefore correct; Fractal's committed .gitlab-ci/flatpak-builder-lint-exceptions.json uses the same form. The nested {repo_key: {rule: reason}} shape would turn the repo keys into exception names and except nothing. A flat {app-id: {rule: reason}} would also load, because set(dict) gives the keys. (https://api.github.com/repos/flathub-infra/flatpak-builder-lint/contents/flatpak_builder_lint/cli.py (lines 75-105); https://gitlab.gnome.org/World/fractal/-/raw/main/.gitlab-ci/flatpak-builder-lint-exceptions.json) -- flatpak-builder-lint risks (the missing --exceptions flag): claimed The plan omits --exceptions, which the README example pairs with --user-exceptions. Presented as a style point., actual This is a hard bug. run_checks reads user_exceptions_path only inside `if enable_exceptions:` (cli.py ~199-214). Without --exceptions the file is ignored, so every CI lint fails on the two expected findings. Fractal passes both flags (.gitlab-ci/test.yml:18). (https://api.github.com/repos/flathub-infra/flatpak-builder-lint/contents/flatpak_builder_lint/cli.py ; https://gitlab.gnome.org/World/fractal/-/raw/main/.gitlab-ci/test.yml) -- test-support-gtk accessibility audit (hand_rolled W9-05/W9-14) libadwaita versions on target distros: claimed Fedora 44 ships libadwaita 1.9.0 and Ubuntu 26.04 ships 1.9.1, so audit rules pinned to 1.9.3 break in package %check., actual Fedora 44 has had libadwaita-1.9.3-1.fc44 in stable since 2026-08-06 (1.9.1 on 2026-05-29, 1.9.2 on 2026-07-02). Ubuntu resolute has 1.9.0-0ubuntu1 in Release and 1.9.1-0ubuntu0.1 in Updates. The drift argument holds for Ubuntu and for future libadwaita releases, but not for Fedora 44 today. FD-D9's '1.9.0' number is stale too. (https://bodhi.fedoraproject.org/updates/?packages=libadwaita&releases=F44 ; https://api.launchpad.net/1.0/ubuntu/+archive/primary?ws.op=getPublishedSources&source_name=libadwaita-1&exact_match=true&distro_series=https://api.launchpad.net/1.0/ubuntu/resolute) -- testcontainers-modules risks (RUSTSEC-2026-0258): claimed A commenter says the pin blocks the h2 fix for RUSTSEC-2026-0258 (not checked against our graph)., actual Checked, and our graph is not affected. The advisory lists patched = [">= 0.4.16"], and linux/Cargo.lock already resolves h2 0.4.19. The commenter's lockfile had h2 0.4.14. (https://api.github.com/repos/rustsec/advisory-db/contents/crates/h2/RUSTSEC-2026-0258.md ; /home/ngoquocdat/Workspaces/TablePro/linux/Cargo.lock (h2 0.4.19)) -- testcontainers-modules risks (ring edge): claimed Default features = [ring] is given as a reason to drop the crate., actual The ring edge is already avoided. testcontainers-modules 0.15.0 has aws-lc-rs = ["testcontainers/aws-lc-rs"], and FD-D10 already specifies default-features = false plus aws-lc-rs for both crates. Default features do not affect the plan. (testcontainers-modules-0.15.0/Cargo.toml [features] (crates.io download); plan.json FD-D10) -- hard requirement 1 (Ubuntu toolchain) evidence: claimed The self-chosen rust-version 1.98 against Ubuntu 26.04's rustc 1.93 rules out dh-cargo and Launchpad PPAs. The reviewer's Launchpad query for 'rustc' returned no entry., actual The block does not depend on the 1.98 choice. sqlx 0.9.0 and sqlx-mysql 0.9.0 declare rust_version 1.94.0, relm4 0.11.0 declares 1.93 and gtk4 0.11.4 declares 1.92, so any MSRV that allows sqlx 0.9 exceeds Ubuntu's newest archive compiler. Launchpad publishes the versioned sources rustc-1.91 (1.91.1), rustc-1.92 (1.92.0) and rustc-1.93 (1.93.1+dfsg-0ubuntu6) in the resolute Release pocket; the unversioned 'rustc' source has no entry. (https://crates.io/api/v1/crates/sqlx/versions ; https://crates.io/api/v1/crates/sqlx-mysql/versions ; https://api.launchpad.net/1.0/ubuntu/+archive/primary?ws.op=getPublishedSources&source_name=rustc-1.93&exact_match=true) -- Fedora cargo-rpm-macros repository / latest_version: claimed Repository src.fedoraproject.org/rpms/rust-packaging; version unverified., actual The package is cargo-rpm-macros 28.5-1.fc44 (stable 2026-07-23, shipped together with cargo2rpm 0.3.4). Its upstream URL is https://codeberg.org/rust2rpm/cargo-rpm-macros (repo updated 2026-09-09). The dist-git owner is decathorpe with admin group rust-sig. The rust-packaging rawhide spec URL returns 404. (https://src.fedoraproject.org/rpms/cargo-rpm-macros/raw/rawhide/f/cargo-rpm-macros.spec ; https://bodhi.fedoraproject.org/updates/?packages=cargo-rpm-macros&releases=F44 ; https://src.fedoraproject.org/api/0/rpms/cargo-rpm-macros) -- Fedora cargo-rpm-macros notable_users: claimed loupe, snapshot, papers, glycin, gnome-tour and fractal all use meson, a Source1 vendor tarball, %cargo_prep -v vendor, %cargo_license_summary and %cargo_vendor_manifest., actual Mostly true, with three exceptions: -- The fractal rawhide spec has no %cargo_prep. It uses %autosetup -a1, then %meson, %cargo_license_summary and %cargo_vendor_manifest, and its %check runs only appstream-util and desktop-file-validate. -- snapshot has no Source1 vendor tarball. vendor/ comes inside the GNOME tarball, and %cargo_prep -v vendor sits under %if bundled_rust_deps. -- gnome-tour's Source1 is an SVG. -loupe (f44 and rawhide), papers and glycin match the claim. (https://src.fedoraproject.org/rpms/fractal/raw/rawhide/f/fractal.spec ; https://src.fedoraproject.org/rpms/snapshot/raw/f44/f/snapshot.spec ; https://src.fedoraproject.org/rpms/gnome-tour/raw/rawhide/f/gnome-tour.spec) -- gtk4 test utilities risks: claimed test_accessible_* needs the GTK test AT context (GTK_A11Y=test)., actual Only GTK_A11Y=none breaks it. With none, gtk_at_context_create returns NULL (gtkatcontext.c:760) and gtk_test_accessible_has_property returns FALSE (gtktestatcontext.c:256-258). Unset falls back to GtkTestATContext when no backend is created (gtkatcontext.c:780-785). GTK_A11Y=test is still the deterministic setting. The plan's meson gtk_test_env and FD-D20 still say GTK_A11Y=none, which contradicts its own cross-workstream note requiring test. (https://gitlab.gnome.org/GNOME/gtk/-/raw/gtk-4-22/gtk/gtkatcontext.c ; https://gitlab.gnome.org/GNOME/gtk/-/raw/gtk-4-22/gtk/gtktestatcontext.c) -- lychee downloads_all_time / downloads_recent: claimed unverified, actual lychee on crates.io: 200,416 all-time and 57,016 recent. Max version 0.24.2 (2026-05-01). (https://crates.io/api/v1/crates/lychee) -- cargo-rpm maintenance: claimed stale, actual Archived. The GitHub repo iqlusioninc/cargo-rpm has archived=true; last push 2022-07-20, last crates.io release 0.8.0 on 2020-07-09. (https://api.github.com/repos/iqlusioninc/cargo-rpm) -- cargo-shear repository contributors: claimed Boshen 269 commits, next human 24, actual Correct, but the top contributor is renovate[bot] with 291 commits, ahead of Boshen (269) and CathalMullan (24). Human bus factor is still about 1. (https://api.github.com/repos/Boshen/cargo-shear/contributors) - -Verdict challenges: - -- W8-26 amendment: single workspace-root clippy.toml: replace check-clippy-shared-keys.py with one linux/clippy.toml, moving disallowed-methods to the root to drop the checker script but keep crates/app/clippy.toml. The disallowed-methods list is app-only by design: tokio::spawn, tokio::task::spawn*, relm4::spawn and the RelmApp bans. Moving it to the root bans them in every crate, and current code uses them outside the app (crates/ssh/src/lib.rs:91 and :288 tokio::spawn; crates/drivers/mssql/src/lib.rs:61 spawn_blocking). Clippy has no config inheritance: the nearest clippy.toml walking up from CARGO_MANIFEST_DIR wins (book/src/configuration.md). The checker is still unnecessary. If crates/app/clippy.toml drops an allow-*-in-tests key, clippy -D warnings fails on the app's tests, so the drift that matters fails CI on its own. -- new amendment: run the meson cargo test target through cargo-nextest: add (meson test via nextest, as Fractal) to add cargo-nextest in GitHub CI only; meson test() keeps plain cargo test. cargo-nextest is not packaged in Fedora 44 (mdapi f44 has no cargo-nextest or rust-cargo-nextest) or Ubuntu 26.04 (no Launchpad source). dh_auto_test and %meson_test would lose their runner. Fractal uses nextest only in a CI custom_target, with a nextest module injected into the Flatpak manifest (.gitlab-ci/nextest.module.json, test.yml:43), and its Fedora spec %check does not run meson tests. Installing it in GitHub jobs with taiki-e/install-action is the standard shape. -- W9-03 amendment: nextest filterset commands: `cargo nextest run --workspace --run-ignored only -E 'not rdeps(gtk4)'` on the host, `-E 'rdeps(gtk4)'` in the GTK container to keep nextest, but select what gets built with cargo package flags. Flag scope, per nextest's CLI help (docs/running): -p/--workspace/--exclude are 'Package selection' and pick which test binaries cargo builds; -E filtersets pick which built tests run. The docs do not say filtersets narrow the build, so treat that as unverified. The host integration job has no GTK 4.22 or libadwaita 1.9 dev packages, so --workspace would fail compiling the app crate. Use `--workspace --exclude tablepro --exclude tablepro-test-support-gtk` on the host. Also, `--run-ignored only -E 'rdeps(gtk4)'` would skip every non-ignored GTK test. -- testcontainers-modules: drop (replace with testcontainers::GenericImage) to keep for now (0.15.0, default-features = false, aws-lc-rs); re-evaluate when issue #497 ships or 0.28 becomes necessary. Two of the three stated reasons do not apply. The ring edge is already removed by FD-D10's aws-lc-rs features, and RUSTSEC-2026-0258 does not affect our lock (h2 0.4.19, patched >= 0.4.16). What remains is release lag. The crate is in the official testcontainers org, has 8.5M recent downloads, and main was active in Aug 2026 (commits 2026-08-08..12, testcontainers 0.28.0 already on main). W9-02 does not build every engine by hand. It uses the modules crate for postgres, mysql, mariadb and mssql_server (readiness waits, EULA handling) and GenericImage only for clickhouse, pgbouncer and openssh. Dropping it means owning those readiness conditions. That is a small cost, so drop stays a defensible second choice, but it is not forced. -- FD-D15 amendment (exceptions file shape): change to the upstream exceptions.json shape with reasons to keep the plan's {app-id: [rules]} list format and add --exceptions. The loader reads exceptions.get(appid, []) directly (cli.py:104), so the nested repo_key shape silently excepts nothing. The only real defect is the missing --exceptions flag, without which the user file is never read. Fractal's Devel CI uses exactly this list form with both flags. -- test-support-gtk accessibility audit amendment (W9-05/W9-14): replace (breaks under Fedora 44 1.9.0 and Ubuntu 1.9.1) to replace (agree), with corrected evidence. Direct test_accessible_* assertions on app-owned widgets are still the lower-debt design, since skip lists keyed to libadwaita-private .ui ids break on every libadwaita change. Fedora 44 is on 1.9.3 today, though, so the concrete break is Ubuntu 26.04 (1.9.0 Release / 1.9.1 Updates) and the next runtime bump. Pair the amendment with setting GTK_A11Y=test in gtk_test_env, Flatpak run-tests, dh_auto_test and %check. The plan's text still says none, and under none every assertion returns FALSE. -- cargo-shear: keep to keep (acceptable), noting cargo-machete is the more widely adopted option. Under the owner's 'most widely adopted' priority, cargo-machete has about 10x the recent downloads (519,592 vs 51,294) and 2.4x the stars (1,374 vs 560). Both are user-owned with a human bus factor of about 1. Shear parses source; machete greps it. Since swapping costs one CI line, keeping shear is fine, but the review should not present it as the standard choice. - -### Hand-rolled code and custom mechanisms audit - -Review of hand-rolled code and custom mechanisms in the TablePro Linux code (linux/crates/**) and in the remediation plan (plan.json), dated 2026-09-14. Numbers come from the crates.io API, the GitHub API and upstream source at tagged releases. Scratch evidence is in /tmp/claude-1000/-home-ngoquocdat-Workspaces-TablePro-linux/f564f546-0075-4f9a-9d3e-20b24faa3f40/scratchpad/deps-review/ (crates.json, tasks/*.txt, hyper_http.rs, sqlparser_tokenizer.rs, russh_known_hosts.rs). - -Overall: the plan already uses platform APIs in the right places. Storage paths and durable writes go through GLib user dirs and file_set_contents_full instead of config_io.rs. The flock gate becomes GApplication uniqueness. Logging uses tracing-journald. The i18n placeholder helpers match Fractal's own gettext_f and ngettext_f. OpenSSH semantics come from running `ssh -G` and `ssh-keygen -F/-Q` rather than a Rust reimplementation, and that last choice is the least-debt option: russh 0.63.3 known_hosts.rs handles only plain, hashed `|1|` and `[host]:port` entries, and ssh2-config does not support Match. - -The plan does add a lot of new custom code that mainstream tools already cover: - -1. **SQL script lexer (W6-05).** A from-scratch lexer for five dialects, although sqlparser 0.63 is already a dependency. Its tokenizer has line/column spans and per-dialect hooks for backslash escapes, nested comments, dollar quotes and `-- ` comment whitespace. Only the client-side directives need custom code: GO, DELIMITER, psql routine bodies and SQLite trigger bodies. -2. **CSV and TSV writer (current export.rs, W5-14, and a duplicate csv_field in storage).** The csv crate covers this: 244M downloads, release 1.4.0 on 2025-10-17. -3. **Happy Eyeballs dialer and socket tuning (W2-10).** hyper-util 0.1.20's HttpConnector, already planned for ClickHouse, has set_happy_eyeballs_timeout, set_connect_timeout, keepalive time/interval/retries, set_tcp_user_timeout and enforce_http(false). -4. **PostgreSQL TLS connector with channel binding (W2-10).** tokio-postgres-rustls 0.14.0 does this: aws-lc-rs feature, tls-server-end-point through x509-cert. -5. **Custom PgManager (W2-12).** deadpool-postgres 0.14.2 has Manager::from_connect with a custom Connect trait, and deadpool 0.13's PoolBuilder has pre_recycle and post_create hooks. -6. **tablepro-build-support crate (W8-14).** Fractal and Loupe generate config.rs with meson configure_file and nothing more. -7. **About ten python3 CI and packaging scripts:** - - integration_matrix.py: cargo-nextest (Fractal runs its Rust tests through nextest) plus clippy::ignore_without_reason. - - check-doc-references.py: lychee. - - license_metadata.py: cargo-about handlebars templates. - - render_flathub_manifest.py: a manifest kept in the flathub repo, with flatpak-external-data-checker, which Flathub runs for every repo. - - assemble_debian.py and render_spec.py: a committed debian/ directory plus dch, and a spec rendered by meson or Packit. - - check-clippy-shared-keys.py: one clippy.toml using clippy's allow-invalid option. - - check-hig-capitalization.py, check-regression-map.py, check-source-size.py, check-icon-names.py, check-app-icons.py, check-screenshots.py and check-protected-msgids.py have no mainstream replacement and should be dropped or narrowed. - -The accessibility audit's stock_internals allowlist is keyed to libadwaita 1.9.3 internal .ui ids and will break on libadwaita updates, so it should be narrowed to widgets the app creates. - -Custom code that is justified, with evidence: -- **PrivateSocketRelay.** sqlx-mysql cannot take a pre-connected stream, and I found no upstream issue for it. Keep it small and file one. -- **SqlDecimal.** PostgreSQL numeric allows NaN, ±Infinity and 131,072 digits. rust_decimal caps at 28 digits and bigdecimal has no NaN. Its only maintainer has made no commits since 2026-03-14. -- **WideInteger.** Display and bounds checks only, no arithmetic. -- **HealthPolicy backoff.** A state machine. The backoff crate is unmaintained (RUSTSEC-2025-0012). -- **The trust-store PEM bundle.** sqlx-mysql accepts CA certificates only as PEM, which is why rustls-platform-verifier cannot give one trust set for every engine. The plan rejects it without stating this reason. -- **gettext_f and the PersistDebouncer.** - -Single-maintainer or stale crates the app depends on directly or through thin wrappers: -- sqlformat: one main maintainer. -- relm4: most commits from one maintainer. -- locale_config 0.3.0 (2019, via gettext-rs 0.7 and 0.8). -- connection-string 0.2.0 (2023, via tiberius). -- enum_dispatch 0.3.13 (2024-03, via russh). -- libgssapi: single maintainer, via tiberius. -- bnum: single maintainer, via clickhouse. -- ssh-key: russh 0.63.3 pins the pre-release =0.7.0-rc.11, plus rsa =0.10.0-rc.18. -- rustls-pemfile 1.0.4: repository archived, RUSTSEC-2025-0134, via tiberius 0.12.3's rustls-native-certs 0.6. -- tiberius itself: no crates.io release since 0.12.3 on 2024-07-19, though tiberius-rs/tiberius main is active. -- secrecy: last release 2024-10. - -| Crate | Role | Status | Latest | Released | Downloads (all / 90d) | Maintenance | Standing | Verdict | -|---|---|---|---|---|---|---|---|---| -| csv | CSV and TSV encoding for export, clipboard and query-history export | candidate | 1.4.0 | 2025-10-17 | 243,793,576 / 47,178,519 | active | de_facto | add | -| sqlparser | Validates user-typed DDL and classifies catalog default text (FB-D14). Proposed as the tokenizer under the script planner (W6-05) | planned | 0.63.0 | 2026-09-13 | 77,314,066 / 11,984,869 | active | de_facto | keep | -| sqlformat | SQL editor Format command | current | 0.5.0 | 2025-10-09 | 63,583,487 / 4,042,763 | active | popular | keep | -| hyper-util | ClickHouse HTTP transport (FC-D5). Proposed to replace the custom DirectTcpTransport dialer (happy_eyeballs.rs, socket_tuning.rs) | planned | 0.1.20 | 2026-02-02 | 471,473,277 / 139,813,356 | active | de_facto | keep | -| happy-eyeballs | Candidate Happy Eyeballs implementation | candidate | 0.2.1 | 2023-05-08 | 10,924 / 190 | stale | niche | avoid | -| socket2 | TCP keepalive and TCP_USER_TIMEOUT tuning in tablepro-net (W2-10) | planned | 0.6.5 | 2026-07-13 | 1,208,522,885 / 289,205,478 | active | de_facto | keep | -| deadpool | Connection pools for PostgreSQL, MySQL and SQL Server (FC-D18) | planned | 0.13.1 | 2026-08-26 | 130,143,101 / 31,960,351 | active | popular | keep | -| deadpool-postgres | Candidate replacement for the hand-written PgManager (W2-12) | candidate | 0.14.2 | 2026-08-26 | 22,252,723 / 5,700,336 | active | popular | add | -| deadpool-tiberius | Candidate SQL Server pool manager | candidate | 0.1.9 | 2025-04-11 | 42,570 / 7,546 | slow | niche | avoid | -| bb8-tiberius | Candidate SQL Server pool manager (already rejected in FC-D18) | candidate | 0.16.0 | 2025-01-08 | 383,001 / 114,579 | stale | niche | avoid | -| tokio-postgres-rustls | Candidate replacement for the custom PgTlsConnector and tls-server-end-point channel binding (W2-10, FC-D23) | candidate | 0.14.0 | 2026-05-21 | 14,281,231 / 4,685,299 | active | popular | add | -| rustls-platform-verifier | Candidate OS-trust verifier (FC-D8 rejects it without a stated reason) | candidate | 0.7.0 | 2026-04-12 | 140,005,036 / 69,413,984 | active | de_facto | avoid | -| rustls-native-certs | System trust anchors | planned | 0.8.4 | 2026-06-01 | 543,653,024 / 125,640,828 | active | de_facto | keep | -| webpki-roots | Bundled Mozilla roots | planned | 1.0.9 | 2026-07-18 | 735,182,985 / 181,967,208 | active | de_facto | keep | -| x509-cert | Parses the peer certificate for PostgreSQL channel binding (W2-10) | planned | 0.3.0 | 2026-07-09 | 38,380,367 / 13,839,238 | active | popular | drop | -| bigdecimal | Candidate backing type for SqlDecimal (FB-D2) | candidate | 0.4.10 | 2025-12-27 | 116,732,834 / 25,791,751 | slow | popular | avoid | -| rust_decimal | Current decimal value type | current | 1.43.0 | 2026-09-02 | 143,234,484 / 34,015,321 | active | de_facto | drop | -| bnum | Transitive dependency of clickhouse 0.15.2 (bnum 0.13.0 in Cargo.lock). Candidate backing for WideInteger | current | 0.14.4 | 2026-03-24 | 15,382,306 / 6,091,406 | active | niche | avoid | -| ethnum | Candidate i256/u256 type for WideInteger | candidate | 1.5.3 | 2026-04-20 | 29,890,976 / 6,371,943 | active | niche | avoid | -| num-bigint | Candidate arbitrary integer for WideInteger. Transitive via russh 0.63.3 | candidate | 0.5.1 | 2026-07-05 | 582,267,617 / 132,393,864 | active | de_facto | avoid | -| tracing-journald | Journald logging layer (W8-21) | planned | 0.3.2 | 2025-11-26 | 8,998,631 / 2,305,669 | active | popular | keep | -| systemd-journal-logger | Candidate journald logger | candidate | 2.2.2 | 2025-05-03 | 1,138,594 / 206,101 | slow | niche | avoid | -| gettext-rs | i18n bindings | current | 0.8.0 | 2026-08-05 | 4,682,679 / 539,166 | active | popular | keep | -| locale_config | Transitive dependency of gettext-rs 0.7.7 and 0.8.0 | current | 0.3.0 | 2019-08-22 | 6,136,907 / 589,972 | stale | niche | keep | -| glib-build-tools | Compiles the GResource in build.rs (W8-14) | planned | 0.22.8 | 2026-06-29 | 446,672 / 74,783 | active | de_facto | keep | -| russh | SSH client for tunnels | current | 0.63.3 | 2026-09-09 | 6,902,722 / 2,763,361 | active | popular | keep | -| ssh-key | SSH key and certificate types via russh | current | 0.6.7 stable; 0.7.0-rc.11 pre-release | 2026-06-29 (rc.11); 2024-10-15 (0.6.7) | 11,246,341 / 4,640,768 | active | popular | keep | -| russh-config | Candidate ssh_config parser | candidate | 0.58.0 | 2026-03-18 | 199,780 / 129,270 | active | niche | avoid | -| ssh2-config | Candidate ssh_config parser | candidate | 0.8.0 | 2026-08-31 | 162,585 / 40,568 | active | niche | avoid | -| nix | getuid, passwd lookup and gethostname for ssh_config token expansion (W1-01) | planned | 0.31.3 | 2026-05-11 | 791,061,994 / 173,794,787 | active | de_facto | keep | -| backoff | Candidate retry and backoff crate for reconnect | candidate | 0.4.0 | 2021-12-14 | 102,412,441 / 14,423,135 | stale | popular | avoid | -| backon | Candidate retry crate | candidate | 1.6.0 | 2025-10-18 | 81,731,330 / 28,633,091 | active | popular | avoid | -| cargo-about | Third-party license notices (FD-D13, W8-23) | planned | 0.9.2 | 2026-08-18 | 1,176,233 / 243,339 | active | de_facto | keep | -| cargo-deny | Advisories, bans, licenses and sources gate | planned | 0.20.2 | 2026-07-09 | 5,676,819 / 1,597,680 | active | de_facto | keep | -| cargo-shear | Unused dependency check | planned | 1.13.4 | 2026-08-11 | 488,406 / 51,294 | active | popular | keep | -| cargo-nextest | Candidate test runner to replace integration_matrix.py (W9-03) | candidate | 0.9.144 | 2026-09-10 | 12,723,536 / 1,866,796 | active | de_facto | add | -| libtest-mimic | libtest-compatible harness for harness = false tests (panic_hook, i18n_catalog, startup, application_uniqueness, session_bus) under nextest | candidate | 0.8.2 | 2026-03-16 | 30,669,350 / 4,839,353 | active | popular | add | -| lychee | Candidate documentation link checker to replace check-doc-references.py (W9-04) | candidate | 0.24.2 | 2026-05-01 | 200,416 (crates.io; most use is via binaries and the GitHub Action) / 57,016 | active | popular | add | -| flatpak-external-data-checker | Candidate Flathub manifest updater to replace render_flathub_manifest.py (W8-24) | candidate | unverified | | / | active | de_facto | add | -| flatpak-builder-tools (flatpak-cargo-generator) | Generates cargo-sources.json | planned | pinned by commit in plan | | / | active | de_facto | keep | -| Packit | Candidate COPR build automation to replace render_spec.py plus hand-driven copr-cli (W8-28) | candidate | unverified | | / | active | popular | add | -| cargo-deb | Candidate .deb builder | candidate | 3.8.0 | 2026-09-01 | 2,196,196 / 222,328 | active | popular | avoid | -| cargo-generate-rpm | Candidate .rpm builder | candidate | 0.21.0 | 2026-05-04 | 340,102 / 66,232 | active | niche | avoid | -| tiberius | SQL Server TDS driver | current | 0.12.3 | 2024-07-19 | 5,149,218 / 674,741 | active | popular | keep | -| connection-string | Transitive dependency of tiberius | current | 0.2.0 | 2023-03-31 | 5,798,353 / 809,268 | stale | niche | keep | -| libgssapi | Thin GSSAPI/Kerberos FFI wrapper via tiberius (kerberos feature) | current | 0.11.0 | 2026-05-30 | 1,246,043 / 532,753 | active | niche | keep | -| enum_dispatch | Transitive proc-macro via russh 0.55 and 0.63.3 | current | 0.3.13 | 2024-03-28 | 79,406,678 / 19,410,511 | stale | popular | keep | -| rustls-pemfile | Transitive via tiberius 0.12.3 and rustls-native-certs 0.6.3 (1.0.4 in Cargo.lock) | current | 2.2.0 | 2024-09-30 | 530,474,033 / 68,169,045 | archived | de_facto | drop | -| secrecy | Secret wrappers for passwords and passphrases | current | 0.10.3 | 2024-10-09 | 159,833,962 / 38,931,421 | active | de_facto | keep | -| relm4 | Component framework kept alongside a direct adw::Application subclass | current | 0.11.0 | 2026-04-08 | 688,462 / 145,460 | active | niche | keep | -| oo7 | Secret Service and portal keyring client | current | 0.6.0 (0.7.0-alpha newest) | 2026-02-21 | 1,916,767 / 1,000,458 | active | popular | keep | -| postgres-protocol | PostgreSQL wire types (W2-12). Offers escape::escape_identifier and escape_literal | planned | 0.6.12 | 2026-06-12 | 71,734,022 / 17,763,290 | active | de_facto | keep | -| clickhouse | ClickHouse client. Public sql::Identifier and Query::bind | current | 0.15.2 | 2026-08-28 | 12,639,703 / 3,738,623 | active | de_facto | keep | -| sqlx (sqlx-mysql, sqlx-sqlite) | MySQL/MariaDB and SQLite drivers (sqlx 0.9 planned) | current | 0.9.0 | 2026-05-21 | 145,469,528 (sqlx); 117,153,400 (sqlx-mysql) / 36,284,208 (sqlx); 33,198,349 (sqlx-mysql) | active | de_facto | keep | -| mysql_async | Alternative MySQL driver (rejected in FC-D2) | candidate | 0.37.1 | 2026-09-01 | 7,387,316 / 1,257,815 | active | popular | avoid | -| icu_decimal | Candidate locale-aware digit grouping for row counts (format_thousands) | candidate | 2.3.0 | 2026-08-13 | 11,909,313 / 3,785,256 | active | de_facto | add | -| formatx | Candidate runtime format-string crate for gettext placeholders | candidate | 0.4.2 | 2026-07-20 | 1,020,221 / 270,231 | active | niche | avoid | -| tabled | Candidate Markdown table writer | candidate | 0.22.0 | 2026-09-05 | 38,696,008 / 8,701,753 | active | popular | avoid | -| proptest | Property test planned in W6-05 (char-boundary and non-overlap properties) | candidate | 1.11.0 | 2026-03-24 | 186,304,236 / 47,045,366 | active | de_facto | add | - -Fact-check corrections: - -- sqlx (sqlx-mysql) risks / forks_and_hard_requirements (upstream request for stream injection): claimed No upstream request exists. A search found only closed PostgreSQL TLS issues #2354 and #3264, so nobody has asked upstream., actual Two open PRs exist. #4187 (opened 2026-03-12) adds connect_socket() for Postgres and MySQL; maintainer abonander requested changes on 2026-03-26 (do not export the Socket trait, call it with_stream). #4281 (2026-05-25, Benjscho) is the follow-up, planned around AsyncRead + AsyncWrite like tokio-postgres connect_raw. Also open: #4045 rustls-platform-verifier support (2025-09-26, updated 2026-07-21) and #4355 TCP keepalive. (gh api repos/transact-rs/sqlx/issues/{4187,4281,4045,4355}; gh api repos/transact-rs/sqlx/pulls/4187/reviews and /comments) -- tokio-postgres-rustls rationale (same channel-binding rule as FC-D23): claimed Implements channel_binding with x509-cert over the signature algorithm OID, the same rule FC-D23 specifies., actual Its map is narrower. channel_binding_digest (src/lib.rs:109-126 at v0.14.0) maps SHA-1/SHA-256 RSA and ECDSA-SHA256 to SHA-256, SHA-384 RSA/ECDSA to SHA-384, and only SHA-512 RSA to SHA-512. FC-D23 also maps md5WithRSAEncryption and ecdsa-with-SHA1 to SHA-256 and ecdsa-with-SHA512 (1.2.840.10045.4.3.4) to SHA-512. Those return None here, so there is no channel binding. (https://raw.githubusercontent.com/jbg/tokio-postgres-rustls/master/src/lib.rs (tag v0.14.0 = 0dd60da1); plan.json FC-D23) -- x509-cert replacement (brings x509-cert transitively): claimed tokio-postgres-rustls brings x509-cert transitively, so the planned direct x509-cert 0.3.0 can go., actual tokio-postgres-rustls 0.14.0 depends on x509-cert ^0.2 (default-features=false, std) and sha2 ^0.11. It brings the older 0.2 line, not the 0.3.0 the plan pins. (https://crates.io/api/v1/crates/tokio-postgres-rustls/0.14.0/dependencies; https://raw.githubusercontent.com/jbg/tokio-postgres-rustls/v0.14.0/Cargo.toml line 28) -- tablepro-build-support / W8-14 rationale (Fractal and Loupe generate config.rs with meson configure_file): claimed Fractal and Loupe generate config.rs with meson configure_file and nothing more., actual Only Fractal does (src/meson.build:76 configure_file config.rs.in). Loupe commits src/config.rs with option_env!("APP_ID"/"VERSION"/"LOCALEDIR") and a const default_env fallback. Meson passes those values through cargo_env (src/meson.build:18-23). Both are standard, and neither needs a build-support crate. (https://gitlab.gnome.org/GNOME/loupe/-/raw/main/src/config.rs; https://gitlab.gnome.org/GNOME/loupe/-/raw/main/src/meson.build; https://gitlab.gnome.org/GNOME/fractal/-/raw/main/src/meson.build) -- backon maintenance: claimed active, actual slow. Last release is 1.6.0 on 2025-10-18, and the GitHub API returns 0 commits on the default branch since 2026-03-14 (repo pushed_at 2026-06-19). The avoid verdict is unaffected. (https://crates.io/api/v1/crates/backon; gh api repos/Xuanwo/backon/commits?since=2026-03-14T00:00:00Z) -- rustls-platform-verifier rationale (why it cannot give one trust set): claimed sqlx-mysql accepts CA certificates only as PEM, which is why rustls-platform-verifier cannot give one trust set for every engine., actual PEM-only CA input is true (MySqlConnectOptions::ssl_ca path and ssl_ca_from_pem, options/mod.rs:189,204). The binding constraint is different: in sqlx-core 0.9.0 the _tls-rustls-aws-lc-rs feature always enables webpki-roots (Cargo.toml:31), and import_root_certs seeds TLS_SERVER_ROOTS (tls_rustls.rs:212-214). Native roots are only available through _tls-rustls-ring-native-roots (Cargo.toml:33), and ring is banned. Upstream PR #4045 (open) would add rustls-platform-verifier to sqlx. (https://raw.githubusercontent.com/transact-rs/sqlx/v0.9.0/sqlx-core/Cargo.toml; https://raw.githubusercontent.com/transact-rs/sqlx/v0.9.0/sqlx-core/src/net/tls/tls_rustls.rs; https://github.com/transact-rs/sqlx/pull/4045) -- hyper-util status: claimed planned (0.1), actual hyper-util 0.1.20 is already in Cargo.lock, pulled in by clickhouse 0.15.2 (hyper-util ^0.1.6). socket2 0.6.5 is also already in the lockfile. So there is no new dependency either way. (linux/Cargo.lock; https://crates.io/api/v1/crates/clickhouse/0.15.2/dependencies) -- nix version_in_use / rationale: claimed 0.29 (planned); bump to 0.31.x so the plan does not start on an outdated release., actual nix 0.29.0 is already in Cargo.lock through russh-cryptovec 0.52 (russh 0.55). russh 0.63.3 depends on russh-cryptovec ^0.62, which requires nix ^0.31 on unix. Pinning 0.29 would therefore duplicate nix, which strengthens the bump to 0.31. (cargo tree --offline -i nix@0.29.0; https://crates.io/api/v1/crates/russh/0.63.3/dependencies; https://crates.io/api/v1/crates/russh-cryptovec/0.62.0/dependencies) -- cargo-nextest risks (harness=false protocol marked unverified): claimed harness = false targets must support libtest's list protocol; not re-fetched, treat as unverified., actual Verified. The nextest custom-test-harnesses doc says a harness MUST support --list --format terse, and it recommends libtest-mimic 0.4.0 or 0.5.2 and later. clippy::ignore_without_reason is present in the Rust 1.93.0 clippy (declared_lints.rs: attrs::IGNORE_WITHOUT_REASON_INFO), and so is disallowed-* allow-invalid (book lint_configuration.md), matching the pinned toolchain 1.93. (https://raw.githubusercontent.com/nextest-rs/nextest/main/site/src/docs/design/custom-test-harnesses.md; https://raw.githubusercontent.com/rust-lang/rust/1.93.0/src/tools/clippy/clippy_lints/src/declared_lints.rs; linux/rust-toolchain.toml) -- russh risks (version count): claimed 126 versions, actual 130 versions on crates.io (max 0.63.3, 2026-09-09). sqlparser shows 87 versions, not 86. Both differences are minor. (https://crates.io/api/v1/crates/russh; https://crates.io/api/v1/crates/sqlparser) -- csv rationale (TSV with Necessary quotes exactly what tsv_field quotes): claimed TSV with delimiter b'\t' and Necessary quotes exactly the characters tsv_field quotes today., actual The character set matches: csv-core requires quotes for the delimiter, the quote character, \r and \n (writer.rs:39-53), and export.rs:279 quotes \t, \n, \r and ". One difference: csv-core writes "" for a record whose only field is empty (writer.rs:371-373 terminator with record_bytes == 0). A single-column export of empty or NULL-to-empty cells therefore changes output. (https://static.crates.io/crates/csv-core (master csv-core/src/writer.rs); linux/crates/core/src/export.rs:278-284) - -Verdict challenges: - -- sqlparser tokenizer as the W6-05 script lexer (hand_rolled_to_replace + W6-05/W6-17 amendments): replace the custom lexer with sqlparser 0.63 Tokenizer; covers the lexical part of W6-05 to conditional: time-boxed spike first. Keep sqlparser; a raw-text DELIMITER/GO pre-pass stays custom. Checked against the v0.63.0 source, the tokenizer does not cover the plan's lexical rules. (1) MySqlDialect keeps the default supports_dollar_placeholder() == false (dialect/mod.rs:1117; mysql.rs has no override), so '$$' starts a DollarQuotedString (tokenizer.rs:1912-1950). The standard 'DELIMITER $$ ... END$$' idiom would be swallowed as one string token unless DELIMITER regions are cut and matched on raw text before tokenizing. (2) '#' comments are only recognised for Snowflake|BigQuery|MySql|Hive (tokenizer.rs:1719), but ClickHouse's own lexer treats '# ' and '#!' as comments (src/Parsers/Lexer.cpp:383-389). (3) A LexicalSettings wrapper Dialect must override Dialect::dialect() to return the wrapped TypeId (dialect/mod.rs:209, upstream test parse_with_wrapped_dialect). Otherwise dialect_of! checks silently turn off MySQL '#' comments, B'' strings (1085) and PostgreSQL \r comment termination (2059). In its favour, tokenize_with_location_into_buf keeps the tokens produced before an error (tokenizer.rs:952-955), so partial input works. Net: reuse is plausible but not a drop-in. The amendment should list these pre-passes and dialect workarounds, and should not delete string_quote/line_comment_prefix until a fixture corpus passes. -- csv (W5-14 amendment: narrow IfNeeded to RFC 4180 Necessary): add; drop forced quoting of neutralised values to add, but keep forced quoting: use QuoteStyle::Always whenever sanitize_formulas is on, or keep the 3-line quote_field for that path. OWASP's CSV Injection guidance says to 'Wrap each cell field in double quotes' and 'Escape every double quote'. export.rs:218-223 quotes neutralised values on purpose so the leading apostrophe stays data. Narrowing to Necessary removes a security measure only to fit the crate, which conflicts with the owner's 'correct and secure' rule. The csv crate has one quote style per writer and no per-field override. Also note the "" single-empty-field record difference (csv-core writer.rs:371-373). -- tokio-postgres-rustls: add (drop custom channel_binding.rs and x509-cert) to add after an upstream PR, and put on the watch list. Single maintainer (jbg 54 contributions, 42 stars; 14.28M downloads, 102 reverse dependencies). Its digest map (src/lib.rs:109-126) lacks md5WithRSA, ecdsa-with-SHA1 and ecdsa-with-SHA512, which FC-D23 and PostgreSQL be_tls_get_certificate_hash handle. A server certificate signed ecdsa-with-SHA512, common under P-521 CAs, gets no SCRAM-SHA-256-PLUS binding, and channel_binding=require fails. Least-debt path: send the three-OID PR upstream, pin to that release or commit, then delete the custom module. It also pulls x509-cert 0.2, not 0.3. -- PrivateSocketRelay / FC-D2 amendment: justified custom code; file a new sqlx feature request to justified temporarily; review and test the existing sqlx PR #4281 (and #4187) instead of filing a new request. Open upstream work already exists. abonander asked for a with_stream-style API on #4187, and #4281 follows up with AsyncRead + AsyncWrite. Adding test coverage or a review to #4281 is the least-debt route. When it lands, the MySQL driver can take transport.open() streams directly like PostgreSQL and SQL Server, and the relay, relay_directory and relay_error can be deleted. Track #4281 in the weekly upstream check, not a new issue. -- rustls-platform-verifier: avoid (reason: sqlx-mysql accepts CA only as PEM) to avoid for now; revisit when sqlx PR #4045 lands. The real blocker is that sqlx-core 0.9's aws-lc-rs feature always seeds webpki-roots (Cargo.toml:31, tls_rustls.rs:212-214), with native roots only under ring. sqlx #4045 would let sqlx use rustls-platform-verifier. Then every engine could share one mainstream verifier (Verifier::new_with_extra_roots, verification/others.rs:35-92, which loads rustls-native-certs on Linux), and SystemTrustSource plus the PEM bundle could go. The ADR should cite #4045, not only PEM input. -- relm4: keep (outside hand-rolled scope) to keep short-term, add to the watch list, and do not grow Relm4-specific infrastructure; evaluate moving to plain gtk-rs subclassing. Relm4 is niche with a bus factor of about one. It has 19 reverse dependencies on crates.io versus 66 for libadwaita, and AaronErhardt has 1,307 contributions against 100 for the next human. None of Fractal, Loupe, Snapshot or Shortwave depend on it (their Cargo.toml files have 0 relm4 matches). The plan already adopts the Fractal/Loupe adw::Application subclass pattern. It also adds custom code because of Relm4: runtime/relm4_spawner.rs, runtime/command_boundary.rs and a crates/app/clippy.toml banning relm4 spawn and application APIs, because 'F-016: relm4 discards the JoinHandle'. The review should count that code and include Relm4 in its own watch-list amendment, which currently omits it. -- hyper-util HttpConnector as the generic DB dialer (W2-10/FC-D6 amendments): keep/adopt; narrow RFC 8305 to RFC 6555 to acceptable, but record GIO GSocketClient as the native alternative and note the legacy-namespace risk. Verified at v0.1.20: all setters exist (http.rs:263-432), the per-address timeout is divided by address count (http.rs:754), and enforce_http(false) accepts any scheme that has a host (http.rs:488-510). However, the connector sits in client::legacy (moved there in hyperium/hyper-util #49), whose pool module says 'A better pool will be designed', so the API may move within 0.1.x. GIO's async g_socket_client_connect_to_host_async has implemented RFC 8305 since GLib 2.60, with a 250 ms attempt delay and 50 ms resolution delay (gsocketclient.c:61-66, 2125-2135; gnetworkaddress.c:43-46). It meets the original FC-D6 requirement without narrowing, using platform code. The cost is a GLib main-context hop plus handing the fd to tokio, and the sync path does not do Happy Eyeballs. diff --git a/linux/docs/plan-2026-09-14-audit-remediation-details.md b/linux/docs/plan-2026-09-14-audit-remediation-details.md deleted file mode 100644 index 43ae45df7b..0000000000 --- a/linux/docs/plan-2026-09-14-audit-remediation-details.md +++ /dev/null @@ -1,16532 +0,0 @@ -# TablePro Linux remediation plan: details - -Companion to [plan-2026-09-14-audit-remediation.md](plan-2026-09-14-audit-remediation.md), which holds the landing sequence. This file holds the reviewed foundation designs, the reconciled contracts and every task specification. - -## Foundations - -### FA · Application, window and session ownership model - -Foundation FA, revised after review. It replaces the global DatabaseService, the thread_local tracker registries, the flock gate and the RelmApp::run single-window root with this model: -- TableProApplication: an adw::Application GObject subclass (the Loupe/Fractal pattern). It owns app actions, GApplication uniqueness, a WindowRegistry of Controller, SessionRegistry, NetworkWatch and ResumeDetector. -- SessionWindow (one adw::ApplicationWindow per connection session) moves through picker, connecting and workspace pages. It owns ConnectAttempts, the close guard, the inhibitor and an explicit teardown order. Window actions live in the window's own GActionMap. -- Session is GTK-free and runtime-agnostic: Session::start needs no tokio context. Every task goes through an injected TaskSpawner (relm4 in production, a paused tokio Handle in tests). Operations lease a generation-stamped transport from a ConnectionLink through one OperationRunner chokepoint. -- An event-driven HealthMonitor runs a pure HealthPolicy over a coalescing MonitorMailbox: - - Signals whose generation is not current are ignored. - - Failed parks until the user retries. - - Offline is decided per session from gio::NetworkMonitor::can_reach_future on the session's endpoint. Loopback and file endpoints are exempt. -- Driver changes that remove the Busy misclassification: - - Connection::probe never waits on a pool slot or a client lock. - - PoolTimedOut maps to Busy only when pool size equals max and no connection is idle. -- Teardown and quit are bounded: - - Session::begin_shutdown cancels, takes the transport and closes it under a Close deadline, awaited under an ApplicationHoldGuard. - - app.quit never calls g_application_quit. Windows close, and GApplication exits when the use count reaches zero. -- A save whose result is unknown freezes its tracker and is never resubmitted. -- The workspace store blocks writes after a failed load, and its directory is per profile. - -Verified on disk: -- sqlx-core 0.8.6: pool/inner.rs acquire/connect PoolTimedOut paths; Pool::size, num_idle, try_acquire, connect_options, options().get_max_connections; Connection::connect_with, ping, close. -- relm4 0.11.0: RUNTIME private Lazy with RELM_THREADS; RelmApp::run holds RUNTIME.enter() and calls shutdown_all; main_application creates a default app; main_adw_application; RelmActionGroup::register_for_main_application; ComponentBuilder::transient_for and transient_for_native; ComponentSender::command, spawn_command, oneshot_command and spawn_oneshot_command, re-exported at relm4::ComponentSender. -- gio 0.22.5: NetworkMonitorExt::can_reach_future, connect_network_changed, connect_connectivity_notify; NetworkAddress::new(host, port); ActionMapExtManual::add_action_entries; ApplicationExtManual::hold -> ApplicationHoldGuard(WeakRef, !Send); register, is_remote; TestDBus new/up/bus_address; DBusConnection::for_address_future; resources_register_include!. -- gtk4 0.11.2: ApplicationWindow @implements gio::ActionMap; inhibit, uninhibit, windows, window_by_id; gtk4-macros test. -- libadwaita 0.9.1: ToolbarView::set_content, AlertDialog choose_future, Dialog::force_close, ApplicationWindow::visible_dialog. -- tokio-util 0.7.18: AbortOnDropHandle needs feature rt; CancellationToken::drop_guard. -- tokio 1.52.1: Mutex::try_lock. -- glib 0.22.5: ExitCode implements Termination; compute_checksum_for_string; user_state_dir. -- tiberius a6b4fcd: Config derives Clone. -- sqlx-mysql MySqlDatabaseError::number; sqlx-postgres PgDatabaseError::code. - -Verified against primary docs and source: -- g_application_quit: 'The hold count is ignored.' -- g_application_run loops while use_count and emits shutdown after the loop. -- gapplicationimpl-dbus.c: a NULL session bus proceeds non-unique; attempt_primary fails when org.gtk.Application registration fails. -- gtk_application_window_init inserts its own 'win' group; windows export at '%s/window/%d'. -- network-available means a default route exists, and the docs recommend can_reach per host. -- clippy config lookup: nearest file wins. disallowed-methods takes fully qualified paths, and clippy_utils lookup_path resolves pub use re-exports and inherent impls. -- glib-build-tools 0.22.8 compile_resources(source_dirs, gresource, target) runs glib-compile-resources. - -#### Decisions - -##### D1 · Application controller is a GObject subclass of adw::Application, not a RelmApp root - -Add application/{mod,imp}.rs with TableProApplication. main.rs returns glib::ExitCode from app.run(), which implements Termination. - -Vfuncs: -- startup: parent startup, install app actions and accels, start HistoryService, NetworkWatch, and the ResumeDetector lifecycle subscription. -- activate: present active_window(), or open a picker window. -- open(files): one WindowLaunch::OpenFile per file. -- handle_local_options: - - Without --new-window: Continue(()). - - With it: register(None). On Err, log and Break(ExitCode::FAILURE). If is_remote(), activate_action("new-window", None) and Break(ExitCode::SUCCESS). Otherwise (primary) Continue(()), so the default activate opens exactly one window. -- shutdown: clear the WindowRegistry. - -Windows are Components started with Component::builder(). The root is added with app.add_window before launch, the controller goes into WindowRegistry, and it is removed in connect_window_removed. - -Because RelmApp::run is bypassed, relm4's RUNTIME is never entered on the GTK thread. So: -- All tokio-context work (time, IO, Handle::current, as in the MSSQL connect) runs inside futures spawned through TaskSpawner (D15) or GuardedCommand. -- GTK-thread code only builds runtime-free primitives: watch, Notify, CancellationToken, async_channel. - -crates/app/clippy.toml bans relm4::RelmApp::run, relm4::main_application, relm4::main_adw_application, relm4::actions::RelmActionGroup::register_for_main_application, relm4::ComponentBuilder::transient_for and relm4::ComponentBuilder::transient_for_native. - -**Why**: RelmApp::run ties the app to one root window and activates with set_visible(true). It offers no open or handle-local-options hook, and it holds RUNTIME.enter() plus shutdown_all, which are useful only to RelmApp roots. - -The review is correct that skipping RUNTIME.enter() makes a GTK-thread tokio::time or Handle::current call panic. That is why tokio-context work is confined to spawned futures and Session::start is specified runtime-free, with a #[gtk::test] that proves it. - -The primary-instance branch fixes a defect in the previous design: a primary started with --new-window returned Break and exited right after startup. - -**Rejected**: - RelmApp::from_app(app).run::: the welcome root outlives or hides behind session windows. -- Entering relm4's runtime by blocking on a spawned Handle::current(): a startup hack that hides context bugs instead of removing them. -- Keeping single_instance.rs: it blocks activate forwarding (F-068). - -##### D2 · One window per session: picker -> connecting -> workspace; disconnect returns to picker - -Every adw::ApplicationWindow is a SessionWindow on one of three pages: -- Picker: WelcomeView. -- Connecting / ConnectFailed: an adw::StatusPage with an adw::Spinner. Cancel is bound to win.cancel-connect; Retry and Edit Connection appear after a failure. -- Workspace: a child Workspace component. - -app.new-window (n, the GNOME Console precedent) opens a picker window. Every connect path claims a SessionKey before any network work (D16). If another window holds the key, that window is presented. - -win.disconnect runs the ordered teardown in D17 and shows the picker again. - -**Why**: This follows the owner's window-per-connection decision and the Text Editor/Console New Window model. A Workspace never changes session, so no retargeting path exists (F-002). - -**Rejected**: - Closing the window on disconnect plus a separate welcome window. -- Swapping sessions inside one Workspace with generation stamps. -- Opening the window only after connect succeeds: Cancel and Retry would then have no owner. - -##### D3 · Session: generation-stamped leases, OperationRunner chokepoint, runtime-free start, bounded shutdown - -Session holds: -- metadata, read_only, endpoint. -- runner: OperationRunner, which is Clone and 'static: Arc, Arc, Arc, a health watch::Receiver and a CancellationToken. -- catalog: Arc. -- spawner: Arc. -- _cancel_on_drop: tokio_util::sync::DropGuard. -- _monitor: tokio_util::task::AbortOnDropHandle<()>. crates/app enables tokio-util feature rt. - -Session::start(pending, SessionDeps { connector, credentials, deadlines, spawner }) builds only runtime-free primitives and spawns run_monitor through the spawner, so it is safe on the GTK thread. - -ConnectionLink wraps watch::Sender> }>. -- lease() returns ConnectionLease { generation, transport }. -- replace() bumps the generation and returns a RetiredTransport. -- take() empties the link. - -Retired transports close immediately. RetiredTransport::close(deadline) awaits Connection::close(&self) under tokio::time::timeout. Ok reports Closed, Err reports CloseFailed, and a timeout reports Abandoned (the close future is dropped). The Arc, and so the SSH tunnel, is freed when the last lease drops. Each lease holder is already bounded by its own operation deadline. - -Session::begin_shutdown(&self) -> TaskResult does three things synchronously: -1. Cancels the token, so every run() returns Closed and drops its lease. -2. Takes the transport. -3. Spawns RetiredTransport::close(deadlines.deadline(OperationKind::Close)), with a 5 s default. -It is idempotent: a second call yields AlreadyClosed. The GTK side awaits the TaskResult while holding app.hold(). - -Drop only cancels (DropGuard) and aborts the monitor (AbortOnDropHandle). - -core Connection::close becomes &self and idempotent. - -**Why**: Review: loss signals carried no generation, so a closed old transport kept re-triggering reconnects. Leases and signals are now stamped, and the policy ignores stale generations. - -Review: 'closed once its last lease ends' had no mechanism. The replaced transport has been declared lost or suspect, so closing it immediately under a deadline is correct and needs no Drop-time await. - -Review: shutdown had no deadline. The Close deadline bounds sqlx Pool::close, which waits for checked-out connections, and the MSSQL mutex. Cancelling first releases leases, so the close usually finishes well inside the deadline. - -Review: the signatures were inconsistent. They are unified around OperationRunner and SessionDeps. - -**Rejected**: - Closing a replaced transport when its last lease drops: Drop cannot await, and a lease stuck in a hung operation would pin the tunnel with no bound. -- RwLock>: no generation, no notification. -- Close inside Drop via a detached spawn: no hold guard, and it races exit. -- Mutex> plus a manual Drop: AbortOnDropHandle and DropGuard say the same thing declaratively. - -##### D4 · Connect attempts: per-window generation gate, cancellation, adoption only on the GTK thread - -ConnectAttempts (pure, !Send, one per SessionWindow): -- begin() bumps AttemptGeneration, cancels the previous token and returns AttemptTicket. -- The GuardedCommand future: - 1. Resolves the ConnectSource into a ConnectRequest. A Saved source is re-read from storage, with secrets loaded from the keyring; errors propagate. - 2. Runs Connector::establish(request, ticket.cancel) with ConnectDeadlines { ssh_handshake 15 s, driver_connect 20 s, initial_catalog 15 s, close 5 s }. - 3. Returns AttemptOutcome. -- On the GTK thread: if attempts.accepts(generation) and the page is still Connecting for the same claim, adopt with Session::start(pending, services.session_deps(key)) and claim.attach(&session). Then persist a pending ConnectDialog save, spawn touch_last_opened and launch the Workspace. -- Otherwise spawner.spawn(pending.discard()), which closes the connection under the close deadline and drops its tunnel. - -establish selects on cancel between stages. A stage that completes after cancel closes its own objects before returning Cancelled. - -**Why**: This is the Rust twin of the macOS ConnectionAttemptRegistry invariant: cancelling does not stop the driver, and a late attempt never clobbers the winner. The MSSQL blocking GSSAPI login keeps running on the blocking pool and drops its own client. - -**Rejected**: - Adopting inside the async task. -- A global attempt registry. -- Aborting the JoinHandle only. - -##### D5 · Health: pure HealthPolicy over a coalescing mailbox; probe outside the pool; Failed parks; per-session reachability - -run_monitor loop, in tokio::select! (biased) order: -1. cancel.cancelled() -2. mailbox.next() -3. sleep_until(policy.next_deadline()), or pending when parked. - -MonitorMailbox is a std Mutex plus tokio::sync::Notify. post() merges signals (max generation per kind, latest reachability, longest resume) and never drops one, so RetryNow cannot be lost. - -Probe: Connection::probe under deadlines.deadline(Probe), 10 s by default. ProbeOutcome::classify returns: -- Healthy. -- Busy (DriverError::Busy from a server that refuses new sessions). -- Unresponsive (timeout, or Query/Internal errors). -- Lost (is_connection_lost). -- Rejected(FailureCause) (AuthFailed, Tls, IntegratedAuth). - -Policy by state: -- Online: - - Probes every 30 s. Healthy and Busy stay Online and reset failures. - - Unresponsive counts toward LOST_AFTER_FAILURES = 3, re-probing after 10 s. - - Lost moves to Reconnecting { attempt 1 } with an immediate Reconnect, or to Offline when the endpoint is Unreachable. - - Rejected on a probe stays Online with a warning: the live transport is still authenticated, and only a new login was refused. - - OperationPanicked { current generation } or Resumed triggers an immediate Probe. -- Reconnecting: - - Backoff 5 s doubling to 60 s. - - Connected(generation) moves to Online and adopts the generation. - - A reconnect Rejected(cause) moves to Failed { cause } and Park. - - Unreachable moves to Offline. -- Offline: - - No backoff escalation. One Reconnect every 60 s (OFFLINE_RECHECK), so false negatives and captive portals self-heal. - - Reachable, Resumed or RetryNow moves to Reconnecting { attempt 1 } with an immediate Reconnect. -- Failed (parked): - - No timers. Only RetryNow reconnects; Reachability and Resumed never unpark. - - Only the cancel token ends the task. -- Any state: - - ConnectionLost, OperationPanicked and OperationSucceeded whose generation is not policy.generation() are ignored. - - OperationSucceeded { current } moves Reconnecting or Offline to Online, because sqlx pools heal dead connections themselves. - -On a successful reconnect the monitor: -- replaces the link, -- spawns the retired close, -- commits the PendingSession tables to SchemaCatalog (fetch-first, commit-over), -- builds the request from ReconnectPlan { connector, request, credentials }. CredentialSource::refresh re-reads only secrets from the keyring, so a user who fixes a password or runs kinit and clicks Retry succeeds, and host, database and user never change (no retarget). - -Reachability: -- NetworkEndpoint::for_request gives Remote { ssh bastion host:port, or service host:port }, or Local for file-based drivers, loopback IPs, 'localhost', '.', and unix-socket paths. -- NetworkWatch (GTK thread) listens to network-changed and notify::connectivity. ReachabilityFanout evaluates each attached Remote session through ReachabilityProbe (production: gio::NetworkMonitor::default().can_reach_future(&gio::NetworkAddress::new(host, port))), stamped so only the latest result is delivered, as MonitorSignal::Reachability. -- Local sessions are never gated. - -ResumeDetector: a CLOCK_BOOTTIME vs CLOCK_MONOTONIC drift above 2 s on a 5 s tick. It runs through the spawner only while SessionRegistry::live_count() > 0, driven by claim attach and release events. - -The banner Retry button uses action-name win.reconnect, which posts RetryNow. - -**Why**: Review: PoolTimedOut is ambiguous (verified in inner.rs, where connect retries until the deadline). The probe therefore no longer uses the pool acquire path (C6), and operation errors classify pool pressure from pool state. - -On the review's suggested test 'four long queries plus a probe yield Busy': with an out-of-pool probe, a saturated pool on a live server makes the probe Healthy and a user operation Busy. That is the intended split: user operations report Busy, the probe proves liveness. The testcontainers tests assert exactly that. - -Review: Stop broke Retry. Park keeps the mailbox alive. - -Review: network-available is a default-route flag (GIO docs recommend can_reach per host). Reachability is now per session with loopback and file exemptions, and a successful operation clears Offline. - -notify::connectivity is kept alongside network-changed because portal to full transitions do not always change routes. - -**Rejected**: - A bounded mpsc with try_send: drops RetryNow under load. -- Global network-available gating: false Offline for loopback, LAN and VPN. -- logind PrepareForSleep: needs a Flathub system-bus exception. -- Re-running resolve_saved on reconnect: silently retargets a live session after the user edits host settings. -- 1 Hz polling. - -##### D6 · Per-workspace tracker registries with borrow-safe handles - -Delete the thread_local REGISTRY and the free functions in change_tracker.rs and structure_tracker.rs. - -Workspace owns Rc and Rc. They hand out TabTrackerHandle and StructureTrackerHandle, which are newtypes over Rc>. - -Handle rules: -- The RefCell never leaves the handle. -- Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (CellOverlay, RowState, TrackerDelta, PendingSummary, SaveBatch). -- Grid code applies a TrackerDelta to its gio::ListStore after the call returns. -- Change notifications go through the existing relm4 channel forward, never through synchronous callbacks. -- No try_borrow fallback exists. - -Workspace::dirty_tabs() aggregates both registries and feeds WorkspaceOutput::DirtyChanged. - -**Why**: F-076: global registries leak edits across windows. - -Review: the try_borrow fallback rendered stale data. Owned snapshots make a re-entrant borrow structurally impossible, so no fallback is needed. - -**Rejected**: - A thread_local keyed by (window, tab). -- Arc trackers. -- try_borrow with a raw-value fallback: shows the database value instead of the pending edit. - -##### D7 · Panic boundary at the command and spawn layers plus a process hook - -- catch_panic(fut) = futures::FutureExt::catch_unwind(AssertUnwindSafe(fut)) mapped to TaskPanic. -- GuardedCommand on ComponentSender offers guarded_command (drop_on_shutdown) and guarded_command_detached. Both always deliver Result. -- TaskSpawnerExt::spawn_task and spawn_blocking_task wrap work in catch_panic and deliver TaskResult, which resolves to Result. -- install_panic_hook() logs through tracing::error!(target: "panic") with location, thread and message. -- panic="unwind" in every profile. - -crates/app/clippy.toml (nearest-file lookup; msrv repeated) bans: -- relm4::ComponentSender::{command, spawn_command, oneshot_command, spawn_oneshot_command} (allowed only in runtime/command_boundary.rs) -- relm4::spawn and relm4::spawn_blocking (allowed only in runtime/relm4_spawner.rs) -- tokio::spawn, tokio::task::spawn_blocking, tokio::runtime::Handle::spawn and tokio::runtime::Handle::spawn_blocking (allowed only in runtime/relm4_spawner.rs and the #[cfg(test)] test_support/tokio_spawner.rs) -- gio::prelude::ApplicationExt::quit -- the D1 relm4 application helpers - -Driver and ssh crates keep the workspace clippy.toml. - -**Why**: F-016: relm4 discards the JoinHandle. - -Review: 'add_action with accelerators' cannot be expressed as a path, and the real stray-application paths were missing. Both are fixed, and the allow-list now matches the actual spawn sites, all routed through the spawner. - -**Rejected**: - panic=abort. -- Awaiting a JoinHandle per command. -- Banning by argument shape: clippy matches paths only. - -##### D8 · Tabs own their fetches through RequestSequencer; supersession includes a server-side cancel contract - -BrowseTab and StructureTab become Components with their own CommandOutput. -- issue(kind) cancels the previous token of that kind and returns RequestTicket. -- Fetches call session.run_cancellable(kind, &ticket.cancel, op). -- Replies are Stamped and applied only if accept(kind, stamp). -- Closing a tab drops its component, its commands and its sequencer, and the sequencer cancels all tokens. -- The App messages FetchBrowse*, RowsLoaded, RowCountLoaded, ColumnsLoaded, TablesReloaded and StructureDataLoaded are deleted. - -Cancellation contract for Page and Count: run_cancellable drops the operation future on cancel and returns OperationError::Cancelled. Per C6, dropping a fetch_rows or query future must also stop the statement on the server: -- PostgreSQL: pg_cancel_backend on the acquired backend pid. -- MySQL: KILL QUERY on CONNECTION_ID(). -- ClickHouse: KILL QUERY by query_id. -- SQLite: sqlite3_interrupt through the sqlx lock_handle raw handle. -- MSSQL: tiberius has no attention API, so the dropped client is marked for replacement. - -The driver implementations of that contract, with a conformance test per driver, belong to the network-loss workstream, the same workstream that owns keepalive and TCP_USER_TIMEOUT. - -**Why**: F-040 and F-042. Review: client-side drop alone leaves server work running and can fill the 4-connection pool. - -FA owns the chokepoint and the contract. Pool saturation can no longer hide liveness, because the probe is out of pool (D5). The server-side cancel is a driver capability implemented by the named workstream in the same plan. - -**Rejected**: - Stamping inside App. -- A per-kind AbortHandle. -- A cancel method on Connection keyed by a request id: forces every driver to track ids even when cancel-on-drop expresses the same thing without a signature change. - -##### D9 · SchemaCatalog in Session with shared in-flight fetches; a refresh never clears the cache - -SchemaCatalog::new(initial, runner: OperationRunner, spawner) builds 'static fetch futures from the cloned runner and spawns them through the spawner wrapped in catch_panic. An awaiter that drops does not cancel a shared fetch. -- tables() joins in-flight work or returns the cache. -- refresh_tables() joins and sets rerun, so exactly one more fetch runs. -- A failure keeps the tables and sets last_error. -- columns(TableKey) uses the same pattern. -- invalidate_table marks the entry stale and keeps its data. -- commit_tables (reconnect) commits over the old value. -- The sidebar subscribes to the snapshot watch. - -**Why**: F-133 and the invariants: schema loading awaits the shared task, and a refresh never clears the cache it is refreshing. Review: the fetch closure must be 'static, which OperationRunner makes possible. - -**Rejected**: - A pending boolean. -- Per-tab list_tables calls. - -##### D10 · Close, quit and logout are event-driven loops over SaveLedger and dialogs; quit exits by use count - -SessionWindow connect_close_request returns Proceed only when close_approved is set. Otherwise it sends CloseRequested(CloseReason::Window) and returns Stop. - -CloseGuard::run is an async loop on glib::spawn_future_local: -1. If saves are in flight: a waiting dialog (heading 'Saving Changes', single response 'cancel' labelled 'Keep Open') races choose_future against ledger.when_idle(). When idle, it force_close()s and loops. -2. If any dirty tab has PendingState::OutcomeUnknown: 'Save Result Unknown' dialog. - - Body names the tabs: 'The connection was lost while saving. The changes may already be in the database.' - - Responses: cancel 'Keep Open' (close response); reload 'Reload Table' (default; cancels the close, reloads those tabs and presents the first); discard 'Discard Changes' (destructive; clears the frozen sets and loops). -3. If other tabs are dirty: SaveChangesDialog, the Text Editor layout (AdwAlertDialog with an AdwPreferencesGroup of AdwActionRow plus GtkCheckButton). Responses are cancel, discard and save. Save saves the checked tabs, discards the unchecked ones and loops. A RolledBack save leaves its tracker dirty, so the loop re-prompts. -4. Otherwise Approved. - -SaveOutcome::classify(Result<_, OperationError>): -- Ok: Committed. -- A known rollback (Transaction, Query, ReadOnly): RolledBack. -- A lost connection, TimedOut, Panicked, Closed, or a mid-batch failure on a driver without transactional DDL: Unknown(cause). - -Unknown calls tracker.mark_outcome_unknown(). Frozen trackers return SaveRejection::OutcomeUnknown from begin_save, so their statements are never resubmitted. - -QuitSequence (app.quit, q): -- Loops over app.windows() not yet asked. -- For each: present it, send CloseRequested(Quit(oneshot)) and await. Any Cancelled aborts. -- Each approved window runs the D17 teardown and closes itself. -- The sequence never calls g_application_quit. GApplication leaves its loop when the use count reaches zero: after the last window is removed and the last teardown or save hold guard drops. Then shutdown runs. - -WindowInhibitor: one cookie per window. -- LOGOUT with 'There are unsaved changes' while dirty. -- LOGOUT|SUSPEND with 'Saving changes' while saves are in flight. -- A cookie of 0 counts as not inhibited. - -**Why**: F-036, F-067, F-110, F-019 routing. - -Review: g_application_quit ignores the hold count (verified), which voided every hold guard. Exiting through the use count (verified in g_application_run) makes save tickets and session teardown real protections. - -Review: Unknown re-prompted Save. On 'Refresh and Discard': a third 'Keep Open' response is kept so Escape never reloads or discards. Reload is the default, which gives the user the Refresh path. - -**Rejected**: - A quit that calls app.quit() after approvals: kills commits and closes. -- One combined quit dialog. -- Resubmitting unknown saves: duplicate rows. -- Close Anyway with a background commit. - -##### D11 · Runtime sizing and blocking I/O - -configure_runtime_threads() runs first in main: RELM_THREADS = available_parallelism clamped to 2..=4. - -- Delete the startup tokio runtime. -- HistoryService::start(retention_days, spawner) runs init plus prune through the spawner, keeps an hourly prune interval inside that task, and publishes watch. win.show-history is disabled until Ready. -- Config writes (column_widths, filter_settings, preferences, window_state) run through TaskSpawner::spawn_blocking with latest-wins coalescing. - -**Why**: F-071. - -**Rejected**: - A second app runtime. -- Unbounded worker count. - -##### D12 · Workspace persistence: in memory, per SessionKey, off-thread writes, write-blocked after a failed load, per-profile directory - -WorkspaceStore (GTK thread, Rc): -- Directory: AppPaths.state_dir.join("workspaces"), where state_dir = glib::user_state_dir().join(config::APP_ID). app.tablepro.TablePro and app.tablepro.TablePro.Devel never share files. -- File names: Saved(uuid) is .json. File(uri) is file-.json. -- load returns WorkspaceLoad::{Restored, Empty (NotFound), Blocked(WorkspaceLoadError)}. -- A Blocked key is recorded in the store. schedule and flush on it return WorkspaceSaveError::Blocked, and the file is never written. -- The Workspace shows one adw::Toast ('Saved tabs could not be read', button 'Reset' bound to win.reset-workspace-layout). -- reset renames the file to .json.corrupt- on the blocking pool and unblocks the key. - -Writer task (through the spawner): keeps the latest record per key and writes with glib::file_set_contents_full(CONSISTENT|DURABLE, 0o600) on spawn_blocking. Flush replies through a oneshot. - -PersistDebouncer::new(delay) is trailing; production uses 500 ms. SqlEditorOutput::ContentChanged carries no text. - -Record version 1, with no migration: the app never shipped. - -**Why**: F-070, F-002 persistence, F-105. - -Review: the load-failure rule contradicted itself. Blocking by key with an explicit reset is unambiguous, and a newer-format file is never overwritten. - -Review: the Devel profile collided with the stable one. The state dir is derived from APP_ID. In Flatpak, user_state_dir is already per app, and the joined id keeps one layout for host and sandbox. - -**Rejected**: - A global JSON file. -- GSettings. -- Overwrite-on-next-change after a failed load (F-003 class). -- A shared state dir with a cross-profile lock. - -##### D13 · GApplication uniqueness and launch surface - -- Delete services/single_instance.rs. -- Flags: HANDLES_OPEN. resource-base-path: /app/tablepro/TablePro. -- Option: add_main_option("new-window", b'n', NONE, None, "Open a new window", None), handled as in D1. -- open: LaunchRequest::OpenFile resolves a SessionKey (D16), presents the owning window or opens a Connecting window. -- App actions: new-window n, quit q (QuitSequence), preferences comma, about. -- shortcuts is auto-installed by AdwApplication from shortcuts-dialog.ui. -- Window actions are added to the adw::ApplicationWindow itself through ActionMapExtManual::add_action_entries, never through a separately inserted 'win' group. Workspace-scoped actions are added the same way and removed in Workspace::shutdown. -- The desktop file has DBusActivatable=true and Actions=new-window. -- config::APP_ID and the resource bundle come from D19. - -**Why**: F-068. - -Review: gtk_application_window_init already inserts 'win'. Verified in gtkapplicationwindow.c: a second group would replace the window's own map, which app accels and the D-Bus export (%s/window/%d) resolve through. - -**Rejected**: - HANDLES_COMMAND_LINE. -- Ctrl+N for new-window. -- A gio::SimpleActionGroup inserted as 'win'. -- Keeping the flock. - -##### D14 · Operation chokepoint with deadlines, generation observation and read replay - -OperationRunner::run_cancellable(kind, cancel, op) is the only way UI code touches a connection; Session::run and run_cancellable delegate to it. It: -- leases the transport (returns Closed when there is none), -- selects on the session token and the caller token, -- applies DeadlinePolicy::deadline(kind), -- wraps the op in catch_panic. - -Observation: -- is_connection_lost: posts ConnectionLost { lease generation }. -- TaskPanic: posts OperationPanicked { generation }. -- Success while health is not Online: posts OperationSucceeded { generation }. - -Replay: when a replayable kind (Metadata, Page, Count) fails as lost and link.generation() is already newer than the lease, the op runs once more on the current lease. Query, Save and Close are never replayed. op is Fn(ConnectionLease) -> Fut. - -OperationKind = { Metadata, Page, Count, Query { timeout }, Save, Probe, Close }. Defaults: Metadata 15 s, Page and Count 60 s, Query from the preference, Save 120 s, Probe 10 s, Close 5 s. - -**Why**: F-002, F-072 and F-016 need one place. Review: generation stamping. Replay limited to idempotent reads keeps a tab from showing a spurious error right after a reconnect, without ever re-running writes. - -**Rejected**: - Ad hoc session.connection() calls. -- Replaying every kind: duplicates DML. - -##### D15 · Injected TaskSpawner for every background task - -TaskSpawner (Send + Sync, object-safe): -- spawn(BoxFuture<'static, ()>) -> tokio::task::JoinHandle<()> -- spawn_blocking(Box) -> JoinHandle<()> - -TaskSpawnerExt adds spawn_task and spawn_blocking_task, which return TaskResult via a futures oneshot. TaskResult is awaitable from glib executors and detached, so dropping the awaiter does not abort. - -Implementations: -- Production Relm4Spawner wraps relm4::spawn and relm4::spawn_blocking. -- Tests use TokioSpawner(tokio::runtime::Handle::current()) under #[tokio::test(start_paused = true)]. - -AppServices.spawner is passed to Session::start (SessionDeps), SchemaCatalog, run_monitor, WorkspaceStore, HistoryService, ResumeDetector and PendingSession discards. - -crates/app dev-dependencies: tokio with features ["test-util", "macros", "rt"]. - -**Why**: Review: relm4's RUNTIME is private and real-time, so paused-clock tests could not control the tasks under test. Injection makes backoff, coalescing and deadline tests deterministic and makes the clippy allow-list truthful. - -**Rejected**: - A generic spawner type parameter on Session: leaks into every consumer. -- Capturing relm4's Handle by blocking on a spawned probe at startup. -- A second runtime owned by the app. - -##### D16 · SessionKey and claim before establish on every path - -SessionKey = { Saved(Uuid), File(String canonical URI) }. - -FileIdentity::from_gio(&gio::File) on the GTK side takes path() through std::fs::canonicalize when a path exists, and falls back to the URI. SessionKey::for_file(identity, saved) returns Saved(id) when a saved file-based connection canonicalizes to the same path, otherwise File(uri). - -Picker, open() and ConnectDialog all claim first: -- ConnectDialog no longer establishes. -- On Connect it validates the form, resolves the id with find_existing_id (or Uuid::new_v4), and emits ConnectDialogOutput::Connect(ConnectIntent { key, request, pending_save }). -- SessionWindow claims. On AlreadyOpen it persists pending_save (the edits apply on the next connect, never to the live session) and presents the owner. On success it enters Connecting and persists pending_save only for the adopted attempt. Keyring store errors surface as a toast. -- TestConnection keeps establishing and discarding inside the dialog, with no claim. - -**Why**: Review: ad-hoc file sessions had no key, and the dialog built a whole connection plus tunnel before discovering the connection was already open. - -**Rejected**: - Keying by saved Uuid only. -- Claiming after establish. -- Saving before connecting: persists configurations that never worked, which changes current behaviour. - -##### D17 · Ordered teardown for disconnect and approved close - -SessionTeardown in SessionWindow: -1. CloseGuard::run returns Approved; Cancelled stops here. -2. await workspace_handle.flush(). Blocked is a no-op; a write error is logged and does not block the close. -3. The window takes SessionPage::Workspace { claim, session, workspace } out of its page. It already holds its own Arc from adoption. -4. Remove the workspace root from the adw::ToolbarView (set_content(None)) and reset the title. -5. Drop the Controller. Workspace::shutdown removes its window actions and disconnects its handlers. -6. Drop the claim, which releases the registry entry and may stop the ResumeDetector. -7. let closing = session.begin_shutdown(); drop(session); glib::spawn_future_local(async move { let _hold = app.hold(); log(closing.await) }). -8. Disconnect shows the Picker. Window and Quit set close_approved and call window.close(). - -**Why**: Review: nobody owned the shutdown call, and relm4 Controller drop never removes the child root. The claim is released before the close finishes so the same connection can be picked again at once. The old transport closes independently under its deadline. - -**Rejected**: - Shutdown from Workspace Drop: the model drops later on the main context. -- Releasing the claim after the close completes: the picker in the same window would see its own claim. - -##### D18 · Testable crate layout: library target, fake driver crate, process-level application tests - -- crates/app gains src/lib.rs (crate tablepro_app) that owns every module. main.rs calls tablepro_app::run(). -- New workspace crate crates/test-support (publish=false, dev-dependency only) provides FakeDriver and FakeConnection. They are scriptable: probe outcomes, a close delay or hang, call counters for close, list_tables and execute_in_transaction. -- Integration tests in crates/app/tests use [[test]] harness = false and run under xvfb-run -a dbus-run-session in build-linux.yml: - - application_uniqueness.rs spawns env!("CARGO_BIN_EXE_tablepro-app") processes on a gio::TestDBus bus (DBUS_SESSION_BUS_ADDRESS from bus_address(), XDG_* pointed at tempdirs). It counts the primary's windows by introspecting /window child nodes through gio::DBusConnection::for_address_future. - - application_lifecycle.rs runs an in-process TableProApplication with AppServices wired to FakeDriver, checking quit and close ordering. -- Unit and #[gtk::test] tests stay in-module. - -**Why**: Review: a second in-process GApplication cannot register on the singleton session bus connection (verified: attempt_primary fails when org.gtk.Application registration fails). Subprocesses on a TestDBus bus exercise real uniqueness, and the stock D-Bus window export observes the result without a test hook. - -**Rejected**: - In-process second instance. -- A test-only D-Bus method. -- A self dev-dependency with a test-support feature. - -##### D19 · config and GResource work for both cargo run and Meson - -FA consumes this contract from the build foundation, which lands before migration step 11. - -crates/app/build.rs: -- Calls glib_build_tools::compile_resources(&["data/resources"], "data/resources/resources.gresource.xml", "tablepro.gresource") (glib-build-tools 0.22.8 build-dependency; needs glib-compile-resources). -- Writes OUT_DIR/config.rs with APP_ID, PROFILE, VERSION, LOCALEDIR, RESOURCE_BASE_PATH from env TABLEPRO_APP_ID, TABLEPRO_PROFILE and TABLEPRO_LOCALEDIR. The defaults are app.tablepro.TablePro.Devel, development, CARGO_PKG_VERSION and the source po build dir. -- Declares cargo:rerun-if-env-changed for each. - -Meson invokes cargo with those env vars set for release profiles. - -src/config.rs does include!(concat!(env!("OUT_DIR"), "/config.rs")). run() calls gio::resources_register_include!("tablepro.gresource") before TableProApplication::new; an Err logs and returns ExitCode::FAILURE. - -**Why**: Review: a Meson-only config.rs broke cargo run. One build.rs is the single source of config for both entry points, and a plain cargo run is the Devel profile. - -**Rejected**: - Meson configure_file only: breaks cargo run. -- Hard-coded constants beside a Meson file: two sources of truth. - -#### Contracts - -##### C1 · `TableProApplication` - -**Location**: `crates/app/src/application/mod.rs, application/imp.rs` · **Kind**: GObject subclass · **Consumers**: tablepro_app::run; SessionWindow via WindowHost; application tests - -``` -glib::wrapper! { pub struct TableProApplication(ObjectSubclass) @extends adw::Application, gtk::Application, gio::Application, @implements gio::ActionGroup, gio::ActionMap; } -impl TableProApplication { - pub fn new(services: AppServices) -> Self; - pub fn services(&self) -> Rc; - pub fn open_window(&self, launch: WindowLaunch) -> adw::ApplicationWindow; - pub fn present_window(&self, window_id: u32) -> bool; - pub fn request_quit(&self); -} -// imp -#[derive(Default)] pub struct TableProApplication { services: OnceCell>, windows: WindowRegistry, network_watch: OnceCell>, resume: RefCell>, registry_subscription: OnceCell, quit: RefCell>> } -const NAME = "TpApplication"; ParentType = adw::Application; -impl ApplicationImpl { fn startup(&self); fn activate(&self); fn open(&self, files: &[gio::File], hint: &str); fn handle_local_options(&self, options: &glib::VariantDict) -> ControlFlow; fn shutdown(&self); } -impl GtkApplicationImpl {} impl AdwApplicationImpl {} -Construction: glib::Object::builder().property("application-id", config::APP_ID).property("flags", gio::ApplicationFlags::HANDLES_OPEN).property("resource-base-path", config::RESOURCE_BASE_PATH).build() -``` - -- request_quit starts QuitSequence once. While it runs, a second call presents the window currently asking. -- ExitCode comes from glib::ExitCode, which implements Termination. - -##### C2 · `App and window actions` - -**Location**: `crates/app/src/application/app_actions.rs, ui/session_window/window_actions.rs` · **Kind**: action tables · **Consumers**: TableProApplication::startup; SessionWindow; Workspace (adds and removes its own action names on the window); W4/W5 shell - -``` -pub const APP_ACCELS: &[(&str, &[&str])] = &[("app.new-window", &["n"]), ("app.quit", &["q"]), ("app.preferences", &["comma"])]; -pub fn install_app_actions(app: &TableProApplication); // gio::ActionEntry: new-window, quit (QuitSequence), preferences, about (adw::AboutDialog::from_appdata) -pub fn install_window_actions(window: &adw::ApplicationWindow, sender: relm4::Sender); -// window.add_action_entries([disconnect, reconnect, cancel-connect, retry-connect, edit-connection, reset-workspace-layout]) -pub fn set_window_action_enabled(window: &adw::ApplicationWindow, name: &str, enabled: bool); // lookup_action + downcast SimpleAction -``` - -- No gio::SimpleActionGroup is ever inserted under 'win'. -- Accels go through set_accels_for_action only. -- Reserved app accels: q, n, comma, question. W4/W5 move grid Set NULL off n. -- A unit test asserts that no win.* accel equals an app accel. - -##### C3 · `AppServices, AppPaths, WindowHost, WindowRegistry, WindowLaunch` - -**Location**: `crates/app/src/application/{app_services,app_paths,window_host,app_window_host,window_registry,window_launch}.rs` · **Kind**: DI container + trait + registry · **Consumers**: SessionWindow, Workspace, QuitSequence, tests - -``` -pub struct AppServices { pub drivers: Arc, pub sessions: Rc, pub connector: Arc, pub credentials: Arc, pub deadlines: Arc, pub spawner: Arc, pub workspaces: WorkspaceStore, pub history: HistoryService, pub paths: AppPaths } -impl AppServices { pub fn production(drivers: DriverRegistry) -> Self; pub fn session_deps(&self, key: &SessionKey) -> SessionDeps; } -pub struct AppPaths { pub state_dir: PathBuf } -impl AppPaths { pub fn for_app_id(app_id: &str) -> Self; } // glib::user_state_dir().join(app_id) -pub trait WindowHost { fn present_window(&self, window_id: u32) -> bool; fn open_window(&self, launch: WindowLaunch); fn application(&self) -> gtk::Application; } -pub struct AppWindowHost { app: glib::WeakRef } -#[derive(Default)] pub struct WindowRegistry { controllers: RefCell>> } -impl WindowRegistry { pub fn insert(&self, id: u32, c: Controller); pub fn remove(&self, id: u32) -> Option>; pub fn sender(&self, id: u32) -> Option>; pub fn clear(&self); } -pub enum WindowLaunch { Picker, Connect(SavedConnection), OpenFile(gio::File) } -``` - -- GTK-thread only. -- session_deps picks KeyringCredentials for Saved keys and NoStoredCredentials for File keys. - -##### C4 · `Session, SessionDeps, OperationRunner, OperationKind, OperationError, DeadlinePolicy` - -**Location**: `crates/app/src/session/{session,session_deps,operation_runner,operation_kind,operation_error,deadline_policy,default_deadlines,connection_metadata,close_report}.rs` · **Kind**: structs + trait · **Consumers**: SessionWindow, Workspace, BrowseTab, StructureTab, SqlEditor, SchemaCatalog, W5/W6/W7 - -``` -pub struct SessionDeps { pub connector: Arc, pub credentials: Arc, pub deadlines: Arc, pub spawner: Arc } - -pub struct Session { metadata: ConnectionMetadata, read_only: bool, endpoint: NetworkEndpoint, runner: OperationRunner, catalog: Arc, mailbox: Arc, health: watch::Receiver, link: Arc, deadlines: Arc, spawner: Arc, cancel: CancellationToken, _cancel_on_drop: tokio_util::sync::DropGuard, _monitor: tokio_util::task::AbortOnDropHandle<()> } -impl Session { - pub fn start(pending: PendingSession, deps: SessionDeps) -> Arc; - pub fn id(&self) -> Uuid; pub fn metadata(&self) -> &ConnectionMetadata; pub fn read_only(&self) -> bool; pub fn endpoint(&self) -> &NetworkEndpoint; - pub fn health(&self) -> watch::Receiver; - pub fn signal(&self, signal: MonitorSignal); - pub async fn run(&self, kind: OperationKind, op: F) -> Result where F: Fn(ConnectionLease) -> Fut + Send + Sync, Fut: Future> + Send, T: Send; - pub async fn run_cancellable(&self, kind: OperationKind, cancel: &CancellationToken, op: F) -> Result where /* same */; - pub async fn tables(&self) -> Result, Arc>; - pub async fn refresh_tables(&self) -> Result, Arc>; - pub async fn columns(&self, key: TableKey) -> Result, Arc>; - pub fn invalidate_table(&self, key: &TableKey); - pub fn table_list(&self) -> watch::Receiver; - pub fn begin_shutdown(&self) -> TaskResult; -} -#[derive(Clone)] pub struct OperationRunner { link: Arc, deadlines: Arc, mailbox: Arc, health: watch::Receiver, cancel: CancellationToken } -impl OperationRunner { pub async fn run_cancellable(&self, kind: OperationKind, cancel: &CancellationToken, op: F) -> Result; } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OperationKind { Metadata, Page, Count, Query { timeout: Option }, Save, Probe, Close } -impl OperationKind { pub fn is_replayable(self) -> bool; } -#[derive(Debug, thiserror::Error)] pub enum OperationError { #[error(transparent)] Driver(#[from] DriverError), #[error("timed out")] TimedOut(OperationKind), #[error(transparent)] Panicked(#[from] TaskPanic), #[error("cancelled")] Cancelled, #[error("session closed")] Closed } -pub trait DeadlinePolicy: Send + Sync { fn deadline(&self, kind: OperationKind) -> Option; } -pub struct DefaultDeadlines { pub query_timeout: Option } -#[derive(Debug, Clone)] pub struct ConnectionMetadata { pub id: Uuid, pub name: String, pub driver_id: String } -#[derive(Debug)] pub enum CloseReport { Closed, AlreadyClosed, CloseFailed(DriverError), Abandoned } -``` - -- Session::start constructs only runtime-free primitives (watch, Notify, CancellationToken, DropGuard, AbortOnDropHandle::new) and spawns the monitor through deps.spawner. -- A #[gtk::test] calls it on the main thread with no entered runtime. -- Callers clone Arc before dispatch. - -##### C5 · `ConnectionLink, Transport, ConnectionLease, LinkGeneration, RetiredTransport` - -**Location**: `crates/app/src/session/{connection_link,transport,connection_lease,link_generation,retired_transport}.rs` · **Kind**: structs · **Consumers**: OperationRunner, run_monitor, Session::begin_shutdown - -``` -pub struct Transport { connection: Box, _tunnel: Option } -impl Transport { pub fn new(connection: Box, tunnel: Option) -> Self; pub fn connection(&self) -> &dyn Connection; } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct LinkGeneration(u64); -#[derive(Clone)] pub struct ConnectionLease { generation: LinkGeneration, transport: Arc } -impl ConnectionLease { pub fn generation(&self) -> LinkGeneration; } -impl std::ops::Deref for ConnectionLease { type Target = dyn Connection; } -pub struct ConnectionLink { state: watch::Sender } -struct LinkState { generation: LinkGeneration, transport: Option> } -impl ConnectionLink { - pub fn new(transport: Transport) -> Self; - pub fn lease(&self) -> Option; - pub fn generation(&self) -> LinkGeneration; - pub fn replace(&self, transport: Transport) -> (LinkGeneration, Option); - pub fn take(&self) -> Option; -} -pub struct RetiredTransport { generation: LinkGeneration, transport: Arc } -impl RetiredTransport { pub async fn close(self, deadline: Duration) -> CloseReport; } -``` - -- A retired transport is closed immediately by whoever retired it (the monitor on replace, begin_shutdown on take), through the spawner. -- Leases still running on it fail with a stale generation, which the policy ignores; replayable reads re-run once (D14). - -##### C6 · `Core Connection, DriverError and PoolPressure changes` - -**Location**: `crates/core/src/{connection,error,pool_pressure,read_only}.rs; crates/drivers/*/src/lib.rs` · **Kind**: trait signature change (atomic) · **Consumers**: Session, run_monitor, PendingSession, ReadOnlyConnection, test-support FakeConnection, error_text ('The database is busy. Try again in a moment.') - -``` -#[async_trait] pub trait Connection: Send + Sync { - ... - async fn probe(&self) -> Result<(), DriverError>; // renamed from ping - async fn close(&self) -> Result<(), DriverError>; // was self: Box -} -pub enum DriverError { ..., #[error("all connections are busy")] Busy } -impl DriverError { pub fn is_connection_lost(&self) -> bool; pub fn is_permanent_connect_failure(&self) -> bool; } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PoolPressure { pub size: u32, pub idle: usize, pub max: u32 } -impl PoolPressure { pub fn is_saturated(&self) -> bool; pub fn acquire_timeout_error(&self) -> DriverError; } -``` - -- is_connection_lost: Disconnected, ConnectionRefused, or a Transaction whose source is lost. -- is_permanent_connect_failure: AuthFailed, Tls, IntegratedAuth. - -sqlx drivers (pg, mysql, sqlite): -- map_sqlx_error(&pool, err): PoolTimedOut maps to PoolPressure { size: pool.size(), idle: pool.num_idle(), max: pool.options().get_max_connections() }.acquire_timeout_error(), which is Busy only when saturated and Disconnected otherwise. -- PoolClosed and Io other than ConnectionRefused map to Disconnected. -- PgDatabaseError code 53300 and MySqlDatabaseError::number 1040 map to Busy. -- probe: pool.try_acquire() then ping. If none is idle, a standalone ::connect_with(&pool.connect_options()), then ping, then close. -- close: pool.close().await; idempotent. - -mssql: -- client: tokio::sync::Mutex>; target: MssqlTarget (Clone, since tiberius Config is Clone). -- probe: client.try_lock() and SELECT 1 on the held client (None returns Disconnected). If the lock is held, open a probe client through the same spawn_blocking plus CONNECT_TIMEOUT path, run SELECT 1 and close it. -- close: takes the Option and awaits Client::close. - -clickhouse: probe is the current HTTP body; close is Ok. - -ReadOnlyConnection delegates both. - -Server-side cancel-on-drop for fetch_rows and query is a documented trait contract implemented by the network-loss workstream (D8). - -##### C7 · `SessionHealth, MonitorSignal, MonitorMailbox, ProbeOutcome, HealthPolicy, run_monitor` - -**Location**: `crates/app/src/session/{session_health,failure_cause,monitor_signal,pending_signals,monitor_mailbox,probe_outcome,reconnect_result,monitor_action,health_policy,health_monitor,reconnect_plan,reachability,network_endpoint}.rs` · **Kind**: enums + pure state machine + task · **Consumers**: Session; Workspace health banner (adw::Banner, action-name win.reconnect, revealed for Reconnecting/Offline/Failed, title from FailureCause via error_text) - -``` -#[derive(Debug, Clone, PartialEq, Eq)] pub enum SessionHealth { Online, Reconnecting { attempt: u32 }, Offline, Failed { cause: FailureCause } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FailureCause { AuthFailed, Tls, IntegratedAuth } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Reachability { Reachable, Unreachable } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum NetworkEndpoint { Local, Remote { host: String, port: u16 } } -impl NetworkEndpoint { pub fn for_request(request: &ConnectRequest) -> Self; } -#[derive(Debug, Clone)] pub enum MonitorSignal { RetryNow, ConnectionLost { generation: LinkGeneration }, OperationPanicked { generation: LinkGeneration }, OperationSucceeded { generation: LinkGeneration }, Reachability(Reachability), Resumed { slept: Duration } } -#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct PendingSignals { pub retry: bool, pub lost: Option, pub panicked: Option, pub succeeded: Option, pub reachability: Option, pub resumed: Option } -pub struct MonitorMailbox { pending: std::sync::Mutex, notify: tokio::sync::Notify } -impl MonitorMailbox { pub fn new() -> Self; pub fn post(&self, signal: MonitorSignal); pub async fn next(&self) -> PendingSignals; } -#[derive(Debug, Clone, PartialEq, Eq)] pub enum ProbeOutcome { Healthy, Busy, Unresponsive, Lost, Rejected(FailureCause) } -impl ProbeOutcome { pub fn classify(result: Result, tokio::time::error::Elapsed>) -> Self; } -#[derive(Debug, Clone, PartialEq, Eq)] pub enum ReconnectResult { Connected(LinkGeneration), Rejected(FailureCause), Failed } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MonitorAction { Probe, Reconnect, Wait, Park } -pub struct HealthPolicy { state: SessionHealth, generation: LinkGeneration, backoff: Duration, failures: u8, reachability: Reachability, next_deadline: Option } -impl HealthPolicy { - pub const PROBE_INTERVAL: Duration = 30 s; pub const PROBE_RETRY: Duration = 10 s; pub const BACKOFF_INITIAL: Duration = 5 s; pub const BACKOFF_MAX: Duration = 60 s; pub const OFFLINE_RECHECK: Duration = 60 s; pub const LOST_AFTER_FAILURES: u8 = 3; - pub fn new(generation: LinkGeneration, now: Instant) -> Self; - pub fn on_signals(&mut self, signals: &PendingSignals, now: Instant) -> MonitorAction; - pub fn on_probe(&mut self, outcome: &ProbeOutcome, now: Instant) -> MonitorAction; - pub fn on_reconnect(&mut self, result: &ReconnectResult, now: Instant) -> MonitorAction; - pub fn on_deadline(&mut self, now: Instant) -> MonitorAction; - pub fn next_deadline(&self) -> Option; - pub fn health(&self) -> &SessionHealth; pub fn generation(&self) -> LinkGeneration; -} -pub struct ReconnectPlan { pub connector: Arc, pub request: ConnectRequest, pub credentials: Arc } -pub(crate) struct MonitorContext { pub link: Arc, pub catalog: Arc, pub plan: ReconnectPlan, pub deadlines: Arc, pub spawner: Arc, pub health: watch::Sender, pub mailbox: Arc, pub cancel: CancellationToken } -pub(crate) async fn run_monitor(ctx: MonitorContext, policy: HealthPolicy); -``` - -- Transitions are exactly as in D5. -- Reconnect races credentials.refresh plus connector.establish against cancel. A result that arrives after cancel is discarded through the spawner. -- Health is published with send_if_modified. -- The std Mutex is never held across await; a poisoned lock is recovered with PoisonError::into_inner. - -##### C8 · `SessionKey, SessionRegistry, SessionClaim, RegistryEvent` - -**Location**: `crates/app/src/session/{session_key,file_identity,session_registry,session_claim,claim_error,registry_event,registry_subscription}.rs` · **Kind**: GTK-thread registry + RAII claim · **Consumers**: SessionWindow (picker, open, ConnectDialog paths), NetworkWatch (Attached/Released), TableProApplication (ResumeDetector on LiveCountChanged), HistoryDialog (saved list) - -``` -#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum SessionKey { Saved(Uuid), File(String) } -impl SessionKey { pub fn for_file(identity: &FileIdentity, saved: &[SavedConnection]) -> Self; } -#[derive(Debug, Clone)] pub struct FileIdentity { pub canonical_path: Option, pub uri: String } -impl FileIdentity { pub fn from_gio(file: &gio::File) -> Self; } // GTK-side constructor in ui/session_window/file_identity_gio.rs -#[derive(Default)] pub struct SessionRegistry { entries: RefCell>, live: Cell, observers: RefCell)>>, next_observer: Cell } -struct RegistryEntry { window_id: u32, session: Option> } -#[derive(Debug, thiserror::Error)] pub enum ClaimError { #[error("connection already open")] AlreadyOpen { window_id: u32 } } -#[derive(Debug, Clone)] pub enum RegistryEvent { Attached { key: SessionKey, endpoint: NetworkEndpoint }, Released { key: SessionKey }, LiveCountChanged(usize) } -impl SessionRegistry { - pub fn claim(self: &Rc, key: SessionKey, window_id: u32) -> Result; - pub fn window_for(&self, key: &SessionKey) -> Option; - pub fn attached_sessions(&self) -> Vec<(SessionKey, Arc)>; - pub fn live_count(&self) -> usize; - pub fn subscribe(self: &Rc, observer: impl Fn(&RegistryEvent) + 'static) -> RegistrySubscription; -} -pub struct SessionClaim { registry: Weak, key: SessionKey } -impl SessionClaim { pub fn key(&self) -> &SessionKey; pub fn attach(&self, session: &Arc); } -impl Drop for SessionClaim { /* remove entry; if attached: live -= 1, emit Released + LiveCountChanged */ } -pub struct RegistrySubscription { registry: Weak, id: u64 } -``` - -- Observers are cloned out and invoked after every RefCell borrow is released. -- The live count moves only on attach and claim drop, both on the GTK thread, so it never depends on observing an Arc drop on a worker. - -##### C9 · `Connector, ConnectRequest, ConnectSource, CredentialSource, PendingSession, ConnectError, ConnectAttempts` - -**Location**: `crates/app/src/session/{connector,driver_connector,connect_request,connect_source,connect_intent,pending_save,connect_deadlines,credential_source,credential_source_factory,keyring_credentials,no_stored_credentials,pending_session,connect_error,connect_stage,connect_attempts,attempt_generation,attempt_ticket,attempt_outcome,resolve_saved}.rs` · **Kind**: traits + structs · **Consumers**: SessionWindow, ConnectDialog (ConnectIntent, TestConnection), run_monitor (reconnect) - -``` -#[derive(Clone)] pub struct ConnectRequest { pub metadata: ConnectionMetadata, pub driver: Arc, pub options: ConnectOptions, pub ssh: Option, pub read_only: bool } // Debug prints id and name only -pub enum ConnectSource { Saved(SavedConnection), Intent(ConnectIntent), File(FileIdentity) } -pub struct ConnectIntent { pub key: SessionKey, pub request: ConnectRequest, pub pending_save: PendingSave } -pub struct PendingSave { pub connection: SavedConnection, pub password: Option, pub ssh_secret: SshSecretToStore } -pub trait Connector: Send + Sync { fn establish(&self, request: ConnectRequest, cancel: CancellationToken) -> BoxFuture<'static, Result>; } -pub struct DriverConnector { pub deadlines: ConnectDeadlines } -#[derive(Debug, Clone, Copy)] pub struct ConnectDeadlines { pub ssh_handshake: Duration, pub driver_connect: Duration, pub initial_catalog: Duration, pub close: Duration } -pub trait CredentialSource: Send + Sync { fn refresh(&self, request: ConnectRequest) -> BoxFuture<'static, Result>; } -pub trait CredentialSourceFactory { fn for_key(&self, key: &SessionKey) -> Arc; } -pub struct KeyringCredentials { pub connection_id: Uuid } // re-reads password / ssh password / passphrase only -pub struct NoStoredCredentials; -#[must_use] pub struct PendingSession { request: ConnectRequest, transport: Transport, tables: Vec, close_deadline: Duration } -impl PendingSession { pub fn metadata(&self) -> &ConnectionMetadata; pub async fn discard(self) -> CloseReport; pub(crate) fn into_parts(self) -> (ConnectRequest, Transport, Vec); } -#[derive(Debug, thiserror::Error)] pub enum ConnectError { #[error(transparent)] Driver(DriverError), #[error(transparent)] Ssh(tablepro_ssh::SshError), #[error(transparent)] Keyring(tablepro_storage::StorageError), #[error("driver {0} is not available")] UnknownDriver(String), #[error("authentication mode not supported by {0}")] UnsupportedAuthMode(String), #[error("timed out during {0:?}")] TimedOut(ConnectStage), #[error("cancelled")] Cancelled, #[error(transparent)] Panicked(TaskPanic) } -#[derive(Debug, Clone, Copy)] pub enum ConnectStage { SshHandshake, DriverConnect, Catalog } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AttemptGeneration(u64); -#[derive(Debug)] pub struct AttemptTicket { pub generation: AttemptGeneration, pub cancel: CancellationToken } -#[derive(Default)] pub struct ConnectAttempts { current: Cell, cancel: RefCell> } -impl ConnectAttempts { pub fn begin(&self) -> AttemptTicket; pub fn cancel(&self); pub fn accepts(&self, generation: AttemptGeneration) -> bool; } -#[derive(Debug)] pub struct AttemptOutcome { pub generation: AttemptGeneration, pub result: Result } -pub async fn resolve_saved(saved: &SavedConnection, drivers: &DriverRegistry) -> Result; -``` - -- Replaces connection_service::{open_saved, establish}. -- Keyring errors propagate. -- redirect_through_tunnel and check_auth_mode move into driver_connector.rs with their tests. - -##### C10 · `SchemaCatalog` - -**Location**: `crates/app/src/session/{schema_catalog,table_list_snapshot,table_key}.rs` · **Kind**: struct · **Consumers**: Session, run_monitor (commit_tables on reconnect), Workspace sidebar, BrowseTab, StructureTab, editor completion (W7) - -``` -#[derive(Debug, Clone)] pub struct TableListSnapshot { pub generation: u64, pub tables: Arc<[TableInfo]>, pub last_error: Option> } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct TableKey { pub schema: Option, pub table: String } -type SharedFetch = futures::future::Shared>>>; -pub struct SchemaCatalog { runner: OperationRunner, spawner: Arc, state: std::sync::Mutex, snapshot: watch::Sender } -struct CatalogState { tables_in_flight: Option>>, rerun_tables: bool, columns: HashMap } -struct ColumnsEntry { value: Option>, stale: bool, in_flight: Option>> } -impl SchemaCatalog { - pub fn new(initial: Vec, runner: OperationRunner, spawner: Arc) -> Self; - pub async fn tables(&self) -> Result, Arc>; - pub async fn refresh_tables(&self) -> Result, Arc>; - pub async fn columns(&self, key: TableKey) -> Result, Arc>; - pub fn commit_tables(&self, tables: Vec); - pub fn invalidate_table(&self, key: &TableKey); - pub fn subscribe(&self) -> watch::Receiver; -} -``` - -- Fetch futures are spawned via spawn_task, so an awaiter dropping does not cancel them. -- The mutex is never held across await. -- A failure updates only last_error. - -##### C11 · `RequestSequencer` - -**Location**: `crates/app/src/runtime/{request_sequencer,request_stamp,request_ticket,stamped}.rs; ui/browse_tab/{browse_request_kind,browse_command,page_reply}.rs` · **Kind**: pure generic type · **Consumers**: BrowseTab (W5), StructureTab (W6), SqlEditor (W7), Workspace show-CREATE - -``` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RequestStamp(u64); -#[derive(Debug)] pub struct RequestTicket { pub stamp: RequestStamp, pub cancel: CancellationToken } -#[derive(Debug)] pub struct Stamped { pub stamp: RequestStamp, pub value: T } -pub struct RequestSequencer { next: u64, latest: HashMap } -impl RequestSequencer { pub fn new() -> Self; pub fn issue(&mut self, kind: K) -> RequestTicket; pub fn accept(&mut self, kind: K, stamp: RequestStamp) -> bool; pub fn is_current(&self, kind: K, stamp: RequestStamp) -> bool; pub fn cancel_all(&mut self); } -impl Drop for RequestSequencer { fn drop(&mut self) { self.cancel_all(); } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum BrowseRequestKind { Page, Count, Columns } -#[derive(Debug)] pub enum BrowseCommand { Page(Stamped>), Count(Stamped>), Columns(Stamped, Arc>>) } -#[derive(Debug)] pub struct PageReply { pub offset: u64, pub result: QueryResult } -``` - -- The ticket's cancel is passed to Session::run_cancellable. -- Server-side cancellation of superseded Page and Count statements is the C6 cancel-on-drop contract, implemented by the network-loss workstream. -- accept removes the entry, so a duplicate reply is dropped. - -##### C12 · `Runtime boundary: GuardedCommand, TaskSpawner, TaskResult, TaskPanic, panic hook, runtime threads` - -**Location**: `crates/app/src/runtime/{command_boundary,catch_panic,task_panic,task_failure,task_spawner,task_spawner_ext,task_result,relm4_spawner,panic_hook,runtime_threads}.rs; test_support/tokio_spawner.rs (#[cfg(test)]); crates/app/clippy.toml` · **Kind**: traits + fns + lint config · **Consumers**: every Component that spawns work; Session internals; WorkspaceStore; HistoryService; ResumeDetector - -``` -#[derive(Debug, Clone, thiserror::Error)] #[error("background task panicked: {message}")] pub struct TaskPanic { pub message: String } -impl TaskPanic { pub fn from_payload(payload: Box) -> Self; } -#[derive(Debug, Clone, thiserror::Error)] pub enum TaskFailure { #[error(transparent)] Panicked(TaskPanic), #[error("task aborted")] Aborted } -pub async fn catch_panic(future: F) -> Result; -pub trait GuardedCommand { - fn guarded_command(&self, future: Fut, into_output: M) where Fut: Future + Send + 'static, Fut::Output: Send + 'static, M: FnOnce(Result) -> C::CommandOutput + Send + 'static; - fn guarded_command_detached(&self, future: Fut, into_output: M) where /* same */; -} -impl GuardedCommand for relm4::ComponentSender {} -pub trait TaskSpawner: Send + Sync + 'static { fn spawn(&self, future: BoxFuture<'static, ()>) -> tokio::task::JoinHandle<()>; fn spawn_blocking(&self, job: Box) -> tokio::task::JoinHandle<()>; } -pub trait TaskSpawnerExt { fn spawn_task(&self, future: impl Future + Send + 'static) -> TaskResult; fn spawn_blocking_task(&self, job: impl FnOnce() -> T + Send + 'static) -> TaskResult; } -impl TaskSpawnerExt for dyn TaskSpawner {} -pub struct TaskResult { receiver: futures::channel::oneshot::Receiver> } -impl Future for TaskResult { type Output = Result; } -pub struct Relm4Spawner; -pub fn install_panic_hook(); -pub fn configure_runtime_threads(); - -tablepro_app::run() -> glib::ExitCode order: -1. tracing init -2. install_panic_hook() -3. configure_runtime_threads() -4. i18n::init() -5. gio::resources_register_include!("tablepro.gresource") -6. TableProApplication::new(AppServices::production(build_registry())) -7. app.run() -``` - -crates/app/clippy.toml carries msrv = "1.93" plus disallowed-methods with reasons: -- relm4::ComponentSender::{command, spawn_command, oneshot_command, spawn_oneshot_command} -- relm4::spawn, relm4::spawn_blocking -- tokio::spawn, tokio::task::spawn_blocking, tokio::runtime::Handle::spawn, tokio::runtime::Handle::spawn_blocking -- relm4::RelmApp::run, relm4::main_application, relm4::main_adw_application -- relm4::actions::RelmActionGroup::register_for_main_application -- relm4::ComponentBuilder::transient_for, relm4::ComponentBuilder::transient_for_native -- gio::prelude::ApplicationExt::quit - -#[allow(clippy::disallowed_methods)] appears only in command_boundary.rs, relm4_spawner.rs and test_support/tokio_spawner.rs. The paths resolve through re-exports (clippy_utils lookup_path). - -##### C13 · `SessionWindow component` - -**Location**: `crates/app/src/ui/session_window/{mod,session_page,session_window_input,session_window_command,close_reason,close_decision,close_guard,close_step,save_changes_dialog,unknown_outcome_dialog,window_inhibitor,inhibit_reason,connecting_page,session_teardown,window_actions,file_identity_gio}.rs` · **Kind**: relm4 Component · **Consumers**: TableProApplication, QuitSequence, WelcomeView, ConnectDialog, Workspace - -``` -pub struct SessionWindowInit { pub services: Rc, pub host: Rc, pub launch: WindowLaunch } -pub enum SessionPage { Picker, Connecting { claim: SessionClaim, source: ConnectSource }, ConnectFailed { claim: SessionClaim, source: ConnectSource, error: ConnectError }, Workspace { claim: SessionClaim, session: Arc, workspace: Controller } } -pub struct SessionWindow { window: adw::ApplicationWindow, services: Rc, host: Rc, page: SessionPage, attempts: ConnectAttempts, close_approved: Rc>, closing: bool, inhibitor: WindowInhibitor, toast_overlay: adw::ToastOverlay, content: adw::ToolbarView } -#[derive(Debug)] pub enum SessionWindowInput { OpenSaved(SavedConnection), OpenFile(gio::File), OpenConnectDialog, ConnectFromDialog(ConnectIntent), DeleteSaved(Uuid), CancelConnect, RetryConnect, EditConnection, Disconnect, Reconnect, ResetWorkspaceLayout, CloseRequested(CloseReason), TeardownApproved(CloseReason), CloseCancelled(CloseReason), DirtyChanged(bool), SavesInFlightChanged(usize) } -#[derive(Debug)] pub enum CloseReason { Window, Quit(futures::channel::oneshot::Sender), Disconnect } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CloseDecision { Approved, Cancelled } -#[derive(Debug)] pub enum SessionWindowCommand { Attempt(AttemptOutcome), SavePersisted(Result<(), tablepro_storage::StorageError>) } -impl Component for SessionWindow { type Init = SessionWindowInit; type Input = SessionWindowInput; type Output = (); type CommandOutput = SessionWindowCommand; type Root = adw::ApplicationWindow; type Widgets = (); } -pub struct CloseGuard; -impl CloseGuard { pub async fn run(window: adw::ApplicationWindow, workspace: WorkspaceHandle) -> CloseDecision; } -pub enum CloseStep { WaitForSaves, ResolveUnknownOutcome(Vec), AskSaveChanges(Vec), Approve } -pub fn next_close_step(saves_in_flight: usize, dirty: &[DirtyTab]) -> CloseStep; -pub struct SessionTeardown; -impl SessionTeardown { pub fn detach_workspace(page: &mut SessionPage, content: &adw::ToolbarView) -> Option<(SessionClaim, Arc, Controller)>; pub fn finish(app: >k::Application, parts: (SessionClaim, Arc, Controller)); } -pub struct WindowInhibitor { app: glib::WeakRef, cookie: Option<(u32, InhibitReason)> } -#[derive(Clone, Copy, PartialEq, Eq)] pub enum InhibitReason { UnsavedChanges, SavingChanges } -impl WindowInhibitor { pub fn update(&mut self, window: &adw::ApplicationWindow, reason: Option); } -impl Drop for WindowInhibitor {} -``` - -- OpenSaved, OpenFile and ConnectFromDialog all claim first (D16), then attempts.begin() and a guarded_command attempt. -- Adoption follows D4; teardown order follows D17. -- Retry re-runs from the stored ConnectSource, which re-reads the keyring. -- Window geometry is read at init and written in the approved close path through the settings foundation's WindowGeometryStore. - -##### C14 · `Workspace component and WorkspaceHandle` - -**Location**: `crates/app/src/ui/workspace/mod.rs (+ browse.rs, row_ops.rs, structure.rs, tabs.rs, sidebar.rs, health_banner.rs, persistence.rs, workspace_actions.rs, workspace_handle.rs, dirty_tab.rs, dirty_kind.rs, pending_state.rs, save_start.rs, save_rejection.rs, structure_save_route.rs)` · **Kind**: relm4 Component · **Consumers**: SessionWindow, CloseGuard, QuitSequence, W5/W6/W7 - -``` -pub struct WorkspaceInit { pub key: SessionKey, pub session: Arc, pub services: Rc, pub window: adw::ApplicationWindow } -pub struct Workspace { key: SessionKey, session: Arc, trackers: Rc, structure_trackers: Rc, saves: Rc, tabs: Rc>>, tab_view: adw::TabView, persist: PersistDebouncer, store: WorkspaceStore, window: adw::ApplicationWindow, closed_tabs: VecDeque } -#[derive(Debug)] pub enum WorkspaceOutput { DirtyChanged(bool), SavesInFlightChanged(usize), TitleChanged { title: String, subtitle: String } } -#[derive(Debug)] pub enum WorkspaceCommand { Restored(WorkspaceLoad), TablesChanged(TableListSnapshot), Health(SessionHealth), Saved { save: SaveId, outcome: SaveOutcome }, DropTable { key: TableKey, outcome: Result<(), OperationError> }, CreateSql(Result), LayoutReset(Result) } -#[derive(Clone)] pub struct WorkspaceHandle { key: SessionKey, store: WorkspaceStore, tabs: Weak>>, trackers: Weak, structure_trackers: Weak, saves: Weak, sender: relm4::Sender } -impl WorkspaceHandle { pub fn dirty_tabs(&self) -> Vec; pub fn save(&self, tabs: &[Uuid]) -> Vec; pub fn discard(&self, tabs: &[Uuid]); pub fn reload(&self, tabs: &[Uuid]); pub fn saves(&self) -> Option>; pub async fn flush(&self) -> Result<(), WorkspaceSaveError>; } -#[derive(Debug, Clone)] pub struct DirtyTab { pub id: Uuid, pub title: String, pub kind: DirtyKind, pub state: PendingState } -#[derive(Debug, Clone, Copy)] pub enum DirtyKind { Rows, Structure } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PendingState { Unsaved, OutcomeUnknown } -#[derive(Debug)] pub enum SaveStart { Started(SaveId), NothingToSave, Rejected(SaveRejection) } -#[derive(Debug, Clone, PartialEq, Eq)] pub enum SaveRejection { OutcomeUnknown, AlreadySaving, ReadOnly, Invalid(String) } -pub struct BrowseTabInit { pub tab_id: Uuid, pub session: Arc, pub tracker: TabTrackerHandle, pub read_only: bool, pub table: TableKey, pub page_size: u64, pub initial_offset: u64, pub initial_sort: Option } -pub struct StructureTabInit { pub tab_id: Uuid, pub session: Arc, pub tracker: StructureTrackerHandle, pub table: TableKey, pub mode: StructureMode } -pub struct SqlEditorInit { pub session: Arc, pub schema_buffer: gtk::TextBuffer, pub initial_query: Option } -pub enum StructureSaveRoute { PromoteToTableTab(TableKey), CompleteInPlace(TableKey) } -pub fn route_structure_save(mode: StructureMode, table: TableKey, created: Option) -> StructureSaveRoute; -``` - -- init issues store.load(key) and restores tabs; a Blocked load shows the reset toast. -- driver_id comes from session.metadata(). -- These fields are deleted: current_driver_id, connected, in_flight_saves, close_after_save, close_window_after_save, structure_saves_in_flight, persist_pending. -- n-pages uses bind_property(...).transform_to(...).sync_create(); connect_create_tab captures #[weak] tab_view. -- shutdown removes the workspace action names from the window and disconnects the StyleManager handler. - -##### C15 · `Tracker registries and handles (instance-owned, borrow-safe)` - -**Location**: `crates/app/src/services/{change_tracker,change_tracker_registry,tab_tracker_handle,structure_tracker,structure_tracker_registry,structure_tracker_handle}.rs` · **Kind**: API removal + instance API · **Consumers**: Workspace, BrowseTab, StructureTab, grid, CloseGuard - -``` -#[derive(Clone)] pub struct TabTrackerHandle(Rc>); -impl TabTrackerHandle { pub fn cell_overlay(&self, row: &RowKey, col: usize) -> CellOverlay; pub fn row_state(&self, row: &RowKey) -> RowState; pub fn apply(&self, edit: TrackerEdit) -> TrackerDelta; pub fn undo(&self) -> Option; pub fn redo(&self) -> Option; pub fn summary(&self) -> PendingSummary; pub fn begin_save(&self) -> Result; pub fn settle_save(&self, outcome: &SaveOutcome) -> TrackerDelta; pub fn discard(&self) -> TrackerDelta; } -#[derive(Debug, Default)] pub struct ChangeTrackerRegistry { trackers: RefCell> } -impl ChangeTrackerRegistry { pub fn register(&self, tab: Uuid) -> TabTrackerHandle; pub fn unregister(&self, tab: Uuid); pub fn get(&self, tab: Uuid) -> Option; pub fn dirty(&self) -> Vec<(Uuid, PendingState)>; } -#[derive(Clone)] pub struct StructureTrackerHandle(Rc>); // same rules -#[derive(Debug, Default)] pub struct StructureTrackerRegistry { /* same shape */ } -#[derive(Clone, Default)] pub struct TabGridContext { pub tracker: Option, pub pk_col_indices: Vec } -TabChangeTracker and StructureChangeTracker gain mark_outcome_unknown(), is_outcome_unknown(); while frozen, begin_save returns Err(SaveRejection::OutcomeUnknown). -Removed: thread_local REGISTRY, with_tab, with_tab_ref, open_tab, close_tab, any_pending_globally, pending_tabs. -``` - -- The RefCell is private to the handle; each method borrows only for its body, calls no GTK and no callbacks, and returns owned data. -- Undo value semantics belong to W5 (F-074); the handle surface is FA. - -##### C16 · `SaveLedger and SaveOutcome` - -**Location**: `crates/app/src/workspace/{save_id,save_kind,save_outcome,unknown_cause,save_ledger}.rs` · **Kind**: GTK-thread struct + pure classification · **Consumers**: Workspace save dispatch, CloseGuard, WindowInhibitor - -``` -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SaveId(u64); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SaveKind { Rows, Structure } -#[derive(Debug)] pub enum SaveOutcome { Committed { warning: Option }, RolledBack(DriverError), Unknown(UnknownCause) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UnknownCause { TimedOut, ConnectionLost, Panicked, Closed, PartiallyApplied } -impl SaveOutcome { pub fn classify(result: Result, OperationError>, transactional: bool) -> Self; } -struct SaveEntry { tab: Uuid, kind: SaveKind, _hold: gio::ApplicationHoldGuard } -pub struct SaveLedger { next: Cell, entries: RefCell>, idle_waiters: RefCell>>, on_change: Box } -impl SaveLedger { pub fn new(on_change: impl Fn(usize) + 'static) -> Self; pub fn begin(&self, app: >k::Application, tab: Uuid, kind: SaveKind) -> Option; pub fn settle(&self, id: SaveId) -> Option<(Uuid, SaveKind)>; pub fn in_flight(&self) -> usize; pub fn is_saving(&self, tab: Uuid, kind: SaveKind) -> bool; pub fn when_idle(&self) -> impl Future + 'static; } -``` - -- Saves run through guarded_command_detached with OperationKind::Save and are never replayed. -- Hold guards stay effective because nothing calls g_application_quit (D10). -- settle is idempotent. - -##### C17 · `WorkspaceStore, WorkspaceLoad, WorkspaceRecord, PersistDebouncer` - -**Location**: `crates/app/src/workspace/{workspace_store,workspace_load,workspace_load_error,workspace_save_error,workspace_file_name,workspace_record,tab_record,sort_record,persist_debouncer}.rs` · **Kind**: persistence service + file schema · **Consumers**: Workspace (restore, schedule, flush, reset), SessionTeardown, W5/W6/W7 restore - -``` -#[derive(Clone)] pub struct WorkspaceStore { inner: Rc } -struct StoreInner { dir: PathBuf, spawner: Arc, writer: async_channel::Sender, blocked: RefCell> } -enum WriteJob { Put(SessionKey, WorkspaceRecord), Flush(SessionKey, futures::channel::oneshot::Sender>) } -impl WorkspaceStore { - pub fn start(dir: PathBuf, spawner: Arc) -> Self; - pub async fn load(&self, key: &SessionKey) -> WorkspaceLoad; - pub fn schedule(&self, key: &SessionKey, record: WorkspaceRecord) -> Result<(), WorkspaceSaveError>; - pub async fn flush(&self, key: &SessionKey) -> Result<(), WorkspaceSaveError>; - pub async fn reset(&self, key: &SessionKey) -> Result; - pub fn is_blocked(&self, key: &SessionKey) -> bool; -} -#[derive(Debug)] pub enum WorkspaceLoad { Restored(WorkspaceRecord), Empty, Blocked(WorkspaceLoadError) } -#[derive(Debug, thiserror::Error)] pub enum WorkspaceLoadError { #[error("io: {0}")] Io(std::io::Error), #[error("corrupt: {0}")] Corrupt(serde_json::Error), #[error("unsupported version {0}")] UnsupportedVersion(u32) } -#[derive(Debug, thiserror::Error)] pub enum WorkspaceSaveError { #[error(transparent)] Glib(glib::Error), #[error(transparent)] Encode(serde_json::Error), #[error(transparent)] Io(std::io::Error), #[error("layout file is blocked until reset")] Blocked, #[error("writer stopped")] WriterStopped } -pub fn workspace_file_name(key: &SessionKey) -> String; // .json | file-.json -#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct WorkspaceRecord { pub version: u32, pub active_tab: Option, pub tabs: Vec } -#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum TabRecord { Table { schema: Option, table: String, offset: u64, page_size: u64, sort: Option }, Structure { schema: Option, table: String }, Editor { query: String } } -#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SortRecord { pub column: String, pub ascending: bool } -impl WorkspaceRecord { pub const VERSION: u32 = 1; pub fn clamped(self) -> Self; } -pub struct PersistDebouncer { source: RefCell>, delay: Duration } -impl PersistDebouncer { pub fn new(delay: Duration) -> Self; pub fn request(&self, fire: impl FnOnce() + 'static); pub fn cancel(&self); } -``` - -- Directory: AppPaths.state_dir.join("workspaces"), per APP_ID. -- Writes: glib::file_set_contents_full(CONSISTENT|DURABLE, 0o600) via spawn_blocking. -- A load returning NotFound is Empty; any other error blocks the key. -- reset renames to .json.corrupt-. -- New-mode Structure drafts are not persisted. - -##### C18 · `NetworkWatch, ReachabilityFanout, ReachabilityProbe, ResumeDetector, QuitSequence, HistoryService` - -**Location**: `crates/app/src/runtime/{network_watch,reachability_fanout,reachability_probe,gio_reachability,clock_source,clock_sample,system_clocks,detect_resume,resume_detector}.rs; application/quit_sequence.rs; services/{history_service,history_availability}.rs` · **Kind**: services · **Consumers**: TableProApplication::startup, app.quit, HistoryDialog, SessionRegistry events - -``` -pub trait ReachabilityProbe { fn can_reach(&self, host: &str, port: u16) -> LocalBoxFuture<'static, Reachability>; } -pub struct GioReachability { monitor: gio::NetworkMonitor } // monitor.can_reach_future(&gio::NetworkAddress::new(host, port)): Ok -> Reachable, Err -> Unreachable -pub struct ReachabilityFanout { sessions: Rc, probe: Rc, stamps: RefCell> } -impl ReachabilityFanout { pub fn new(sessions: Rc, probe: Rc) -> Rc; pub fn reevaluate_all(self: &Rc); pub fn reevaluate(self: &Rc, key: &SessionKey); } -pub struct NetworkWatch { monitor: gio::NetworkMonitor, handlers: Vec, fanout: Rc, _subscription: RegistrySubscription } -impl NetworkWatch { pub fn start(monitor: gio::NetworkMonitor, fanout: Rc, sessions: &Rc) -> Rc; } -impl Drop for NetworkWatch { /* monitor.disconnect each handler */ } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ClockSample { pub boottime: Duration, pub monotonic: Duration } -pub trait ClockSource: Send + Sync + 'static { fn sample(&self) -> std::io::Result; } -pub struct SystemClocks; -pub fn detect_resume(prev: ClockSample, now: ClockSample, threshold: Duration) -> Option; -pub struct ResumeDetector { cancel: CancellationToken } -impl ResumeDetector { pub const TICK: Duration = 5 s; pub const THRESHOLD: Duration = 2 s; pub fn start(clocks: Arc, spawner: Arc, events: async_channel::Sender) -> Self; } -impl Drop for ResumeDetector { fn drop(&mut self) { self.cancel.cancel(); } } -pub struct QuitSequence; -impl QuitSequence { pub async fn run(app: TableProApplication) -> CloseDecision; } -#[derive(Debug, Clone, PartialEq, Eq)] pub enum HistoryAvailability { Starting, Ready, Failed(String) } -pub struct HistoryService { availability: watch::Receiver, cancel: CancellationToken } -impl HistoryService { pub fn start(retention_days: u32, spawner: Arc) -> Self; pub fn availability(&self) -> watch::Receiver; } -``` - -NetworkWatch: -- Connects connect_network_changed and connect_connectivity_notify, both calling fanout.reevaluate_all(). -- On Attached, it reevaluates that key; Local endpoints are skipped. - -ResumeDetector: -- Starts on LiveCountChanged 0 -> 1 and is dropped at 0. -- Uses tokio::time::interval with MissedTickBehavior::Delay inside the spawned task. -- Events are drained on the GTK thread with glib::spawn_future_local; each posts MonitorSignal::Resumed to attached sessions and calls reevaluate_all. - -QuitSequence: -- Asks each window not yet asked, in app.windows() order. -- Never calls ApplicationExt::quit. - -#### Test strategy - -- Pure unit tests (cargo test --lib, no GTK): -- PoolPressure: saturated gives Busy, otherwise Disconnected. -- DriverError classification. -- ProbeOutcome::classify. -- HealthPolicy transitions: - - Healthy and Busy stay Online; three Unresponsive become Lost. - - Lost with Unreachable becomes Offline; Rejected on a probe stays Online. - - Reconnect Rejected becomes Failed and Park; Failed plus RetryNow reconnects; Failed plus Reachable or Resumed stays parked. - - Stale-generation ConnectionLost, OperationPanicked and OperationSucceeded are ignored; OperationSucceeded at the current generation from Reconnecting becomes Online. - - Backoff doubles and caps at 60 s; Offline rechecks every 60 s. -- MonitorMailbox merge keeps the max generation and never drops RetryNow. -- ConnectAttempts; SessionRegistry claim, release and live count; SessionKey::for_file; NetworkEndpoint::for_request (loopback, '.', unix socket, file driver, ssh bastion). -- RequestSequencer; detect_resume; next_close_step; route_structure_save; SaveOutcome::classify including PartiallyApplied. -- WorkspaceRecord serde, clamp and version refusal; workspace_file_name; TaskPanic::from_payload; OperationKind::is_replayable. -- Async tests with #[tokio::test(start_paused = true)], TokioSpawner(Handle::current()) and test-support FakeDriver and FakeConnection: -- Leases survive link replace. -- An operation failing on a retired transport after replace does not trigger another reconnect. -- Signals posted before a successful reconnect are discarded. -- A replayable Page re-runs once on the new generation; Save does not. -- The monitor reconnects on ConnectionLost and publishes transitions with exact paused-time backoff. -- The reconnect path calls CredentialSource::refresh each attempt. -- begin_shutdown closes exactly once and reports AlreadyClosed on a second call; a hanging close reports Abandoned at the Close deadline. -- Dropping the last Arc ends the monitor task. -- SchemaCatalog: 10 concurrent tables() calls make one list_tables call; refresh during a fetch reruns once; a failed refresh keeps the tables. -- A cancelled establish returns Cancelled and closes a late connection; PendingSession::discard closes. -- WorkspaceStore writer coalesces. -- Driver integration tests (testcontainers): -- PostgreSQL pg_terminate_backend: next run is Disconnected, monitor reconnects, next query succeeds. -- Four pg_sleep queries saturating the pool: a fifth operation returns Busy while probe returns Ok, so HealthPolicy stays Online. -- max_connections exhausted on the server: probe returns Busy. -- Container stopped: probe classifies Lost; restarted: reconnect succeeds. -- MySQL saturation and probe equivalents. -- MSSQL: probe while a long query holds the client lock succeeds through the probe client; close then query returns Disconnected. -- GTK tests (#[gtk::test], xvfb-run -a dbus-run-session in build-linux.yml): -- Session::start and adoption on the main thread with no entered tokio runtime. -- Disconnect runs SessionTeardown: workspace root removed from the ToolbarView, FakeConnection close count is 1, the claim is released and the same key can be claimed again immediately. -- Workspace drop leaves no live TabView or SourceView WeakRef. -- CloseGuard via window.visible_dialog(): Cancel keeps the window; Discard closes; a RolledBack save re-presents; an in-flight save shows the waiting dialog, which force-closes on settle. -- An OutcomeUnknown tab shows the unknown dialog; Reload cancels the close; execute_in_transaction count stays 1. -- WindowInhibitor cookie state follows dirty and saving. -- Claiming an open key presents the owning window. -- Window actions exist on the ApplicationWindow's own action map (lookup_action) and no extra 'win' group is inserted. -- Tracker edit during rebind shows the pending value. -- PersistDebouncer with a 20 ms injected delay fires once after the last request, iterating MainContext with a 2 s wall bound. -- Application process tests (crates/app/tests, harness = false): -- application_uniqueness.rs, on a gio::TestDBus bus with XDG dirs in tempdirs: - - The primary is a CARGO_BIN_EXE_tablepro-app subprocess, ready when NameHasOwner(APP_ID). - - A second plain launch exits 0 and the window count stays 1 (count read by introspecting /window). - - --new-window makes it 2; a sqlite tempfile argument makes it 3 with a Connecting page. - - The same file again keeps it 3. -- In-process: handle_local_options with new-window on the primary returns Continue. -- application_lifecycle.rs: an in-process TableProApplication with FakeDriver. - - Quit with a 300 ms FakeConnection close: close is complete when run() returns, and ApplicationExt::quit is never used. - - Quit with a hanging close and a 50 ms Close deadline: run() returns and close is recorded abandoned. - - Quit with one window's close guard cancelled: the app keeps running. -- Static gates: -- cargo clippy --all-targets -D warnings with crates/app/clippy.toml disallowed-methods. -- CI grep: no thread_local! under crates/app/src/services. -- CI grep: no insert_action_group("win". -- cargo fmt --check. - -#### Risks - -- Skipping RelmApp::run means relm4's RUNTIME is never entered on the GTK thread. Any GTK-thread tokio::time, IO or Handle::current call panics at runtime. Mitigation: - All tokio-context work goes through TaskSpawner or GuardedCommand (D15), enforced by the spawn bans. -- Session::start, ConnectionLink and the mailbox use only runtime-free primitives. -- A #[gtk::test] starts a Session on the main thread without a runtime. -- The MSSQL Handle::current runs inside establish, which always runs in a spawned future. -- gio can_reach can return false negatives, for example DNS unavailable during a network transition or a monitor backend without route data. Mitigation: - Offline is entered only after a real loss, rechecks with a reconnect every 60 s, and clears on any successful operation or RetryNow. -- Loopback and file endpoints are never gated. -- The out-of-pool probe opens a fresh database session when the pool is saturated. Mitigation: - It happens only when try_acquire finds no idle connection, at most once per 30 s probe. -- It is closed immediately. -- A server refusal for too many connections classifies as Busy, never Lost. -- A close that exceeds the Close deadline is abandoned, leaving server-side sessions until TCP timeout. Mitigation: - Cancelling the session token first releases leases, so pool close normally finishes quickly. -- Abandoned is logged. -- Keepalive and TCP_USER_TIMEOUT belong to the network-loss workstream. -- MSSQL has no server-side cancel in tiberius, so a superseded Page or Count keeps the single client busy. Mitigation: - The cancel-on-drop contract marks the client for replacement (C6 and D8, network-loss workstream). -- The probe bypasses the held lock, so liveness is never hidden. -- GApplication falls back to non-unique without a session bus (verified in gapplicationimpl-dbus.c), so two processes could write the same workspace file. Mitigation: - Writes are atomic and per key (file_set_contents_full CONSISTENT), so the worst case is last-writer-wins on tab layout, never corruption. -- Profiles use separate directories. -- Steps 5 and 11 are large atomic commits. Mitigation: - Required by the API changes. -- git mv inside the commit, full tests in the same step, unrelated churn kept in steps 6-10. -- catch_unwind can leave driver state inconsistent after a panic. Mitigation: - OperationPanicked forces an immediate probe. -- An MSSQL client corrupted mid-stream fails the probe and is replaced. -- sqlx drops the panicking operation's pooled connection. -- Accelerator conflicts: n with grid Set NULL, and slash with the editor comment toggle. Mitigation: - C2 reserves the app accels; W4/W5 move Set NULL and drop the alias in step 11. -- A unit test checks for collisions. -- GtkApplication::inhibit returns 0 without a session manager or portal. Mitigation: A cookie of 0 counts as not inhibited, and the close guard remains the primary protection. -- Step 11 depends on the build foundation's build.rs (glib-build-tools needs glib-compile-resources at build time). Mitigation: - The build foundation lands before step 11. -- resources_register_include! fails compilation without the bundle, which enforces the order. -- CI and the Flatpak manifest already install GLib dev tools. -- Process-level application tests need X and a D-Bus daemon and may be slow. Mitigation: - They run only under xvfb-run -a dbus-run-session in CI with harness = false. -- Each scenario has explicit readiness polling (NameHasOwner, introspection) with bounded timeouts, never fixed sleeps. - -### FB · Value model, column kinds and SQL dialect contracts - -Revised foundation FB. It replaces the lossy data layer with typed contracts in tablepro-core; engine behaviour lives in the driver crates. Every blocker and major issue from the review is resolved, and so are the minor points. Two review points are resolved differently from what was asked: the ClickHouse invalid-UTF-8 key (D12) and part of the classification scope (D8). Each decision explains why. - -1. **Value model** (crates/core/src/value/). A lossless `Value` with exact signed and unsigned integers and bounded string-backed decimals (NaN, +/-Infinity, allocation checked before parse). f32 and f64 stay separate. Temporals are infinity-aware; time covers 24:00:00 and MySQL durations. `OffsetTimestamp` keeps the offset in equality and hash. JSON text is validated. `Other` and `Undecodable` are always distinct from NULL. -2. **Column metadata** (crates/core/src/column/). Each driver classifies columns from its catalog, not from type text: - - PG: pg_type/pg_enum. - - MySQL: information_schema columns. - - MSSQL: sys.types. - - SQLite: affinity rules plus STRICT. - - ClickHouse: the official `clickhouse-types` parser. - - `ColumnInfo` drives browse tabs. `ResultColumn` is used only for editor results. `ColumnType.read_form` says which columns the server converts to text or numeric before sending. -3. **Bound statements** (crates/core/src/statement/). `Statement { sql, params: Vec }` records the encoding decision (`ParamType`) once, when the dialect renders the marker. Drivers encode exactly that type, so NULLs are typed on PG and MSSQL. A Value that does not fit its column is a `BindError`. -4. **SqlDialect/DdlDialect** (crates/core/src/dialect/, implemented in each driver crate). The dialect is reached through `Connection::dialect()` and built only from properties a session cannot change (server version, flavor). It owns identifier quoting, placeholders, projections, sort expressions, row windows, key probes, LIKE forms, clipboard literals that parse the same under every session mode, and DDL. No `driver_id` string match remains. -5. **Core builders**: - - `browse::build_page`/`build_count` replace `Connection::fetch_rows` and the hand-built SQL in browse.rs. - - `dml` builds INSERT/UPDATE/DELETE and key probes. - - `save::plan_save` gives each UPDATE/DELETE a row guard: exactly one affected row where the engine's counts are exact and its keys unique, otherwise a count probe. A failed guard refuses the whole save. ClickHouse runs all probes before any mutation. -6. **Edit and export**. Edit text is lossless and depends on the edit context (insert draft or row update). Export keeps full precision; CSV formula neutralisation applies only to text values. - -**Facts that shaped the revision** (all checked against on-disk sources or upstream docs): -- sqlx `Option::None` produces `T::type_info()`, and `PgArguments::add` uses `produces()` (sqlx-core encode.rs, sqlx-postgres arguments.rs). `PgTypeInfo::with_oid` is public, and `resolve_type_id` passes a declared OID straight into Parse. -- chrono `DateTime` equality and hash ignore the offset; `FixedOffset` derives PartialEq, Eq and Hash. -- sqlx-mysql negotiates CLIENT_FOUND_ROWS, so rows_affected counts matched rows. -- tiberius sums every Done/DoneInProc count, and SQL Server adds trigger row counts unless the trigger sets NOCOUNT. MSSQL counts are therefore not exact. -- tiberius money decodes to f64 and UDT/sql_variant metadata hits `todo!`. `TypeInfo` sits under a private `mod tds`, so adding a variant is internal. `to_sql!` implements ToSql for `Option`. -- sqlparser 0.63.0 was published 2026-09-13. It has Enum/Set/unsigned/Datetime64 but no Year, Money, Geometry or Point. `Expr::Value` holds `ValueWithSpan`, and `Number` is `(String, bool)`. -- `clickhouse-types` 0.1.2 (MIT/Apache, already in the tree through clickhouse 0.15.1) parses types into `DataTypeNode`. -- ClickHouse forces UTF-8 validation only for the JSON, JSONCompact and JSONColumnsWithMetadata formats (PR #40910); the EachRow formats follow `output_format_json_validate_utf8`. serde_json `deserialize_bytes` accepts invalid UTF-8. ClickHouse docs say query parameters are "primarily designed to work in SELECT statements". -- PG `E''` strings process backslashes under both standard_conforming_strings settings. -- MySQL accepts `[_charset] X'hex'`. -- In PG, a qualified name in ORDER BY always refers to an input column. -- SQLite affinity rules come from datatype3 §3.1, and `PRAGMA table_list` reports `strict` from 3.37.0. - -**Migration**: 17 commits, each one compiling and passing tests, in dependency order. - -#### Decisions - -##### D1 · Value and column metadata are in-memory transport types and are never persisted - -Value, ColumnInfo, ResultColumn, ColumnKind, ColumnType, ColumnDefault, Statement and BoundParam get no Serialize/Deserialize derives. - -FilterSet keeps persisting only the text the user typed (FilterValue strings). `filter::build_filter` re-parses that text against the live ColumnType on every build, so a schema change can never bind a stale typed value. - -WorkspaceTabRecord (crates/app/src/services/workspace_state.rs) stores names, offsets and sort column names only. Drafts and pending edits are not persisted. - -**Why**: grep shows no persisted Value or ColumnInfo. storage has no Value use, and workspace_state stores names, offsets and sort. An unused serde derive on a type whose shape is changing is an unversioned file format waiting to happen. - -**Rejected**: A tagged, versioned serde schema for Value: pays for a format nobody reads. Persisting typed filter values: they go stale when column types change. - -##### D2 · Decimal is a string-backed, validated SqlDecimal with NaN, +/-Infinity and allocation-bounded parsing - -`Value::Decimal(SqlDecimal)` holds a sign, a canonical ASCII coefficient and a scale (trailing zeros kept), or NaN, Infinity or -Infinity. The limits follow PostgreSQL: `MAX_INTEGER_DIGITS = 131_072` and `MAX_SCALE = 16_383`. - -`FromStr` computes the final digit counts before allocating anything: -- The exponent text is parsed as i64 with checked arithmetic. More than 18 exponent digits is `ExponentOutOfRange`. -- `integer_digits = significant_integer_len + max(0, exp - frac_len)` and `scale = max(0, frac_len - exp)` are computed with `checked_add`/`checked_sub`. -- The input is rejected with `TooManyDigits` when either bound is exceeded. - -Drivers convert at the boundary: -- PG: driver-owned base-10000 codec over `PgValueRef::as_bytes`, bound under NUMERIC OID 1700. -- MySQL: a newtype that delegates decode/encode to `<&str as Decode/Encode>`, typed NEWDECIMAL. -- MSSQL: `tiberius::numeric::Numeric::new_with_scale(i128, u8)` and `.value()/.scale()`. -- ClickHouse: the exact JSON number token, rendered back into `CAST('' AS )`. -- SQLite: storage classes. - -**Why**: PG numeric allows 131072 integer digits, 16383 fraction digits, NaN and Infinity. MySQL allows 65 digits and ClickHouse Decimal256 76. rust_decimal stops at 28 digits of scale. - -Why not bigdecimal: sqlx's PgNumeric decode rejects NaN, it is not vendored on disk, and tiberius Numeric is i128 either way. - -A canonical string gives exact export text, cheap Eq/Hash for row identity and raw JSON number tokens. - -The review showed that expanding the exponent before bounding it allows `1e2147483647` to allocate gigabytes. Bounding the arithmetic first removes that. - -**Rejected**: rust_decimal (28-digit cap). bigdecimal (no NaN/Infinity, exponent Display). serde_json arbitrary_precision (changes Number semantics crate-wide). Checking length after expansion (the DoS the review found). - -##### D3 · Integers: Int(i64), UInt(u64), WideInt(WideInteger) - -Mapping: -- Signed engine integers up to 64 bits map to `Int`. -- Unsigned integers from 8 to 32 bits map to `Int`, which is exact. -- MySQL BIGINT UNSIGNED, ClickHouse UInt64 and PG oid (as a 32-bit unsigned kind) map to `UInt`, except that oid stays `Int` since it fits. -- ClickHouse Int128/UInt128/Int256/UInt256 map to `WideInt`. - -`IntegerKind { width, signed }` carries exact bounds for edit range checks and ParamType selection. - -**Why**: Laravel and WordPress ids are BIGINT UNSIGNED. UInt128 does not fit in i128. One string-backed wide type covers 128 and 256 bits without a bignum dependency. - -**Rejected**: Int(i128) only: cannot hold UInt128 or 256-bit values. The current text fallback: loses the kind and breaks binding. - -##### D4 · Float32 and Float64 are separate, and non-finite values are first-class - -`Value::Float32(f32)` and `Value::Float64(f64)`. -- Text uses Rust's shortest round-trip Display. -- NaN, +inf and -inf render as "NaN", "Infinity" and "-Infinity". -- Parsing accepts NaN, Infinity, -Infinity, inf, +inf and -inf, case-insensitive. -- ClickHouse queries set `output_format_json_quote_denormals=1`, and floats are parsed from raw tokens with `str::parse`. - -**Why**: Widening f32 to f64 displays 0.10000000149011612. ClickHouse emits null for NaN by default (documented default 0). serde_json's default float parsing does not guarantee a round trip. - -**Rejected**: A single Float(f64): corrupts real/FLOAT display. serde_json float_roundtrip: still loses f32 identity and slows every parse. - -##### D5 · Temporals: Temporal for infinity, SqlTime for extended time, OffsetTimestamp with offset-sensitive identity, unrepresentable dates as Other - -Variants: -- `Value::Date(Temporal)` -- `Value::Timestamp(Temporal)` -- `Value::TimestampTz(Temporal)` -- `Value::Time(SqlTime)` -- `Value::TimeTz(TimeWithOffset)` - -`OffsetTimestamp { utc: NaiveDateTime, offset: FixedOffset }` derives PartialEq, Eq and Hash over both fields, so `10:00+05:30` and `04:30+00:00` are distinct values. `TimeWithOffset { time: SqlTime, offset: FixedOffset }` derives the same way. - -Precision comes from `ColumnKind::{Time,Timestamp}.fractional_digits`. - -PG binary date and timestamp values are decoded by the driver from i32/i64. The INT_MAX/INT_MIN sentinels mean infinity, and `checked_add_signed` is used throughout; overflow becomes Undecodable(OutOfRange). - -MySQL DATE/DATETIME/TIMESTAMP values are decoded by the driver from raw bytes in both wire formats: -- Binary (length 0/4/7/11): fields are read manually. -- Text: `YYYY-MM-DD[ hh:mm:ss[.ffffff]]` is parsed. -- A value chrono cannot represent (zero date, month 0, day 0, day 31 in a 30-day month) becomes `Other { type_name, text }` carrying the server's text, or text rebuilt from the binary fields in the server's text format. - -**Why**: sqlx-postgres date and timestamp decode computes epoch + TimeDelta, and chrono's Add calls `expect`, so PG 'infinity' panics the task. - -chrono `DateTime` PartialEq and Hash compare only the UTC instant (chrono-0.4.44 datetime/mod.rs:1472-1516). Storing `DateTime` would make an offset-only edit to an MSSQL datetimeoffset look unchanged, so the edit would be dropped (review). - -sqlx-mysql `decode_date` handles only a zero-length binary value (chrono.rs:279-295). A text-protocol '0000-00-00' or a binary '2024-00-15' fails there and would surface as an error. - -**Rejected**: Storing `DateTime` directly: identity ignores the offset. Handling zero dates only through the binary length byte: misses the text protocol and partial zero dates. - -##### D6 · JSON is validated server text - -`Value::Json(JsonText)` is text validated once with `serde_json::from_str::`. - -Export embeds it verbatim through `serde_json::value::RawValue::from_string`, and the workspace serde_json gains the `raw_value` feature. Editing validates the same way. - -**Why**: `serde_json::Value` without arbitrary_precision rounds large numbers. PG json keeps whitespace and duplicate keys. Raw text is the only lossless form. - -**Rejected**: serde_json::Value: lossy numbers. Storing Box: no PartialEq. - -##### D7 · Other and Undecodable are distinct from Null, and a decode failure never yields Null - -`Other(OtherValue { type_name, text })`: -- The server's exact text for types without a typed variant (ranges, composites, hierarchyid text, ClickHouse Map/Tuple token text). -- It binds as `ParamType::ServerText` where the column's kind allows editing. - -`Undecodable(UndecodedValue { type_name, reason })`: -- Renders as a placeholder. -- `BoundParam::new` and `SqlDialect::literal` reject it with an explicit error. -- Copy, IN and INSERT export report it. -- A primary key containing it makes the row non-editable (`RowKey::from_pk_values` returns None). - -**Why**: F-006: turning a decode failure into NULL produced `WHERE id IS NULL` saves that matched zero rows. - -**Rejected**: Text fallback for everything: binding TEXT into an enum column fails on PG. A single Unknown variant: cannot tell editable text from undecodable bytes. - -##### D8 · Columns are classified from each engine's structured catalog; sqlparser only where text is the sole source - -Catalog classification lives in each driver's `catalog.rs`: -- **PostgreSQL**: pg_attribute/pg_type. It selects `atttypid`, `atttypmod`, `attnotnull`, `format_type(atttypid, atttypmod)` and `format_type(atttypid, NULL)`. A recursive CTE resolves domain chains through `typbasetype` and `typtypmod`, giving the base type's `oid`, `typtype`, `typcategory` and `typelem`, and the element type's base for arrays. Enum labels come from `ARRAY(SELECT enumlabel FROM pg_enum WHERE enumtypid = ORDER BY enumsortorder)`. The driver decodes typmods with PostgreSQL's encodings: varchar/bpchar `typmod-4`; numeric precision `((typmod-4)>>16)&0xffff` and scale `((((typmod-4)&0x7ff)^1024)-1024)`; time/timestamp precision = typmod; bit/varbit length = typmod. typtype 'e' is Enum{labels}; 'r', 'm' and 'c' are Other; base types map by OID. -- **MySQL/MariaDB**: information_schema.columns. It reads `DATA_TYPE`, `NUMERIC_PRECISION`, `NUMERIC_SCALE`, `DATETIME_PRECISION`, `CHARACTER_MAXIMUM_LENGTH`, `CHARACTER_SET_NAME` (NULL means a binary type) and `EXTRA`. `COLUMN_TYPE` is parsed with sqlparser `MySqlDialect` `parse_data_type` only for what exists nowhere else: the unsigned and zerofill flags, `tinyint(1)` (`DataType::TinyInt(Some(1))`), and ENUM/SET members (`DataType::Enum(Vec, _)`, `DataType::Set(Vec)`). YEAR, BIT (width = NUMERIC_PRECISION) and the geometry family come from DATA_TYPE. -- **SQL Server**: sys.columns joined to sys.types. It reads `TYPE_NAME(c.system_type_id)`, `ty.is_user_defined`, `ty.is_assembly_type`, `ty.name`, `c.max_length`, `c.precision` and `c.scale`. Alias types map through their system base type. Built-in assembly types map as: hierarchyid is Other with read_form ServerText; geometry and geography are Spatial; user CLR types are Other with read_form Native. -- **SQLite**: `PRAGMA table_xinfo` declared type classified by the datatype3 §3.1 affinity rules (INT → INTEGER; CHAR/CLOB/TEXT → TEXT; BLOB or empty → BLOB; REAL/FLOA/DOUB → REAL; otherwise NUMERIC). `PRAGMA table_list(t).strict` sets `dynamic_storage = !strict`; before 3.37.0 no table can be STRICT, so all are dynamic. A hint table on the declared type's first word (BOOLEAN/BOOL → Bool(Integer); DATE → Date; DATETIME/TIMESTAMP → Timestamp; TIME → Time) refines the editor kind only. Values are always decoded by storage class. -- **ClickHouse**: `system.columns.type` and result type headers go through `clickhouse_types::data_types::DataTypeNode::new`. Nullable and LowCardinality are unwrapped, and a parse error maps to Other. - -Result-set classification (editor results only): -- PG: `PgTypeInfo::oid()` plus `kind()`, including `PgTypeKind::Enum(labels)` and `Domain(base)`. -- MySQL: `TypeInfo::name()`. -- tiberius: `ColumnType`. -- ClickHouse: the type header through clickhouse-types. -- SQLite: storage class. - -Authority rule: a browse tab's grid, filter strip and editors use `ColumnInfo.column_type` from `fetch_columns`. The page result's columns are checked to have the same count and names, and a mismatch triggers a column refetch. `ResultColumn` is used only for SQL editor results. - -Deleted: core `filter::classify`, grid `is_bool_type`/`is_int_type`/`is_float_type`/`is_json_type`/`is_date_type`/`is_bytes_type`/`classify_editor_kind`/`column_accepts_empty` (grid.rs:701-873), browse_tab `classify_type`/`TypeKind` (browse_tab.rs:2623-2642), and filter_strip `operators_for(&str)` (filter_strip.rs:71). - -**Why**: The review is correct: -- `format_type` text cannot distinguish enum from domain from composite from range. They all parse as `DataType::Custom`, so PG enums would get text operators and `UnknownEnumLabel` could never be reached. -- sqlparser 0.63 has no Year, Money, Geometry or Point variant (verified in data_type.rs at v0.63.0), so those would fall back to name matching. -- The original design's SQLite affinity rule contradicted D8. - -Structured catalog columns are authoritative and version-stable. clickhouse-types is ClickHouse's own client type parser, already resolved through clickhouse 0.15.1. - -For browse tabs, catalog metadata is the only source that knows nullability, primary keys and enum labels. - -**Rejected**: Re-parsing catalog type text with sqlparser (the original design): loses typtype, has no Year/Money/Geometry variants, and depends on a release published the day before. A shared string classifier in core (F-054). Merging catalog and result-set kinds heuristically: two sources of truth. - -##### D9 · The dialect is owned by its connection and depends only on server-invariant properties - -`Connection::dialect(&self) -> Arc` is a required method. The driver builds the dialect at connect time from properties that cannot change within a server session: -- PG: `server_version_num`. -- MySQL: `VERSION()` and the MariaDB flavor. -- SQLite: `sqlite_version()`. -- MSSQL: `SERVERPROPERTY('ProductMajorVersion')` read as a CAST to nvarchar. -- ClickHouse: `version()`. - -No literal, marker or capability decision reads session settings (standard_conforming_strings, sql_mode, lc_monetary, time_zone). - -`DatabaseDriver` loses `ddl_is_transactional` and `reports_rows_affected`. `ReadOnlyConnection::dialect` returns `self.inner.dialect()`. - -The app never stores a dialect. Every operation takes a fresh `Arc` from the owning window session at the moment it runs and calls `.dialect()` on it, so connection and dialect cannot diverge after a reconnect. - -**Why**: The review showed two problems: -- A pool of 4 (postgres lib.rs:48, mysql lib.rs:44) means a probed session trait describes one pooled session, not all four. -- Clipboard SQL leaves the session entirely. - -D22 makes literals mode-independent, D11 makes binding session-independent, and PG money is read through a `::numeric` projection, which removes lc_monetary. After that, nothing needs session traits. - -Ownership through the connection makes the review's reconnect-staleness issue impossible by construction: `swap_connection` (connection_monitor.rs:86-92) replaces the connection, and the dialect comes with it. - -**Rejected**: Probing session traits at connect (original): stale across the pool and wrong for pasted SQL. Storing `Arc` in tabs or in EntryInner next to the connection (original C18): two fields that can drift, and tabs outliving a reconnect. A static per-driver dialect: cannot see the server version. - -##### D10 · Engine SQL generation lives in the driver crates; core keeps engine-agnostic structure - -Core keeps: -- statement/ (Statement, BoundParam, ParamType, StatementBuilder, GuardedStatement) -- browse/ (build_page, build_count) -- dml/ (insert, update, delete, key probe) -- save/ (plan_save) -- filter/ -- ddl/ (StructureOp, DraftColumn, diff_to_ops, materialize_ops) -- sql_syntax/ - -Each driver crate implements `SqlDialect` in src/dialect.rs, `DdlDialect` in src/ddl.rs, catalog classification in src/catalog.rs, default classification in `catalog::classify_default` (driver-private), decode in src/decode.rs and encode in src/encode.rs. - -The engine branches in sql_dialect.rs, filter.rs:258 and sql_ddl.rs move to the drivers together with their tests. - -**Why**: Removes every `match driver_id`. Pure SQL-output tests run under `cargo test --lib` in each driver without a database. Default classification is only ever called by the driver's own catalog code, so it is not part of the trait. - -**Rejected**: Core builders driven by strategy enums: engine knowledge stays centralised in core. Keeping sql_ddl.rs behind capability queries: MSSQL batches and MySQL MODIFY still need engine code. - -##### D11 · The encoding decision is made once, when the dialect renders the marker, and carried to the binder in BoundParam - -`SqlDialect::placeholder(ordinal, value, target) -> Result` returns the marker SQL and an optional `BoundParam { value, param_type: ParamType }`. - -Every `Connection` bound-SQL method takes `&Statement`, whose params are `Vec`. The driver encodes each param strictly from its `ParamType` and never re-infers from the Value. - -`BoundParam::new` rejects a Value that `ParamType::accepts` does not allow (`BindError::ValueTypeMismatch`) and every `Undecodable` value. - -Which engines derive the ParamType from the column kind: -- PostgreSQL, MySQL and SQL Server derive it from `ColumnKind` (plus catalog for ServerText), for NULL too. -- SQLite with dynamic_storage derives it from the Value. -- ClickHouse emits no params (D12). - -PostgreSQL details: -- The binder is one driver newtype, `PgParam<'a>(&'a BoundParam)`, implementing `Encode<'_, Postgres>`. -- `produces()` returns `Some(PgTypeInfo::with_oid(oid))` for every ParamType, NULL included. -- `encode_by_ref` delegates to the sqlx encoders (bool, i16, i32, i64, f32, f64, &str, &[u8], Uuid, PgInterval) or to driver codecs (numeric_wire, date i32, timestamp i64, time/timetz, json, jsonb version byte), and returns `IsNull::Yes` for Null. -- `Text { length: Fixed }` maps to ParamType::FixedText (bpchar OID 1042) with a bare `$n`, so comparison uses bpchar's pad-insensitive semantics. -- Money maps to ParamType::Decimal with marker `$n::money`. -- Enum, range, network, xml, bit, array and Other kinds map to `ServerText(cast_target)` with marker `$n::`, where cast_target is `format_type(atttypid, NULL)` (and `varbit` for bit kinds). -- Infinite temporals and 24:00:00 map to ServerText with the text `infinity`/`-infinity`/`24:00:00`. - -SQL Server details: -- NULLs bind as a typed `Option::None` for the kind's tiberius type. `to_sql!` generates `ToSql for Option<$ty>` (tiberius macros.rs:50), so a varbinary NULL is typed varbinary rather than nvarchar. - -**Why**: Review (major): the old binder saw only `Value::Null` and bound `Option::<&str>::None`. sqlx then typed it TEXT (sqlx-core encode.rs:93-98; sqlx-postgres arguments.rs:88), so PG rejected `SET int_col = $1` with 42804. - -Deciding once and carrying the decision makes marker/binder drift structurally impossible. - -The explicit OID reaches Parse unchanged (`resolve_type_id` returns `try_oid()`, describe.rs:350-353). - -On SQL Server, an nvarchar NULL into varbinary fails implicit conversion, so typed NULLs are needed there too. - -A Value/kind mismatch is a bug and must surface as a BindError, never as a bare marker. - -**Rejected**: Recomputing the encoding in the binder from the Value alone (original): typed NULL impossible, drift on mismatch. Unspecified OID 0 for NULLs: fails wherever PG cannot infer a type (42P18). Casting every param to the full typmod: silent truncation. - -##### D12 · ClickHouse: typed JSON cell decoding, exact UTF-8 handling, CAST literals for writes - -Reads: -- Keep `JSONCompactEachRowWithNamesAndTypes`. Every query sets `output_format_json_quote_denormals=1` and `output_format_json_validate_utf8=0`. -- Column types are parsed once with `clickhouse_types::data_types::DataTypeNode::new`. -- Each row line is decoded with `serde_json::Deserializer::from_slice` and a `RowSeed` that visits the array with one type-directed `CellSeed` per column: - - String, FixedString and Enum use `deserialize_bytes`; `String::from_utf8` failure gives `Value::Bytes`. - - Number kinds use `&serde_json::value::RawValue` and parse the token, quoted or bare. - - Nullable uses `deserialize_option`. - - Array recurses into `Value::Array`. - - Map, Tuple and named Tuple recurse through key and value seeds and produce `Other` with canonical JSON token text; if any leaf is not UTF-8 the cell becomes `Undecodable(InvalidEncoding)`. - - Variant, Dynamic, JSON, Object, AggregateFunction and geo types use `&RawValue`, producing `Other`. -- If a line fails to parse, it is decoded again once per Variant/Dynamic/JSON/AggregateFunction column. Each pass decodes that one column with its RawValue seed and skips the others with `serde::de::IgnoredAny`; serde_json's ignore path does not validate UTF-8. A column that still fails becomes `Undecodable(InvalidEncoding)`. - -Writes and filters: -- The ClickHouse placeholder is `CAST( AS )` and emits no BoundParam. -- `` is: - - `''` with `\`, `'`, backtick, tab and newline backslash-escaped (the clickhouse crate's `escape::escape` set); - - `unhex('')` for Bytes and for any text containing another control character; - - `NULL` for Null. -- LIKE patterns are rendered as `''`. -- `bind_placeholders`, `literal` and the `?`/`$n` scanner are deleted. `escape_bind_markers` stays because the crate treats `?` in its template as a marker. - -Invalid-UTF-8 String keys stay editable: the key binds as `CAST(unhex('') AS )`, which matches the stored bytes exactly. - -**Why**: Review (minor, point 3) is correct that `output_format_json_validate_utf8=1` replaces invalid sequences. PR #40910 shows the EachRow formats obey the setting, while JSON/JSONCompact always validate. serde_json documents that `deserialize_bytes` succeeds on non-UTF-8 strings, so bytes reach the driver intact. - -The review asked for read-only rows when a key holds invalid UTF-8. That is not needed, because `unhex` binds the exact bytes. Read-only would needlessly block editing correct rows. - -Server-side `{pN:Type}` parameters (original design) are replaced for a verified reason: ClickHouse's syntax docs say query parameters "are primarily designed to work in SELECT statements", and support in `ALTER TABLE … UPDATE` mutations is not documented. CAST of a string literal is valid in every statement kind. It parses the exact server text (a DateTime64 in the column's own time zone, a Decimal(76,20) digit for digit), which is what fixes F-017. No type is guessed, because the declaration is `system.columns.type`. - -**Rejected**: `output_format_json_validate_utf8=1` (lossy, undetectable). RowBinaryWithNamesAndTypes: exact bytes, but DateTime values arrive as UTC ticks that cannot be shown in the column's named zone without a bundled tz database that may disagree with the server. A hand-written JSON byte tokenizer: duplicates serde_json. `{pN:Type}` query parameters in mutations: undocumented. - -##### D13 · LIKE and case-insensitive matching are owned by the dialect - -Escaped patterns (Contains, StartsWith, EndsWith): -- PG, MySQL, SQLite and MSSQL prefix `!`, `%` and `_` with `!` (MSSQL also `[`) and emit `LIKE ESCAPE '!'`. -- ClickHouse backslash-escapes `\`, `%` and `_` inside the pattern literal and emits no ESCAPE clause. - -Case-insensitive (LikeCase::Insensitive): -- PG and ClickHouse use native ILIKE. -- MySQL, SQLite and MSSQL use `LOWER(col) LIKE LOWER() ESCAPE '!'`. - -Other rules: -- LikeCase::EngineDefault follows the engine's collation, and the UI stops claiming it is case-sensitive. -- Raw Like/NotLike pass the user's pattern with no escaping and no ESCAPE clause. - -**Why**: F-022 and F-054: backslash is not the default LIKE escape on SQLite or T-SQL. `!` avoids backslash ambiguity in every string-literal mode. ClickHouse LIKE uses backslash escapes, and its ESCAPE clause only arrived in 2026 (issue #99599). - -**Rejected**: ESCAPE '\\' everywhere: mode-dependent on PG and MySQL. SQLite GLOB: a different wildcard grammar. - -##### D14 · sqlparser 0.63 validates user-typed DDL and classifies catalog default text; no step waits on an upstream release - -Dependency: workspace `sqlparser = { version = "0.63", default-features = false, features = ["std"] }`. This drops `recursive-protection`/`recursive`; 0.63 enforces its parser recursion limit in std builds. - -sqlparser is used for exactly three things: -1. `SqlDialect::parse_type` validates a user-typed type in the structure editor and maps the parsed `DataType` to a draft ColumnKind. For ClickHouse, the kind comes from `DataTypeNode::new` on the same validated text, falling back to Other. -2. `SqlDialect::parse_expression` validates user-typed default expressions. -3. Driver-private `catalog::classify_default` parses catalog default text: pg_get_expr, sys.default_constraints.definition, SQLite dflt_value, MariaDB quoted COLUMN_DEFAULT, and ClickHouse default_expression. - -It also parses MySQL COLUMN_TYPE for unsigned, zerofill and ENUM/SET members (D8). - -`tablepro_core::sql_syntax` builds `Parser::new(dialect).try_with_sql(text)`, calls `parse_data_type()`/`parse_expr()`, and requires `peek_token_ref().token == Token::EOF`. Input is capped at 200 chars for types and 500 for defaults. - -Validated text is spliced exactly as typed, never re-rendered from the AST. An unchanged existing column never re-parses its type, because its `DraftColumn.type_text` is its trusted `CatalogType.declaration`. Only edited or new types are validated. - -Each engine's `DdlDialect::type_suggestions()` list is part of the acceptance corpus. A spelling 0.63 rejects is excluded from the suggestions. A user who types such a spelling gets the parser's error inline in the AdwEntryRow (`add_css_class("error")` plus the message), never at Save. - -**Why**: F-117: substring blacklists are neither safe nor complete. The parser's security role is "exactly one type or expression and nothing else", which the tokenizer plus the EOF check guarantees for any grammar shape 0.63 knows. Unknown type names parse as `DataType::Custom(ObjectName, Vec)`. - -Review (major, point 5): 0.63.0 was published 2026-09-13, so blocking step 8 on an upstream fix could stall indefinitely. Catalog types no longer pass through sqlparser at all (D8), and existing columns are never re-validated, so a grammar gap can only affect a newly typed spelling. That surfaces inline and blocks nothing. - -**Rejected**: Per-engine hand-written type tokenizers: a parallel grammar. Server round-trip validation: cannot validate default expressions and needs a live connection while editing a draft. Classifying catalog types with sqlparser (original): see D8. - -##### D15 · Edit text is lossless and edit parsing depends on the edit context - -Edit text: -- `edit::edit_text(&Value, &ColumnType)` returns the canonical full text or `ReadOnly(reason)`. -- The grid fills editors from the tracker's effective Value, never from `label.text()`. -- `normalize_single_line_input` (browse_tab.rs:2533) is deleted. - -Parsing: `edit::parse_edit_text(input, &ColumnInfo, EditContext) -> Result`, where `CellInput { Default, Value(Value) }` and `EditContext { InsertDraft, UpdateRow }`. - -Empty input: -- Kinds that accept an empty string: `Value(Text(""))`. -- InsertDraft, non-textual: `Default` when the column has a default, is auto-increment or is generated; `Value(Null)` when nullable; otherwise `Err(Required)`. -- UpdateRow, non-textual: `Value(Null)` when nullable; otherwise `Err(Required)`. UpdateRow never yields Default. - -Draft rows: -- Tracker draft cells are `CellInput`, initialised to Default for columns with a default, auto-increment or generation, and `Value(Null)` otherwise. -- The Set NULL action (grid.rs:1639) stores `Value(Null)`. -- `build_insert` omits Default cells. When every cell is Default it uses `SqlDialect::insert_default_row`, and `BuildSqlError::NoInsertValues` where that returns None. - -Widgets: -- Spin buttons are removed. -- Single-line GtkText sets `truncate-multiline` only when `!kind.accepts_line_breaks()`, and `input-purpose` Number or Digits for numeric kinds. -- Multi-line values use a sourceview5::View editor. - -**Why**: Review (minor) is correct: on a NOT NULL column with a default, an UPDATE writing NULL fails on the server. Only INSERT can omit the column. - -`SET c = DEFAULT` is not available on SQLite or in ClickHouse `ALTER TABLE … UPDATE`, so UpdateRow requires input. - -F-005 and F-024: display truncation and f64 spin rounding were written back to the database. - -**Rejected**: A DEFAULT keyword sentinel in UPDATE: unsupported on SQLite/ClickHouse. Treating Value::Null in drafts as meaning default (today): makes an explicit NULL on a defaulted column impossible. - -##### D16 · MSSQL money, sql_variant and CLR UDT are fixed in a maintained tiberius fork with an upstream-compatible API - -Fork and pin: -- Fork: `https://github.com/TableProApp/tiberius`, branch `tablepro/lossless-types`, created from prisma/tiberius `a6b4fcdae0de5702427290b89f8d05bc51f3bcfa`, which keeps the libgssapi fix the current pin exists for. -- `[patch.crates-io] tiberius` points at the fork's branch head commit, recorded by full SHA. -- The Cargo.toml comment next to the patch names both reasons (libgssapi UB from prisma/tiberius#343, and this lossless decoding), plus the upstream PR numbers. -- The pin moves only to a later fork commit. - -Patches, all additive or panic removals: -1. `Config::money_as_numeric(&mut self, enabled: bool)`, default false. The value is stored in the connection `Context`, which the codec reaches through `SqlReadBytes::context()`. `money::decode` then emits `ColumnData::Numeric(Some(Numeric::new_with_scale(v, 4)))`, where v is `i32` for 4 bytes and `((high as i64) << 32) | (low as u32 as i64)` for 8 bytes. With the flag off, the F64 path is unchanged. -2. sql_variant: `TypeInfo::decode` reads the 4-byte max length instead of `todo!` (type_info.rs:317). The value decoder reads the 4-byte total length (0 means NULL), the base type byte, the property byte count and each base type's properties (precision/scale, time scale, collation plus max length, binary max length), then decodes the payload into the existing ColumnData variants. -3. UDT: `TypeInfo::decode` reads UDT_INFO (u16 max length, B_VARCHAR db/schema/type name, US_VARCHAR assembly-qualified name) into a new crate-internal `TypeInfo::Udt` variant (TypeInfo lives under the private `mod tds`). Values are PLP-decoded as `ColumnData::Binary`. -4. The remaining `todo!` arms (type_info.rs:102, column_data.rs:136) return `Error::Protocol`. - -The MSSQL driver calls `config.money_as_numeric(true)` in `build_target`. - -**Why**: Money is already f64 before the driver sees it (money.rs:8-15), and the metadata `todo!` panics inside the codec, so neither can be fixed in the driver. - -Review (minor) is correct that changing money's default decode would break tiberius's public `f64: FromSql` behaviour. An opt-in Config flag keeps default behaviour identical, which makes the patch upstreamable. sql_variant and UDT previously panicked, so decoding them changes no working behaviour. `ColumnData` is not `#[non_exhaustive]`, so no public variant is added. - -**Rejected**: Changing money to Numeric unconditionally: breaks existing tiberius users. A Cargo feature flag: features unify across the dependency graph, so a type-changing feature is not additive. Server-side CASTs only: editor queries still panic. - -##### D17 · CSV formula neutralisation is keyed on the Value variant - -`export::csv` neutralises a field only when all of these hold: -- The Value is `Text`, `Json` or `Other`, or the field is a header. -- Its first char is `=`, `+`, `-`, `@`, tab or CR. -- The text is not a plain number matching `^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$`. - -Decimal comma applies only to Float32, Float64 and Decimal values. The existing test that converted Text("1.5") changes in the same commit. - -**Why**: F-023: -5 became "'-5". Typed values are rendered from structured data and cannot carry formula payloads. - -**Rejected**: Guarding every non-numeric kind: mangles negative intervals. Relying on quoting alone: Excel still evaluates. - -##### D18 · MySQL tinyint(1) is Bool with integer storage and keeps its exact integer - -Classification: -- MySQL `tinyint(1)` (COLUMN_TYPE parsed as `DataType::TinyInt(Some(1))`) and SQLite BOOLEAN/BOOL are `ColumnKind::Bool(BoolStorage::Integer)`, with values staying `Value::Int`. -- PG boolean, MSSQL bit and ClickHouse Bool are `Bool(Native)`. - -Checkbox: -- 0 shows unchecked, 1 checked, and any other integer shows inconsistent with the exact value in the tooltip. -- Toggling writes Int(0) or Int(1). - -**Why**: MySQL BOOLEAN is TINYINT(1) and legally stores 2..127. - -**Rejected**: Decoding as Value::Bool: loses values. A plain Integer kind: loses the checkbox. - -##### D19 · Saves are guarded per row with typed statement kinds, and a failed guard refuses the whole save - -`save::plan_save(dialect, table, columns, &ChangeSet) -> Result` gives each UPDATE and DELETE a `RowGuard`: -- `AffectedExactly(1)` when `capabilities.exact_row_counts && capabilities.unique_row_keys`. -- Otherwise `ProbeExactly { probe: build_key_probe(..), rows: 1 }`. -- INSERT gets `RowGuard::None`. - -Driver contract for `execute_in_transaction(&[GuardedStatement])`: -- A transactional driver evaluates each step in order inside the transaction: probe (read the single count via `Value::as_exact_u64`), then statement, then the affected-rows check. The first mismatch rolls back and returns `DriverError::RowGuardFailed { statement_index, expected, actual }`. -- A driver with `transactional_dml == false` (ClickHouse) runs every probe before executing any statement. It returns the same error without sending a mutation, and rejects `AffectedExactly` with `DriverError::GuardUnsupported { statement_index }`. - -Key probes cap at two rows, rendered through `SqlDialect::key_probe`: -- PG/MySQL/SQLite: `SELECT COUNT(*) FROM (SELECT 1 AS one FROM t WHERE p LIMIT 2) AS probe`. -- MSSQL: `SELECT COUNT_BIG(*) FROM (SELECT TOP (2) 1 AS one FROM t WITH (UPDLOCK, HOLDLOCK) WHERE p) AS probe`, which holds the probed key until commit. -- ClickHouse: `SELECT count() FROM (SELECT 1 FROM t WHERE p LIMIT 2)`, with no FINAL, so unmerged duplicates count. - -App changes: -- `compute_concurrency_warning` and its SQL-prefix sniffing (row_ops.rs:150-173) are deleted. -- `SavePlan.origins: Vec` maps each statement index to the typed `StatementSource::{Insert, Update, Delete}` (change_tracker.rs:142-146). -- On `RowGuardFailed` the tab flashes that row and shows either "This row no longer exists in the database. Refresh and review." (actual 0) or "This row's key matches more than one row, so it was not saved." (actual 2), localised with gettext. - -Capabilities: -| Engine | transactional | exact_row_counts | unique_row_keys | -|---|---|---|---| -| PG | true | true | true | -| MySQL | true | true (CLIENT_FOUND_ROWS) | true | -| SQLite | true | true (sqlite3_changes excludes trigger changes) | true | -| MSSQL | true | false (trigger counts are summed) | true | -| ClickHouse | false | false | false | - -**Why**: Review (major): a MergeTree sorting key is not unique (clickhouse lib.rs:300-306), so `ALTER TABLE … UPDATE` rewrites every row sharing the key. The check must come before the mutation, and prefix sniffing never matched `ALTER TABLE`. - -Verified: tiberius pushes every Done/DoneInProc count (result.rs:59-61), and SQL Server adds trigger-affected rows unless the trigger sets NOCOUNT. MSSQL therefore uses lock-holding probes instead of affected counts. sqlx-mysql negotiates FOUND_ROWS (handshake.rs:104), so MySQL counts matched rows even when a value is unchanged. - -Rolling back on a mismatch replaces the old post-commit warning (row_ops.rs:53-65), which had already committed a phantom save. A save is one unit, so refusing all of it keeps it atomic. - -**Rejected**: Post-commit warning (today): the damage is already committed. Probing only on ClickHouse: MSSQL trigger counts produce false conflicts. Counting without LIMIT 2: a full scan on large ClickHouse tables. - -##### D20 · Browse SQL is built in core with dialect projections; Connection::fetch_rows is removed - -`browse::build_page(dialect, &PageRequest) -> Result` renders `SELECT FROM [WHERE ] )>`. `browse::build_count` renders `SELECT FROM [WHERE ]`. - -Projection is decided by `ColumnType.read_form`, which the driver's catalog classifier sets: -- `Native`: `quote(c)`. -- `ServerText`: PG `"c"::text AS "c"`, MSSQL `CAST([c] AS nvarchar(max)) AS [c]`. -- `ServerNumeric`: PG `"c"::numeric AS "c"`. - -Which columns get which form: -- PG: ServerText for kinds Other and Spatial, and arrays whose innermost element is Other or Spatial. ServerNumeric for money. -- MSSQL: ServerText for hierarchyid. -- MySQL, SQLite and ClickHouse: always Native. - -Sort expressions are `quote(table.name).quote(column)` on PG, MySQL, SQLite and MSSQL, and `quote(column)` on ClickHouse, whose select list has no aliases. - -`Connection::fetch_rows` is removed from the trait, all five drivers, ReadOnlyConnection and test fakes. browse.rs's fast path (lines 79-105) and hand-built count SQL (176-193) are deleted. - -The browse command fetches columns when the tab has none, then runs `query_bound(build_page)` and `query_bound(build_count)`. - -**Why**: Review (major): per-kind projections had no contract, and browse SQL was assembled in two places. - -PG docs state that a simple ORDER BY name matching both an output and an input column means the output column, while names in expressions (including qualified names) are input columns. `"t"."c"` therefore sorts by the source column even when the projection aliases a `::text` cast with the same name. - -WHERE never sees output aliases on PG or MSSQL. ClickHouse, where aliases are visible in WHERE, has no aliases to see. - -MSSQL: COUNT(*) is int, so a large table needs COUNT_BIG. - -**Rejected**: Keeping `fetch_rows` as a fast path: a second SQL assembly path that cannot apply projections. Sorting by the alias: text order for ranges and hierarchyid. A table alias `AS src`: breaks raw filter SQL that names the table. - -##### D21 · Clipboard and export literals parse identically under every session mode - -`SqlDialect::literal(&Value, &ColumnType)` never reads session settings. - -PostgreSQL: -- Text with no backslash: `'…'` with quotes doubled. -- Text containing a backslash: `E'…'` with backslashes and quotes doubled. -- Bytes: `decode('', 'hex')`. -- Decimal NaN/Infinity: `'NaN'::numeric` and so on. -- Infinite temporals: `'infinity'`. - -MySQL/MariaDB: -- Text containing a backslash or any `char::is_control` other than tab and newline: `_utf8mb4 X''`. -- Otherwise `'…'` with quotes doubled. -- Bytes: `X''`. Bits: `b'0101'`. - -ClickHouse: -- Backslash-escaped `'…'`, or `unhex('')` for Bytes and control characters. - -SQL Server: -- `N'…'` with quotes doubled, and `0x`. - -SQLite: -- `'…'` with quotes doubled, and `X''`. - -**Why**: Review (major): copied SQL is pasted into other sessions and clients. Under standard_conforming_strings=on, a plain `'…'` that contains no backslash means the same in both modes, and PG docs state E'' strings process backslash escapes regardless of the setting. - -MySQL documents `[_charset] X'val'` as a string in that charset, and hex digits contain no quote or backslash, so NO_BACKSLASH_ESCAPES cannot change the meaning. - -PG `'\x..'::bytea` would contain a backslash; `decode(…, 'hex')` contains none. - -**Rejected**: Choosing escaping from the probed session (original): recreates the F-053 break-out in the paste target. Always hex-encoding MySQL text: unreadable for ordinary values. - -#### Contracts - -##### C1 · `Value` - -**Location**: `crates/core/src/value/mod.rs (replaces query.rs:65-80; re-exported from tablepro_core)` · **Kind**: enum · **Consumers**: All drivers (decode, encode), core statement/dml/browse/save/filter/edit/export, app change_tracker, grid, browse_tab, row_ops, editor - -``` -```rust -#[derive(Debug, Clone, PartialEq)] -pub enum Value { - Null, - Bool(bool), - Int(i64), - UInt(u64), - WideInt(WideInteger), - Float32(f32), - Float64(f64), - Decimal(SqlDecimal), - Text(String), - Bytes(Vec), - Uuid(uuid::Uuid), - Json(JsonText), - Date(Temporal), - Time(SqlTime), - TimeTz(TimeWithOffset), - Timestamp(Temporal), - TimestampTz(Temporal), - Interval(SqlInterval), - Bits(BitString), - Array(Vec), - Other(OtherValue), - Undecodable(UndecodedValue), -} -impl Value { - pub fn is_null(&self) -> bool; - pub fn variant_name(&self) -> &'static str; - pub fn as_exact_u64(&self) -> Option; -} -``` -`as_exact_u64` accepts Int >= 0, UInt, and Decimal with scale 0 that fits. It replaces browse.rs:198-203 and reads guard probe counts. No serde derives. -``` - -PartialEq is structural: NaN != NaN, and TimestampTz compares offsets because OffsetTimestamp derives over both fields. ValueIdentity handles tracking. - -##### C2 · `SqlDecimal` - -**Location**: `crates/core/src/value/decimal.rs` · **Kind**: struct + error enum · **Consumers**: PG numeric_wire, MySQL decimal newtype, MSSQL Numeric mapping, ClickHouse token parse, edit parse, export, literals - -``` -```rust -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct SqlDecimal(DecimalRepr); -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -enum DecimalRepr { Finite { negative: bool, coefficient: Box, scale: u32 }, NaN, Infinity, NegInfinity } -impl SqlDecimal { - pub const MAX_INTEGER_DIGITS: u32 = 131_072; - pub const MAX_SCALE: u32 = 16_383; - pub fn nan() -> Self; - pub fn infinity(negative: bool) -> Self; - pub fn from_scaled_i128(mantissa: i128, scale: u32) -> Self; - pub fn from_parts(negative: bool, coefficient: &str, scale: u32) -> Result; - pub fn is_finite(&self) -> bool; - pub fn scale(&self) -> Option; - pub fn precision(&self) -> Option; - pub fn to_scaled_i128(&self) -> Option<(i128, u32)>; - pub fn fits(&self, precision: u32, scale: u32) -> bool; - pub fn cmp_numeric(&self, other: &Self) -> std::cmp::Ordering; -} -impl std::str::FromStr for SqlDecimal { type Err = DecimalParseError; } -impl std::fmt::Display for SqlDecimal {} -#[derive(Debug, thiserror::Error)] -pub enum DecimalParseError { Empty, InvalidSyntax, ExponentOutOfRange, TooManyDigits { integer_digits: u64, scale: u64 } } -``` -FromStr grammar: `[+-]?(d+(.d*)?|.d+)([eE][+-]?d+)?`, plus NaN, Infinity, inf, +inf, -Infinity and -inf (case-insensitive). - -Parse order: -1. Scan the mantissa and record the significant integer digit count and fraction length without allocating. -2. Reject an exponent longer than 18 digits (ExponentOutOfRange). Parse it as i64. -3. With checked i64 arithmetic compute `integer_digits = sig_int_len + max(0, exp - frac_len)` and `scale = max(0, frac_len - exp)`. Reject if `integer_digits > MAX_INTEGER_DIGITS || scale > MAX_SCALE` (TooManyDigits). -4. Only then allocate the coefficient. - -Normalisation: no leading zeros, and negative zero becomes non-negative. Display uses plain notation with exactly `scale` fraction digits. -``` - -Eq is structural (1.0 != 1.00). - -##### C3 · `WideInteger, SqlTime, TimeWithOffset, OffsetTimestamp, SqlInterval, Temporal, JsonText, BitString, OtherValue, UndecodedValue` - -**Location**: `crates/core/src/value/{wide_integer.rs, time.rs, time_with_offset.rs, offset_timestamp.rs, interval.rs, temporal.rs, json_text.rs, bit_string.rs, other.rs, undecodable.rs}` · **Kind**: structs/enums · **Consumers**: Drivers, edit, export, literals - -```rust -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct WideInteger { negative: bool, magnitude: Box } -impl WideInteger { pub fn from_i128(v: i128) -> Self; pub fn from_u128(v: u128) -> Self; pub fn to_i128(&self) -> Option; pub fn fits(&self, kind: IntegerKind) -> bool; } -impl FromStr for WideInteger {} impl Display for WideInteger {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct SqlTime { negative: bool, hours: u32, minutes: u8, seconds: u8, nanos: u32 } -impl SqlTime { - pub fn new(negative: bool, hours: u32, minutes: u8, seconds: u8, nanos: u32) -> Result; - pub fn from_time_of_day(t: chrono::NaiveTime) -> Self; - pub fn to_time_of_day(&self) -> Option; - pub fn from_micros(micros: i64) -> Self; - pub fn total_nanos(&self) -> i128; - pub fn format(&self, fractional_digits: Option) -> String; -} -impl FromStr for SqlTime {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct TimeWithOffset { pub time: SqlTime, pub offset: chrono::FixedOffset } - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct OffsetTimestamp { utc: chrono::NaiveDateTime, offset: chrono::FixedOffset } -impl OffsetTimestamp { - pub fn from_datetime(value: chrono::DateTime) -> Self; - pub fn to_datetime(&self) -> chrono::DateTime; - pub fn utc(&self) -> chrono::NaiveDateTime; - pub fn offset(&self) -> chrono::FixedOffset; -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct SqlInterval { pub months: i32, pub days: i32, pub microseconds: i64 } -impl FromStr for SqlInterval {} impl Display for SqlInterval {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Temporal { Finite(T), Infinity, NegInfinity } -impl Temporal { pub fn finite(&self) -> Option<&T>; } - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct JsonText { text: Box } -impl JsonText { pub fn parse(text: String) -> Result; pub fn as_str(&self) -> &str; } - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct BitString { bit_len: u32, bytes: Box<[u8]> } -impl BitString { pub fn from_bytes(bit_len: u32, bytes: Vec) -> Result; pub fn from_u64(bits: u64, bit_len: u32) -> Self; pub fn to_u64(&self) -> Option; } -impl FromStr for BitString {} impl Display for BitString {} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct OtherValue { pub type_name: String, pub text: String } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct UndecodedValue { pub type_name: String, pub reason: UndecodableReason } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum UndecodableReason { UnsupportedType, OutOfRange, InvalidEncoding } -``` -Text forms: -- SqlTime: `[-]H+:MM[:SS[.f{1,9}]]`. -- SqlInterval: ISO 8601 duration with signed components; zero is PT0S. -- OffsetTimestamp: RFC 3339 with the original offset. -- BitString: `0101`. - -Every error is a thiserror enum. - -chrono `FixedOffset` derives PartialEq, Eq and Hash (chrono-0.4.44 offset/fixed.rs:22), so OffsetTimestamp identity includes the offset. Each file holds one public type plus its error enum. - -##### C4 · `ValueIdentity` - -**Location**: `crates/core/src/value/identity.rs` · **Kind**: struct · **Consumers**: crates/app/src/services/change_tracker.rs: `RowKey::Persisted(Vec)` replaces KeyValue (lines 62-101) and deletes keyvalue_to_value (714-734); edit change detection - -```rust -#[derive(Debug, Clone)] -pub struct ValueIdentity(Value); -impl ValueIdentity { pub fn new(value: Value) -> Self; pub fn value(&self) -> &Value; pub fn into_value(self) -> Value; } -impl PartialEq for ValueIdentity {} -impl Eq for ValueIdentity {} -impl std::hash::Hash for ValueIdentity {} -pub fn same_value(a: &Value, b: &Value) -> bool; -``` -Identity rules: -- Floats compare and hash by `to_bits()`, so NaN equals NaN and -0.0 differs from 0.0. -- TimestampTz compares and hashes `(utc, offset.local_minus_utc())`, and TimeTz `(time, offset.local_minus_utc())`, so equal instants with different offsets are distinct. -- Arrays compare element-wise, and every other variant is structural. - -PK values bound in WHERE are the original server Values, bit-exact. - -##### C5 · `ColumnKind and its parts` - -**Location**: `crates/core/src/column/kind.rs` · **Kind**: enum + small value types · **Consumers**: Drivers produce it. Filter operators, edit parse, editor widget choice, grid editability, export formatting and ParamType selection consume it. - -```rust -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ColumnKind { - Bool(BoolStorage), - Integer(IntegerKind), - Float(FloatWidth), - Decimal { precision: Option, scale: Option }, - Money { scale: Option }, - Text { length: TextLength }, - Enum { labels: Vec, multiple: bool }, - Bytes { length: TextLength }, - Uuid, - Json { binary: bool }, - Date, - Time { fractional_digits: Option, with_offset: bool, range: TimeRange }, - Timestamp { fractional_digits: Option, zone: TemporalZone }, - Interval, - BitString { length: Option, varying: bool }, - Network(NetworkFamily), - Xml, - Spatial, - Array { element: Box }, - Variant, - Composite, - Other, -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BoolStorage { Native, Integer } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct IntegerKind { pub width: IntegerWidth, pub signed: bool } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IntegerWidth { W8, W16, W24, W32, W64, W128, W256 } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FloatWidth { F32, F64 } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TextLength { Unbounded, Varying(u32), Fixed(u32) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TimeRange { TimeOfDay, Duration } -#[derive(Debug, Clone, PartialEq, Eq)] pub enum TemporalZone { Naive, Offset, Named(String) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NetworkFamily { Inet, Cidr, MacAddr, MacAddr8, Ipv4, Ipv6 } -impl ColumnKind { - pub fn is_textual(&self) -> bool; - pub fn is_numeric(&self) -> bool; - pub fn accepts_empty_string(&self) -> bool; - pub fn accepts_line_breaks(&self) -> bool; -} -``` -Helper semantics: -- `is_textual`: Text, Enum, Xml, Other. -- `is_numeric`: Integer, Float, Decimal, Money. -- `accepts_empty_string`: Text, Enum with a "" label, Other. -- `accepts_line_breaks`: Text, Json, Xml, Other. - -Composite covers ClickHouse Map, Tuple, Nested, Dynamic, JSON (the type), AggregateFunction and PG composites, which edit_text reports as ReadOnly(Composite). - -Classification is infallible: unknown types map to Other. - -##### C6 · `ColumnType, CatalogType, ReadForm, SqlTypeExpr` - -**Location**: `crates/core/src/column/column_type.rs, column/read_form.rs, column/sql_type_expr.rs` · **Kind**: structs + enum · **Consumers**: Dialect placeholder and projection, DDL rendering, grid, editor - -```rust -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ColumnType { - pub name: String, - pub kind: ColumnKind, - pub catalog: Option, - pub dynamic_storage: bool, - pub read_form: ReadForm, -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CatalogType { pub declaration: SqlTypeExpr, pub cast_target: SqlTypeExpr } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReadForm { Native, ServerText, ServerNumeric } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct SqlTypeExpr(Box); -impl SqlTypeExpr { pub fn from_catalog(text: impl Into>) -> Self; pub fn as_sql(&self) -> &str; } -``` -Invariant: `SqlTypeExpr` comes only from engine catalog output or from a successful `SqlDialect::parse_type`. - -Catalog sources: -- PG: `format_type(atttypid, atttypmod)` for the declaration; `format_type(atttypid, NULL)` for the cast target (`varbit` for bit kinds). -- MySQL: COLUMN_TYPE. -- ClickHouse: `system.columns.type` or the result type header. -- MSSQL: the sys.types name with length/precision, and QUOTENAME for user types. -- SQLite: the declared type. - -`read_form` is set by the catalog classifier (D20). Result-set columns use `Native`. - -Result-set columns set `catalog: None`, except ClickHouse, whose result types are exact. The PG and ClickHouse dialects return `BindError::MissingCatalogType` rather than guessing a cast. - -##### C7 · `ColumnDefault and SqlExpression` - -**Location**: `crates/core/src/column/default.rs; driver classification in crates/drivers/*/src/catalog.rs` · **Kind**: enum + struct · **Consumers**: core dml::build_insert (via CellInput::Default for drafts), edit parse (Default in InsertDraft), DdlDialect::column_default_clause, structure tab default row - -```rust -#[derive(Debug, Clone, PartialEq)] -pub enum ColumnDefault { None, Literal(Value), Expression(SqlExpression) } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct SqlExpression(Box); -impl SqlExpression { pub fn from_catalog(text: impl Into>) -> Self; pub fn as_sql(&self) -> &str; } -``` -Each driver fills it with the private `catalog::classify_default(catalog_text: &str, column: &ColumnType) -> ColumnDefault`. It uses `sql_syntax::parse_single_expression` with the engine's sqlparser dialect plus `literal_of`: -- PG `pg_get_expr`: `'hi'::text` and `42` are literals; `now()` is an expression. -- MSSQL `sys.default_constraints.definition`: `((0))` is Literal Int; `(getdate())` is an expression. -- SQLite `dflt_value`. -- ClickHouse `default_expression` when `default_kind` = DEFAULT. -- MySQL 8: without DEFAULT_GENERATED in EXTRA, COLUMN_DEFAULT is unquoted literal text parsed directly with `edit::parse_literal_text`; with DEFAULT_GENERATED it is an expression. -- MariaDB: quoted COLUMN_DEFAULT is a literal, unquoted is an expression, and `NULL` is None. - -Literal text is parsed against the column kind with `edit::parse_literal_text`. Parse failure yields `Expression(verbatim)`. - -Identity, serial (`nextval(`) and IDENTITY machinery yield None with `is_auto_increment` set. - -User-typed expressions are constructed only through `SqlDialect::parse_expression`. - -Replaces `ColumnInfo.default_value: Option` (query.rs:53-58), `DraftColumn.default_value`, `normalize_pg_default` (postgres lib.rs:496-520), `normalize_mssql_default` (mssql lib.rs:601-639) and `normalize_default_value` (sqlite lib.rs:459-467). - -##### C8 · `ColumnInfo, ResultColumn, QueryResult, ReferentialAction` - -**Location**: `crates/core/src/column/{info.rs, result_column.rs}; crates/core/src/query.rs; crates/core/src/ddl/referential_action.rs` · **Kind**: structs/enums · **Consumers**: All drivers, core, app grid, browse_tab, filter_strip, structure_tab, editor - -``` -```rust -#[derive(Debug, Clone, PartialEq)] -pub struct ColumnInfo { - pub name: String, - pub column_type: ColumnType, - pub nullable: bool, - pub primary_key: bool, - pub is_auto_increment: bool, - pub is_generated: bool, - pub default: ColumnDefault, -} -impl ColumnInfo { pub fn result_column(&self) -> ResultColumn; } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResultColumn { pub name: String, pub column_type: ColumnType } -#[derive(Debug, Clone)] -pub struct QueryResult { pub columns: Vec, pub rows: Vec>, pub truncated: bool } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReferentialAction { NoAction, Restrict, Cascade, SetNull, SetDefault } -impl ReferentialAction { pub fn as_sql(self) -> &'static str; } -``` -`ForeignKeyInfo.on_delete` and `on_update` become `Option`. Authority rule (D8): browse tabs use ColumnInfo; editor results use ResultColumn. -``` - -Each crate has a `#[cfg(test)]` builder helper for these structs. - -##### C9 · `Connection and DatabaseDriver changes` - -**Location**: `crates/core/src/connection.rs, crates/core/src/driver.rs, crates/core/src/read_only.rs, crates/core/src/error.rs` · **Kind**: trait changes · **Consumers**: All drivers, ReadOnlyConnection and its FakeConn tests, app browse.rs, row_ops.rs, structure.rs, editor - -```rust -#[async_trait] -pub trait Connection: Send + Sync { - fn dialect(&self) -> std::sync::Arc; - async fn list_tables(&self) -> Result, DriverError>; - async fn fetch_columns(&self, schema: Option<&str>, table: &str) -> Result, DriverError>; - async fn query(&self, sql: &str) -> Result; - async fn query_bound(&self, statement: &Statement) -> Result; - async fn execute(&self, sql: &str) -> Result; - async fn execute_bound(&self, statement: &Statement) -> Result; - async fn execute_in_transaction(&self, steps: &[GuardedStatement]) -> Result, DriverError>; - async fn fetch_indexes(&self, schema: Option<&str>, table: &str) -> Result, DriverError>; - async fn fetch_foreign_keys(&self, schema: Option<&str>, table: &str) -> Result, DriverError>; - async fn ping(&self) -> Result<(), DriverError>; - async fn close(self: Box) -> Result<(), DriverError>; -} - -pub enum DriverError { - // existing variants unchanged - RowGuardFailed { statement_index: usize, expected: u64, actual: u64 }, - GuardUnsupported { statement_index: usize }, -} -``` -Trait changes: -- `fetch_rows` and the defaulted `query_params` are removed. -- `execute_params` becomes `execute_bound`. -- The default bodies of `fetch_indexes`/`fetch_foreign_keys` stay. -- `DatabaseDriver` removes `ddl_is_transactional` and `reports_rows_affected`, and keeps `id`, `display_name`, `default_port`, `is_file_based`, `supports_integrated_auth` and `connect`. - -ReadOnlyConnection: -- `dialect()` delegates. -- `query_bound` passes through. -- `execute_bound` and `execute_in_transaction` return `DriverError::ReadOnly`. - -The `query`/`execute` raw-SQL protocol stays owned by the F-014 editor-session contract. - -`driver_id` survives only as the SavedConnection/ConnectionMetadata persistence key and a UI label. `App::driver_id()` with its "postgres" fallback (app/mod.rs:536-550) and `App.current_driver_id` (mod.rs:90) are deleted. - -##### C10 · `SqlDialect` - -**Location**: `crates/core/src/dialect/sql_dialect.rs (support types in dialect/{capabilities.rs, bind_target.rs, placeholder.rs, like.rs, table_ref.rs, errors.rs})` · **Kind**: trait · **Consumers**: core statement::StatementBuilder, browse, dml, save, filter, export::sql, ddl::materialize; app structure tab (via capabilities/ddl) - -```rust -pub trait SqlDialect: Send + Sync + std::fmt::Debug { - fn capabilities(&self) -> DialectCapabilities; - fn quote_identifier(&self, name: &str) -> String; - fn qualify(&self, table: TableRef<'_>) -> String; - fn projection(&self, column: &ColumnInfo) -> String; - fn sort_expression(&self, table: TableRef<'_>, column: &ColumnInfo) -> String; - fn count_all(&self) -> &'static str; - fn placeholder(&self, ordinal: usize, value: Value, target: BindTarget<'_>) -> Result; - fn literal(&self, value: &Value, column: &ColumnType) -> Result; - fn escape_like_text(&self, text: &str) -> String; - fn like_predicate(&self, column_sql: &str, pattern_sql: &str, form: LikeForm) -> String; - fn row_window(&self, order_by: Option<&str>, limit: u64, offset: u64) -> String; - fn update_statement(&self, table_sql: &str, assignments_sql: &str, predicate_sql: &str) -> String; - fn delete_statement(&self, table_sql: &str, predicate_sql: &str) -> String; - fn insert_default_row(&self, table_sql: &str) -> Option; - fn key_probe(&self, table_sql: &str, predicate_sql: &str) -> String; - fn parse_type(&self, text: &str) -> Result; - fn parse_expression(&self, text: &str) -> Result; - fn ddl(&self) -> Option<&dyn DdlDialect>; -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DialectCapabilities { pub transactional_dml: bool, pub exact_row_counts: bool, pub unique_row_keys: bool } - -#[derive(Debug, Clone, Copy)] -pub enum BindTarget<'a> { Column(&'a ColumnInfo), Pattern } - -#[derive(Debug, Clone, PartialEq)] -pub struct Placeholder { pub sql: String, pub param: Option } - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct LikeForm { pub negated: bool, pub case: LikeCase, pub escaped: bool } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LikeCase { EngineDefault, Insensitive } -#[derive(Debug, Clone, Copy)] -pub struct TableRef<'a> { pub schema: Option<&'a str>, pub name: &'a str } - -#[derive(Debug, thiserror::Error)] -pub enum BindError { - ValueTypeMismatch { expected: ParamType, found: &'static str }, - MissingCatalogType { column: String }, - Undecodable { type_name: String }, - Unrepresentable { type_name: String }, -} -#[derive(Debug, thiserror::Error)] -pub enum LiteralError { Unrepresentable { variant: &'static str }, Undecodable { type_name: String } } -#[derive(Debug, thiserror::Error)] -pub enum TypeSyntaxError { Empty, TooLong { max: usize }, Invalid(String) } -#[derive(Debug, thiserror::Error)] -pub enum ExpressionSyntaxError { Empty, TooLong { max: usize }, Invalid(String) } -``` -Default bodies: -- `qualify`: `quote(schema).quote(name)` when the schema is non-empty. -- `projection`: `quote_identifier(name)`. -- `sort_expression`: `quote(table.name).quote(column.name)`. -- `count_all`: `"COUNT(*)"`. -- `key_probe`: `SELECT COUNT(*) FROM (SELECT 1 AS one FROM {t} WHERE {p} LIMIT 2) AS probe`. -- `insert_default_row`: `Some(format!("INSERT INTO {t} DEFAULT VALUES"))`. - -`placeholder` receives `ordinal` = the number of params already emitted in the statement. - -No method takes a driver id or reads session state. Per-engine behaviour is fixed by C12. - -##### C11 · `DdlDialect and DraftColumn` - -**Location**: `crates/core/src/dialect/ddl_dialect.rs; crates/core/src/ddl/{ops.rs, draft_column.rs, materialize.rs, error.rs}; engine impls in crates/drivers/*/src/ddl.rs` · **Kind**: trait + types · **Consumers**: crates/app/src/ui/app/structure.rs:108-111,168-169,246-253,462-511; structure_tab/columns.rs, fks.rs; structure_tab_dialogs.rs; services/structure_tracker.rs::materialize (line 54) - -```rust -pub trait DdlDialect: Send + Sync { - fn capabilities(&self) -> &DdlCapabilities; - fn type_suggestions(&self) -> &'static [&'static str]; - fn default_column_type(&self) -> &'static str; - fn referential_actions(&self) -> &'static [ReferentialAction]; - fn column_default_clause(&self, default: &ColumnDefault, column: &ColumnType) -> Result, BuildDdlError>; - fn create_table(&self, table: TableRef<'_>, columns: &[DraftColumn], indexes: &[IndexInfo], foreign_keys: &[ForeignKeyInfo]) -> Result, BuildDdlError>; - fn drop_table(&self, table: TableRef<'_>, options: DropTableOptions) -> Result, BuildDdlError>; - fn rename_table(&self, table: TableRef<'_>, new_name: &str) -> Result, BuildDdlError>; - fn add_column(&self, table: TableRef<'_>, column: &DraftColumn) -> Result, BuildDdlError>; - fn drop_column(&self, table: TableRef<'_>, column: &str) -> Result, BuildDdlError>; - fn rename_column(&self, table: TableRef<'_>, old_name: &str, new_name: &str) -> Result, BuildDdlError>; - fn alter_column(&self, table: TableRef<'_>, column: &DraftColumn) -> Result, BuildDdlError>; - fn reorder_column(&self, table: TableRef<'_>, column: &DraftColumn, after: Option<&str>) -> Result, BuildDdlError>; - fn create_index(&self, table: TableRef<'_>, index: &IndexInfo) -> Result, BuildDdlError>; - fn drop_index(&self, table: TableRef<'_>, index: &str) -> Result, BuildDdlError>; - fn add_foreign_key(&self, table: TableRef<'_>, fk: &ForeignKeyInfo) -> Result, BuildDdlError>; - fn drop_foreign_key(&self, table: TableRef<'_>, name: &str) -> Result, BuildDdlError>; -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DdlCapabilities { - pub transactional: bool, - pub create_table: bool, pub rename_table: bool, - pub add_column: bool, pub add_not_null_column_without_default: bool, - pub drop_column: bool, pub rename_column: bool, - pub alter_column_type: bool, pub alter_column_nullability: bool, pub alter_column_default: bool, - pub reorder_columns: bool, - pub create_index: bool, pub drop_index: bool, - pub add_foreign_key_to_existing_table: bool, pub drop_foreign_key: bool, - pub drop_table_cascade: bool, -} -#[derive(Debug, Clone, Copy)] -pub struct DropTableOptions { pub if_exists: bool, pub cascade: bool } - -#[derive(Debug, Clone, PartialEq)] -pub struct DraftColumn { - pub original: Option, - pub name: String, - pub type_text: DraftType, - pub nullable: bool, - pub primary_key: bool, - pub auto_increment: bool, - pub default: ColumnDefault, -} -#[derive(Debug, Clone, PartialEq)] -pub enum DraftType { Catalog(ColumnType), Typed(String) } - -pub fn materialize_ops(ops: &[StructureOp], ddl: &dyn DdlDialect, dialect: &dyn SqlDialect) -> Result, BuildDdlError>; - -#[derive(Debug, thiserror::Error)] -pub enum BuildDdlError { - EmptyTableName, NoColumns, EmptyColumnName, EmptyIndexName, EmptyForeignKeyName, NoChange, - Unsupported(DdlOperation), InvalidType(TypeSyntaxError), InvalidDefault(ExpressionSyntaxError), Literal(LiteralError), -} -``` -Behaviour: -- `DraftType::Catalog` splices `CatalogType.declaration` unchanged and is never re-parsed. -- `DraftType::Typed` is validated through `SqlDialect::parse_type`. -- `column_default_clause` renders Literal through `SqlDialect::literal(value, column)` and Expression verbatim. - - MySQL parenthesises expressions except the CURRENT_TIMESTAMP/NOW family. - - MSSQL emits `DEFAULT (expr)`. -- StructureOp and diff_to_ops move unchanged from sql_ddl.rs (lines 207, 910) to ddl/ops.rs. - -UI affordances are gated on DdlCapabilities read from the session's current connection, so no failure first appears at Save. BuildDdlError's SqliteNotSupported, PostgresNotSupported and UnsupportedDriver(String) are deleted. - -##### C12 · `Engine dialect behaviour (normative)` - -**Location**: `crates/drivers/{postgres,mysql,sqlite,mssql,clickhouse}/src/dialect.rs` · **Kind**: behaviour table · **Consumers**: All SQL builders; workstreams W2, W3, W5, W6 - -### PostgreSQL (`PostgresDialect { server_version_num }`) -- Identifiers: double quotes, embedded `"` doubled. -- ParamType from kind: - - Bool→Bool; Integer W16/W32/W64→Int16/Int32/Int64; Float→Float32/Float64; Decimal→Decimal; Money→Decimal. - - Text Fixed→FixedText; other Text→Text; Bytes→Bytes; Uuid→Uuid; Json→Json/JsonBinary. - - Date→Date; Time→Time/TimeTz; Timestamp Naive→Timestamp; Offset→TimestampTz; Interval→Interval. - - Enum, BitString, Network, Xml, Spatial, Array, Composite, Other→ServerText(cast_target). - - Infinite temporals and 24:00:00→ServerText(cast_target). -- OIDs: Bool 16, Bytes 17, Int64 20, Int16 21, Int32 23, Text and ServerText 25, Json 114, Float32 700, Float64 701, FixedText 1042, Date 1082, Time 1083, Timestamp 1114, TimestampTz 1184, Interval 1186, TimeTz 1266, Decimal 1700, Uuid 2950, JsonBinary 3802. -- Markers: `$n` (1-based); `$n::` for ServerText; `$n::money` for Money. Pattern: `$n` Text. -- Projection: ServerText `"c"::text AS "c"`; ServerNumeric `"c"::numeric AS "c"`. -- Literals (D21): `'…'`, or `E'…'` when the text contains a backslash; `decode('','hex')`; TRUE/FALSE; `'NaN'::numeric`; `'NaN'::float8`/`::float4`; `'infinity'`; `INTERVAL ''`. -- LIKE: `[NOT] LIKE|ILIKE p ESCAPE '!'`. -- Window: `[ORDER BY o] LIMIT n OFFSET m`. Standard UPDATE/DELETE. Default-row insert: `INSERT INTO t DEFAULT VALUES`. -- Capabilities: (true, true, true). - -### MySQL/MariaDB (`MySqlDialect { version, flavor }`) -- Identifiers: backticks, embedded backtick doubled. -- Markers: `?`. -- ParamType from kind: Integer unsigned W64→UInt64, other integers→Int64, Decimal→Decimal (NEWDECIMAL text newtype), Time→Time (`MySqlTime`), TimestampTz→TimestampTz (converted to UTC; the session time_zone is pinned by `MySqlConnectOptions::timezone(Some("+00:00".into()))`), BitString→UInt64 (at most 64 bits), Enum/Text→Text, Bytes/Spatial→Bytes. -- Literals: `'…'` with quotes doubled; `_utf8mb4 X''` when the text has a backslash or control character; `X''`; `b'0101'`; TRUE/FALSE. NaN/Infinity are Unrepresentable. -- LIKE: `LIKE ? ESCAPE '!'`; Insensitive: `LOWER(c) LIKE LOWER(?) ESCAPE '!'`. -- Window: LIMIT/OFFSET. Default-row insert: `INSERT INTO t () VALUES ()`. -- Capabilities: (true, true, true). - -### SQLite (`SqliteDialect { version }`) -- Identifiers: double quotes, doubled. Markers: `?`. -- ParamType from the Value when dynamic_storage; from kind in STRICT tables. -- Literals: `'…'` doubled, `X''`, 1/0. NaN Unrepresentable. -- LIKE: `LIKE ? ESCAPE '!'`; Insensitive: `LOWER(c) LIKE LOWER(?) ESCAPE '!'`. -- Window: LIMIT/OFFSET. Default-row insert: `INSERT INTO t DEFAULT VALUES`. -- DdlCapabilities: `drop_column` requires version 3.35.0; `rename_column` requires 3.25.0. -- Capabilities: (true, true, true). - -### SQL Server (`MssqlDialect { major_version }`) -- Identifiers: square brackets, `]` doubled. -- Markers: `@Pn`. -- ParamType from kind, with typed `Option::None` NULLs: Integer W8 unsigned→u8 via Int16 path, W16→i16, W32→i32, W64→i64; Decimal and Money→Decimal (`Numeric`; more than 38 digits is Unrepresentable); TimestampTz→`DateTime`; Time→NaiveTime within range; Bytes→`Vec`; Text/Xml→String; hierarchyid (ServerText)→String; Variant→ParamType from the Value. -- Projection: ServerText `CAST([c] AS nvarchar(max)) AS [c]`. count_all: `COUNT_BIG(*)`. -- Literals: `N'…'` doubled, `0x`, 1/0. NaN Unrepresentable. -- LIKE: escapes `!`, `%`, `_`, `[`; `LIKE @Pn ESCAPE '!'`; Insensitive: `LOWER(c) LIKE LOWER(@Pn) ESCAPE '!'`. -- Window: `ORDER BY OFFSET m ROWS FETCH NEXT n ROWS ONLY`. -- key_probe: `SELECT COUNT_BIG(*) FROM (SELECT TOP (2) 1 AS one FROM {t} WITH (UPDLOCK, HOLDLOCK) WHERE {p}) AS probe`. -- Default-row insert: `INSERT INTO t DEFAULT VALUES`. -- Capabilities: (true, false, true). - -### ClickHouse (`ClickhouseDialect { version }`) -- Identifiers: backticks, with `\` becoming `\\` and a backtick becoming backslash-backtick (the crate's `escape::identifier`). -- Placeholder: `CAST( AS )`, param None. The literal is `'…'` with `\`, `'`, backtick, tab and newline backslash-escaped; `unhex('')` for Bytes or text with other control characters; `NULL` for Null. Pattern: `''`. -- Projection: always `quote(c)`. sort_expression: `quote(c)`. count_all: `count()`. -- Literals: same text rules, plus true/false and nan/inf/-inf. -- LIKE: backslash-escapes `\`, `%`, `_`; `[NOT] LIKE|ILIKE '

'` with no ESCAPE clause. -- Window: LIMIT/OFFSET. -- UPDATE: `ALTER TABLE t UPDATE a WHERE p`. DELETE: `DELETE FROM t WHERE p`. -- key_probe: `SELECT count() FROM (SELECT 1 FROM {t} WHERE {p} LIMIT 2)`. insert_default_row: None. -- Capabilities: (false, false, false). - -The ClickHouse connection sets `output_format_json_quote_denormals=1` and `output_format_json_validate_utf8=0` on every query and keeps `mutations_sync=1` (lib.rs:58). - -##### C13 · `Driver decode and encode contract` - -**Location**: `crates/drivers/*/src/{decode.rs, encode.rs, catalog.rs}; PG adds src/numeric_wire.rs, src/array_wire.rs, src/pg_param.rs; ClickHouse adds src/cell_seed.rs` · **Kind**: behaviour contract · **Consumers**: W3 (drivers), W2 (grid), W5 (export), W6 (DDL/structure) - -### Universal rules -- A non-NULL cell never becomes Value::Null. -- No decode path panics: checked arithmetic only, never chrono Add operators. -- The binder encodes each BoundParam strictly by its ParamType. - -### PostgreSQL -Decode: -- Dispatch on `PgValueRef::type_info()` `oid()`/`kind()`. Domain decodes as its base, Array recurses into the element, Enum is label bytes as UTF-8. -- bool, int2/4/8, float4/8, the text family, bpchar, bytea and uuid use sqlx Decode. -- json is UTF-8; jsonb skips version byte 1. -- numeric: numeric_wire. Sign 0x0000 is positive, 0x4000 negative, 0xC000 NaN, 0xD000 +Infinity, 0xF000 -Infinity. -- date: i32 with MAX/MIN as infinity. timestamp/timestamptz: i64 with MAX/MIN as infinity. time: i64 micros, where 86_400_000_000 is 24:00:00. timetz: micros plus i32 seconds west. -- interval: `PgInterval`. bit/varbit: i32 length plus bytes. inet/cidr/macaddr: wire bytes to canonical text as Other. xml: Text. -- Arrays: the array_wire header. A lower bound other than 1 becomes Other. -- Money in binary is unreachable from app SQL (projected `::numeric`) and becomes Undecodable(UnsupportedType). -- Anything else in binary: Undecodable(UnsupportedType). -- Text format (raw editor path): parse per OID from `as_str`; anything else becomes Other. - -Encode: `PgParam` (D11). - -### MySQL -Decode: `Row::try_get_raw(i)` plus explicit `Decode::decode`, dispatching on `TypeInfo::name()` and `MySqlValueFormat`. -- `* UNSIGNED` below 64 bits: Int. BIGINT UNSIGNED: u64 into UInt. Signed ints: i64. YEAR: Int. -- BIT: `<&[u8]>` into Bits, with length from the catalog. DECIMAL: `<&str>` into SqlDecimal. FLOAT: f32. DOUBLE: f64. -- DATE/DATETIME/TIMESTAMP: raw `<&[u8]>`, decoded manually for binary (0/4/7/11 bytes) or parsed as text. Anything chrono cannot represent becomes `Other { type_name, text }`. -- TIME: `MySqlTime` into SqlTime. JSON: JsonText. GEOMETRY: Bytes. ENUM/SET/CHAR/VARCHAR/TEXT: Text. BINARY/VARBINARY/BLOB: Bytes. tinyint(1): Int. - -### SQLite -- `ValueRef::type_info()` gives the runtime storage class (sqlite3_value_type; sqlx-sqlite value.rs:135-143): INTEGER to Int, REAL to Float64, TEXT to Text, BLOB to Bytes, NULL to Null. - -### SQL Server (with the D16 fork, `money_as_numeric(true)`) -- Numeric: `SqlDecimal::from_scaled_i128(n.value(), n.scale())`; money arrives as Numeric scale 4. -- DateTimeOffset: `DateTime::::from_sql` into OffsetTimestamp. DateTime2/Time: 100ns precision. -- U8/I16/I32/I64: Int. F32: Float32. F64: Float64. Bit: Bool. Guid: Uuid. Xml and String: Text. Binary (including UDT geometry/geography): Bytes. -- sql_variant: its base type. hierarchyid: projected text into Other. - -### ClickHouse (D12) -- Rows decode through `RowSeed`/`CellSeed` driven by `DataTypeNode`. -- Int8..Int64 and UInt8..UInt32: Int. UInt64: UInt. (U)Int128/256: WideInt. -- Float32/Float64: `str::parse` of the token. Decimal: SqlDecimal from the token. -- Bool: Bool. String/FixedString/Enum8/16: `deserialize_bytes`, giving Text, or Bytes when not UTF-8. -- UUID: Uuid. Date/Date32: Date. DateTime/DateTime64: Timestamp with zone Named from the type. Time/Time64: SqlTime. IPv4/IPv6: Other. -- Array: Array. Map/Tuple/Nested: Other or Undecodable(InvalidEncoding). Variant/Dynamic/JSON/AggregateFunction/geo: Other through RawValue, with the per-column retry. - -Verified APIs: -- sqlx: PgValueRef::{as_bytes,as_str,format}; PgTypeInfo::{oid,kind,with_oid}; PgTypeKind::{Simple,Pseudo,Domain,Composite,Array,Enum,Range}; PgArguments::add uses produces(); resolve_type_id passes declared OIDs; MySqlConnectOptions::timezone; FOUND_ROWS negotiated. -- tiberius: Numeric::{new_with_scale,value,scale}; to_sql! Option impls. -- Others: clickhouse_types::data_types::DataTypeNode::new; serde_json Deserializer::deserialize_bytes accepts non-UTF-8. - -##### C14 · `Core statement, DML, filter and browse builders` - -**Location**: `crates/core/src/statement/{statement.rs, bound_param.rs, param_type.rs, builder.rs, guarded.rs}; crates/core/src/dml/{insert.rs, update.rs, delete.rs, key_probe.rs, error.rs}; crates/core/src/filter/{builder.rs, operators.rs}; crates/core/src/browse/{page.rs, sort_key.rs, error.rs}` · **Kind**: types + functions · **Consumers**: crates/app/src/services/change_tracker.rs (materialize replaced by save::plan_save; deletes the hand-built SET/WHERE at 583-694), crates/app/src/ui/app/browse.rs, crates/app/src/ui/filter_strip.rs - -```rust -#[derive(Debug, Clone, PartialEq)] -pub struct Statement { sql: String, params: Vec } -impl Statement { pub fn new(sql: String, params: Vec) -> Self; pub fn sql(&self) -> &str; pub fn params(&self) -> &[BoundParam]; } - -#[derive(Debug, Clone, PartialEq)] -pub struct BoundParam { value: Value, param_type: ParamType } -impl BoundParam { pub fn new(value: Value, param_type: ParamType) -> Result; pub fn value(&self) -> &Value; pub fn param_type(&self) -> &ParamType; } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ParamType { Bool, Int16, Int32, Int64, UInt64, Float32, Float64, Decimal, Text, FixedText, Bytes, Uuid, Json, JsonBinary, Date, Time, TimeTz, Timestamp, TimestampTz, Interval, ServerText(SqlTypeExpr) } -impl ParamType { pub fn accepts(&self, value: &Value) -> bool; } - -pub struct StatementBuilder { sql: String, params: Vec } -impl StatementBuilder { - pub fn new() -> Self; - pub fn push_sql(&mut self, sql: &str); - pub fn push_value(&mut self, dialect: &dyn SqlDialect, value: Value, target: BindTarget<'_>) -> Result<(), BindError>; - pub fn finish(self) -> Statement; -} - -#[derive(Debug, Clone, PartialEq)] -pub struct GuardedStatement { pub statement: Statement, pub guard: RowGuard } -#[derive(Debug, Clone, PartialEq)] -pub enum RowGuard { None, AffectedExactly(u64), ProbeExactly { probe: Statement, rows: u64 } } - -pub fn build_insert(dialect: &dyn SqlDialect, table: TableRef<'_>, columns: &[ColumnInfo], cells: &[CellInput]) -> Result; -pub fn build_update(dialect: &dyn SqlDialect, table: TableRef<'_>, columns: &[ColumnInfo], key: &[Value], assignments: &[(usize, Value)]) -> Result; -pub fn build_delete(dialect: &dyn SqlDialect, table: TableRef<'_>, columns: &[ColumnInfo], key: &[Value]) -> Result; -pub fn build_key_probe(dialect: &dyn SqlDialect, table: TableRef<'_>, columns: &[ColumnInfo], key: &[Value]) -> Result; -#[derive(Debug, thiserror::Error)] -pub enum BuildSqlError { NoPrimaryKey, NothingToUpdate, NoInsertValues, LengthMismatch { expected: usize, got: usize }, UndecodableKey { column: String }, Bind(BindError) } - -pub fn build_filter(builder: &mut StatementBuilder, dialect: &dyn SqlDialect, columns: &[ColumnInfo], set: &FilterSet) -> Result; -pub fn operators_for(kind: &ColumnKind) -> &'static [FilterOp]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SortKey { pub column: usize, pub ascending: bool } -pub struct PageRequest<'a> { pub table: TableRef<'a>, pub columns: &'a [ColumnInfo], pub filter: &'a FilterSet, pub sort: &'a [SortKey], pub offset: u64, pub limit: u64 } -pub fn build_page(dialect: &dyn SqlDialect, request: &PageRequest<'_>) -> Result; -pub fn build_count(dialect: &dyn SqlDialect, table: TableRef<'_>, columns: &[ColumnInfo], filter: &FilterSet) -> Result; -#[derive(Debug, thiserror::Error)] -pub enum BrowseSqlError { NoColumns, UnknownSortColumn(usize), Filter(BuildFilterError) } -``` -Semantics: -- `key` holds the original PK Values in PK column order. A NULL component renders `IS NULL` and consumes no marker. -- Every value goes through `StatementBuilder::push_value`, so ordinals always equal `params.len()`. -- `build_filter` returns whether it wrote a predicate. Filter values are parsed with `edit::parse_filter_text`. LIKE ops use escape_like_text plus like_predicate; Ilike maps to LikeCase::Insensitive. - -build_single_cell_update, build_full_row_update, build_insert_from_draft, placeholder_for, quote_ident and build_order_and_pagination (sql_dialect.rs) are deleted. - -##### C15 · `Edit contract` - -**Location**: `crates/core/src/edit/{mod.rs, parse.rs, text.rs, context.rs, cell_input.rs, error.rs}` · **Kind**: functions + types · **Consumers**: crates/app/src/ui/grid.rs (value_to_edit_text at 2113, show_spin_button_popover at 1094, commit_cell_edit, show_json_popover), cell_editor.rs, browse_tab.rs (parse_input_for_column at 2595 and parse_* helpers, GridCellEdited), services/change_tracker.rs DraftRow (values become Vec), filter builder - -```rust -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum EditText { Editable(String), ReadOnly(ReadOnlyReason) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReadOnlyReason { Bytes, Spatial, Undecodable, Composite, Variant } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EditContext { InsertDraft, UpdateRow } -#[derive(Debug, Clone, PartialEq)] -pub enum CellInput { Default, Value(Value) } - -pub fn edit_text(value: &Value, column: &ColumnType) -> EditText; -pub fn parse_edit_text(input: &str, column: &ColumnInfo, context: EditContext) -> Result; -pub fn parse_literal_text(input: &str, column: &ColumnType) -> Result; -pub fn parse_filter_text(input: &str, column: &ColumnType) -> Result; -pub fn is_unchanged(original: &Value, edited: &Value, column: &ColumnType) -> bool; - -#[derive(Debug, thiserror::Error)] -pub enum EditParseError { - Required, InvalidInteger, IntegerOutOfRange { kind: IntegerKind }, InvalidFloat, - InvalidDecimal(DecimalParseError), PrecisionExceeded { precision: u32 }, ScaleExceeded { scale: u32 }, - InvalidBoolean, InvalidUuid, InvalidJson(String), InvalidDate, InvalidTime, TimeOutOfRange, - InvalidTimestamp, OffsetRequired, InvalidInterval, InvalidBits, UnknownEnumLabel, - BitLengthMismatch { expected: u32 }, NotEditable(ReadOnlyReason), -} -``` -Parsing rules: -- Integers go through i128/WideInteger with the IntegerKind bounds. Decimal uses `SqlDecimal::fits(p, s)`. Floats use `str::parse` plus the non-finite words. -- Empty-input rules follow D15. -- With dynamic_storage, a failed strict parse returns `Value::Text(input)`. -- Network, Xml and Other kinds return `Other { type_name, text: input }`. -- `is_unchanged` is `same_value`, or for dynamic_storage columns also equality of `export::value_text` forms. - -UI-only (app): `ui::grid::display::display_text(&Value, &ColumnType) -> DisplayText { text, placeholder: bool }` owns the 10k-char truncation, `` and the Undecodable placeholder. It is never used for edit or export. - -The editor widget is chosen from ColumnKind: Bool(Native) checkbox, Date calendar, Json or multi-line sourceview5::View, otherwise GtkText with input-purpose set. The Edit action is disabled for ReadOnly. Set NULL on a NOT NULL column is disabled. - -##### C16 · `Export and copy contract` - -**Location**: `crates/core/src/export/{value_text.rs, csv.rs, tsv.rs, json.rs, markdown.rs, sql.rs}` · **Kind**: functions · **Consumers**: crates/app/src/ui/grid.rs copy actions (1525-1633), export_dialog.rs, app/row_ops.rs::on_copy_row_as_insert (format_sql_literal at 178-194 deleted; its unqualified table at 125 is replaced by qualify), browse_tab.rs - -```rust -pub fn value_text(value: &Value, column: &ColumnType) -> Option; -pub fn render_csv(columns: &[ResultColumn], rows: &[Vec], opts: &CsvOptions) -> String; -pub fn render_tsv(columns: &[ResultColumn], rows: &[Vec], with_headers: bool) -> String; -pub fn render_json(columns: &[ResultColumn], rows: &[Vec]) -> String; -pub fn row_to_json(columns: &[ResultColumn], row: &[Value]) -> String; -pub fn render_markdown(columns: &[ResultColumn], rows: &[Vec]) -> String; -pub fn render_in_clause(dialect: &dyn SqlDialect, column: &ResultColumn, rows: &[Vec], index: usize) -> Result; -pub fn render_insert(dialect: &dyn SqlDialect, table: TableRef<'_>, columns: &[ResultColumn], row: &[Value]) -> Result; -``` -`value_text` returns None for Null and is canonical and lossless: -- Ints in decimal. Floats in shortest round-trip form with NaN/Infinity/-Infinity. Decimal via Display at full scale. -- Date `%Y-%m-%d`. Time via SqlTime::format(fractional_digits). Timestamp `%Y-%m-%d %H:%M:%S` plus exactly fractional_digits digits (trimmed nanos when None). -- TimestampTz in the same form plus the original `%:z`. Infinities as `infinity`/`-infinity`. -- Interval ISO 8601. Bits as 0/1. Bytes as `0x`. Uuid hyphenated. Json verbatim. Arrays as JSON-array text. Other as its text. Undecodable yields None and callers report it. - -JSON export: -- Int, UInt, WideInt and finite Decimal are raw number tokens via `RawValue::from_string`. -- Non-finite values are strings, Json is raw, and timestamps use RFC 3339. - -CSV follows D17. SQL output uses D21 literals, `dialect.qualify` for the table, NULLs skipped with a count in IN clauses, and bytes rendered through the dialect. - -export_snapshot returns a QueryResult whose ResultColumns come from `ColumnInfo::result_column`, so literals see catalog types. - -##### C17 · `sql_syntax helpers` - -**Location**: `crates/core/src/sql_syntax.rs` · **Kind**: functions · **Consumers**: Driver impls of parse_type and parse_expression, driver-private classify_default, MySQL COLUMN_TYPE parse - -```rust -pub fn parse_data_type(dialect: &dyn sqlparser::dialect::Dialect, text: &str, max_len: usize) -> Result; -pub fn parse_single_expression(dialect: &dyn sqlparser::dialect::Dialect, text: &str, max_len: usize) -> Result; -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LiteralToken { Text(String), Number(String), Boolean(bool), Null } -pub fn literal_of(expr: &sqlparser::ast::Expr) -> Option; -``` -Both parse functions: -1. Trim the text and reject empty or over-length input before tokenizing. -2. Run `Parser::new(dialect).try_with_sql(text)`, then `parse_data_type()`/`parse_expr()`. -3. Require `peek_token_ref().token == Token::EOF`. - -`literal_of`: -- Unwraps `Expr::Nested` and `Expr::Cast { expr, .. }`. -- Maps `Expr::Value(ValueWithSpan { value, .. })`: SingleQuotedString, NationalStringLiteral and EscapedStringLiteral to Text; `Number(s, _)` to Number; Boolean to Boolean; Null to Null. -- Maps `Expr::UnaryOp { op: UnaryOperator::Minus, expr }` over a Number to a negative Number. -- The Number string passes through SqlDecimal's bounded parser (C2) before any use. - -sqlparser 0.63 facts (verified in src/ast/value.rs and data_type.rs at v0.63.0): `Expr::Value` holds `ValueWithSpan { value, span }`; `Value::Number(String, bool)` without the bigdecimal feature; `DataType::Enum(Vec, Option)` and `Set(Vec)` exist; there are no Year/Money/Geometry/Point variants. - -##### C18 · `Session ownership of connection and dialect (app)` - -**Location**: `the per-window session type from the app-model foundation (one adw::ApplicationWindow per connection session); crates/app/src/services/connection_monitor.rs; crates/app/src/ui/app/{browse.rs, row_ops.rs, structure.rs, workspace_tabs.rs}; ui/browse_tab.rs; ui/structure_tab/mod.rs` · **Kind**: ownership rule · **Consumers**: All app SQL paths - -FB requires exactly one accessor from the window session: `fn connection(&self) -> Option>`, returning the current connection after any reconnect. It also requires a `SessionEvent::Reconnected` notification. - -Rules: -- No component stores `Arc`. -- Every operation (page fetch, count, save plan, copy SQL, DDL materialize) snapshots `let connection = session.connection()?; let dialect = connection.dialect();` at the moment it runs and uses that pair for both building and executing. -- BrowseTab, StructureTab and Editor inits take the session handle instead of `driver_id: String` (workspace_tabs.rs:281-287, 358-366, 434-441). -- Capability-driven affordances (DdlCapabilities rows, drop-column button, referential action lists) are recomputed on `SessionEvent::Reconnected`. -- `connection_monitor::swap_connection` (lines 86-92) keeps replacing only the connection, and the dialect follows because the connection owns it. -- `DatabaseService::active()` is not used by any FB code path. - -This resolves the review's contradiction: the tab-held Arc is gone, and the global active() accessor is not extended. - -##### C19 · `Save plan` - -**Location**: `crates/core/src/save/{change_set.rs, plan.rs, origin.rs}` · **Kind**: types + function · **Consumers**: crates/app/src/services/change_tracker.rs, crates/app/src/ui/app/row_ops.rs::on_execute_browse_transaction, browse_tab.rs (materialize call at 2337) - -```rust -#[derive(Debug, Clone, Default)] -pub struct ChangeSet { pub inserts: Vec>, pub updates: Vec, pub deletes: Vec> } -#[derive(Debug, Clone)] -pub struct RowUpdate { pub key: Vec, pub assignments: Vec<(usize, Value)> } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChangeOrigin { Insert(usize), Update(usize), Delete(usize) } -#[derive(Debug, Clone)] -pub struct SavePlan { pub steps: Vec, pub origins: Vec } -pub fn plan_save(dialect: &dyn SqlDialect, table: TableRef<'_>, columns: &[ColumnInfo], changes: &ChangeSet) -> Result; -``` -Plan shape: -- Order: inserts, then updates, then deletes (the current materialize order). -- `origins[i]` identifies `steps[i]`. -- Guards follow D19. - -App side: -- `TabChangeTracker::change_set()` replaces `materialize`. -- row_ops.rs maps `ChangeOrigin` to `StatementSource` for flashing. -- `DriverError::RowGuardFailed` produces the per-row message. -- `DriverError::Transaction` keeps its current flash behaviour. - -ClickHouse steps never carry AffectedExactly; a unit test asserts this invariant for all three capability combinations. - -##### C20 · `tiberius fork API` - -**Location**: `https://github.com/TableProApp/tiberius branch tablepro/lossless-types; workspace Cargo.toml [patch.crates-io]` · **Kind**: dependency contract · **Consumers**: crates/drivers/mssql - -```rust -impl tiberius::Config { pub fn money_as_numeric(&mut self, enabled: bool); } -``` -With the flag off, behaviour is identical to prisma/tiberius a6b4fcd. - -With the flag on: -- `money` and `smallmoney` decode to `ColumnData::Numeric(Some(Numeric::new_with_scale(v, 4)))`, exact over the full ranges -922337203685477.5808..922337203685477.5807 and -214748.3648..214748.3647. -- sql_variant columns decode to the base type's ColumnData. -- UDT columns decode to `ColumnData::Binary`. -- The remaining codec `todo!` arms return `Error::Protocol`. - -Fork CI: -- Codec unit tests: money 4 and 8 bytes with negative high word, min, max and zero; sql_variant for every base type in the TDS spec; UDT_INFO metadata for hierarchyid, geometry and geography. -- Integration tests in tests/query.rs through the fork's existing docker-compose SQL Server service and .github/workflows/test.yml. - -Upstream PRs to prisma/tiberius carry the same commits. The pin comment records the SHA, both rationales and the PR links. - -#### Test strategy - -- Core value unit tests (step 3), table-driven: -- SqlDecimal: round trips for 0, -0 to 0, 12.30 (scale kept), 1e-40 and 1E+3 expansion, a 1000-digit coefficient, NaN/Infinity/-inf, fits(p,s), to_scaled_i128 at 10^38-1. -- SqlDecimal bounds: `1e2147483647`, `1e-2147483647`, `1e99999999999999999999` and `0.` followed by 20000 zeros return TooManyDigits or ExponentOutOfRange. A counting global allocator (`#[global_allocator]` in the test binary) asserts no allocation over 1 MiB during those parses. -- WideInteger W128/W256 bounds. SqlTime 24:00:00, -838:59:59.000000, 100ns. SqlInterval ISO negatives. JsonText trailing garbage. -- ValueIdentity: NaN equals NaN; 0.0 and -0.0 are distinct; `2024-01-01T10:00:00+05:30` and `2024-01-01T04:30:00+00:00` are unequal and hash differently; the same holds for TimeWithOffset; Array hashing. -- Statement and binding unit tests (steps 4 and 7): -- `BoundParam::new(Value::Text("x"), ParamType::Uuid)` returns BindError::ValueTypeMismatch; Undecodable is rejected; Null is accepted by every ParamType. -- PG `placeholder` for Text into a uuid column returns BindError and never a bare marker. -- PG NULL into int4/int8/bool/date/uuid/numeric/timestamptz columns yields ParamType Int32/Int64/Bool/Date/Uuid/Decimal/TimestampTz with marker `$n`. -- PG NULL into an enum or int[] column yields ServerText with `$n::mood` / `$n::integer[]`. char(10) yields FixedText. Money yields `$n::money`. -- MSSQL NULL into varbinary yields ParamType Bytes. -- ClickHouse placeholder yields `CAST('2024-06-15 08:30:00.123' AS DateTime64(3, 'UTC'))` with param None, and `CAST(unhex('ff00') AS String)` for Bytes. -- StatementBuilder ordinals are continuous across filter, update and key sections. -- PostgreSQL integration (testcontainers): set NULL through TabChangeTracker plus plan_save on int4, int8, bool, date, uuid, numeric, timestamptz, an enum and an int[] column, and assert the saved value is NULL. Update a row keyed by char(10) 'ab' and assert one row is affected. -- Save guard tests: -- Core unit: plan_save yields AffectedExactly(1) for (exact counts, unique keys), ProbeExactly for MSSQL-like and ClickHouse-like capabilities, and never AffectedExactly without exact counts. -- Probe SQL per dialect: PG LIMIT 2, MSSQL TOP (2) WITH (UPDLOCK, HOLDLOCK), ClickHouse count() LIMIT 2. -- ClickHouse integration: in a MergeTree table with two rows sharing a DateTime64(3) sorting key, editing one sends no mutation (system.mutations count unchanged, data unchanged), reports RowGuardFailed actual=2 and flashes the row. A unique key saves. -- PG integration: a concurrent DELETE before Save rolls the transaction back with RowGuardFailed actual=0, and the other rows' edits are not committed. -- MSSQL integration: a table with an AFTER UPDATE trigger that updates another table saves through the probe path without a false conflict. -- App unit tests replace the compute_concurrency_warning tests: StatementSource mapping from ChangeOrigin. -- Browse builder tests: -- Per-dialect projection unit tests: PG int4range column gives `"r"::text AS "r"`; money gives `::numeric`; MSSQL hierarchyid gives `CAST([h] AS nvarchar(max)) AS [h]`. -- sort_expression unit tests (table-qualified; ClickHouse bare); count_all (MSSQL COUNT_BIG). -- PG integration: browse and sort an int4range column, and assert ordering matches `ORDER BY r` on the server, not text order. -- MSSQL integration: browse and sort a hierarchyid column, assert depth-first order and the text value `/1/2/`. -- SQLite tests previously using fetch_rows run `query_bound(build_page)`. -- Literal tests (step 17): -- MySQL: render `abc\`, `it's`, `a\'b`, and a string with U+0000, then parse back with sqlparser MySqlDialect (backslash escapes on) and GenericDialect (off). Both yield the original string, via IntroducedString/HexStringLiteral decoding for hex forms. -- PG: backslash strings render as E'' and parse back with PostgreSqlDialect to the original. Strings without a backslash render as plain '' and are asserted to contain no backslash. -- MSSQL N'' and ClickHouse escapes round-trip through MsSqlDialect and ClickHouseDialect. -- Copy as INSERT from a MySQL session with NO_BACKSLASH_ESCAPES, executed on a default-mode MySQL container, stores the original value. -- Catalog classification tests (step 6), integration per engine: -- PG: enum `mood` gives Enum{labels in enumsortorder}; domain `posint` over int4 gives Integer(W32); `int4range` gives Other/ServerText; numeric(40,20) gives precision 40 scale 20; numeric(5,-2) gives scale -2 handled as None precision rules; timestamp(3) with time zone; varchar(255); int[] gives Array{Integer}. -- MySQL: `int(10) unsigned zerofill` gives Integer W32 unsigned; YEAR; tinyint(1) gives Bool(Integer); enum('a','b'); set('x','y'); bit(10); geometry gives Spatial; json. -- MSSQL: money gives Money{Some(4)}; geography gives Spatial; hierarchyid gives Other/ServerText; an alias type over nvarchar(50); datetimeoffset(7). -- SQLite: `VARCHAR(10)` gives Text; `DECIMAL(10,2)` gives Decimal dynamic; `BOOLEAN` gives Bool(Integer); a STRICT table gives dynamic_storage false. -- ClickHouse: `LowCardinality(Nullable(String))`, `DateTime64(3, 'Asia/Tokyo')`, `Decimal(76, 20)`, `Map(String, Array(UInt8))`. -- classify_default: PG `'hi'::text` gives Literal, `now()` Expression; MSSQL `((0))` Literal Int, `(getdate())` Expression; MariaDB quoted vs unquoted; MySQL DEFAULT_GENERATED. -- Decode edge tests: -- MySQL: `SELECT CAST('0000-00-00' AS DATE)` under a permissive sql_mode, and a stored `2024-00-15`, read through both raw `query` (text protocol) and `query_bound` (binary), give Other with the server text. -- ClickHouse: a String column holding `unhex('ff61')` decodes to Bytes([0xff, 0x61]), and editing another column of that row keyed on it affects exactly that row. A Dynamic column holding invalid bytes becomes Undecodable(InvalidEncoding) while the other cells in the row decode. -- PG malformed numeric and array buffers return Undecodable, never panic. -- Edit parse matrix over every ColumnKind: -- Integer edges i8..i256 and u64::MAX. 1234567890123456789 exact. numeric(10,2) rejects 1.005 with ScaleExceeded. -- Text keeps "", newlines and spaces. -- Empty input on a NOT NULL int column with a default: InsertDraft gives CellInput::Default; UpdateRow gives Err(Required). -- Nullable int: both contexts give Value(Null). -- dynamic_storage fallback to Text, with is_unchanged treating Int(1) and Text("1") as unchanged. -- Enum label check; OffsetRequired. -- sql_syntax and DDL tests: every entry in each engine's `type_suggestions()` parses with that engine's sqlparser dialect. Trailing `; DROP TABLE x`, `-- comment` and `/* */` injection are rejected. An unchanged catalog column with a type sqlparser rejects (for example a PG composite type) materializes ALTER statements for other attributes without re-parsing its declaration. -- Session tests: a fake DatabaseDriver whose reconnect returns a connection with a different dialect (SQLite version 3.34 then 3.46). After `swap_connection`, an already-open StructureTab's `SessionEvent::Reconnected` handler enables the drop-column affordance, and a page statement built from that tab uses the new connection's dialect. -- tiberius fork tests (C20) run in the fork's CI. The TablePro MSSQL integration suite covers money max/min, smallmoney, datetimeoffset +05:30 round trip, sql_variant int/nvarchar/datetime2 values and a geography column, with no panic. -- Integration round-trip matrix per engine (testcontainers in CI, including MSSQL and ClickHouse services): insert edge values through plan_save, read back through browse::build_page, filtered pages and the raw editor path, and assert ValueIdentity equality. Adds PG numeric(40,20) NaN, infinity dates, 24:00:00, interval, inet, bit(5); MySQL BIGINT UNSIGNED max, DECIMAL(65,30), BIT(10), TIME '-838:59:59', tinyint(1)=2; ClickHouse UInt64 max, Int256, Decimal(76,20), Float32 0.1, nan, Array(Nullable(String)). -- Security integration tests: tables and columns named with each engine's escape characters are browsed, sorted, filtered, edited and deleted without SQL errors. ClickHouse values containing `\'`, `\\`, backtick, `?` and newline are saved and read back exactly. A PG enum type with a quote in its name binds through its format_type cast. -- App tests (#[gtk::test] on the Xvfb CI runner): -- Opening a 40 KB multi-line text cell and committing unchanged records no tracker edit. -- A bigint 1234567890123456789 edit round-trips. -- Clearing a NOT NULL int cell shows the Required error inline. -- The structure tab default mode combo produces Literal vs Expression. - -#### Risks - -- Step 8 is a large atomic commit: the Connection signature, builders, save guards, session handles and ClickHouse writes. It will conflict with parallel workstreams and depends on the app-model foundation's window session. Mitigation: Steps 3, 4 and 7 are additive, so step 8's diff is limited to call-site rewrites and deletions. Step 8 lands after the window session type exists and before W2/W3/W5/W6 start their feature commits. Every builder has pure unit tests in core or the driver crates, so reviewers can check SQL text without a database. -- ClickHouse CAST literals put escaped user data into SQL text instead of sending parameters. Mitigation: The escape set matches clickhouse-0.15.1 `escape::escape` (backslash, quote, backtick, tab, newline). Any other control character and every Bytes value go through `unhex('')`, whose argument is hex digits only. Hostile-value integration tests cover every escape character, and a property test feeds arbitrary strings through the literal and back via `SELECT` equality. The declaration comes only from system.columns. -- Rolling back a save on a row-guard mismatch replaces the old post-commit warning, which users may notice. Mitigation: The failing row flashes (existing StatementSource path) with a specific message, and nothing half-applies. The Linux user docs and CHANGELOG record the behaviour under Changed in the same commit. -- PG typmod decoding formulas and the recursive domain CTE are driver-owned code. Mitigation: Integration tests compare every decoded precision, scale and length against `information_schema.columns` for the same column, across PG 13 through 18 containers. Unit tests pin the numeric negative-scale form. -- MySQL TIMESTAMP reads rely on the pinned `+00:00` session time zone, and a `SET time_zone` typed in the SQL editor would change one pooled session. Mitigation: `MySqlConnectOptions::timezone` applies to every pooled connection at connect. The F-014 editor-session contract runs editor SQL on a dedicated connection outside the pool that serves app-built SQL. FB adds an integration test that runs `SET time_zone = '+05:00'` in the editor and then reads a TIMESTAMP through browse unchanged. -- The tiberius fork must be maintained until upstream accepts the patches. Mitigation: The patches are additive (a Config flag plus two panic removals) and submitted upstream. Fork CI runs codec and SQL Server integration tests. The pin comment records the SHA and PR links, and the fork rebases onto upstream releases. -- ClickHouse per-column retry decoding costs extra parse passes on rows with invalid UTF-8 in dynamic-typed columns. Mitigation: Retries happen only when a line fails the first pass and only for Variant/Dynamic/JSON/AggregateFunction columns. A benchmark in the ClickHouse integration suite over 10k rows with 1% invalid cells records decode time. -- sqlparser 0.63 may reject a spelling a user types in the structure editor. Mitigation: Catalog types are never re-parsed (D8, D14), and suggestion lists are gated by the acceptance corpus. A rejected hand-typed spelling shows the parser's error inline in the AdwEntryRow and blocks no other operation. -- A larger Value (Other, SqlDecimal and OffsetTimestamp heap or inline data) increases grid memory. Mitigation: A 10k x 50 result benchmark in the PG and ClickHouse suites records resident memory before and after step 5. The Other and Undecodable payloads are boxed if `size_of::()` exceeds 40 bytes, and a unit test asserts the bound. - -### FC · Driver connection contract, transport security, cancellation and sessions - -Foundation FC (revision 2) replaces the method-level Connection trait with driver contract v2 in tablepro-core, puts every network path on injected transports with one rustls trust model, and resolves every review item. - -- **Connect.** ConnectOptions { target: ConnectTarget::{Network, File}, workspace_settings, liveness, environment }. NetworkTarget carries an injected Transport, TrustSource, Credentials and TlsConfig { mode: Disable|Require|VerifyFull, ca_file, client_identity, server_name }. DatabaseDriver::connect and Connection::open_session take a CancellationToken. Every physical-connection establishment is bounded by connect_timeout (stream open) plus a new login_timeout (TLS, auth, GSSAPI, pooler wait, session statements). -- **Pooler-safe session bounds.** PostgreSQL sends no startup `options`. Each workspace call is one pipelined flight: START TRANSACTION READ ONLY (reads) or READ WRITE (write_batch), SET LOCAL statement_timeout and lock_timeout, the statement, COMMIT. Schema batches use statement_timeout 0. Editor sessions use SET after connect. MySQL and MariaDB are detected per connection from VERSION() and get flavour-specific timeouts (max_execution_time ms vs max_statement_time s) and error codes. -- **Cancellation.** CallOptions/RunOptions carry a CancellationToken and deadline. Work runs on tasks owned by the Connection (TaskTracker) or the Session actor, so a dropped caller future cancels through a DropGuard and the owned task still sends the server stop: PG CancelRequest, KILL QUERY over COM_QUERY, TDS attention, ClickHouse KILL QUERY by query_id, SQLite progress handler. -- **Display row limit.** Reaching it never raises an error, changes transaction state, or skips a statement. The server is stopped early only by LIMIT-like mechanisms: PG portal suspension (idle session, single statement), MySQL/MariaDB sql_select_limit, ClickHouse KILL of its single-statement request. SQL Server and SQLite read on without retaining rows. -- **Streaming.** ResultSink::accept is async and awaited, so a slow consumer applies TCP backpressure. Rows are never dropped; SinkFlow::ConsumerGone is the only sink stop. -- **Editor sessions.** One physical connection per tab. RunSummary reports RunEnd and TransactionState (PG from ReadyForQuery, others by probe). -- **Writes.** write_batch is Ok only when committed; otherwise RolledBack, CommitOutcomeUnknown { token } or PartiallyApplied. PG resolves through pg_xact_status. ClickHouse resolves through the answering node's query_log, Unresolvable when no row is found. -- **Errors.** DriverError v2 with ServerDiagnostics, TimeoutPhase { Connect, Login, SshHandshake, SshChannelOpen, PoolWait, LockWait, Statement, Probe, CancelAcknowledgement }, and DriverError::category() -> ErrorCategory. -- **Metadata.** ObjectKind within the connected database (MySQL and ClickHouse list only it, per the F-057 verifier), IndexKey::{Column, Expression}, RowIdentity with keyset and ctid-window paging, TableEstimate. - -Transport: tablepro-net provides DirectTcpTransport (RFC 8305 Happy Eyeballs, keepalive 30/10/3, TCP_USER_TIMEOUT 45 s), PrivateSocketRelay for sqlx-mysql only, TrustAnchors (webpki TrustAnchor slice plus parse-checked system certificates plus CA file), PostgreSQL tls-server-end-point channel binding, and aws-lc-rs only. tablepro-ssh moves to russh 0.63.3 with SshTransport over direct-tcpip, Transport::reroute and an injected HostKeyVerifier. No loopback listener remains. - -Dependencies: tokio-postgres 0.7.18 on a TableProApp fork (column OIDs, ReadyForQuery status, row-limited text portal); sqlx 0.9.0 for MySQL/MariaDB, SQLite and storage; tiberius c1d2e74 plus the TableProApp patch series; clickhouse 0.15.2 over our hyper-util client. ring, rustls<0.23 and openssl-sys are banned. MSRV 1.94. Stored connections change schema in place, with no migration. - -Delivery: five additive or dependency commits that each compile under clippy -D warnings, one atomic contract switch, then docs. No adapters, no _v2 modules. - -#### Decisions - -##### FC-D1 · PostgreSQL moves to tokio-postgres 0.7.18 with a three-commit TableProApp fork - -crates/drivers/postgres depends on: -- tokio-postgres 0.7.18 (features runtime, with-chrono-0_4, with-uuid-1, with-serde_json-1, array-impls) -- postgres-types 0.2.14 -- rust_decimal (db-tokio-postgres) -- deadpool 0.13.1 (managed, rt_tokio_1) -- tablepro-net - -**Physical connections.** Config::connect_raw(transport.open().await?, PgTlsConnector) runs under login_timeout. It sets only user, password, dbname, application_name("TablePro") and channel_binding(Prefer); Config::options is never used. - -**Cancel.** CancelToken::cancel_query_raw over a fresh transport stream. - -**User SQL.** Client::query_text_portal (fork) when the session is Idle and the text is one statement (FC-C23); Client::simple_query_raw otherwise. - -**App SQL.** query_typed_raw / execute_typed on the unnamed statement. Parameters are Type::UNKNOWN in text format, except bytea in binary. Each call is pipelined with its transaction preamble and COMMIT (FC-D21). - -**Fork.** [patch.crates-io] pins tokio-postgres, postgres-protocol and postgres-types to TableProApp/rust-postgres at one rev on tokio-postgres-v0.7.18. Each commit is filed upstream (FC-C24): -1. SimpleColumn::type_oid, table_oid, column_id. -2. TransactionStatus captured from ReadyForQuery on SimpleQueryStream and TextPortalStream. -3. Client::query_text_portal(query, max_rows). It sends Parse, Bind (text results), Describe portal, Execute(max_rows), Close portal and Sync in one flight. CopyInResponse is answered with CopyFail, and CopyOut data is yielded as CopyData. - -**Why**: sqlx-postgres cannot serve the contract: -- process_id and secret_key are private (connection/mod.rs:49-54, 0.8.6 and v0.9.0), so there is no CancelRequest. -- pg_cancel_backend from another connection targets the wrong backend behind PgBouncer. -- sqlx-core sets only nodelay and builds its own ClientConfig. -- raw_sql returns no metadata for empty result sets. - -tokio-postgres 0.7.18 provides CancelToken::cancel_query_raw(stream, tls) (cancel_token.rs:51), connect_raw over any stream, TlsStream::channel_binding with ChannelBinding::tls_server_end_point (tls.rs:28), and pipelining when futures are polled concurrently (lib.rs, Pipelining). Its Config::connect_raw doc says every setting other than user, password, dbname, options and application_name is ignored, so connect_timeout and keepalive belong to our transport and login_timeout. - -Upstream gaps the fork closes: -- simple_query.rs builds SimpleColumn::new(f.name()) only. -- It maps ReadyForQuery(_) to Poll::Ready(None), dropping the status byte. -- Execute row limits exist only for named portals inside Transaction (query.rs:178-182). - -The portal commit exists because Execute with a row count is PostgreSQL's only error-free server stop (FC-D19). crates.io lists MSRV 1.85. - -**Rejected**: (1) sqlx-postgres 0.9.0 with pg_cancel_backend: wrong backend under PgBouncer, no socket options. -(2) Startup options for read-only and timeouts (previous revision): refused by a default PgBouncer (FC-D21). -(3) libpq FFI: a second TLS stack and a C dependency. -(4) Upstream without the fork: no simple-result column types, no transaction status, no row-limited portal. -(5) Transaction::bind + query_portal: wraps user SQL in BEGIN/COMMIT, which changes autocommit semantics and forbids VACUUM and CREATE DATABASE. - -##### FC-D2 · MySQL and MariaDB on sqlx-mysql 0.9.0 through a private socket relay, flavour-aware - -crates/drivers/mysql serves MySQL 8.x and MariaDB 10.6+/11.x with sqlx 0.9.0 (mysql, tls-rustls-aws-lc-rs, chrono, rust_decimal, uuid, json). deadpool manages physical MySqlConnection::connect_with(options) connections. - -**Connect options.** MySqlConnectOptions::new().host(server_name).port(service_port).socket(relay.socket_path()), plus: -- pipes_as_concat(false), no_engine_substitution(false), timezone(None), statement_cache_capacity(0); -- ssl_mode per FC-D7; -- ssl_ca_from_pem(TlsClientConfigBuilder::mysql_ca_bundle_pem()); -- ssl_client_cert_from_pem and ssl_client_key_from_pem. - -**Flavour.** Under login_timeout the driver runs raw_sql("SELECT CONNECTION_ID(), VERSION()") and stores MySqlPhysical { connection_id: u32, flavour }. The flavour is MariaDb when VERSION() contains "MariaDB", otherwise MySql. - -**Session statements** per flavour (FC-C4), and error mapping per flavour (FC-C11). - -**SQL paths.** -- User SQL: raw_sql(AssertSqlSafe(text)).fetch_many over COM_QUERY. Editor sessions set sql_select_limit (FC-D19). -- App SQL: sqlx::query(AssertSqlSafe(sql)) with binds and persistent(false). -- KILL QUERY: raw_sql over COM_QUERY with the integer connection id (FC-C18). - -**Application name.** None is sent (see rationale). - -**Why**: sqlx-mysql establish.rs connects over UDS when socket is set, and TLS upgrade uses options.host as the name, so TLS through the relay verifies the service name. - -The review is right about MariaDB. MariaDB has no max_execution_time, so the SET fails every connection. MariaDB documents max_statement_time in seconds, covering all statements except stored procedures, with error 1969. MySQL 8.4 documents max_execution_time in milliseconds for read-only SELECT, with error 3024. Both flavours support SET SESSION TRANSACTION READ ONLY and error 1792. - -VERSION() is read because sqlx keeps the handshake version private (establish.rs stores it on the stream only). - -The review is also right about the application name. sqlx-mysql 0.9.0 HandshakeResponse::encode_with writes username, auth response, database and plugin name, and no connection-attribute block. Connection attributes are MySQL's only client-identification channel, so MySQL sessions carry no program name. F-116's name defect, as verified, covers PostgreSQL (none) and SQL Server ('tiberius'), both fixed here. - -**Rejected**: (1) mysql_async 0.37: rustls refuses Endpoint::Socket, and keepalive sets only with_time. -(2) sqlx over direct TCP: no keepalive, and a loopback forward for SSH. -(3) Loopback relay: F-122. -(4) A sqlx fork for connection attributes or handshake version: a third fork of a large workspace for no finding. -(5) One statement set for both flavours: MariaDB rejects max_execution_time. - -##### FC-D3 · sqlx 0.9.0 and MSRV 1.94 - -sqlx 0.9.0 is used in driver-mysql, driver-sqlite and tablepro-storage. - -- The version-bump commit keeps the ring backend under its 0.9 name, tls-rustls-ring-webpki (the meaning of 0.8's tls-rustls), so it changes no behaviour. -- The atomic switch moves driver-mysql to tls-rustls-aws-lc-rs. -- Every dynamic SQL string passes sqlx::AssertSqlSafe. -- rust-toolchain.toml channel 1.94; clippy.toml msrv 1.94; workspace rust-version 1.94; dtolnay/rust-toolchain@1.94 in build-linux.yml. - -**Why**: The sqlx CHANGELOG heading reads '0.9.0 - 2026-05-06'. crates.io shows the publish date as 2026-05-21 and rust_version 1.94.0. The review's date refers to the changelog heading; the previous revision quoted the publish date. - -sqlx-core 0.9.0 Cargo features: -- _tls-rustls-aws-lc-rs = [_tls-rustls, rustls/aws-lc-rs, webpki-roots]; -- tls-rustls-ring-webpki keeps ring. - -The driver rewrite touches every call site, so one upgrade avoids a second rewrite. sqlx-sqlite 0.9.0 keeps LockedSqliteHandle::set_progress_handler and as_raw_handle. Flatpak rust-stable//25.08 ships Rust 1.98.1. - -**Rejected**: (1) Staying on 0.8.6. -(2) Switching TLS backend in the bump commit: ring would still be present through tiberius a6b4fcd, mixing providers. -(3) rusqlite: synchronous. - -##### FC-D4 · SQL Server uses tiberius-rs main plus a TableProApp patch series - -``` -[patch.crates-io] tiberius = TableProApp/tiberius at a 40-hex rev on branch tablepro, based on tiberius-rs/tiberius c1d2e74 (2026-09-04). Five commits, each opened upstream: - -(a) **No panics on server input.** todo!, panic!, unimplemented! and expect become Error::Protocol at type_info.rs:317, token_col_metadata.rs:170/174/200/204, column_data/var_len.rs:44, stream/token.rs:250 and login.rs:526/540/550, and on the String::from_utf16 expect. pre_login.rs:73 returns Error::Tls instead. -(b) **sql_variant and UDT.** SSVariant is decoded from base-type and property bytes (MS-TDS 2.2.5.5.1.1). Udt TYPE_INFO is parsed, values are read as PLP bytes into ColumnData::Binary, and Column::udt_type_name() is added. -(c) **Money.** MONEY and SMALLMONEY decode to ColumnData::Numeric(Numeric::new_with_scale(i64, 4)). -(d) **Attention.** Client::cancel_pending() sends an Attention packet and reads to DONE with DoneStatus::Attention. -(e) **TLS config injection.** TrustConfig::ClientConfig(Arc) via Config::tls_client_config. The trust_cert/trust_cert_ca combination returns Error::Config. - -The driver owns a deadpool of Clients plus one Client per editor session; the Mutex is removed. Login behaviour is in FC-D22. -``` - -**Why**: a6b4fcd ships rustls 0.21 and ring; `cargo tree -i ring` shows the rustls 0.21.12 → tokio-rustls 0.24.1 → tiberius path today. - -c1d2e74 uses rustls 0.23 with aws_lc_rs::default_provider() (rustls_tls_stream.rs) and keeps libgssapi 0.8, but it still panics on sql_variant and UDT and has no attention API. c1d2e74 error.rs already declares Error::Routing { host, port }. MS-TDS cancels a request only with an Attention packet followed by reading the acknowledgment. - -Money decodes to f64 inside tiberius, so exact MONEY needs a tiberius change. - -**Rejected**: (1) KILL spid: destroys the session. -(2) CAST projections only: SERVERPROPERTY still panics. -(3) catch_unwind: desynchronised stream. -(4) Upstream without patches. -(5) Another TDS crate: none offers GSSAPI with rustls. - -##### FC-D5 · ClickHouse uses Client::with_http_client over our transport, TLS and product info - -clickhouse 0.15.2 with default-features = false and features uuid and chrono. Client::with_http_client(hyper_util legacy Client::builder(TokioExecutor::new()).pool_idle_timeout(2 s).build(https)), where: -- https = hyper_rustls::HttpsConnectorBuilder::new().with_tls_config((*config).clone()).https_or_http().enable_http1().wrap_connector(TransportConnector { transport }); -- TransportConnector implements tower_service::Service by calling transport.open() under connect_timeout, returning TransportIo(TokioIo>) that implements connect::Connection; -- the URL is {http|https}://{server_name}:{service_port}. - -Every request carries query_id = Uuid::new_v4(). Client::with_product_info("TablePro", env!("CARGO_PKG_VERSION")) identifies the app. The first request of every new client is SELECT 1 under login_timeout. - -**Why**: clickhouse 0.15.1 lib.rs:121 with_http_client accepts any HttpClient. lib.rs:391 with_product_info prepends name/version to the User-Agent, which ClickHouse stores in system.query_log.http_user_agent. That fixes F-116 for ClickHouse without a query setting a session SET could override. - -The rustls-tls feature is [rustls-tls-aws-lc, rustls-tls-webpki-roots] (Cargo.toml:61-63). It builds webpki-only trust and names 127.0.0.1 through tunnels, so it stays off. - -**Rejected**: (1) Local forward with FixedServerNameResolver: keeps a loopback listener. -(2) rustls-tls-native-roots: no CA file or client certificate. -(3) native-tls: OpenSSL. -(4) log_comment as the application name: a per-query setting. - -##### FC-D6 · Transport model: injected streams, Happy Eyeballs, reroute, private UDS relay only for sqlx-mysql - -core::Transport has service_endpoint, kind, open and reroute(endpoint) -> Arc. - -**net::DirectTcpTransport.** Resolves with tokio::net::lookup_host and orders addresses per RFC 8305 section 4: families alternate, starting with the family of the first answer. It then races attempts: attempt n starts when attempt n-1 fails or happy_eyeballs_attempt_delay (250 ms) after it started, whichever is first. The first connected stream wins, the others are dropped, and the race is bounded by connect_timeout. SocketTuning is applied to the winner: nodelay; keepalive 30 s idle / 10 s interval / 3 retries; TCP_USER_TIMEOUT 45 s (socket2 0.6, feature all). - -**ssh::SshTransport.** Opens channel_open_direct_tcpip on a shared russh session, and reroute keeps the same session. - -**Consumers.** PostgreSQL (connect_raw), SQL Server (Client::connect) and ClickHouse (connector) take streams directly. MySQL uses net::PrivateSocketRelay: -- a 0700 per-connection directory under ConnectEnvironment.private_socket_dir, verified with symlink_metadata; -- a UnixListener with a SO_PEERCRED uid check on each accepted peer; -- copy_bidirectional to transport.open(); -- directory removal on Drop. - -The TLS name is always TlsConfig.server_name or the service host. service_endpoint rewriting is deleted. - -**Why**: This gives F-048, F-122 and F-041 one code path for direct and tunnelled connections. - -The review is right that sequential attempts inside one timeout let a black-holed first address (commonly AAAA) consume the budget. RFC 8305 section 5 recommends 250 ms (minimum 100 ms, maximum 2 s), and GIO GSocketClient implements Happy Eyeballs. - -reroute exists for SQL Server routing (FC-D22), so a redirect can go through the same SSH session. - -**Rejected**: (1) Loopback forward with per-driver name overrides: sqlx cannot split the TLS name from the dial host, and F-122 stays open. -(2) Relaying every engine: an unneeded copy hop. -(3) Spawning OpenSSH -L. -(4) Abstract sockets: no permission boundary. -(5) Sequential address attempts. - -##### FC-D7 · TLS modes: Disable, Require, VerifyFull; default VerifyFull - -TlsMode { Disable, Require, VerifyFull }. TlsConfig adds ca_file, client_identity and server_name. New connections default to VerifyFull. Stored connections carry TlsConfig directly (FC-D24). - -Engine mappings: -- **PostgreSQL:** SslMode Disable / Require+UnverifiedServerCertVerifier / Require+WebPkiServerVerifier. -- **MySQL:** Disabled / Required / VerifyIdentity with the CA bundle. -- **SQL Server:** EncryptionLevel::NotSupported (subtitle 'Not encrypted. The password is sent without encryption.') / Required with an unverified ClientConfig / Required with a verified ClientConfig. Client identity is rejected at validation. -- **ClickHouse:** http / https unverified / https verified. - -The connect dialog explains each mode. - -**Why**: sqlx appends the CA file to webpki roots, so VerifyCa on MySQL would accept any public certificate. The server_name override covers VerifyCa's real use. Prefer allows TLS stripping. SQL Server EncryptionLevel::Off encrypts only the login. - -**Rejected**: (1) use_tls boolean (F-008). -(2) All five libpq modes. -(3) EncryptionLevel::Off as Disable. - -##### FC-D8 · Trust anchors as TrustAnchor slice plus parse-checked system certificates; aws-lc-rs only - -core::TrustAnchors { bundled: &'static [TrustAnchor<'static>], system_certificates: Arc<[CertificateDer<'static>]> }, returned by async TrustSource::anchors. - -**net::tls::SystemTrustSource.** bundled = webpki_roots::TLS_SERVER_ROOTS. system_certificates = rustls_native_certs::load_native_certs().certs, each kept only if RootCertStore::add on an empty store accepts it. Rejected certificates are counted in one tracing::warn (count plus the first error), never logged whole. Loading runs once via spawn_blocking into a tokio::sync::OnceCell owned by the instance. - -**TlsClientConfigBuilder.** -- Extends a RootCertStore with bundled, then add_parsable_certificates(system_certificates), then the CA file. -- Every CA-file certificate must pass RootCertStore::add (else ConfigError::CaFile { InvalidCertificate }), and at least one must exist (else NoCertificates). -- For sqlx-mysql, mysql_ca_bundle_pem() PEM-encodes system_certificates plus the CA-file certificates (base64 0.22 STANDARD, 64-column lines). webpki roots are not included because sqlx seeds them itself. - -**Provider.** rustls::crypto::aws_lc_rs::default_provider() is passed explicitly everywhere. main.rs also installs it as the process default and logs an Err. deny.toml bans ring, rustls:<0.23.0 and openssl-sys. - -**Why**: The review is right: webpki-roots 1.0.7 lib.rs declares TLS_SERVER_ROOTS: &[TrustAnchor<'static>], which cannot become CertificateDer or PEM. - -rustls 0.23.39 anchors.rs provides pub roots: Vec>, Extend>, add_parsable_certificates and add(CertificateDer) -> Result. - -sqlx-core 0.9.0 tls_rustls.rs: import_root_certs() seeds webpki roots for _tls-rustls-aws-lc-rs, then calls cert_store.add(cert).map_err(..)? for each CA PEM entry. One unparsable system certificate would therefore fail every MySQL VerifyFull connect; pre-filtering prevents that, and every engine trusts the same union. - -CryptoProvider::from_crate_features picks aws-lc-rs only when ring is off, which the ban guarantees. - -openssl-sys: `cargo tree --offline -e normal -i openssl-sys` finds no package today. oo7 0.6.0 pulls openssl only through openssl_crypto, and the workspace enables native_crypto. - -**Rejected**: (1) webpki-root-certs: a second copy of the Mozilla roots next to sqlx's webpki-roots. -(2) Unfiltered native certificates to sqlx. -(3) GIO TlsDatabase: sqlx and tiberius cannot use it. -(4) rustls-platform-verifier. -(5) A synchronous anchors() doing file IO on async workers. - -##### FC-D9 · Read-only enforced per engine; ReadOnlyConnection deleted - -The driver owns read-only via ConnectOptions.workspace_settings.read_only. -- **PostgreSQL:** workspace reads always run in START TRANSACTION READ ONLY, whatever the switch. write_batch returns ReadOnly(ClientGuard) before the network. Editor sessions run SET default_transaction_read_only = on after connect (SessionDefault). -- **MySQL and MariaDB:** SET SESSION TRANSACTION READ ONLY on every physical connection. -- **SQLite:** SqliteConnectOptions::read_only(true). -- **ClickHouse:** with_setting("readonly","2") and with_setting("allow_ddl","0"). -- **SQL Server:** capability LoginPrivileges plus ClientGuard. Config::readonly is never set (FC-D22). - -Server refusals map to ReadOnly(ServerRejected): PG 25006, MySQL/MariaDB 1792, ClickHouse 164, SQLite 8, SQL Server 3906. - -Switch subtitles by capability: EngineEnforced 'The database refuses changes on this connection.'; SessionDefault 'Changes are refused unless a statement turns read-only off. Use a read-only login for a guarantee.'; LoginPrivileges 'SQL Server cannot enforce this. Use a login granted only SELECT.' - -core read_only.rs is removed; its tests move to driver integration tests. - -**Why**: F-001; ClickHouse readonly=2 per the verifier. Startup options cannot carry the PG setting through a default PgBouncer (FC-D21). - -A session SET persists only when the pooler keeps the server connection for the client: direct or session pooling. The SessionDefault subtitle already names a read-only login as the guarantee, and the PostgreSQL docs page names session pooling. Workspace reads in READ ONLY transactions stay read-only under every pool mode that permits transactions. - -ApplicationIntent=ReadOnly adds no write protection (F-001 verifier). - -**Rejected**: (1) Startup options. -(2) A text classifier wrapper. -(3) ClickHouse readonly=1. -(4) ApplicationIntent from the switch. -(5) Always-rollback wrappers. - -##### FC-D10 · Server-side cancellation and deadlines from tasks the connection owns - -CallOptions and RunOptions carry deadline: Option and cancel: CancellationToken. - -**Ownership.** Work runs on a task owned by the Connection (tokio::spawn tracked by tokio_util::task::TaskTracker, tokio-util feature rt) or by the Session actor. The caller future only awaits a oneshot and holds cancel.clone().drop_guard(). - -**On cancel or deadline**, the owned task: -1. Issues the engine stop under cancel_grace. -2. Keeps reading until acknowledged. -3. If no acknowledgement arrives, discards the physical connection (a session becomes Lost) and returns ConnectionLost { during: Statement }. - -**Engine stops.** -- PG: CancelToken::cancel_query_raw on a new transport stream. -- MySQL/MariaDB: raw_sql KILL QUERY on the control connection. -- SQL Server: drop the QueryStream, then cancel_pending(). -- ClickHouse: KILL QUERY WHERE query_id = ? ASYNC with the id bound, on a sessionless control client. -- SQLite: an AtomicBool read by set_progress_handler(1000, ..). - -**Server bounds**, equal to the call deadline, with the client timer firing cancel_grace later: -- PG: SET LOCAL statement_timeout per workspace call; SET statement_timeout in editor sessions. -- MySQL: max_execution_time. -- MariaDB: max_statement_time. -- ClickHouse: max_execution_time with timeout_overflow_mode=throw. -- SQL Server: SET LOCK_TIMEOUT only. -- SQLite: busy_timeout. - -**Mapping.** Own flag plus acknowledgement → Cancelled. Deadline → Timeout { Statement, server_cancelled }. PG 55P03, MySQL/MariaDB 1205 and TDS 1222 → Timeout { LockWait }. - -Row limits never use this path (FC-D19). - -**Why**: F-012. The review is right that the previous DropGuard claim failed: the stop logic lived in the dropped future. - -With an owned task, dropping the caller cancels the token and a live task still reaches the server. A reachable server stops itself first; the client path covers an unreachable one. PG 57014 is shared by cancel and statement_timeout, so the driver's own flag decides which it was. - -**Rejected**: (1) pg_cancel_backend from a pool connection. -(2) KILL spid. -(3) sqlite3_interrupt FFI when a safe API exists. -(4) Defining shutdown as awaiting cancellation: a dropped relm4 command would still leave server work running. -(5) cancel_http_readonly_queries_on_client_close. - -##### FC-D11 · Liveness and establishment deadlines with cancellable connect and open_session - -LivenessPolicy::DESKTOP: -- connect_timeout 10 s, login_timeout 15 s -- keepalive 30/10/3, tcp_user_timeout 45 s -- happy_eyeballs_attempt_delay 250 ms -- probe_timeout 5 s, pool_wait 5 s, cancel_grace 5 s -- ssh_handshake_timeout 20 s, ssh_channel_open_timeout 10 s -- ssh_keepalive_interval 15 s, ssh_keepalive_max 3 - -**Establishment.** Every physical-connection establishment runs as select! over the work, cancel.cancelled() and phase deadlines: -- Transport::open under connect_timeout → TimeoutPhase::Connect (SshChannelOpen for SSH); -- everything after the stream opens (TLS, startup, authentication, GSSAPI, pooler wait, flavour detection, session statements) under login_timeout → TimeoutPhase::Login. - -Sites: DatabaseDriver::connect(options, cancel), Connection::open_session(options, cancel), deadpool create (create_timeout = connect + login), the MySQL control connection, ClickHouse's first request, and the PG cancel dial (under cancel_grace, phase CancelAcknowledgement). Cancelled establishment → Cancelled, and the half-open stream is dropped. - -**SSH.** russh Config { keepalive_interval, keepalive_max, inactivity_timeout: None, nodelay: true }. The handshake runs under ssh_handshake_timeout. - -**Loss.** io TimedOut, ConnectionReset, ConnectionAborted, BrokenPipe and UnexpectedEof; tokio_postgres Error::is_closed; sqlx Io/WorkerCrashed; tiberius Io; and hyper errors map to ConnectionLost. LinkState becomes Lost and the lost() future resolves, so the monitor reconnects immediately. - -**Probe.** SELECT 1 under probe_timeout. - -The pooled idle_in_transaction_session_timeout is removed. - -**Why**: The review is right: only the TCP connect had a deadline. The tokio-postgres connect_raw doc says connect_timeout is ignored. A peer that accepts and stalls ACKs everything, so keepalive and TCP_USER_TIMEOUT never fire. - -Phases are limited to what every driver can observe. Stream open is visible to all, but TLS runs inside sqlx and tiberius, so TlsHandshake and Authentication were never produced; Login replaces them. tcp(7): TCP_USER_TIMEOUT also overrides keepalive's count. - -Workspace transactions are owned by driver tasks that always finish with COMMIT or ROLLBACK, so idle_in_transaction is unnecessary. It also has no per-client meaning under transaction pooling (review). - -**Rejected**: (1) Per-driver connect_timeout settings: ignored by connect_raw, absent in sqlx and tiberius login. -(2) One overall deadline: cannot tell an unreachable host from a stalled login. -(3) App-level timeouts only. -(4) russh inactivity_timeout. - -##### FC-D12 · Editor sessions: one owned physical connection per tab, run by a session actor - -Connection::open_session(EditorSessionOptions, cancel) returns Arc, a handle to an actor task owning one connection that never returns to a pool: -- PG: own Client plus Connection task. -- MySQL: a detached MySqlConnection through the relay. -- SQLite: own SqliteConnection. -- SQL Server: own Client. -- ClickHouse: base client clone with session_id = uuid and session_timeout = 3600. - -Session::run(sql: UserSql, options: RunOptions, sink: Box) sends the text once: -- PG: query_text_portal when the last ReadyForQuery status is Idle and FC-C23 says Single; simple_query_raw otherwise. -- MySQL/MariaDB: raw_sql fetch_many, after SET SESSION sql_select_limit when the limit changed. -- SQLite: raw_sql fetch_many. -- SQL Server: simple_query (an SQL batch, not sp_executesql). -- ClickHouse: one statement per run (ScriptExecution::SingleStatement). - -Concurrent run → Err(SessionBusy). A lost session reports SessionState::Lost and is never silently re-opened. - -RunSummary { elapsed, end: RunEnd, transaction_state } is returned whenever the server completed the exchange, including statement errors, cancel and timeout. Err is reserved for SessionBusy, ConnectionLost and Protocol. - -Session state requires a direct connection or PgBouncer session pooling. Under transaction pooling, transactions work but SET, temp tables and session read-only do not persist, and the docs say so. App-generated SQL uses the workspace pool. - -**Why**: F-013 and F-014. The simple/text protocol keeps multi-statement text, $$ bodies, MySQL CREATE TRIGGER over COM_QUERY, and SQL Server USE and SET. PgBouncer statement mode disallows multi-statement transactions (pgbouncer.org config). Returning RunSummary for statement failures lets the editor show the transaction state after an error or cancel. - -**Rejected**: (1) Pooled connections with reset. -(2) A client-side splitter for every engine. -(3) Extended protocol per statement for scripts. -(4) Transparent reopen after loss: silently discards BEGIN state. - -##### FC-D13 · Commit outcome model; PostgreSQL resolves exactly, ClickHouse resolves on the answering node - -write_batch returns Ok only after a confirmed commit, or after every statement is confirmed on StatementByStatement engines. Otherwise: -- RolledBack: ROLLBACK succeeded, or the failure was before COMMIT on a discarded connection. -- CommitOutcomeUnknown { token }: COMMIT was sent and its ack lost. -- PartiallyApplied { applied, statement_index, failed_outcome }: StatementByStatement engines. - -**PostgreSQL.** Inside the transaction, before COMMIT: SELECT pg_current_xact_id()::text, or txid_current() below server_version_num 130000. resolve_commit on a new pooled connection uses pg_xact_status($1::text::xid8) or txid_status($1::bigint): committed → Applied; aborted → NotApplied; in progress → StillRunning; NULL → Unresolvable. - -**ClickHouse.** Each statement has a query_id. Token ClickHouseQuery { query_ids, first_unconfirmed }; earlier statements are confirmed. resolve_commit: -1. SYSTEM FLUSH LOGS (access denied is ignored). -2. SELECT groupUniqArray(type) FROM system.query_log WHERE query_id = {id:String}. -3. Map: QueryFinish → Applied; ExceptionBeforeStart → NotApplied; ExceptionWhileProcessing → PartiallyApplied; only QueryStart with the id present in system.processes → StillRunning; no row or access denied → Unresolvable. - -**Others.** MySQL, SQL Server and SQLite return token None, and the UI blocks a blind re-Save until rows refresh. SQL Server workspace connections run SET XACT_ABORT ON. - -**Why**: F-058. The review is right on two points. - -ClickHouse documents that query_log data resides on each node, and a complete view needs clusterAllReplicas with a cluster name the client does not know. So 'no row' means the answering node did not see the query, not that it did not run: Unresolvable. - -ExceptionWhileProcessing can follow already-written blocks of a non-atomic INSERT, so it is PartiallyApplied, not a rollback. - -pg_xact_status is documented for exactly this purpose, and xids are cluster-global, so resolution works through PgBouncer. - -**Rejected**: (1) clusterAllReplicas with a guessed cluster name. -(2) Treating no row as NotApplied. -(3) A single Transaction variant. -(4) Treating a lost ack as success. -(5) allow_experimental_transactions. - -##### FC-D14 · DriverError v2 with typed diagnostics, observable timeout phases and ErrorCategory - -core::DriverError is #[non_exhaustive] with ServerDiagnostics (FC-C11). - -TimeoutPhase is { Connect, Login, SshHandshake, SshChannelOpen, PoolWait, LockWait, Statement, Probe, CancelAcknowledgement }. core adds ErrorCategory and DriverError::category(&self) -> ErrorCategory as an exhaustive match inside tablepro-core. app error_text matches ErrorCategory without a wildcard. - -Classification covers MySQL and MariaDB (3024 vs 1969; 1205 LockWait), PG 55P03, TDS 1222 and ClickHouse 159. net::tls::classify walks source() to rustls CertificateError, AlertReceived and InvalidMessage. - -Auth codes: PG 28P01/28000; MySQL/MariaDB 1045; TDS 18456; ClickHouse 192/193/194/516, plus 497 during connect. - -**Why**: F-049 and F-116. #[non_exhaustive] forces wildcard arms outside core, so category() gives the app a closed vocabulary: adding a variant fails to compile in core until it is categorised. - -**Rejected**: (1) Display-string matching. -(2) Query { message, sqlstate }. -(3) Wildcard matches in the app. - -##### FC-D15 · Metadata: kinds within the connected database, index key model, row identity, keyset and ctid-window paging - -list_schemas returns namespaces inside the connected database: -- PG and SQL Server: schemas. -- SQLite: main plus attached databases (temp excluded). -- MySQL/MariaDB: DATABASE() only; capability database_required = true. -- ClickHouse: currentDatabase() only. - -list_objects and ObjectKind are per FC-C12, ORDER BY schema, name, with bound parameters. PG excludes pg_is_other_temp_schema(relnamespace). - -**Index keys.** IndexInfo { keys: Vec, unique, primary, predicate }, IndexKey::{Column { name, descending }, Expression { sql, descending }}: -- PG: pg_index.indkey over 1..indnkeyatts; attnum 0 → Expression via pg_get_indexdef(indexrelid, k, true); descending via pg_index_column_has_property(indexrelid, k, 'desc'); predicate via pg_get_expr(indpred, indrelid, true). -- MySQL: information_schema.STATISTICS EXPRESSION non-null → Expression; COLLATION 'D' → descending. MariaDB selects COLUMN_NAME only. -- SQL Server: sys.index_columns (key_ordinal > 0) are always columns, computed ones included; filter_definition is the predicate. -- SQLite: PRAGMA index_xinfo key rows, where cid -2 is Expression. Its text is taken positionally from the index's sqlite_schema.sql key list, split at depth-0 commas while honouring quotes, brackets and comments; the WHERE tail becomes the predicate. -- ClickHouse: system.data_skipping_indices expr → one Expression key. - -**row_identity.** PK; else a unique, non-partial index whose keys are all NOT NULL columns; else EngineRowId (PostgresCtid for relkind r/m, PostgresTableoidCtid for p, SqliteRowid); ClickHouse SortingKey; else Unordered. - -**Paging** (build_page_select, FC-C12): -- UniqueKey and SqliteRowid without user sort: keyset After/Before. -- PostgresCtid without user sort: block windows, ctid > $after AND ctid < '(end_block,0)'::tid ORDER BY ctid LIMIT n. The fetcher sizes windows from TableEstimate.heap_blocks and advances until n rows or relation end. -- User sort, PostgresTableoidCtid, SortingKey and page jumps: OFFSET with identity tiebreak. - -**table_estimate** → TableEstimate { rows: RowCountEstimate, heap_blocks }: -- PG: reltuples summed over pg_inherits; heap_blocks = pg_relation_size(oid) / current_setting('block_size')::bigint. -- MySQL: TABLE_ROWS. -- SQL Server: sys.partitions. -- ClickHouse: total_rows. -- SQLite: sqlite_stat1. - -count_rows runs under CallOptions. - -**Why**: F-055, F-057, F-042. - -The F-057 verifier correction names the PG temp-schema filter and the missing expression index keys, and drops the MySQL cross-database point because listing DATABASE() matches the per-database model. The review is right that the previous revision reversed that and lacked index keys. - -ORDER BY ctid with OFFSET is a full scan plus sort per page (review). A ctid range is served by the TID Range Scan added in PostgreSQL 14, so a window reads only its blocks. Windows still need a small sort of their own rows, which is bounded. - -SQL Server has no expression index keys; it indexes computed columns. The ClickHouse primary key is not unique (F-055 verifier). - -**Rejected**: (1) ORDER BY ctid with OFFSET. -(2) Open-ended keyset ctid > $last: still reads to the end of the heap and sorts. -(3) Listing every MySQL database. -(4) %%physloc%%. -(5) sys.dm_db_partition_stats (needs VIEW DATABASE STATE). - -##### FC-D16 · SQLite: explicit create, absolute paths, one workspace connection, exact transaction state - -ConnectTarget::File(FileEndpoint). The driver uses SqliteConnectOptions::new().filename(path), create_if_missing(false), read_only, busy_timeout(5 s), foreign_keys(true), pragma("trusted_schema","OFF"). - -**CreateNew.** First OpenOptions::new().write(true).create_new(true).mode(0o600); an existing file → FileExists. Saved connections and reconnects always use OpenExisting. - -**Error mapping.** 14 → FileNotFound if !path.exists(), else FileAccessDenied; 26 → NotADatabase; 5/6 → Busy; 8 → ReadOnly(ServerRejected). - -**Pools.** Workspace: SqlitePoolOptions max_connections(1), acquire_timeout(pool_wait), with after_connect installing the progress handler. Each editor session owns a SqliteConnection. - -**Transaction state.** libsqlite3_sys::sqlite3_get_autocommit on LockedSqliteHandle::as_raw_handle(), called while the lock guard is held. This is the driver's only unsafe block. libsqlite3-sys uses sqlx-sqlite 0.9.0's range ">=0.30.1, <0.38.0" so Cargo unifies one version. - -Row limit: reads on past the limit (FC-D19). In-memory databases are not offered. - -**Why**: F-032 and F-109. sqlx-sqlite 0.9.0 LockedSqliteHandle exposes set_progress_handler, the hooks and as_raw_handle, but no autocommit accessor, so the one FFI call is the only way to report whether a session holds the write transaction that blocks workspace saves. The safe progress handler remains the cancel mechanism. - -**Rejected**: (1) create_if_missing when missing (TOCTOU). -(2) :memory:. -(3) Forcing WAL. -(4) Reporting Unknown for SQLite: the tab-close guard could not tell whether a write lock is held. - -##### FC-D17 · russh 0.63.3 with an injected decision-only HostKeyVerifier; tablepro-net; no globals - -crates/ssh moves to russh 0.63.3. client::Handler::check_server_key(&mut self, &PublicKeyOrCertificate) delegates to an injected Arc. For a certificate, the certified host key is checked. - -**ssh::KnownHostsVerifier { files: Vec }.** Inside spawn_blocking it runs russh::keys::known_hosts::check_known_hosts_path(host, port, key, path) for each file: -- Ok(true) in any file → Trusted; -- Err(KeyChanged { line }) → Changed; -- else Unknown. -It never writes. - -**ssh::KnownHostsStore { path }::remember** wraps learn_known_hosts_path. - -Unknown and Changed end the handshake with SshFailure::HostKeyUnknown or HostKeyChanged carrying the SHA-256 fingerprint. No prompt ever runs inside the 20 s handshake. The security foundation (F-009) owns which files are consulted and the AdwAlertDialog; on acceptance it calls remember and reconnects. - -load_secret_key takes passphrase.as_ref().map(|s| s.expose_secret()). - -**crates/net** (tablepro-net) holds DirectTcpTransport, SocketTuning, happy_eyeballs, PrivateSocketRelay, RelayDirectory and tls. Trust and environment reach drivers only through ConnectOptions. DriverRegistry keeps Arc. - -**Why**: russh 0.55 falls under RUSTSEC-2026-0153/0154. crates.io lists 0.63.3 as published 2026-09-09 with MSRV 1.89. In 0.63.3, keys/known_hosts.rs has check_known_hosts_path, learn_known_hosts_path and Error::KeyChanged { line }, and client/mod.rs has connect_stream(config, stream, handler) and the Config keepalive fields. - -Scheduling (review): the trait and KnownHostsVerifier land in migration step 3 on the existing SshTunnel. The security foundation's dialog then lands directly after and before the contract switch, so no step depends on unscheduled work. - -**Rejected**: (1) Prompting inside check_server_key: blocks the handshake deadline. -(2) Trust on first use (F-009). -(3) TLS and transport in core. -(4) A global trust store. -(5) Staying on russh 0.55. - -##### FC-D18 · Pooling through deadpool managers except SQLite - -deadpool 0.13.1 managers: -- PgManager: Type PgPooled { client, cancel: CancelToken, task }. recycle runs simple_query_raw("") under probe_timeout and discards unless the fork's transaction_status() is Idle. -- MysqlManager: MysqlPooled { conn, physical: MySqlPhysical }; recycle pings. -- MssqlManager: tiberius Client; recycle runs SELECT 1. - -Pool::builder(manager).max_size(4).wait_timeout(Some(pool_wait)).create_timeout(Some(connect_timeout + login_timeout)).recycle_timeout(Some(probe_timeout)).runtime(Runtime::Tokio1).build(). - -PoolError mapping: Timeout(Wait) → Timeout { PoolWait }; Timeout(Create) → the phase reached; Timeout(Recycle) → discard and create. - -**Why**: Managers own per-connection cancel keys and flavour, which sqlx Pool cannot attach. This removes SQL Server's Mutex (F-041) and the PoolTimedOut misreport (F-116). The Idle check keeps a connection left inside a transaction from being reused. - -**Rejected**: (1) Hand-rolled Semaphore pools. -(2) sqlx Pool for MySQL. -(3) bb8-tiberius. - -##### FC-D19 · Display row limit never changes outcome, transaction state or statement order - -**Contract.** Reaching RunOptions.row_limit never raises an error, never changes transaction state and never skips a later statement. The driver emits RowLimitReached { retained } once, stops decoding rows past the limit, and ends the set with ResultSetFinished { retained, total_rows, truncation }. - -The server is stopped early only by a mechanism that ends the row stream as LIMIT or a closed cursor would: -- **PostgreSQL:** when the last ReadyForQuery status is Idle and FC-C23 returns Single, the run is query_text_portal(text, limit + 1). PortalSuspended → ServerStopped; Close and Sync travel in the same flight, so the implicit transaction commits as it would under the simple protocol. Otherwise simple_query_raw reads on → ReadPastLimit with total_rows. -- **MySQL/MariaDB:** editor sessions SET SESSION sql_select_limit = limit + 1 before a run whose limit differs from the last value set. Exactly limit + 1 rows → ServerStopped (total unknown); more rows (explicit LIMIT, CALL result sets) → ReadPastLimit. -- **ClickHouse:** on row limit + 1, KILL QUERY WHERE query_id = ? ASYNC on the control client and the body is dropped → ServerStopped. -- **SQL Server and SQLite:** ReadPastLimit, retaining nothing. - -The editor's Stop is the explicit cancel (FC-D10); RunSummary.transaction_state shows its effect. - -**Why**: The review is right that cancelling at the limit aborts PG transaction blocks (25P02), skips later statements and, under XACT_ABORT, rolls back SQL Server work. - -Its rule of 'server stops only in autocommit' is not enough: -- A multi-statement PG Query message runs as one implicit transaction that an error rolls back (protocol-flow docs). -- Every engine skips the remaining statements after a cancel. -- Cancelling DELETE ... RETURNING or UPDATE ... OUTPUT rolls that statement back. - -The chosen mechanisms raise no error: -- **PostgreSQL:** portal.h (REL_17_STABLE) runs PORTAL_ONE_RETURNING and PORTAL_ONE_MOD_WITH to completion on first execution, and PORTAL_UTIL_SELECT into a tuplestore. A row count therefore stops only PORTAL_ONE_SELECT, which runs incrementally like a cursor. Requiring Idle makes a Parse error harmless, since nothing executes and no user transaction exists, so even a classifier mistake cannot abort a user's work. -- **MySQL 8.4:** a SELECT's own LIMIT takes precedence over sql_select_limit. Sql_cmd_select::prepare_inner sets m_use_select_limit only on lex->unit->global_parameters() when it has no limit, and sql_call.cc and sp.cc reset select_limit for CALL and stored programs. Subqueries, INSERT ... SELECT and procedures keep full semantics. -- **SQL Server:** no SELECT-only limit exists (SET ROWCOUNT also limits DML), and attention ends the batch. -- **SQLite:** no network transfer to save, and no stop that spares later statements. -- **ClickHouse:** one statement per request, and no multi-statement transactions (F-058 verifier). - -When the server stops early, a SELECT that calls data-modifying functions evaluates them only for the rows produced, which is documented LIMIT and cursor behaviour. - -**Rejected**: (1) Cancel at the limit (previous revision). -(2) Cancel only in autocommit (review): implicit multi-statement blocks and RETURNING/OUTPUT still lose work, and later statements are skipped. -(3) Pausing reads at the limit until the user decides: holds locks and snapshots on production servers. -(4) ClickHouse max_result_rows with result_overflow_mode=break: adds settings that interact with the user's own session SETs and query shape, while KILL of a single-statement request has no side effect. -(5) SET ROWCOUNT: limits DML too. -(6) Rewriting user SQL with LIMIT: needs a full parser. - -##### FC-D20 · Result streaming with an async sink and TCP backpressure - -#[async_trait] ResultSink { async fn accept(&mut self, event: ResultEvent) -> SinkFlow }. - -- The session actor awaits accept before polling the protocol stream again, so a slow consumer stops socket reads and TCP flow control holds the server back. -- Rows are never dropped or merged. Batches hold at most 256 rows. -- SinkFlow is { Continue, ConsumerGone }. ConsumerGone is returned only when the consumer no longer exists; the driver then treats the run as cancelled. -- User Stop uses RunOptions.cancel, never the sink. While accept is pending, the actor also selects on cancel and the deadline. -- App sink: async_channel::bounded(8); accept = sender.send(event).await; Err → ConsumerGone. - -**Why**: Review major item: a synchronous accept had no defined full-channel behaviour, which invited dropped rows or false truncation. - -Every stack applies backpressure when not polled: -- tokio-postgres feeds Responses through an mpsc receiver (client.rs Responses); -- sqlx, tiberius and hyper read only when polled. - -**Rejected**: (1) Synchronous accept with try_send: drops rows or truncates. -(2) Unbounded channel: memory grows with a slow consumer. -(3) A poll_ready/accept pair: two methods for one async operation. - -##### FC-D21 · PostgreSQL session bounds that pass through PgBouncer - -No PostgreSQL connection sends startup options. - -**Workspace reads.** Each call (list_*, fetch_*, row_identity, table_estimate, query_bound, count_rows) is one pipelined flight on a pooled client, with the three futures polled together so their requests go out in order: -1. batch_execute("START TRANSACTION READ ONLY; SET LOCAL statement_timeout = ; SET LOCAL lock_timeout = ") -2. query_typed_raw / execute_typed -3. batch_execute("COMMIT") - -statement_timeout = the call deadline, else settings.statement_timeout. - -**write_batch.** START TRANSACTION READ WRITE; SET LOCAL statement_timeout = settings.statement_timeout (RowChanges) or 0 (SchemaChanges); SET LOCAL lock_timeout = settings.lock_timeout. Then statements one by one, then pg_current_xact_id, then COMMIT. - -**Editor sessions.** After connect, one simple query: SET default_transaction_read_only = on (if read-only) and SET statement_timeout = (if the preference > 0). - -**Other.** probe runs SELECT 1 without a transaction. Values come from Duration::as_millis; no user text reaches these statements. - -**Why**: PgBouncer ignore_startup_parameters doc: 'By default, PgBouncer allows only parameters it can keep track of in startup packets ... All others parameters will raise an error.' The previous revision therefore failed every connection through a default PgBouncer (review correct). application_name is tracked by default. - -SET LOCAL lasts to transaction end, and transaction pooling keeps one server connection per transaction, so the bounds apply directly, under session pooling and under transaction pooling. START TRANSACTION in a multi-statement Query message turns the implicit block into a regular transaction block. tokio-postgres pipelining makes the flight one round trip. - -READ ONLY on reads means app-generated reads can never write. Schema batches get no server statement timeout because ALTER TABLE rewrites and CREATE INDEX may legitimately run long (review); the caller's deadline and cancel still apply. lock_timeout stays because DDL waiting on a lock blocks every later reader. - -Statement pooling disallows multi-statement transactions; its error is shown as ServerDiagnostics. - -**Rejected**: (1) Startup options. -(2) SET after connect on pooled connections: under transaction pooling the setting lands on server connections other clients later receive. -(3) Client deadlines only: a partitioned client cannot deliver CancelRequest. -(4) idle_in_transaction_session_timeout. - -##### FC-D22 · SQL Server login: no ApplicationIntent, one followed redirect, Kerberos off the async workers - -Config::readonly is never called; ApplicationIntent stays ReadWrite. The driver sets Config::host(server_name), port, application_name("TablePro"), encryption per FC-D7, tls_client_config unless Disable, and authentication from Credentials. - -**Routing.** Client::connect returning Error::Routing { host, port } is followed once: -- NetworkEndpoint::new(host, port), then transport.reroute(endpoint) (the same SSH session for tunnels); -- log in again with Config::host(routed host), so TLS verifies it and Kerberos asks for MSSQLSvc/:; -- server_name applies to the first endpoint only; -- a second Routing → DriverError::Protocol("the server redirected the login twice"). - -**Kerberos.** The login runs as tokio::task::spawn_blocking(move || handle.block_on(login)), with Handle::current() captured first. The caller selects on the JoinHandle, cancel and login_timeout. A login that completes late drops its Client on the blocking thread. - -**Why**: The review is right that ApplicationIntent=ReadOnly adds no write protection and that routing surfaces as Error::Routing, which c1d2e74 error.rs describes as 'Server requested a connection to an alternative address'. Failing on it would make redirect-policy servers unusable. - -c1d2e74 client/connection.rs calls Cred::acquire and ClientCtx::step synchronously inside the async login. relm4 0.11.0 builds its runtime with worker_threads defaulting to 1 (lib.rs:183-185), so a KDC stall there would freeze every task. tokio 1.52.1 Handle::block_on is valid from non-async threads (handle.rs docs), multi-thread workers keep driving IO and timers, and the stream stays registered with the same runtime. - -One redirect prevents loops between endpoints. - -**Rejected**: (1) ApplicationIntent from the read-only switch. -(2) Surfacing Routing as a failure. -(3) spawn_blocking for password logins, which make no blocking calls. -(4) A tiberius change to move GSSAPI off-thread: tiberius is runtime-agnostic, and the runtime choice belongs to the driver. - -##### FC-D23 · PostgreSQL tls-server-end-point channel binding - -PgTlsStream::channel_binding() returns tokio_postgres::tls::ChannelBinding::tls_server_end_point(hash), computed from the first tokio_rustls peer_certificates() entry by net::tls::channel_binding::tls_server_end_point: -1. Parse with x509-cert 0.3.0 Certificate::from_der and read signature_algorithm().oid. -2. Compare the OID's DER bytes against constants: - - md5WithRSAEncryption 1.2.840.113549.1.1.4, sha1WithRSAEncryption .1.1.5, ecdsa-with-SHA1 1.2.840.10045.4.1 → SHA-256; - - sha256WithRSAEncryption .1.1.11, ecdsa-with-SHA256 1.2.840.10045.4.3.2 → SHA-256; - - .1.1.12, .4.3.3 → SHA-384; - - .1.1.13, .4.3.4 → SHA-512. -3. Hash the certificate DER with aws_lc_rs::digest::digest. - -Other algorithms (RSASSA-PSS, Ed25519, Ed448) or a parse failure → ChannelBinding::none(). Config::channel_binding stays Prefer. - -**Why**: PostgreSQL be_tls_get_certificate_hash (be-secure-openssl.c, REL_17_STABLE) hashes with SHA-256 for MD5 or SHA-1 signatures and with the signature digest otherwise (RFC 5929 section 4.1), so the client must follow the same rule. SCRAM-SHA-256-PLUS binds authentication to the TLS session, which also defeats a relaying attacker in Require mode. - -none() for PSS and EdDSA keeps those servers working through plain SCRAM. The review's test wording, a server 'with channel_binding=require', does not exist: channel_binding is a libpq client parameter, so tests set Config::channel_binding(Require) on the client. - -**Rejected**: (1) Always none() (previous revision). -(2) x509-parser: a larger tree for one field. -(3) Hand-parsed DER. -(4) Require by default: refuses servers using md5 or trust authentication. - -##### FC-D24 · Stored connections change schema in place; no migration - -tablepro-storage SavedConnection replaces use_tls: bool with a required tls: TlsConfig. No serde default, alias or decoding path accepts use_tls. The JSON fixtures in crates/storage/src/connections.rs tests (lines 209, 245, 277 today) are rewritten to the new schema. SQLite saved connections store an absolute path and always reopen with OpenExisting. - -**Why**: The app moves to app.tablepro.TablePro with fresh config paths and has never shipped, so no stored file uses the old schema. A mapping would be a compatibility shim, and it would carry use_tls=true forward as unverified Require against F-008's VerifyFull default (review correct). - -**Rejected**: (1) Mapping use_tls (previous revision). -(2) #[serde(default)] on tls. - -##### FC-D25 · Delivery: additive commits, one atomic switch, docs - -Five commits first, each compiling under clippy -D warnings and each used or tested inside its own crate: the sqlx and toolchain bump; core leaf types; the russh bump with HostKeyVerifier; the tiberius fork pin; tablepro-net with the rust-postgres fork pin and cargo-deny advisories and sources. - -Then one atomic commit switches the contract, all five drivers, the ssh transport, storage and every app caller and test, and adds the deny.toml bans once ring is gone. Docs follow. No adapters, no _v2 modules, no warn-only bans. - -**Why**: The review is right that the previous order could not compile: -- The old drivers dial TcpStream::connect themselves (mssql lib.rs:132) or build URLs, so an adapter over the old ConnectOptions cannot pass streams. -- Crate-private _v2 modules are dead code under -D warnings and unreachable from tests/. -- A second DriverError cannot coexist under the same name. -- cargo-deny has no per-entry warn level. - -The Connection trait change forces every driver and caller into one commit under the atomic-API rule. Everything independent of the trait lands first. - -**Rejected**: (1) The previous 14-step order. -(2) Per-driver rewrites behind the old trait: every method body written twice. -(3) Folding tablepro-net into the switch: it has no dependency on the new traits. - -#### Contracts - -##### FC-C1 · `ConnectOptions v2, ConnectTarget, NetworkTarget, Credentials, ConnectEnvironment` - -**Location**: `crates/core/src/connect_options.rs, connect_target.rs, credentials.rs, connect_environment.rs` · **Kind**: rust-types · **Consumers**: All drivers; app connection_service (establish), connect_dialog, connection_monitor; W2, W6. - -``` -pub struct ConnectOptions { pub target: ConnectTarget, pub workspace_settings: SessionSettings, pub liveness: LivenessPolicy, pub environment: ConnectEnvironment } -#[derive(Debug, Clone)] pub struct ConnectEnvironment { pub private_socket_dir: PathBuf } -#[derive(Debug, Clone)] pub enum ConnectTarget { Network(NetworkTarget), File(FileEndpoint) } -#[derive(Debug, Clone)] pub struct NetworkTarget { pub transport: Arc, pub credentials: Credentials, pub database: Option, pub tls: TlsConfig, pub trust: Arc } -impl NetworkTarget { pub fn tls_server_name(&self) -> &str } // tls.server_name, else transport.service_endpoint().host() -#[derive(Debug, Clone)] pub enum Credentials { Password { username: String, password: secrecy::SecretString }, Integrated } -impl Credentials { pub fn mode(&self) -> AuthMode } -AuthMode { Password, Kerberos } is unchanged. pub const APPLICATION_NAME: &str = "TablePro"; -Deleted: the old ConnectOptions fields, service_address, and the Default impl. -``` - -The app sets private_socket_dir = glib::user_runtime_dir().join(APP_ID). SecretString Debug stays redacted. Tests build targets with test_support::network_target(endpoint). - -##### FC-C2 · `Endpoints` - -**Location**: `crates/core/src/network_endpoint.rs, file_endpoint.rs` · **Kind**: rust-types · **Consumers**: Drivers, DirectTcpTransport, SshTransport, Transport::reroute, connect dialog, storage (W6). - -``` -pub struct NetworkEndpoint { host: String, port: u16 } -impl NetworkEndpoint { - pub fn new(host: impl Into, port: u16) -> Result; // trims; rejects empty, whitespace or control characters and port 0; strips [ ] around IPv6 - pub fn host(&self) -> &str; - pub fn port(&self) -> u16; -} -Display: host:port or [v6]:port. - -pub struct FileEndpoint { path: PathBuf, open_mode: FileOpenMode } -pub enum FileOpenMode { OpenExisting, CreateNew } -impl FileEndpoint { - pub fn open_existing(path: PathBuf) -> Result; - pub fn create_new(path: PathBuf) -> Result; // both require a non-empty absolute path - pub fn path(&self) -> &Path; - pub fn open_mode(&self) -> FileOpenMode; -} -``` - -SQL Server's '.' shorthand maps to localhost inside the mssql driver only. - -##### FC-C3 · `TLS configuration and capabilities` - -**Location**: `crates/core/src/tls.rs` · **Kind**: rust-types · **Consumers**: Drivers; net TlsClientConfigBuilder; connect dialog; storage SavedConnection.tls (required field, FC-D24). - -#[serde(rename_all="snake_case")] pub enum TlsMode { Disable, Require, VerifyFull } -pub struct ClientIdentity { pub certificate_file: PathBuf, pub private_key_file: PathBuf } -pub struct TlsConfig { - pub mode: TlsMode, - #[serde(default)] pub ca_file: Option, - #[serde(default)] pub client_identity: Option, - #[serde(default)] pub server_name: Option, -} -impl TlsConfig { - pub fn verify_full() -> Self; - pub fn disabled() -> Self; - pub fn validate(&self, caps: &TlsCapabilities) -> Result<(), ConfigError>; -} -pub struct TlsCapabilities { pub modes: &'static [TlsMode], pub ca_file: bool, pub client_identity: bool, pub server_name_override: bool } - -validate: the mode is in caps.modes; ca_file and client_identity are allowed only when supported and mode != Disable; server_name parses as rustls_pki_types::ServerName; paths are absolute. - -Per engine: -- postgres, mysql, clickhouse: all modes, CA file, client identity, server name. -- mssql: all modes, CA file, no client identity, server name. -- sqlite: None. - -The mode itself is required in SavedConnection (no default). Private keys are read at connect and live only inside the rustls config. - -##### FC-C4 · `SessionSettings, EditorSessionOptions, LivenessPolicy and per-engine application` - -**Location**: `crates/core/src/session_settings.rs, liveness.rs` · **Kind**: rust-types · **Consumers**: Drivers; app preferences (query_timeout_secs → EditorSessionOptions; 0 → None); W3. - -pub struct SessionSettings { pub read_only: bool, pub statement_timeout: Duration, pub lock_timeout: Duration } -impl SessionSettings { pub fn workspace(read_only: bool) -> Self } // statement 60 s, lock 10 s -pub struct EditorSessionOptions { pub statement_timeout: Option } -pub struct LivenessPolicy { pub connect_timeout, login_timeout, tcp_keepalive_idle, tcp_keepalive_interval: Duration, pub tcp_keepalive_retries: u32, pub tcp_user_timeout, happy_eyeballs_attempt_delay, probe_timeout, pool_wait, cancel_grace, ssh_handshake_timeout, ssh_channel_open_timeout, ssh_keepalive_interval: Duration, pub ssh_keepalive_max: usize } -LivenessPolicy::DESKTOP = 10 s, 15 s, 30 s, 10 s, 3, 45 s, 250 ms, 5 s, 5 s, 5 s, 20 s, 10 s, 15 s, 3 - -Workspace application: -- **PostgreSQL** (per call, FC-D21): reads use START TRANSACTION READ ONLY + SET LOCAL statement_timeout (deadline, else settings) + SET LOCAL lock_timeout. write_batch uses READ WRITE with statement_timeout = settings (RowChanges) or 0 (SchemaChanges). Startup parameters: user, dbname, application_name. -- **MySQL** (per physical connection): SET SESSION TRANSACTION READ ONLY (if read-only); SET SESSION max_execution_time = ; SET SESSION lock_wait_timeout = ; SET SESSION innodb_lock_wait_timeout = . -- **MariaDB:** the same, but SET SESSION max_statement_time = instead of max_execution_time. A SchemaChanges write_batch sets max_statement_time = 0 before and restores the workspace value after; a failed restore discards the connection. -- **SQL Server** (per connection): SET LOCK_TIMEOUT ; SET XACT_ABORT ON; Config::application_name. -- **ClickHouse:** client settings readonly=2 and allow_ddl=0 (if read-only), mutations_sync=1, product info. Per request: max_execution_time = (deadline, settings.statement_timeout for RowChanges, 0 for SchemaChanges) and timeout_overflow_mode=throw. -- **SQLite:** read_only, busy_timeout 5 s. - -Editor sessions: -- PG: SET default_transaction_read_only = on / SET statement_timeout after connect. -- MySQL/MariaDB: read-only SET plus the flavour's statement timeout (None → 0); sql_select_limit per run (FC-C25). -- SQL Server: nothing beyond the login. -- ClickHouse: max_execution_time from the option. -- SQLite: read_only. - -Integers come only from Duration::as_millis, as_secs or as_secs_f64. No user text reaches session SQL. - -##### FC-C5 · `Transport trait, TransportError, SshFailure` - -**Location**: `crates/core/src/transport.rs, transport_error.rs, ssh_failure.rs` · **Kind**: rust-trait · **Consumers**: net DirectTcpTransport and PrivateSocketRelay; ssh SshTransport and SshTunnel (step 3); drivers; app connection_service and monitor; security foundation (host-key UI). - -``` -pub trait TransportStream: AsyncRead + AsyncWrite + Send + Unpin + 'static {} // blanket impl -#[async_trait] pub trait Transport: Send + Sync + Debug { - fn service_endpoint(&self) -> &NetworkEndpoint; - fn kind(&self) -> TransportKind; - async fn open(&self) -> Result, TransportError>; - fn reroute(&self, endpoint: NetworkEndpoint) -> Arc; -} -pub enum TransportKind { DirectTcp, SshTunnel { bastion: NetworkEndpoint } } -#[non_exhaustive] pub enum TransportError { - NameResolution { host, source: io::Error }, - Refused { endpoint }, // every attempt got ConnectionRefused - Unreachable { endpoint, source: io::Error }, - Timeout { phase: TimeoutPhase }, - Ssh(#[from] SshFailure), -} -#[non_exhaustive] pub enum SshFailure { - HostKeyUnknown { host, port, algorithm, sha256_fingerprint }, - HostKeyChanged { host, port, algorithm, sha256_fingerprint, known_hosts: PathBuf, line: usize }, - AuthenticationRejected { username }, - PrivateKeyUnreadable { path, detail }, - ChannelOpenRejected { reason }, - SessionClosed, - Protocol(String), -} -``` - -open() is called for every physical connection, PG cancel dial, relay accept and ClickHouse connector call; it must be cheap and concurrency-safe. reroute builds a transport to another endpoint with the same tuning or SSH session. is_lost is removed; the app watches SshSession::closed(). - -##### FC-C6 · `TrustSource and TrustAnchors` - -**Location**: `crates/core/src/trust.rs` · **Kind**: rust-trait · **Consumers**: net TlsClientConfigBuilder; net SystemTrustSource; test_support FixedTrustSource (bundled &[], system [generated CA]). - -``` -#[async_trait] pub trait TrustSource: Send + Sync + Debug { async fn anchors(&self) -> Result; } -#[derive(Debug, Clone)] pub struct TrustAnchors { pub bundled: &'static [rustls_pki_types::TrustAnchor<'static>], pub system_certificates: Arc<[rustls_pki_types::CertificateDer<'static>]> } -#[derive(Debug, Clone, thiserror::Error)] pub enum TrustError { #[error("no trusted certificate authorities are available")] NoAnchors } -``` - -Invariant: system_certificates holds only certificates accepted by rustls RootCertStore::add. core depends on rustls-pki-types (std), tokio (io-util, sync), tokio-util and async-trait. - -##### FC-C7 · `DatabaseDriver v2 and DriverCapabilities` - -**Location**: `crates/core/src/driver.rs, capabilities.rs` · **Kind**: rust-trait · **Consumers**: DriverRegistry; connect_dialog; row_ops.rs; structure.rs; editor; W2/W3/W4. - -#[async_trait] pub trait DatabaseDriver: Send + Sync { - fn id(&self) -> &'static str; - fn display_name(&self) -> &'static str; - fn capabilities(&self) -> &'static DriverCapabilities; - async fn connect(&self, options: ConnectOptions, cancel: CancellationToken) -> Result, DriverError>; -} -pub struct DriverCapabilities { pub endpoint: EndpointKind, pub auth_modes: &'static [AuthMode], pub tls: Option, pub read_only: ReadOnlyEnforcement, pub dml_writes: WriteAtomicity, pub ddl_writes: WriteAtomicity, pub reports_rows_affected: bool, pub script_execution: ScriptExecution, pub commit_resolution: CommitResolutionSupport, pub row_count_estimate: EstimateQuality, pub object_kinds: &'static [ObjectKind], pub database_required: bool } -pub enum EndpointKind { Network { default_port: u16 }, File } -pub enum ReadOnlyEnforcement { EngineEnforced, SessionDefault, LoginPrivileges } -pub enum WriteAtomicity { Transactional, StatementByStatement } -pub enum ScriptExecution { NativeBatch, SingleStatement } -pub enum CommitResolutionSupport { ServerTransactionStatus, QueryLog, Unavailable } -pub enum EstimateQuality { Statistics, ExactMetadata, Unavailable } - -Values: -- **postgres:** 5432; [Password]; SessionDefault; Transactional/Transactional; rows_affected true; NativeBatch; ServerTransactionStatus; Statistics; [Table, PartitionedTable, View, MaterializedView, ForeignTable, Sequence, Function, Procedure]; database_required false. -- **mysql:** 3306; [Password]; SessionDefault; Transactional/StatementByStatement; true; NativeBatch; Unavailable; Statistics; [Table, View, Sequence, Function, Procedure]; true. -- **sqlite:** File; []; EngineEnforced; Transactional/Transactional; true; NativeBatch; Unavailable; Statistics; [Table, View]; false. -- **mssql:** 1433; [Password, Kerberos]; LoginPrivileges; Transactional/Transactional; true; NativeBatch; Unavailable; Statistics; [Table, View, Sequence, Function, Procedure]; false. -- **clickhouse:** 8123; [Password]; EngineEnforced; StatementByStatement/StatementByStatement; false; SingleStatement; QueryLog; ExactMetadata; [Table, View, MaterializedView, Dictionary, Function]; false. - -Deleted: default_port, is_file_based, ddl_is_transactional, reports_rows_affected (method), supports_integrated_auth. - -ClickHouse keeps port 8123; the TLS row subtitle names 8443 as the usual HTTPS port. - -##### FC-C8 · `Connection v2, CallOptions, LinkState and the execution model` - -**Location**: `crates/core/src/connection.rs, call_options.rs, link_state.rs, server_info.rs` · **Kind**: rust-trait · **Consumers**: app browse.rs, row_ops.rs, structure.rs, sidebar, connection_monitor (lost(), probe), export_dialog; W2/W3/W4. - -pub struct CallOptions { pub deadline: Option, pub cancel: CancellationToken } -impl CallOptions { - pub fn metadata(cancel: CancellationToken) -> Self; // deadline 15 s - pub fn with_deadline(deadline: Option, cancel: CancellationToken) -> Self; -} -pub enum LinkState { Open, Lost } -pub struct ServerInfo { pub product: String, pub version: String, pub version_num: Option, pub current_database: Option, pub default_schema: Option } // product 'MySQL' or 'MariaDB' for the mysql driver -#[async_trait] pub trait Connection: Send + Sync { - fn server_info(&self) -> &ServerInfo; - fn read_only(&self) -> bool; - fn link_state(&self) -> LinkState; - fn lost(&self) -> tokio_util::sync::WaitForCancellationFutureOwned; - async fn probe(&self, o: CallOptions) -> Result<(), DriverError>; - async fn list_schemas(&self, o: CallOptions) -> Result, DriverError>; - async fn list_objects(&self, scope: &ObjectScope, o: CallOptions) -> Result, DriverError>; - async fn fetch_columns(&self, t: &TableRef, o: CallOptions) -> Result, DriverError>; - async fn fetch_indexes(&self, t: &TableRef, o: CallOptions) -> Result, DriverError>; - async fn fetch_foreign_keys(&self, t: &TableRef, o: CallOptions) -> Result, DriverError>; - async fn row_identity(&self, t: &TableRef, o: CallOptions) -> Result; - async fn table_estimate(&self, t: &TableRef, o: CallOptions) -> Result; - async fn query_bound(&self, s: &BoundStatement, limit: RowLimit, o: CallOptions) -> Result; - async fn count_rows(&self, s: &BoundStatement, o: CallOptions) -> Result; - async fn write_batch(&self, b: &WriteBatch, o: CallOptions) -> Result; - async fn resolve_commit(&self, token: &CommitToken, o: CallOptions) -> Result; - async fn open_session(&self, options: EditorSessionOptions, cancel: CancellationToken) -> Result, DriverError>; - async fn close(&self); -} -pub struct QueryResult { pub columns: Arc<[ResultColumn]>, pub rows: Vec>, pub truncated: bool } - -Execution model: every method clones its inputs into a task spawned on the current runtime and tracked by the connection's tokio_util::task::TaskTracker (tokio-util feature rt). The returned future awaits a oneshot and holds o.cancel.clone().drop_guard(). close() cancels the connection's shutdown token, closes the tracker and waits up to cancel_grace. - -Protocols: -- PG: the FC-D21 pipelined flight. -- MySQL/MariaDB and SQLite: sqlx::query(AssertSqlSafe) with binds and persistent(false); transactions via begin. -- SQL Server: Client::query/execute (sp_executesql); write_batch runs BEGIN TRANSACTION, the statements, COMMIT. -- ClickHouse: Query::param on {pN:Type} placeholders; each statement gets its own query_id. - -Deleted: list_tables, fetch_rows, query, query_params, execute, execute_params, execute_in_transaction, ping, close(self: Box), MAX_QUERY_ROWS, estimate_row_count. - -core::LinkState replaces the previous core ConnectionHealth, so it no longer collides with app database_service::ConnectionHealth { Healthy, Reconnecting }, which W2 owns. Metadata SQL is static with bound parameters; only quote_ident output reaches format!. - -##### FC-C9 · `Session, RunOptions, async ResultSink, RunSummary` - -**Location**: `crates/core/src/session.rs, run_options.rs, result_sink.rs, result_column.rs` · **Kind**: rust-trait · **Consumers**: W3 editor (one Session per tab, async_channel sink), history, W4 grid (Truncation, RowLimitReached), W2 tab-close guard. - -pub struct SessionId(uuid::Uuid); -pub enum SessionState { Ready, Running, Lost } -pub enum TransactionState { Idle, InTransaction, Failed, Unknown } -pub struct RowLimit(NonZeroU64); -impl RowLimit { pub const EDITOR_DEFAULT: RowLimit /* 10_000 */; pub fn new(u64) -> Option; pub fn get(self) -> u64 } -pub struct RunOptions { pub row_limit: RowLimit, pub deadline: Option, pub cancel: CancellationToken } -#[async_trait] pub trait ResultSink: Send + 'static { async fn accept(&mut self, event: ResultEvent) -> SinkFlow; } -pub enum SinkFlow { Continue, ConsumerGone } -pub enum ResultEvent { - ResultSetStarted { columns: Arc<[ResultColumn]> }, - Rows(Vec>), - RowLimitReached { retained: u64 }, - ResultSetFinished { retained: u64, total_rows: Option, truncation: Truncation }, - CommandCompleted { rows_affected: Option }, - Notice(ServerNotice), -} -pub enum Truncation { Complete, ServerStopped, ReadPastLimit } -pub struct ResultColumn { pub name: String, pub data_type: String, pub native_type: NativeTypeId } -pub enum NativeTypeId { PostgresOid(u32), MySql { column_type: u8, flags: u16 }, Tds(u8), ClickHouse(String), Sqlite(String), Unknown } -pub struct ServerNotice { pub severity: String, pub message: String, pub code: Option } -pub enum RunEnd { Completed, Failed(DriverError), Cancelled, TimedOut { server_cancelled: bool } } -pub struct RunSummary { pub elapsed: Duration, pub end: RunEnd, pub transaction_state: TransactionState } -#[async_trait] pub trait Session: Send + Sync { - fn id(&self) -> SessionId; - fn state(&self) -> SessionState; - async fn run(&self, sql: UserSql, options: RunOptions, sink: Box) -> Result; - async fn transaction_state(&self, options: CallOptions) -> Result; - async fn close(&self); -} - -Rules: -- At most 256 rows per Rows event; accept is awaited before the stream is polled again. -- ResultSetStarted precedes rows. -- MySQL and SQLite emit ResultSetStarted only with the first row; a statement with no rows ends in CommandCompleted. -- An error mid-script: events for completed statements, then RunSummary { end: Failed }. -- Err only for SessionBusy, ConnectionLost and Protocol. -- ConsumerGone → the driver cancels the run. - -transaction_state sources: -- PG: the fork's ReadyForQuery status (I/T/E), with no round trip. -- MariaDB: SELECT @@in_transaction. -- MySQL: SELECT COUNT(*) FROM performance_schema.events_transactions_current WHERE THREAD_ID = PS_CURRENT_THREAD_ID() AND STATE = 'ACTIVE'; access denied or a missing function → Unknown. -- SQL Server: SELECT @@TRANCOUNT, XACT_STATE() (-1 → Failed). -- SQLite: sqlite3_get_autocommit. -- ClickHouse: Idle. -RunSummary fills it after every run with the same source. - -The session is an actor task; run sends a request with an owned sink and returns SessionBusy if one is active. MySQL 8.4 documents that performance_schema tables require SELECT and that the transaction consumers are enabled by default. INNODB_TRX is not used because it needs PROCESS. - -##### FC-C10 · `Statements, write batches, commit tokens` - -**Location**: `crates/core/src/generated_sql.rs, user_sql.rs, bound_statement.rs, write_batch.rs, commit.rs` · **Kind**: rust-types · **Consumers**: sql_dialect, sql_ddl, filter builders; change_tracker, structure_tracker; row_ops.rs; structure.rs; W4/W5. - -``` -pub struct GeneratedSql(String) { new, as_str } -pub struct UserSql(Arc) { new(String), as_str } -pub struct BoundStatement { pub sql: GeneratedSql, pub params: Vec } -pub enum WriteKind { RowChanges, SchemaChanges } -pub struct WriteBatch { pub kind: WriteKind, pub statements: Vec } -pub struct WriteReport { pub rows_affected: Vec> } -#[derive(Debug, Clone, PartialEq, Eq)] pub enum CommitToken { PostgresXid { xid: u64, legacy_txid: bool }, ClickHouseQuery { query_ids: Vec, first_unconfirmed: usize } } -pub enum CommitResolution { Applied, NotApplied, PartiallyApplied, StillRunning, Unresolvable } -pub enum StatementOutcome { Failed, Unknown } - -Atomicity follows capabilities.dml_writes (RowChanges) or ddl_writes (SchemaChanges). -``` - -CommitToken has no serde derives: it is never persisted, and the previous serde round-trip test is dropped. The resolution mapping is in FC-D13. - -##### FC-C11 · `DriverError v2, ErrorCategory, ServerDiagnostics, TimeoutPhase, ConfigError` - -**Location**: `crates/core/src/error.rs, error_category.rs, server_diagnostics.rs, tls_failure.rs, timeout_phase.rs, config_error.rs` · **Kind**: rust-error-enum · **Consumers**: app error_text.rs (match on ErrorCategory, no wildcard; UnknownIssuer → 'Choose CA Certificate…', NameMismatch → 'Set TLS Server Name'); row_ops, structure, editor, connection_monitor; W2/W3/W4. - -#[non_exhaustive] pub enum DriverError { - Transport(#[from] TransportError), - Auth { diagnostics: Option }, - IntegratedAuth(String), - Tls { failure: TlsFailure, detail: String }, - Server(ServerDiagnostics), - ReadOnly(ReadOnlyRefusal), - Cancelled, - Timeout { phase: TimeoutPhase, server_cancelled: bool }, - ConnectionLost { during: LossPhase }, - RolledBack { statement_index: usize, source: Box }, - CommitOutcomeUnknown { token: Option, source: Box }, - PartiallyApplied { applied: usize, statement_index: usize, failed_outcome: StatementOutcome, source: Box }, - FileNotFound { path }, FileAccessDenied { path }, NotADatabase { path }, FileExists { path }, - Busy, SessionBusy, - Config(#[from] ConfigError), - Decode { column, detail }, - Protocol(String), - Unsupported { feature: &'static str }, -} -pub enum ErrorCategory { Configuration, Network, Tls, Authentication, ReadOnly, Server, Interrupted, Timeout, ConnectionLost, WriteOutcome, File, Busy, Internal } -impl DriverError { pub fn category(&self) -> ErrorCategory } - -category mapping: -- Transport → Network; Auth, IntegratedAuth → Authentication; Tls → Tls; Server → Server; ReadOnly → ReadOnly. -- Cancelled → Interrupted; Timeout → Timeout; ConnectionLost → ConnectionLost. -- RolledBack, CommitOutcomeUnknown, PartiallyApplied → WriteOutcome. -- File* → File; Busy, SessionBusy → Busy; Config, Unsupported → Configuration; Decode, Protocol → Internal. - -pub struct ServerDiagnostics { code: ServerCode, severity, message, detail, hint, position, where_context, schema, table, column, constraint } -pub enum ServerCode { SqlState(String), MySql { number: u16, sqlstate: Option }, Tds { number: u32, state: u8, class: u8 }, ClickHouse { code: i32 }, Sqlite { primary: i32, extended: i32 } } -pub enum TlsFailure { UnknownIssuer, NameMismatch, Expired, NotYetValid, Revoked, ServerRefusedTls, ServerRequiresTls, HandshakeRejected, CaFileInvalid, ClientIdentityInvalid, Other } -pub enum TimeoutPhase { Connect, Login, SshHandshake, SshChannelOpen, PoolWait, LockWait, Statement, Probe, CancelAcknowledgement } -pub enum LossPhase { Connect, Idle, Statement, Commit } -pub enum ReadOnlyRefusal { ClientGuard, ServerRejected(ServerDiagnostics) } -pub enum ConfigError { EmptyHost, InvalidHost, InvalidPort, RelativePath { path }, UnsupportedTlsMode { mode }, ClientIdentityIncomplete, CaFile { path, problem: CertificateFileProblem }, InvalidServerName { name }, AuthModeUnsupported { mode }, DatabaseRequired } -pub enum CertificateFileProblem { Unreadable, NoCertificates, InvalidPem, InvalidCertificate, NoPrivateKey } - -Classification: -- Auth: PG 28P01/28000; MySQL/MariaDB 1045; TDS 18456; ClickHouse 192/193/194/516 (497 during connect). -- ReadOnly(ServerRejected): PG 25006; MySQL/MariaDB 1792; TDS 3906; ClickHouse 164; SQLite 8. -- Cancelled (own flag): PG 57014; MySQL/MariaDB 1317; ClickHouse 394; TDS attention acknowledgement; SQLite 9. -- Timeout Statement: PG 57014 without own flag; MySQL 3024; MariaDB 1969 (and 3024); ClickHouse 159. -- Timeout LockWait: PG 55P03; MySQL/MariaDB 1205; TDS 1222. -- PoolWait: deadpool Timeout(Wait). -- Busy: SQLite 5/6. -- ConnectionLost: io TimedOut, ConnectionReset, ConnectionAborted, BrokenPipe, UnexpectedEof; tokio_postgres is_closed; sqlx WorkerCrashed; tiberius Io; hyper. -- Tls: net::tls::classify. - -TlsHandshake and Authentication phases are removed because no driver can observe them separately (FC-D11). - -##### FC-C12 · `Metadata types, index keys, identity and paging` - -**Location**: `crates/core/src/schema_info.rs, object_info.rs, table_ref.rs, index_info.rs, row_identity.rs, table_estimate.rs, page.rs` · **Kind**: rust-types · **Consumers**: Sidebar (kind icons), browse paginator (estimates 'about N'), structure tab (keys, predicates, kind gating), sql_ddl, workspace persistence (TableRef). - -pub struct SchemaInfo { pub name, pub is_default: bool, pub is_system: bool } -pub enum ObjectScope { DefaultSchema, Schema(String), AllSchemas } -pub struct TableRef { pub schema: Option, pub name: String } -pub struct ObjectInfo { pub schema, pub name, pub kind: ObjectKind } -#[non_exhaustive] #[serde(rename_all="snake_case")] pub enum ObjectKind { Table, PartitionedTable, View, MaterializedView, ForeignTable, Sequence, Function, Procedure, Dictionary } -impl ObjectKind { pub fn is_browsable(self) -> bool; pub fn is_structure_editable(self) -> bool } -pub struct IndexInfo { pub name: String, pub keys: Vec, pub unique: bool, pub primary: bool, pub predicate: Option } -pub enum IndexKey { Column { name: String, descending: bool }, Expression { sql: String, descending: bool } } -pub enum RowIdentity { UniqueKey { columns: Vec }, EngineRowId(EngineRowId), SortingKey { expressions: Vec }, Unordered } -pub enum EngineRowId { PostgresCtid, PostgresTableoidCtid, SqliteRowid } -pub enum RowCountEstimate { Exact(u64), Approximate(u64), Unknown } -pub struct TableEstimate { pub rows: RowCountEstimate, pub heap_blocks: Option } -pub enum PagePosition { First, Offset(u64), AfterKey(Vec), BeforeKey(Vec), CtidWindow { after: Option<(u32, u16)>, end_block: u64 } } -pub struct PageSpec { pub statement: BoundStatement, pub stability: PageStability, pub cost: PageCost } -pub enum PageStability { Stable, NonUniqueOrder, Unordered } -pub enum PageCost { KeySeek, BlockWindow, SortPerPage } - -SQL foundation signature: -pub fn build_page_select(dialect: Dialect, table: &TableRef, filter: Option<&BoundWhere>, user_order: &[OrderTerm], identity: &RowIdentity, position: &PagePosition, limit: u64) -> Result - -Rules: -- **Keyset** (UniqueKey or SqliteRowid, no user sort): row-value comparison on PG, MySQL and SQLite; expanded OR form on SQL Server. Before reverses the order and the fetcher reverses rows back. -- **PostgresCtid, no user sort:** ctid > $after AND ctid < '(end_block,0)'::tid ORDER BY ctid LIMIT n. The fetcher sizes windows as ceil(2 × limit × heap_blocks / max(rows, 1)) blocks and advances until limit rows or end_block ≥ heap_blocks. -- **Otherwise:** OFFSET with the identity appended after the user order. SortingKey → NonUniqueOrder; Unordered → Unordered. - -TableInfo and IndexInfo.columns are deleted in the atomic switch; sql_ddl and structure_tracker move to IndexKey. Per-engine sources are listed in FC-D15. - -##### FC-C13 · `net::DirectTcpTransport, SocketTuning, happy_eyeballs` - -**Location**: `crates/net/src/direct_tcp.rs, socket_tuning.rs, happy_eyeballs.rs` · **Kind**: rust-impl · **Consumers**: app connection_service; ssh SshSession (its own socket); tests. - -``` -pub struct SocketTuning { keepalive_idle, keepalive_interval: Duration, keepalive_retries: u32, user_timeout: Duration } -impl SocketTuning { - pub fn from_policy(&LivenessPolicy) -> Self; - pub fn apply(&self, s: &tokio::net::TcpStream) -> io::Result<()>; // set_nodelay; SockRef::set_tcp_keepalive(TcpKeepalive::new().with_time().with_interval().with_retries()); set_tcp_user_timeout -} -pub fn interleave(addresses: Vec) -> Vec; // RFC 8305 section 4: alternate families, starting with the first answer's family -pub async fn race(addresses: Vec, attempt_delay: Duration, dial: D) -> Result - where D: Fn(SocketAddr) -> F, F: Future>; -// attempt n starts when n-1 fails or attempt_delay after n-1 started; first Ok wins; losers are dropped -pub struct RaceFailure { pub attempts: Vec<(SocketAddr, io::Error)> } -pub struct DirectTcpTransport { endpoint: NetworkEndpoint, tuning: SocketTuning, connect_timeout: Duration, attempt_delay: Duration } -impl DirectTcpTransport { pub fn new(endpoint: NetworkEndpoint, policy: &LivenessPolicy) -> Self } -impl Transport for DirectTcpTransport -// open = timeout(connect_timeout, lookup_host → interleave → race(TcpStream::connect)) then tuning.apply -// lookup error → NameResolution; all refused → Refused; elapsed → Timeout { Connect }; otherwise Unreachable -// reroute = new(endpoint, same tuning) - -Cargo: socket2 = { version = "0.6", features = ["all"] }. -``` - -TCP_USER_TIMEOUT is Linux-only, matching the target. race is generic so tests use fake dialers under tokio::time::pause. - -##### FC-C14 · `net::PrivateSocketRelay` - -**Location**: `crates/net/src/relay.rs, relay_directory.rs` · **Kind**: rust-impl · **Consumers**: drivers/mysql only. - -``` -pub struct RelayDirectory { base: PathBuf } -impl RelayDirectory { pub fn new(private_socket_dir: PathBuf) -> Self } -pub struct PrivateSocketRelay { dir, socket_path, owner_uid: u32, cancel: CancellationToken, task: JoinHandle<()> } -impl PrivateSocketRelay { - pub async fn start(directory: &RelayDirectory, transport: Arc) -> Result; - pub fn socket_path(&self) -> &Path; -} -impl Drop // cancel, then remove_dir_all, logging errors -pub enum RelayError { Directory(io::Error), NotPrivate { path }, PathTooLong { path }, Bind(io::Error) } - -start: -1. base/tunnels with DirBuilderExt::mode(0o700).recursive(true). -2. base/tunnels/<12 hex chars> with mode 0o700. -3. symlink_metadata checks: is a directory, not a symlink, mode 0o700; owner_uid = its uid. -4. Reject socket paths over 107 bytes. -5. UnixListener::bind(dir/mysql.sock). - -Accept: peer_cred().uid() == owner_uid, else drop and warn. Then copy_bidirectional with transport.open(). An upstream error closes the unix side. -``` - -Inside Flatpak, glib::user_runtime_dir() is private to the sandbox instance. - -##### FC-C15 · `net::tls module` - -**Location**: `crates/net/src/tls/{system_trust.rs, client_config.rs, pem_bundle.rs, unverified.rs, classify.rs, channel_binding.rs, postgres_connector.rs, setup_error.rs}` · **Kind**: rust-impl · **Consumers**: All network drivers (map TlsSetupError to DriverError::Config or Tls); app main.rs (SystemTrustSource::new + preload); connect dialog Test (build dry run). - -pub struct SystemTrustSource { cache: tokio::sync::OnceCell, loader: fn() -> rustls_native_certs::CertificateResult } -impl SystemTrustSource { - pub fn new() -> Self; // loader = rustls_native_certs::load_native_certs - pub fn with_loader(loader: fn() -> CertificateResult) -> Self; - pub async fn preload(&self); -} -impl TrustSource for SystemTrustSource // spawn_blocking(loader); keep certs accepted by RootCertStore::add; warn with the rejected count; bundled = webpki_roots::TLS_SERVER_ROOTS - -pub struct TlsClientConfigBuilder<'a> { tls: &'a TlsConfig, trust: &'a dyn TrustSource } -impl TlsClientConfigBuilder<'_> { - pub async fn build(&self) -> Result, TlsSetupError>; - pub async fn mysql_ca_bundle_pem(&self) -> Result, TlsSetupError>; - pub fn client_identity_pem(&self) -> Result, Vec)>, TlsSetupError>; -} -// build: ClientConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider())).with_protocol_versions(DEFAULT_VERSIONS); roots = extend(bundled) + add_parsable_certificates(system) + CA file (each add must succeed); -// VerifyFull → WebPkiServerVerifier::builder_with_provider; Require → UnverifiedServerCertVerifier; -// identity via PrivateKeyDer::from_pem_file and with_client_auth_cert -pub enum TlsSetupError { Config(ConfigError), Trust(TrustError), Rustls(rustls::Error) } -pub fn encode_certificates_pem(certs: &[CertificateDer<'_>]) -> Vec; // base64 0.22 STANDARD, 64-column lines -pub struct UnverifiedServerCertVerifier { provider: Arc } // signatures still verified via verify_tls12/13_signature -pub fn classify(error: &(dyn Error + 'static)) -> Option<(TlsFailure, String)>; -pub fn tls_server_end_point(certificate: &CertificateDer<'_>) -> Option>; // FC-D23 -#[derive(Clone)] pub struct PgTlsConnector { config: Arc, server_name: ServerName<'static> } -impl tokio_postgres::tls::TlsConnect for PgTlsConnector { type Stream = PgTlsStream; type Error = io::Error; type Future = BoxFuture<'static, io::Result>>; } -pub struct PgTlsStream(tokio_rustls::client::TlsStream); -impl tokio_postgres::tls::TlsStream for PgTlsStream // channel_binding() → tls_server_end_point(peer_certificates()[0]).map_or(ChannelBinding::none(), ChannelBinding::tls_server_end_point) - -Cargo: -- tablepro-core -- tokio (net, io-util, time, sync, rt) -- tokio-util (rt) -- socket2 0.6 [all] -- rustls 0.23 (default-features false; aws_lc_rs, std, tls12, logging) -- rustls-native-certs 0.8 -- webpki-roots 1 -- tokio-rustls 0.26 (aws_lc_rs, tls12) -- rustls-pki-types 1 [std] -- tokio-postgres 0.7.18 (default-features false) -- x509-cert 0.3.0 -- aws-lc-rs 1 -- base64 0.22 -- futures-util, tracing, thiserror, uuid -- dev: rcgen - -PgTlsConnector is shared by the postgres driver and its cancel dial. - -##### FC-C16 · `ssh crate v2: SshSession, SshTransport, HostKeyVerifier, KnownHostsVerifier` - -**Location**: `crates/ssh/src/{session.rs, transport.rs, host_key_verifier.rs, known_hosts.rs, config.rs}` · **Kind**: rust-impl · **Consumers**: app connection_service; security foundation F-009 (file policy, AdwAlertDialog, KnownHostsStore::remember then reconnect); connection_monitor. - -``` -pub struct SshConfig { pub endpoint: NetworkEndpoint, pub username: String, pub auth: SshAuth } -pub enum SshAuth { Password { password: SecretString }, PrivateKey { path: PathBuf, passphrase: Option } } -#[async_trait] pub trait HostKeyVerifier: Send + Sync + Debug { async fn verify(&self, host: &str, port: u16, key: &russh::keys::ssh_key::PublicKey) -> HostKeyDecision; } -pub enum HostKeyDecision { Trusted, Unknown { algorithm, sha256_fingerprint }, Changed { algorithm, sha256_fingerprint, known_hosts: PathBuf, line: usize } } -pub struct KnownHostsVerifier { files: Vec } // spawn_blocking check_known_hosts_path per file; never writes -pub struct KnownHostsStore { path: PathBuf } -impl KnownHostsStore { pub async fn remember(&self, host: &str, port: u16, key: &PublicKey) -> io::Result<()> } // learn_known_hosts_path -pub struct SshSession { handle: Arc>, closed: CancellationToken } -impl SshSession { - pub async fn connect(config: &SshConfig, verifier: Arc, policy: &LivenessPolicy, cancel: CancellationToken) -> Result, TransportError>; - pub fn is_closed(&self) -> bool; - pub fn closed(&self) -> WaitForCancellationFutureOwned; -} -// connect: DirectTcpTransport::open; russh::client::connect_stream(Arc::new(Config { keepalive_interval: Some(15 s), keepalive_max: 3, inactivity_timeout: None, nodelay: true, ..Default::default() }), stream, handler) -// kex, host key and auth run under ssh_handshake_timeout; Unknown/Changed → SshFailure; auth failure → AuthenticationRejected -// ClientHandler: check_server_key delegates to the verifier; disconnected cancels `closed` -pub struct SshTransport { session: Arc, service: NetworkEndpoint, bastion: NetworkEndpoint, channel_open_timeout: Duration } -impl Transport for SshTransport -// open: timeout(channel_open_timeout, handle.channel_open_direct_tcpip(service.host(), u32::from(service.port()), "127.0.0.1", 0)) → Box::new(channel.into_stream()) -// ChannelOpenFailure → ChannelOpenRejected; elapsed → Timeout { SshChannelOpen } -// reroute: same session, new service - -Step 3 variant: SshTunnel::open(cfg, verifier: Arc, remote) with errors as SshFailure, deleted in the switch. -Deleted at the switch: SshTunnel, forwarder_loop, forward_one, LOCAL_BIND_HOST, verify_or_learn, default_known_hosts_path. -``` - -russh workspace dep "0.63.3" (default aws-lc-rs). `cargo tree -i libcrux-secrets` and `cargo tree -i russh-cryptovec` must not show the vulnerable versions. - -##### FC-C17 · `PostgreSQL driver internals` - -**Location**: `crates/drivers/postgres/src/{lib.rs, driver.rs, connection.rs, session.rs, pool.rs, workspace_call.rs, statement_shape.rs, cancel.rs, metadata.rs, errors.rs, decode.rs}` · **Kind**: driver-behaviour · **Consumers**: W2/W3/W4 through Connection and Session. - -**connect / create** (under connect_timeout then login_timeout, select! on cancel): -1. stream = transport.open(). -2. Config::new().user().password().dbname().application_name("TablePro").channel_binding(Prefer).ssl_mode(Disable|Require). -3. connect_raw(stream, PgTlsConnector). tokio::spawn(connection) forwards AsyncMessage::Notice to the owner's channel and flips LinkState to Lost on completion. -4. ServerInfo from SHOW server_version_num, current_database(), current_schema(). - -**Workspace call** (workspace_call.rs): futures::join!(client.batch_execute(preamble), op, client.batch_execute("COMMIT")) with the preamble from FC-D21. An op error still awaits COMMIT, which the server answers as ROLLBACK. A preamble error returns that error. - -**write_batch:** sequential START TRANSACTION READ WRITE + SET LOCAL; execute_typed per statement; SELECT pg_current_xact_id()::text; COMMIT. An I/O error during COMMIT → CommitOutcomeUnknown { PostgresXid }. - -**Session::run** (actor): -- If status Idle and statement_shape(text) == Single: client.query_text_portal(text, limit + 1). -- Else: client.simple_query_raw(text). -- Loop select! { stream.next(), cancel, deadline }: - - RowDescription → ResultSetStarted with PostgresOid(type_oid) and data_type from Type::from_oid or a per-session pg_type cache. - - Row → Value from text per OID. - - CommandComplete(n) → ResultSetFinished if a set is open, else CommandCompleted(Some(n)). - - Suspended → ResultSetFinished { ServerStopped }. - - CopyData → a single text column set. -- Err items become RunEnd::Failed, and polling continues to ReadyForQuery to read transaction_status(). -- Rows past the limit on the simple path are counted, not decoded. - -**Cancel:** cancel_token.cancel_query_raw(transport.open(), PgTlsConnector). 57014 with own flag → Cancelled, else TimedOut; no acknowledgement within cancel_grace → drop the client → ConnectionLost. - -**Cargo:** tokio-postgres 0.7.18 (runtime, with-chrono-0_4, with-uuid-1, with-serde_json-1, array-impls); postgres-types 0.2.14; rust_decimal [db-tokio-postgres]; deadpool 0.13.1; tablepro-net; futures-util. - -Nothing is sent in Config::options. The reroute capability is unused on PostgreSQL. - -##### FC-C18 · `Cancellation and deadline behaviour (user cancel, deadline, dropped caller)` - -**Location**: `crates/drivers/*/src/cancel.rs` · **Kind**: driver-behaviour · **Consumers**: Connection and Session implementations; editor Stop; query-timeout preference; browse refresh and filter (cancel the previous token). - -Common owned-task algorithm: -1. Race the work against the stop signal (cancel | deadline | ConsumerGone). -2. On stop, set own_flag, record the reason (UserCancel or Deadline), and issue the engine stop under cancel_grace. -3. Keep polling the work until it returns or cancel_grace elapses. -4. Map: acknowledged + UserCancel → Cancelled (RunEnd::Cancelled); acknowledged + Deadline → Timeout { Statement, server_cancelled: true } (RunEnd::TimedOut); no acknowledgement → discard the connection (never pooled; session Lost) → ConnectionLost { Statement }. - -Engine stops: -- **postgres:** CancelToken::cancel_query_raw(transport.open().await?, PgTlsConnector). -- **mysql/mariadb:** control MySqlConnection (lazy, through the relay, created under login_timeout, recreated on error) runs sqlx::raw_sql(AssertSqlSafe(format!("KILL QUERY {}", physical.connection_id))). connection_id is the u32 read from CONNECTION_ID(), and KILL QUERY is valid on both flavours. Acknowledgement: 1317 or normal end. -- **sqlite:** flag.store(true); the progress handler returns false; SQLITE_INTERRUPT; the flag is reset before each run. -- **mssql:** drop the QueryStream, then client.cancel_pending() (reads to DONE_ATTN). -- **clickhouse:** control client query("KILL QUERY WHERE query_id = ? ASYNC").bind(query_id.to_string()).execute(); drop the body; acknowledgement is 394 or end of body. - -Dropped caller: the caller's future owns cancel.drop_guard(), so dropping it (relm4 command drop, window close) runs step 2 inside the still-live owned task. - -Row limits do not use this algorithm (FC-C25). The KILL text is built only from the server-supplied u32, never from user input. - -##### FC-C19 · `tiberius fork API` - -**Location**: `TableProApp/tiberius branch tablepro (on c1d2e74); linux/Cargo.toml [patch.crates-io]` · **Kind**: external-api · **Consumers**: crates/drivers/mssql. - -impl Client { pub async fn cancel_pending(&mut self) -> crate::Result<()>; } -impl Config { pub fn tls_client_config(&mut self, config: Arc); } - -ColumnData changes: -- SSVariant → the base type's ColumnData. -- Udt → ColumnData::Binary, with Column::udt_type_name() -> Option<&str>. -- MONEY/SMALLMONEY → ColumnData::Numeric(Numeric::new_with_scale(i64, 4)). - -No panic, todo, unimplemented, expect or unreachable is reachable from server bytes. - -Workspace dep: tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "chrono", "rust_decimal", "integrated-auth-gssapi"] }; patch rev = <40-hex>. - -The mssql driver dials transport.open() and calls Client::connect(config, stream.compat_write()) per FC-C26. DateTimeOffset decodes as DateTime into the offset-preserving Value variant. - -Every fork commit carries a regression test (captured COLMETADATA/ROW bytes for sql_variant, geography, hierarchyid, money; attention against the mssql/server 2022 container). Error::Routing already exists upstream at c1d2e74. - -##### FC-C20 · `Workspace dependencies and policy files` - -**Location**: `linux/Cargo.toml, linux/deny.toml, linux/rust-toolchain.toml, linux/clippy.toml, .github/workflows/build-linux.yml` · **Kind**: build-config · **Consumers**: Every crate; CI. - -[workspace.package] rust-version = "1.94" - -[workspace.dependencies]: -- russh "0.63.3" -- sqlx 0.9.0 (default-features false; runtime-tokio, chrono, uuid, json). The mysql crate adds mysql, tls-rustls-aws-lc-rs, rust_decimal; sqlite and storage add sqlite. -- tokio-postgres 0.7.18 (default-features false; runtime, with-chrono-0_4, with-uuid-1, with-serde_json-1, array-impls) -- postgres-types 0.2.14 -- deadpool 0.13.1 (default-features false; managed, rt_tokio_1) -- clickhouse 0.15.2 (default-features false; uuid, chrono) -- hyper-rustls 0.27 (default-features false; aws-lc-rs, http1, tls12) -- hyper-util 0.1 (client-legacy, http1, tokio) -- tower-service 0.3 -- rustls 0.23 (default-features false; aws_lc_rs, std, tls12, logging) -- rustls-native-certs 0.8, webpki-roots 1, rustls-pki-types 1 [std] -- tokio-rustls 0.26 (default-features false; aws_lc_rs, tls12) -- socket2 0.6 [all] -- tokio-util 0.7 (default-features false; compat, rt) -- x509-cert 0.3.0, aws-lc-rs 1, base64 0.22 -- libsqlite3-sys ">=0.30.1, <0.38.0" (default-features false) - -[patch.crates-io]: tiberius and tokio-postgres/postgres-protocol/postgres-types fork revs. - -deny.toml (final): -[graph] exclude-dev = true -[advisories] yanked = "deny" -[bans] multiple-versions = "warn"; deny = [{ crate = "ring", reason = "aws-lc-rs is the only crypto provider" }, { crate = "rustls:<0.23.0", reason = "one rustls major" }, { crate = "openssl-sys", reason = "no OpenSSL" }] -[sources] unknown-registry = "deny"; unknown-git = "deny"; allow-git = ["https://github.com/TableProApp/tiberius", "https://github.com/TableProApp/rust-postgres"] - -rust-toolchain.toml channel/msrv 1.94; clippy.toml msrv 1.94. - -CI: dtolnay/rust-toolchain@1.94; EmbarkStudios/cargo-deny-action `check bans advisories sources`; integration job with libkrb5-dev and clang; suites PG, PgBouncer, MySQL, MariaDB, SQLite, SQL Server, ClickHouse, SSH, TLS. - -openssl-sys is banned unconditionally: `cargo tree --offline -e normal -i openssl-sys` finds no package (oo7 uses native_crypto). cargo-deny's PackageSpec takes name:version-requirement (its docs show "crate:<=0.7.0"), and [graph] exclude-dev removes dev-dependencies from all checks. CI additionally asserts `cargo tree -e normal -i ring` finds no package. - -##### FC-C21 · `App connection service and monitor contract` - -**Location**: `crates/app/src/services/connection_service.rs, connection_monitor.rs` · **Kind**: app-service · **Consumers**: W2 (per-window session ownership), connect_dialog run_connect and Cancel, open_saved, security foundation F-009 dialog. - -pub struct ConnectRecipe { pub driver: Arc, pub target: RecipeTarget, pub workspace_settings: SessionSettings } -pub enum RecipeTarget { Network { endpoint: NetworkEndpoint, ssh: Option, credentials: Credentials, database: Option, tls: TlsConfig }, File(FileEndpoint) } -pub struct EstablishedConnection { pub connection: Arc, pub ssh_session: Option>, pub recipe: ConnectRecipe } -pub struct ConnectServices { pub trust: Arc, pub environment: ConnectEnvironment, pub host_keys: Arc, pub liveness: LivenessPolicy } -pub async fn establish(recipe: ConnectRecipe, services: &ConnectServices, cancel: CancellationToken) -> Result; - -establish: -1. Check the auth mode against capabilities, tls.validate(caps.tls) and database_required. -2. Build the transport: SshSession::connect + SshTransport, or DirectTcpTransport. -3. driver.connect(options, cancel). - -connection_monitor::run(handle, recipe, services, cancel): -- Wakes on connection.lost(), ssh_session.closed() or a 30 s tick; probes with CallOptions { probe_timeout }. -- Reconnects with 5..60 s backoff. -- Stops and shows a persistent banner for categories Authentication, Tls and Configuration, and for SshFailure::HostKeyUnknown or HostKeyChanged. - -The SQLite recipe is always open_existing. - -W2 owns where EstablishedConnection lives; FC defines the value and monitor behaviour. - -##### FC-C22 · `Connect dialog exposure` - -**Location**: `crates/app/src/ui/connect_dialog.rs, tls_section.rs (new), sqlite_file_row.rs (new)` · **Kind**: ui-contract · **Consumers**: W1/W4 dialog work; FC supplies capabilities, validation and cancellable establish. - -**Network drivers, adw::PreferencesGroup 'Security':** -- adw::ComboRow 'Encryption' over capabilities.tls.modes. Subtitles: Disable 'Not encrypted'; Require 'Encrypted. The server certificate is not checked.'; VerifyFull 'Encrypted. The certificate must be valid for the server name.' -- adw::ExpanderRow 'Certificates' (hidden for Disable): adw::ActionRow 'CA Certificate' with a 'Choose…' button (gtk::FileDialog::open_future; gtk::FileFilter for application/x-pem-file, application/x-x509-ca-cert, *.pem, *.crt, *.cer) and a Clear button; 'Client Certificate' and 'Client Key' rows when supported; adw::EntryRow 'TLS Server Name' with the host as placeholder. -- adw::SwitchRow 'Read-only', subtitle from ReadOnlyEnforcement. -- The Database row is required when capabilities.database_required. - -**Validation** on input: TlsConfig::validate plus PEM parse of the chosen files. An error adds the 'error' CSS class and a subtitle naming the problem, and disables Connect. - -**Buttons.** Connect and Test run establish with a CancellationToken; the dialog's Cancel button cancels it. New connections default to VerifyFull. UnknownIssuer and NameMismatch errors show an adw::Toast action that reveals and focuses the matching row. - -**SQLite:** adw::ActionRow 'Database File' with 'Choose…' (FileDialog::open_future; application/vnd.sqlite3, *.db, *.sqlite, *.sqlite3) and 'New Database…' (FileDialog::save_future, initial name 'Untitled.sqlite3') → FileEndpoint::create_new. Switching the driver clears the host, database and username prefills. - -Stock libadwaita 1.9 / GTK 4.22 widgets only; FileDialog is portal-backed. No ApplicationIntent control exists. - -##### FC-C23 · `PostgreSQL statement shape classifier` - -**Location**: `crates/drivers/postgres/src/statement_shape.rs` · **Kind**: rust-impl · **Consumers**: PostgreSQL Session::run routing (FC-C25). - -pub(crate) enum StatementShape { Empty, Single, Multiple, Undetermined } -pub(crate) fn statement_shape(text: &str) -> StatementShape - -Lexer states: Normal, SingleQuoted, DoubleQuoted, DollarQuoted(tag), LineComment, BlockComment(depth). -- `;` in Normal ends a statement; any non-space, non-comment token after it → Multiple. -- A dollar-quote opens only when `$` is not preceded by an identifier character and matches $tag$ (tag empty or an identifier). -- Block comments nest. -- A backslash inside a single-quoted literal → Undetermined (E'' strings and standard_conforming_strings=off lex differently). -- $ outside quotes → Undetermined (parameters). -- Unterminated quote or comment → Undetermined. -- Only whitespace or comments → Empty. - -Only Single selects the portal path, and only in an Idle session, so a misclassification can at worst produce a Parse error outside any transaction. BEGIN ATOMIC bodies classify as Multiple and use the simple protocol. - -##### FC-C24 · `TableProApp/rust-postgres fork API` - -**Location**: `TableProApp/rust-postgres (on tokio-postgres-v0.7.18); linux/Cargo.toml [patch.crates-io]` · **Kind**: external-api · **Consumers**: crates/drivers/postgres; crates/net (TlsConnect only). - -``` -impl SimpleColumn { pub fn type_oid(&self) -> u32; pub fn table_oid(&self) -> Option; pub fn column_id(&self) -> Option } -#[non_exhaustive] pub enum TransactionStatus { Idle, InTransaction, Failed } -impl SimpleQueryStream { pub fn transaction_status(&self) -> Option } // Some after ReadyForQuery ('I','T','E') -impl Client { pub async fn query_text_portal(&self, query: &str, max_rows: i32) -> Result } -// one flight: Parse("") Bind("", no params, result format text) Describe(portal) Execute("", max_rows) Close(portal) Sync -pub struct TextPortalStream; // Stream>; transaction_status() -#[non_exhaustive] pub enum TextPortalMessage { RowDescription(Arc<[SimpleColumn]>), Row(SimpleQueryRow), CommandComplete(u64), Suspended, CopyData(Bytes) } - -SimpleQueryMessage gains CopyData(Bytes) (the enum is #[non_exhaustive] upstream). CopyInResponse on either stream is answered with CopyFail("COPY FROM STDIN is not supported"). -``` - -Three commits, each with tests in the fork and each filed upstream. Error items do not end either stream: tokio-postgres Responses returns Err per ErrorResponse and keeps reading to ReadyForQuery. - -##### FC-C25 · `Display row limit behaviour per engine` - -**Location**: `crates/drivers/*/src/session.rs` · **Kind**: driver-behaviour · **Consumers**: W3 editor, W4 grid ('Showing 10,000 of 1,234,567 rows', 'Showing the first 10,000 rows'). - -Common: -- Before the limit: Rows events. -- At retained == limit: RowLimitReached { retained } once. -- Past the limit: no Value decoding and no events. -- End: ResultSetFinished { retained, total_rows, truncation }. - -Per engine: -- **postgres:** Idle + Single → query_text_portal(limit + 1); Suspended or limit + 1 rows → ServerStopped (total None). Else simple_query_raw → ReadPastLimit (total counted). -- **mysql/mariadb:** before the run, SET SESSION sql_select_limit = limit + 1 if it differs from the last value set. limit + 1 rows then end → ServerStopped (total None); more → ReadPastLimit. -- **clickhouse:** at row limit + 1, KILL QUERY for query_id and drop the body → ServerStopped. -- **mssql:** ReadPastLimit. -- **sqlite:** ReadPastLimit. - -Never: an engine cancel, a changed transaction state, a skipped statement, or an error from reaching the limit. - -Rationale and primary sources are in FC-D19. - -##### FC-C26 · `SQL Server login behaviour` - -**Location**: `crates/drivers/mssql/src/login.rs` · **Kind**: driver-behaviour · **Consumers**: mssql driver connect, deadpool MssqlManager::create, open_session. - -async fn login(target: &NetworkTarget, policy: &LivenessPolicy, cancel: CancellationToken) -> Result>>, DriverError> - -loop (max 2 endpoints): -1. stream = transport.open() under connect_timeout. -2. Config: host(name), port, application_name("TablePro"), encryption, tls_client_config, authentication. readonly is never called. -3. Password auth: Client::connect under login_timeout, select! on cancel. -4. Kerberos: handle = Handle::current(); tokio::task::spawn_blocking(move || handle.block_on(Client::connect(config, stream))); the caller selects on the JoinHandle, cancel and login_timeout. -5. Err(Error::Routing { host, port }) on the first endpoint → transport = transport.reroute(NetworkEndpoint::new(host, port)?), name = routed host, continue. On the second → DriverError::Protocol("the server redirected the login twice"). - -Workspace connections then run SET LOCK_TIMEOUT ; SET XACT_ABORT ON. - -A late Kerberos login drops its Client on the blocking thread. Blocking-pool threads never starve relm4's single async worker. - -##### FC-C27 · `MySQL and MariaDB driver internals` - -**Location**: `crates/drivers/mysql/src/{driver.rs, physical.rs, flavour.rs, session.rs, pool.rs, cancel.rs, metadata.rs, errors.rs}` · **Kind**: driver-behaviour · **Consumers**: W2/W3/W4 through Connection and Session. - -pub(crate) enum MySqlFlavour { MySql, MariaDb } -pub(crate) struct MySqlPhysical { connection_id: u32, flavour: MySqlFlavour } - -connect / create (under connect_timeout + login_timeout, select! on cancel): -1. PrivateSocketRelay::start. -2. MySqlConnection::connect_with(options) (FC-D2). -3. raw_sql("SELECT CONNECTION_ID(), VERSION()"); MariaDb if VERSION() contains "MariaDB". -4. raw_sql of the FC-C4 statements for that flavour, joined with ';' (COM_QUERY multi-statement, all text from integers). - -ServerInfo.product = "MySQL" | "MariaDB". Error mapping per FC-C11. transaction_state per FC-C9. list_schemas = [DATABASE()]. fetch_indexes per FC-D15 with flavour-specific STATISTICS columns. - -A MySQL-compatible server without 'MariaDB' in VERSION() gets MySQL statements; if it lacks max_execution_time, connect fails with its own 1193 ServerDiagnostics rather than silently. - -#### Test strategy - -- **Core unit tests** -- NetworkEndpoint and FileEndpoint validation. -- TlsConfig::validate table per capabilities. -- category(): a test builds every DriverError variant and asserts its ErrorCategory; the in-core exhaustive match stops a new variant compiling without one. -- RowLimit::new(0) is None. -- ObjectKind serde. -- build_page_select golden SQL per dialect: keyset row-value on PG/MySQL/SQLite, SQL Server expanded OR, ctid window, OFFSET with identity tiebreak, and PageStability/PageCost flags. -- **PostgreSQL statement_shape unit tests** -- Single: $$ and $tag$ bodies containing ';'. -- Multiple: BEGIN ATOMIC bodies. -- Undetermined: E'\\;', a backslash in a standard literal, $1 parameters, unterminated quotes or comments. -- Nested block comments; identifiers such as a$b$; comment-only text is Empty. -- **net unit tests** -- SocketTuning values read back through socket2. -- interleave order per RFC 8305 section 4. -- race under tokio::time::pause with fake dialers: - - a stalled first address lets the second win at 250 ms; - - a refused first address starts the second at once; - - all failures return RaceFailure listing every attempt; - - connect_timeout bounds the race. -- Relay: - - directory 0700; NotPrivate for a symlink; PathTooLong at 108 bytes; directory removed on Drop; - - byte-exact 64 MiB random transfer both ways over a tokio::io::duplex Transport, compared by SHA-256; - - an upstream error gives the client EOF. -- **net TLS tests** -- classify through io::Error, sqlx::Error::Io and the hyper-util error chain. -- SystemTrustSource::with_loader given one valid, one truncated and one garbage DER: exactly one system certificate kept, rejected count 2. -- mysql_ca_bundle_pem round-trips via CertificateDer::pem_slice_iter, and each entry passes RootCertStore::add. -- TlsClientConfigBuilder against a tokio-rustls acceptor with an rcgen CA: VerifyFull OK; name mismatch → NameMismatch; empty CA → NoCertificates; malformed CA entry → InvalidCertificate. -- tls_server_end_point: rcgen RSA-SHA256 and ECDSA-P256 give SHA-256 of the DER, ECDSA-P384 gives SHA-384, Ed25519 gives None. -- **Establishment deadlines** (per network driver with a FakeTransport, policy login_timeout 300 ms) -- A listener that accepts and never speaks → Timeout { Login }. -- A proxy forwarding only the server's first TLS record → Timeout { Login }. -- Cancelling the token mid-login → Cancelled within 100 ms, for DatabaseDriver::connect and Connection::open_session. -- An unreachable endpoint → Timeout { Connect }. -- mssql Kerberos path on a 1-worker runtime with a login future that blocks its thread 2 s: another task still completes, and the caller returns Timeout at the deadline. -- **PostgreSQL integration** (postgres:17, ssl=on, generated certs, password_encryption=scram-sha-256, hostssl scram-sha-256) - -TLS and channel binding -- VerifyFull with CA OK; without CA → UnknownIssuer; wrong server_name → NameMismatch; Require OK. -- Client Config::channel_binding(Require) connects with RSA-SHA256 and ECDSA-P384 server certs. -- With an Ed25519 cert, Require fails client-side and Prefer connects. - -Cancellation -- pg_sleep(30) cancelled → RunEnd::Cancelled within 2 s, and the backend is idle. -- Deadline 1 s → TimedOut { server_cancelled: true }. -- Dropping the caller future of a query_bound running pg_sleep(30) → backend idle within 2 s. - -Session state -- BEGIN/UPDATE/ROLLBACK, SET search_path and a temp table persist across runs. -- CREATE FUNCTION with a $$ body containing ';'. -- ALTER TABLE ADD COLUMN, then query_bound on all four pooled connections. - -Read-only and bounds -- Read-only: session DELETE → Failed(ReadOnly 25006); write_batch → ClientGuard. -- On a non-read-only connection, query_bound of SELECT nextval('s') → 25006 (reads run READ ONLY). -- With SessionSettings statement_timeout 1 s: RowChanges batch containing pg_sleep(2) → Timeout { Statement }; the same as SchemaChanges → Ok. -- lock_timeout 1 s with ACCESS EXCLUSIVE held elsewhere → Timeout { LockWait }. - -Liveness and commit outcome -- pg_terminate_backend → ConnectionLost, LinkState Lost, lost() resolves. -- A FaultTransport that black-holes after the pg_current_xact_id response → CommitOutcomeUnknown with a token; resolve_commit on a healthy connection → Applied. - -Paging and metadata -- Identity: PK and unique-NOT-NULL keyset. -- A 100k-row keyless heap paged by ctid windows returns every row exactly once, in the order of SELECT ctid ORDER BY ctid. -- EXPLAIN of a window shows Tid Range Scan. -- A partitioned table uses OFFSET. -- list_objects kinds; another session's temp table is excluded. -- CREATE INDEX ON t (lower(name), id DESC) WHERE active → [Expression lower(name), Column id desc] with predicate active. -- table_estimate heap_blocks equals pg_relation_size / block_size. -- **PostgreSQL row limit and backpressure** -- SELECT * FROM generate_series(1, 10000000) with limit 100 → ServerStopped within 2 s. -- DELETE FROM t RETURNING * over 50,000 rows with limit 100 → truncated, and count(*) = 0 afterwards; the same for WITH d AS (DELETE ... RETURNING *) SELECT * FROM d. -- Runs BEGIN; INSERT; SELECT generate_series(1,1000000) at limit 100 → ReadPastLimit, total 1,000,000, state InTransaction; then COMMIT → row visible from another connection. -- One run 'INSERT (1); SELECT generate_series(1,1000000); INSERT (2)' at limit 100 → both rows persist. -- A sink sleeping 1 ms every 1,000 rows receives all 100,000 rows in sequence at limit 200,000. -- Dropping the sink consumer mid-stream → backend idle and the session Ready. -- **PgBouncer** (container with a mounted minimal pgbouncer.ini: only [databases], listen_addr, listen_port, auth_type, auth_file, pool_mode; no ignore_startup_parameters or track_extra_parameters) -- Transaction pooling: connect, list_objects, query_bound, count_rows, write_batch, resolve_commit and cancel of pg_sleep all work. With statement_timeout 1 s, a read against pg_sleep(2) → Timeout (SET LOCAL through the pooler). -- Session pooling: an editor session keeps SET search_path and read-only across runs. -- Statement pooling: write_batch returns the pooler's ServerDiagnostics without hanging. -- **MySQL 8.4 and MariaDB 11.4** (one parameterised suite) - -Connection -- ServerInfo.product flavour. -- VerifyFull through the relay with server_name. -- PIPES_AS_CONCAT off and time_zone unchanged. -- A two-result-set script yields two ResultSetStarted. -- list_schemas returns only DATABASE(). -- MySQL expression index → Expression key. - -Writes and bounds -- CREATE TRIGGER and CREATE PROCEDURE in a session. -- SELECT SLEEP(30) cancelled → Cancelled, and the processlist thread is idle (KILL QUERY over COM_QUERY). -- Read-only: INSERT and CREATE TABLE → 1792. -- statement_timeout 1 s with SLEEP(2) → Timeout (3024 on MySQL, 1969 on MariaDB). -- MariaDB SchemaChanges batch with a 2 s statement → Ok, and @@max_statement_time restored on that pooled connection. -- Lock wait → Timeout { LockWait } (1205). - -Row limit -- 200,000-row table at limit 100 → ServerStopped with 101 rows sent. -- LIMIT 150000 → ReadPastLimit, total 150,000. -- SELECT COUNT(*) FROM (SELECT * FROM t) s = 200,000. -- INSERT INTO t2 SELECT * FROM t copies 200,000 rows. -- CALL p() returning 150,000 rows → ReadPastLimit. -- BEGIN; INSERT; big SELECT; COMMIT → the insert persists. - -Transaction state -- MariaDB via @@in_transaction. -- MySQL root after BEGIN → InTransaction. -- MySQL user without performance_schema SELECT → Unknown. -- **SQLite** (tempfile) -- open_existing on a missing path → FileNotFound with no file created, including via the reconnect recipe. -- create_new on an existing path → FileExists. -- A literal '?' and '%41' in the path. -- read_only INSERT → ReadOnly. -- A TEMP table persists across session runs. -- A recursive CTE cancelled → Cancelled. -- transaction_state after BEGIN = InTransaction. -- BEGIN; INSERT; recursive 1e6 SELECT at limit 100; COMMIT → the insert persists. -- A session read concurrent with a workspace write → Busy or commit, never a hang. -- CREATE INDEX i ON t (substr(a, 1, 3), "b,c" DESC) WHERE a <> 'x,y' → [Expression substr(a, 1, 3), Column b,c desc] with the predicate. -- **SQL Server 2022** -- Decodes sql_variant, geography, hierarchyid, money 922337203685477.5807 and datetimeoffset +07:00. -- WAITFOR cancel, then SELECT 1 on the same client. -- A 3 s WAITFOR does not delay a concurrent probe. -- Disable against forced encryption → Tls ServerRequiresTls. -- VerifyFull with CA OK; without → UnknownIssuer. -- 200k rows at limit 100 → ReadPastLimit, total 200,000. -- BEGIN TRAN; INSERT; big SELECT; COMMIT → the insert persists. -- transaction_state after BEGIN TRAN = InTransaction. -- The driver-built Config for a read-only connection has readonly false. -- Unit test with a fake login returning Error::Routing once: the second attempt uses the rerouted transport and routed host; a second Routing → Protocol. -- **ClickHouse** -- HTTPS through TransportConnector: VerifyFull OK; mismatch → NameMismatch. -- readonly=2: SELECT OK; INSERT → ReadOnly(164). -- sleepEachRow cancel → Cancelled, and the query is gone from system.processes. -- numbers(1e9) at limit 100 → ServerStopped, and query_log read_rows is far below 1e9. -- Three INSERTs with the second failing → PartiallyApplied { applied: 1 }. -- resolve_commit: a finished id → Applied; a random id → Unresolvable; a syntax-error INSERT id → NotApplied. -- A session keeps SET and a temporary table across runs. -- system.query_log.http_user_agent contains TablePro. -- **SSH and liveness** -- linuxserver/openssh-server and postgres:17 (TLS) on one docker network: PG VerifyFull through SshTransport uses the real server name. -- No loopback listener: collect socket inodes from /proc/self/fd links, and assert none matches a LISTEN (state 0A) row in /proc/self/net/tcp or tcp6 while tunnelled MySQL and PostgreSQL connections are open. -- HostKeyUnknown writes to no file. -- FaultTransport black-holed mid-query (deadline 500 ms, cancel_grace 500 ms): the cancel dial also black-holes → ConnectionLost, LinkState Lost. -- Kernel path: `docker network disconnect` of the database container mid-query → ConnectionLost within 75 s, whether the kernel reports unreachable or TCP_USER_TIMEOUT expires. -- **App tests** -- establish with fake DatabaseDriver, Transport and TrustSource: Kerberos refused for non-Kerberos drivers; TLS validation and database_required fail before any dial; the cancel token stops establish. -- connection_monitor under tokio::time::pause: stops for Authentication, Tls, Configuration and host-key failures; reconnects at once when lost() resolves. -- error_text covers every ErrorCategory. -- #[gtk::test] connect dialog: Encryption lists only capability modes; SQLite shows the file row and hides Security; an invalid CA marks the row 'error' and disables Connect; MySQL requires Database. -- **CI gates** -- cargo deny check bans advisories sources. -- `cargo tree -e normal -i ring` finds no package; `cargo tree -i rustls@0.21.12` is empty. -- Container suites run with --test-threads=1. -- No throughput or netem assertions: relay correctness is byte-exact, and fault injection goes through FaultTransport. - -#### Risks - -- Two forked dependencies (TableProApp/tiberius with 5 commits, TableProApp/rust-postgres with 3) must track upstream security fixes. Mitigation: Each fork is a minimal series rebased on an upstream tag or commit, and every commit is filed upstream. cargo-deny restricts git sources to exactly these two. A weekly CI cron checks upstream releases; when upstream contains a patch, the [patch.crates-io] entry goes in the same commit that bumps the version. -- The tiberius attention path may mishandle a request that finished before the attention arrived. Mitigation: cancel_pending reads to an EOM whose final DONE has DONE_ATTN, or DONE_ERROR on an ignored request, bounded by cancel_grace. On timeout the client is discarded. An integration test cancels an already-finished query. -- Under PgBouncer transaction pooling, editor-session SET state (search_path, temp tables, read-only) does not persist. Mitigation: Workspace calls do not depend on session state (SET LOCAL inside each transaction). The SessionDefault read-only subtitle names a read-only login as the guarantee, and the PostgreSQL docs page requires session pooling for editor state. The session-pooling integration test pins the supported behaviour. -- MySQL KILL QUERY by CONNECTION_ID() through multiplexing proxies such as ProxySQL can target another backend thread. Mitigation: Session variables and transactions pin the backend in ProxySQL. The acknowledgement is required within cancel_grace, otherwise the connection is discarded, so the UI never claims a cancel that did not happen. Documented on the MySQL page. -- SQL Server and SQLite read the whole result past the display limit, so a huge SELECT on SQL Server still transfers everything. Mitigation: Memory stays bounded (no decoding past the limit). RowLimitReached lets the editor show that rows are still arriving next to Stop. The editor statement deadline applies. The trade-off is recorded in FC-D19: every SQL Server stop ends the batch, and SET ROWCOUNT limits DML. -- A statement_shape classifier error routes a multi-statement PostgreSQL script to the portal path. Mitigation: The portal path is used only from an Idle session, so the worst case is a Parse error with nothing executed and no transaction affected. Unit tests cover dollar quotes, nested comments, E'' strings, parameters and BEGIN ATOMIC. -- MySQL transaction state is Unknown for users without SELECT on performance_schema, and on servers with performance_schema disabled. Mitigation: MariaDB reports exactly through @@in_transaction. W2's tab-close guard treats Unknown as possibly open. The state is still reported per run, so a later successful probe updates it. -- A MySQL-compatible server whose VERSION() lacks 'MariaDB' but behaves like MariaDB gets MySQL session statements. Mitigation: The mismatch fails at connect with the server's own 1193 diagnostics instead of silently degrading. The suites cover mysql:8.4 and mariadb:11.4. -- sqlx 0.9 raises MSRV to 1.94; distro rustc 1.93.1 is older. Mitigation: rust-toolchain.toml pins 1.94 so rustup installs it. The README states rustup is required. Flatpak rust-stable//25.08 ships 1.98.1. -- Host-only private CAs are invisible inside the Flatpak sandbox. Mitigation: The UnknownIssuer toast opens the portal-backed CA chooser. The Flatpak docs say host CAs are chosen per connection. SystemTrustSource logs how many system certificates it loaded and rejected. -- TCP_USER_TIMEOUT 45 s can drop legitimate connections on very lossy links. Mitigation: It bounds only unacknowledged data. All values live in one LivenessPolicy passed at connect, so a preference can change them without driver changes. -- ClickHouse commit resolution returns Unresolvable behind load balancers or on clusters, because query_log is per node. Mitigation: Unresolvable falls back to refresh-before-retry, exactly as for MySQL and SQL Server, so no blind re-save happens. -- A timed-out Kerberos login keeps a blocking-pool thread busy until libkrb5 returns. Mitigation: The caller has already returned Timeout, and the late Client is dropped on that thread. Blocking-pool threads are separate from relm4's async worker, so the app stays responsive. -- ctid windows can show a row twice or miss it while concurrent updates move tuples between windows. Mitigation: PageSpec marks EngineRowId pages with PageCost::BlockWindow, and the grid labels keyless tables as having no stable key, the same guarantee as any non-snapshot keyset paging. Refresh re-reads the current window. -- The atomic step 6 is large. Mitigation: Steps 1-5 land all dependency and transport work with their own tests, so step 6 contains only the trait switch and its callers. It must pass the full unit, #[gtk::test] and integration matrix before merge. - -### FD · Platform, build, runtime and distribution layer - -Foundation FD sets up TablePro Linux's platform, build, runtime and distribution layer, modeled on Snapshot and Fractal (GNOME Rust apps built with Meson + Cargo). This revision resolves every blocker and major review issue and the minor ones. - -1. **Build.** linux/meson.build drives cargo through a custom_target (CARGO_HOME and CARGO_TARGET_DIR in the build dir) and installs the binary `tablepro`. -2. **One source for values, two build drivers.** linux/VERSION and linux/build-aux/APP_ID feed meson and crates/app/build.rs (via tablepro-build-support, which renders config.rs). TABLEPRO_PROFILE present in cargo's env means meson drives the build; absent means plain cargo. Meson builds of both profiles use the installed schema (or meson devenv's GSETTINGS_SCHEMA_DIR) and never embed build-tree paths. Only plain cargo builds point at $OUT_DIR schemas and locale. The development suffix is the git hash, or `devel` in git-less trees (Fractal fallback). An empty suffix counts as absent. Storage dir and secret schema are profile-scoped (tablepro / tablepro-devel). -3. **Settings.** One static GSettings schema. Preferences rows use Settings::bind. Window geometry is read when a session window is created and written from that window's close-request, never bound. -4. **Persistence.** StoragePaths resolved once from GLib user dirs and injected. Durable 0600 writes use glib::file_set_contents_full into 0700 dirs. Clean break for the app ID change. -5. **i18n.** gettextrs plus named-placeholder `_f` helpers. xgettext >= 0.24 through meson i18n, with a configure-time version guard that is fatal in the fedora:44 i18n job. The job regenerates POTFILES.in before the POT, and the app name is protected through translator comments plus a po check. -6. **Logging.** tracing with journald detection, RUST_LOG > G_MESSAGES_DEBUG > profile, a logging-only panic hook, and a typed StartupError. -7. **Cargo.** - - Rust 1.98, workspace lints denying unwrap/expect/panic, relm4 gnome_50 / sourceview5 v5_18. - - aws-lc-rs is the only crypto provider across normal, build and dev graphs. testcontainers and testcontainers-modules use default-features = false with aws-lc-rs. Enforced by cargo-deny (dev deps included) and a rustls provider regression test. - - Kerberos is a cargo feature. sqlx 0.9 with system SQLite. anyhow removed because unused, not banned. - - Offline builds pass vendored-source config through the meson option cargo_config (cargo --config). -8. **Flatpak.** - - The committed Devel manifest (dir source) is the single manifest source. The Flathub manifest is rendered from it by a tested script, in PR CI (archive path of the CI meson dist tarball plus the commit's screenshot URLs) and at release (archive URL + sha256). No placeholder manifest is committed. - - llvm22 stays permanently: no llvm or clang element exists in org.gnome.Sdk//50. - - Lint uses a committed --user-exceptions file. krb5.conf is installed by meson (-Dflatpak=true). -9. **CI.** An identical workflow copy lives on main and linux. A detect job skips build jobs where linux/ is absent. - - fast: ubuntu:26.04, xvfb, produces the dist tarball and rendered manifest. - - supply-chain. - - i18n: fedora:44. - - flatpak-devel: tests in a git-less tree, x86_64 + aarch64. - - flatpak-flathub: release-shaped build, screenshot mirroring, manifest and repo lint. - - integration. - - weekly dispatcher. -10. **Other channels.** .deb (ubuntu:26.04, rustup 1.98, because the archive stops at rustc 1.93) and .rpm (COPR fedora-44) are built from one vendor tarball whose vendor/cargo-config.toml is passed as -Dcargo_config. Package tests run under xvfb-run. The RPM License expression and debian/copyright are generated from third-party.json. AppImage is dropped. -11. **Security acceptance.** RUSTSEC-2023-0071 is accepted with its real scope (in-process SSH RSA signatures) plus a product decision (identity row recommends Ed25519 or ssh-agent for RSA keys), recorded in ADR 0009. - -#### Decisions - -##### FD-D1 · Meson + Cargo layout, build drivers, one source for build values - -linux/meson.build is the entry point (meson >= 1.4, project('tablepro','rust', version: files('VERSION'))). Subdirs: data/, po/, build-aux/, crates/app/. The cargo custom_target in crates/app/meson.build follows Snapshot/Fractal: CARGO_HOME=builddir/cargo-home, CARGO_TARGET_DIR=builddir/cargo-target, build_always_stale, `cargo build ... && cp cargo-target//tablepro @OUTPUT@`, install to bindir. - -The version lives only in linux/VERSION and the base ID only in linux/build-aux/APP_ID. meson reads them with files('VERSION') and fs.read(...).strip(); build.rs reads them through tablepro-build-support. - -Build driver is separate from profile: -- Meson driver (TABLEPRO_PROFILE present in cargo's env; the custom_target, the meson cargo tests and meson devenv all carry cargo_env): - - profile from -Dprofile - - LOCALEDIR from TABLEPRO_LOCALEDIR (required, absolute) - - suffix from TABLEPRO_VERSION_SUFFIX (empty = absent) - - DEVELOPMENT_SCHEMA_DIR = None for both profiles, so the binary uses SettingsSchemaSource::default() (installed schema, or the GSETTINGS_SCHEMA_DIR meson devenv exports for gnome.compile_schemas) - - no build-tree path is compiled into a meson-built binary -- Cargo driver (TABLEPRO_PROFILE absent: cargo run, cargo test, rust-analyzer): Development profile, LOCALEDIR=$OUT_DIR/locale, DEVELOPMENT_SCHEMA_DIR=Some($OUT_DIR/schemas), suffix 'devel'. - -meson development suffix: `git -C rev-parse --short HEAD` when git is found and prints a hash, else 'devel' (Fractal meson.build fallback). The default profile passes ''. Git-less trees (Devel Flatpak dir source, meson dist tarball) therefore give 0.1.0-devel. - -Profile-scoped values rendered once: APP_ID (base or base+'.Devel'), STORAGE_DIR_NAME (tablepro or tablepro-devel), SECRET_SCHEMA (APP_ID+'.Password'). RESOURCE_BASE_PATH always derives from the base ID. - -Package rename tablepro-app -> tablepro: GETTEXT_PACKAGE = env!("CARGO_PKG_NAME") and meson.project_name() are both 'tablepro'. - -Offline builds select vendored sources with the meson option cargo_config: a .toml path relative to the source root, passed as cargo's global `--config ` to build and test. Cargo resolves relative paths in a --config file two directories above it (Cargo config reference), so vendor/cargo-config.toml maps directory="vendor" to /vendor and cargo/config.toml maps "cargo/vendor" to /cargo/vendor. meson errors on a missing or non-.toml file. - -Options: profile, kerberos, flatpak, cargo_config, screenshots_base_url, adwaita_icon_dir. - -**Why**: Snapshot/Fractal is the GNOME Rust shape, so i18n.merge_file, gnome.post_install, meson dist and flatpak-builder buildsystem meson work unchanged. - -Separating driver from profile fixes the review blocker. Choosing the schema dir by profile compiled /run/build/tablepro/_flatpak_build/cargo-target/debug/build/tablepro-*/out/schemas into the Devel Flatpak, where it does not exist. SettingsSchemaSource::from_directory (gio 0.22.5, returns Result) then failed and settings never opened. - -linux/ is a subdirectory of the monorepo (.git at the repo root, verified), so dir sources and dist tarballs have no git metadata. The 'devel' fallback plus empty-as-absent removes '0.1.0-'. - -An explicit --config replaces working-directory config discovery, which only works when the build dir is inside the source tree. - -**Rejected**: Schema dir chosen by profile: embeds a build path in every meson development build. configure_file config.rs into src/ (Snapshot/Fractal): generated file in the tree, and a fresh-clone cargo run breaks. Loupe option_env! defaults: duplicates meson values. meson devenv as the only dev path: breaks cargo run and rust-analyzer. Version in [workspace.package]: project() cannot run commands, and the literal drifts. /.cargo/config.toml found from the cwd: depends on build dir placement. No suffix in git-less trees: the About dialog could not distinguish development builds. - -##### FD-D2 · Embedded GResource with an explicit resource base path - -build.rs calls glib_build_tools::compile_resources(&["../../data/resources"], "../../data/resources/resources.gresource.xml", "tablepro.gresource") (glib-build-tools 0.22.8). The app registers it with gio::resources_register_include!("tablepro.gresource"), which returns Result<(), glib::Error> (gio 0.22.5 resource.rs), inside build_application. - -Prefix /app/tablepro/TablePro/. Application built with resource_base_path(config::RESOURCE_BASE_PATH), derived from the base ID; otherwise GtkApplication derives /app/tablepro/TablePro/Devel for development and misses the resources. - -Contents: -- style.css (auto-loaded by AdwApplication) -- shortcuts-dialog.ui (AdwShortcutsDialog id shortcuts_dialog, giving app.shortcuts) -- icons/scalable/actions/*.svg -- licenses/third-party.json - -No PKGDATADIR. - -Failure contract: compile_resources unwraps OUT_DIR and command output and asserts that glib-compile-resources succeeded (verified in the crate source). A broken manifest aborts build.rs with a panic carrying glib-compile-resources' stderr, and cargo fails the build. Every other build.rs failure is a typed BuildConfigError reported through cargo::error. - -**Why**: One compile path for meson and cargo, no runtime file lookup, and the platform's automatic style, shortcut and icon loading replaces the inline CssProvider in ui/app/mod.rs:716-764. - -**Rejected**: gnome.compile_resources installed to pkgdatadir: cargo run needs a second path. Re-implementing the glib-compile-resources call in build-support to get a Result: duplicates the platform crate for a failure that already stops the build. Inline CssProvider: no automatic dark or high-contrast handling. GtkShortcutsWindow help-overlay: deprecated since GTK 4.18. - -##### FD-D3 · GSettings for preferences and window state; files for structured or unbounded data - -data/app.tablepro.TablePro.gschema.xml: static id app.tablepro.TablePro, path /app/tablepro/TablePro/, shared by both profiles (Fractal). meson installs it, gnome.post_install(glib_compile_schemas: true) compiles it, and test validate-gschema (glib-compile-schemas --strict --dry-run) checks it. data/meson.build also calls gnome.compile_schemas(build_by_default: true); since meson 0.59 that adds the compiled dir to GSETTINGS_SCHEMA_DIR in meson devenv. - -Schema source by driver: -- meson builds: SettingsSchemaSource::default() -- plain cargo: build.rs compiles into $OUT_DIR/schemas, and AppSettings::open receives Some(dir) - -AppSettings::open uses SettingsSchemaSource::from_directory(dir, default().as_ref(), false) only for Some, then lookup(id, true) and Settings::new_full. A missing schema gives SettingsError::SchemaNotFound; an unreadable dir gives SchemaDirectory. The app never sets GSETTINGS_SCHEMA_DIR. - -Keys (every field of services/preferences.rs and window_state.rs; GNOME Text Editor font keys replace editor-font-size): -- default-page-size, confirm-destructive, history-retention-days, query-timeout-secs -- use-system-font, custom-font, style-scheme -- csv-null-to-empty, csv-line-break-to-space, csv-header-row, csv-sanitize-formulas, csv-delimiter, csv-quote, csv-line-break, csv-decimal -- window-width, window-height, is-maximized - -Preference rows bind with Settings::bind. Window keys are read at window creation and written from close-request (FD-D22), never bound. - -Files: -- config dir: connections.json, known_hosts -- state dir: history.db, workspace_state.json, column_widths.json, filter_settings.json -- data dir: drafts/ - -Consumers add keys to this one schema file in their own commits. - -**Why**: GSettings gives binding for rows, changed:: notifications (live font updates), vendor defaults, locks and the Flatpak keyfile backend. dconf is multi-process with change notification, and Flatpak builds of the two IDs get separate keyfiles, so sharing preferences between a stable install and a Devel build is safe. Per-object maps don't fit GSettings. - -**Rejected**: JSON preferences: no binding or notifications (F-064). GSETTINGS_SCHEMA_DIR via .cargo/config.toml [env]: a second config source that meson builds also pick up. Per-profile schema ids: build.rs would duplicate configure_file. Settings::bind for window keys: couples all session windows (FD-D22). a{s...} dicts for widths and filters: unbounded keyed data. - -##### FD-D4 · Persistence locations, profile isolation, durable private writes, clean break - -tablepro_storage::paths::StoragePaths { config, state, data, cache }. StoragePaths::from_glib(dir_name) joins config::STORAGE_DIR_NAME (tablepro or tablepro-devel) to glib::user_config_dir()/user_state_dir()/user_data_dir()/user_cache_dir(). It is called once in main and injected into every store and into tablepro-ssh (known_hosts path). StoragePaths::under(root, dir_name) serves tests. - -The stable profile and a Devel build hold different D-Bus names and can run together, so natively they never share connections.json, workspace_state.json or history.db. Flatpak builds are isolated per app ID anyway. - -Secrets: tablepro_storage::secrets::SecretStore::new(config::SECRET_SCHEMA) replaces the SCHEMA const at secrets.rs:9, so keyring items are profile-scoped too. - -tablepro-storage declares glib and gio with features ["v2_72"] (user_state_dir v2_72, file_set_contents_full v2_66; needed because `cargo test -p tablepro-storage` does not unify relm4 features). - -Durable I/O: -- ensure_private_dir: DirBuilderExt::mode(0o700), recursive, then set_permissions(0o700) on the leaf -- write_private_blocking: glib::file_set_contents_full(path, bytes, CONSISTENT|DURABLE, 0o600); write_private wraps it in spawn_blocking -- history.db is pre-created with OpenOptionsExt::mode(0o600) before sqlx create_if_missing - -Deleted: -- services/config_io.rs -- the path code at storage/connections.rs:127-139 and query_history::db_path env code -- ssh::default_known_hosts_path -- services/single_instance.rs, plus libc - -ssh_section.rs:188-231 reads glib::home_dir() instead of $HOME. - -Clean break: installed default builds keep ~/.config/tablepro; cargo run now uses tablepro-devel; history/workspace are not moved; com.tablepro.linux.Password secrets and ~/.var/app/com.tablepro.linux are abandoned. The developer mv commands go in the PR description only. - -**Why**: There has been no public release, so migration code would be a permanent shim. Resolving paths once removes the duplicated XDG parsing F-062 lists and makes every store testable against a TempDir. file_set_contents_full is GLib's audited write-temp+fsync+rename+dir-fsync with mode. Profile-scoped names close the concurrent Devel+stable overlap the review found, the same isolation Flatpak gives by app ID. GApplication D-Bus uniqueness replaces the flock. - -**Rejected**: One-time data move: permanent shim. XDG_DATA_HOME for history/workspace: the XDG spec assigns them to XDG_STATE_HOME. A std-only durable writer: second implementation of GLib semantics. Keeping the flock: hand-rolled platform mechanism. A shared data dir with per-file locking for Devel and stable: new locking code for a problem profile isolation removes. - -##### FD-D5 · Application identity, desktop integration and D-Bus activation - -Base ID app.tablepro.TablePro; development app.tablepro.TablePro.Devel; binary and package tablepro. - -data/app.tablepro.TablePro.desktop.in.in, merged with i18n.merge_file(type: 'desktop'): -- Exec=tablepro %U, DBusActivatable=true -- MimeType=application/vnd.sqlite3; (shared-mime-info canonical, alias application/x-sqlite3) -- Keywords, Categories=Development;Database;, StartupNotify=true, no StartupWMClass -- [Desktop Action new-window] with no Exec, mapped to app.new-window - -data/app.tablepro.TablePro.service.in installs as datadir/dbus-1/services/@app-id@.service with Exec=@bindir@/tablepro --gapplication-service. - -adw::Application: HANDLES_OPEN, application_id(config::APP_ID), resource_base_path; handed to relm4::RelmApp::from_app. The MimeType line lands in the same commit as the open handler (W1). - -Name translation: Name is a default desktop keyword and cannot be excluded per group, so Name=TablePro carries '# Translators: Do NOT translate or transliterate this text (this is the application name)!' (Fractal translator-comment pattern). build-aux/check-protected-msgids.py fails the i18n job if any po file gives msgid "TablePro" a msgstr other than empty or "TablePro". The metainfo name uses translate="no". - -Icons, drawn in the HIG app icon template: -- data/icons/hicolor/scalable/apps/app.tablepro.TablePro.svg -- app.tablepro.TablePro.Devel.svg (striped) -- data/icons/hicolor/symbolic/apps/app.tablepro.TablePro-symbolic.svg - -**Why**: Meets Flathub app ID rules (tablepro.app is project-controlled, last component not generic) and matches GNOME core apps with DBusActivatable and a .service. %U plus HANDLES_OPEN is the GIO Open With path Files uses. A translator comment is how GNOME apps protect names inside desktop files, and the po check makes it enforceable. - -**Rejected**: io.github.TableProApp.TablePro: the project owns tablepro.app. %F: GApplication apps use %U. Exec=tablepro --new-window: needs HANDLES_COMMAND_LINE for no gain. Disabling the Name keyword with --keyword=: also drops [Desktop Action new-window] Name=New Window from translation. - -##### FD-D6 · i18n pipeline - -GETTEXT_PACKAGE 'tablepro'. crates/app/src/i18n.rs re-exports gettextrs::{gettext, ngettext, pgettext, npgettext} and defines gettext_f/ngettext_f/pgettext_f with single-pass named {placeholder} substitution (Fractal). All tr! call sites (549 grep hits in crates/app/src) are replaced in one commit. i18n::init: unsafe gettextrs::setlocale (gettext-rs 0.8) as the first statement of main, then bindtextdomain(GETTEXT_PACKAGE, config::LOCALEDIR), bind_textdomain_codeset UTF-8 and textdomain. TABLEPRO_LOCALEDIR and /app/share/locale probing are removed. - -po/meson.build: -- Guard: xgettext = find_program('xgettext', required: false); when found and xgettext.version().version_compare('< 0.24') (ExternalProgram.version since meson 0.62), warning() names the requirement. meson's i18n.gettext creates the tablepro-pot target unconditionally with no version check (verified in mesonbuild/modules/i18n.py), so the guard is a configure diagnostic, made fatal in CI by `meson setup --fatal-meson-warnings` in the fedora:44 i18n job. -- i18n.gettext('tablepro', preset: 'glib', args: ['--keyword=gettext', '--keyword=ngettext:1,2', '--keyword=pgettext:1c,2', '--keyword=npgettext:1c,2,3', '--keyword=gettext_f', '--keyword=ngettext_f:1,2', '--keyword=pgettext_f:1c,2']). Default desktop keywords stay. - -POTFILES.in is generated by build-aux/generate-potfiles.sh. Desktop and metainfo are merged with i18n.merge_file. po/LINGUAS holds codes only. - -The i18n job order: -1. regenerate POTFILES.in -2. meson compile tablepro-pot -3. check protected msgids -4. git diff --exit-code po/ - -**Why**: xgettext through meson i18n is the GNOME single extractor for Rust (native since gettext 0.24), desktop, metainfo, schema and .ui. Explicit Rust keywords avoid depending on unstated defaults. Fedora 44 has gettext 0.26; Ubuntu 26.04 has 0.23.2 (verified), hence the guard. Regenerating POTFILES before the POT prevents extraction from a stale file list. - -**Rejected**: xtr: Rust only. tr! macro: xgettext documents functions, not macro keywords. formatx: positional only. Runtime LOCALEDIR guessing: breaks relocated prefixes (F-099). Skipping i18n.gettext on old xgettext: the .deb would ship no .mo files. A wrapper overriding find_program('xgettext'): hides the tool behind a shim. - -##### FD-D7 · Logging and startup errors - -crates/app/src/logging.rs builds tracing_subscriber::registry() with EnvFilter from filter_spec() and one output layer: -- tracing_journald::layer() with syslog identifier config::APP_ID when glib::log_writer_is_journald(std::io::stderr()) (glib 0.22.5 log.rs, takes AsFd) and the layer constructs Ok -- otherwise fmt::layer().with_writer(stderr) - -Precedence: RUST_LOG, then G_MESSAGES_DEBUG containing all/APP_ID/tablepro, then debug for Development, info for Default. The panic hook logs message, location and a forced backtrace, then chains the previous hook, touching no UI state. - -Startup: main returns glib::ExitCode and runs `run() -> Result`. i18n::init runs first; its error is logged as a warning after logging::init (untranslated UI still works). A LoggingError is reported with glib::g_critical! because tracing is not installed. Every other StartupError is reported with tracing::error! and returns ExitCode::FAILURE. - -**Why**: Matches docs/error-handling.md (journald) and GLib's own fd-based journald detection. filter_spec is a pure testable function. The hook stays logging-only because RefCell UI state may be borrowed while panicking. - -**Rejected**: JOURNAL_STREAM presence only: inherited by redirected children. glib::log_structured! bridge: loses spans. Flushing state in the panic hook: unsound. eprintln for startup failures: denied by print_stderr and bypasses the journal. - -##### FD-D8 · Toolchain, workspace lints, error crates - -rust-toolchain.toml channel 1.98 (rustfmt, clippy). - -[workspace.package]: rust-version 1.98, license AGPL-3.0-or-later. clippy.toml drops msrv (read from rust-version) and sets allow-unwrap-in-tests, allow-expect-in-tests, allow-panic-in-tests, allow-dbg-in-tests, allow-print-in-tests. - -Lints: -- [workspace.lints.clippy] denies unwrap_used, expect_used, panic, todo, unimplemented, dbg_macro, print_stdout, print_stderr, allow_attributes, allow_attributes_without_reason -- [workspace.lints.rust] denies unsafe_op_in_unsafe_fn -- every crate sets [lints] workspace = true -- every #[allow] becomes #[expect(..., reason)] or is removed with its cause -- build.rs files carry #![expect(clippy::print_stdout, reason = "cargo build script protocol")] - -Error crates: crates/app loses its unused thiserror in the dependency-cleanup commit and regains it in the lint commit, which introduces StartupError to replace main.rs's `.expect("history runtime")`. anyhow is removed only because nothing uses it. CONTRIBUTING.md:33 and docs/error-handling.md allow anyhow inside the app crate, so no ban is added. CONTRIBUTING.md's MSRV row becomes 1.98. - -**Why**: 1.98.1 is the toolchain of Flatpak rust-stable//25.08 and Fedora 44 and satisfies sqlx 0.9's 1.94. The honest MSRV is the one CI builds with. The lints enforce CONTRIBUTING (F-132). Each commit's dependency list matches its code. - -**Rejected**: 1.93: blocks sqlx 0.9. rust-version 1.94 with CI on 1.98: untested floor. unused_crate_dependencies: fires on every test crate, so cargo-shear is used. Banning anyhow in deny.toml: contradicts CONTRIBUTING.md and docs/error-handling.md. - -##### FD-D9 · GNOME 50 API floor - -[workspace.dependencies]: -- relm4 = { version 0.11, default-features false, features [macros, libadwaita, gnome_50] } -- sourceview5 = { 0.11, features [v5_18] } -- gtk4 = "0.11" and libadwaita = "0.9" without feature pins (relm4 gnome_50 enables gtk/gnome_50 and adw/v1_9) -- glib/gio = "0.22" - -The floor commit replaces: -- gtk::ShortcutsWindow (ui/app/mod.rs:1548) with data/resources/shortcuts-dialog.ui AdwShortcutsDialog -- gtk::Calendar::select_day (ui/grid.rs:1055) with Calendar::set_date -- gtk::Spinner (ui/app/mod.rs:68,608; ui/editor.rs:20,141) with adw::Spinner - -Resulting floor: GTK 4.22, libadwaita 1.9, GLib/GIO 2.88, GtkSourceView 5.18. Ubuntu 26.04 has 4.22.4/1.9.1/5.18.0/2.88.0, Fedora 44 4.22.5/1.9.0/5.20.0/2.88.3, org.gnome.Platform//50 4.22.4/1.9.3/5.20.0/2.88.3. - -**Why**: Owner decision; also unlocks adw::StyleManager::monospace_font_name, AdwShortcutsDialog and Calendar::set_date (F-108, F-091). - -**Rejected**: gnome_49: owner chose gnome_50. Explicit v4_14/v1_6 pins: misleading, relm4 overrides them. - -##### FD-D10 · Single aws-lc-rs crypto provider across the complete dependency graph - -Dependencies: -- russh 0.63 (0.63.3; defaults flate2, aws-lc-rs, rsa) -- [patch.crates-io] tiberius = tiberius-rs main rev c1d2e741ee6fe0935091826d91fb5886b6a4082a (tokio-rustls 0.26 aws_lc_rs, drops rustls 0.21/webpki 0.101) -- sqlx tls-rustls-aws-lc-rs -- clickhouse default-features false with rustls-tls-aws-lc + rustls-tls-webpki-roots -- testcontainers = { 0.27, default-features = false, features [aws-lc-rs] } and testcontainers-modules = { 0.15, default-features = false, features [aws-lc-rs] }, keeping per-crate module features (postgres, mysql, mssql_server) and http_wait_plain - -Verified chains: testcontainers-modules 0.15.0 default = [ring] -> testcontainers/ring -> bollard/ssl -> rustls/ring; testcontainers aws-lc-rs = bollard/aws-lc-rs = ssl_providerless + rustls/aws-lc-rs. - -tablepro-ssh Handler::check_server_key takes &PublicKeyOrCertificate. - -Ban scope: deny.toml [graph] all-features = true and exclude-dev = false (explicit; cargo-deny default). The ring, openssl-sys, native-tls and rustls:<0.23 bans therefore cover normal, build and dev edges. - -Verification: `cargo tree -e all -i ring` reports no matching package. - -Regression test: crates/drivers/postgres/tests/tls_provider.rs calls rustls::ClientConfig::builder() (dev-dependency rustls 0.23, default-features false, std) and runs in every cargo test of the workspace. - -No CryptoProvider::install_default in main. - -**Why**: rustls picks the process provider from crate features only when exactly one of ring/aws_lc_rs is enabled. Otherwise from_crate_features returns None and ClientConfig::builder() panics (rustls 0.23.39 crypto/mod.rs, verified); hyper-rustls with_webpki_roots calls it. Provider choice is a property of each resolved feature set: the app binary, unit test binaries and every driver integration binary. install_default in main would cover only the app, not the test binaries where both features were enabled. - -The fix is at the graph: remove ring from every edge, ban it with dev edges in scope, and prove it at runtime with a test that panics if a second provider returns. That keeps crate-feature selection unambiguous everywhere, so an explicit install adds nothing. russh >= 0.60.3 fixes RUSTSEC-2026-0153/0154 (F-046, F-111). - -**Rejected**: ring everywhere: tiberius main and russh default use aws-lc-rs. install_default at startup: leaves integration test binaries ambiguous and hides the second provider. exclude-dev = true: misses exactly the testcontainers chain. Keeping the prisma/tiberius a6b4fcd pin: carries rustls 0.21. - -##### FD-D11 · SQL Server Kerberos as a cargo feature - -Remove integrated-auth-gssapi from the workspace tiberius entry. tablepro-driver-mssql: [features] kerberos = ["tiberius/integrated-auth-gssapi"], default = []. The AuthMode::Kerberos arm is cfg-gated, and supports_integrated_auth() = cfg!(feature = "kerberos"). - -App: default = ["kerberos"], kerberos = ["tablepro-driver-mssql/kerberos"]. - -meson option kerberos (true): dependency('mit-krb5-gssapi', required: option). --no-default-features for the app when false. Library tests get --features tablepro-driver-mssql/kerberos when true. - -With -Dflatpak=true and kerberos, meson installs build-aux/flatpak/krb5.conf to prefix/sysconfdir (/app/etc). Without the feature, the connect dialog hides the Windows (Kerberos) method. - -**Why**: Contributors, docker integration jobs and cargo test -p tablepro-driver-mssql need no libkrb5-dev/libclang, while Flathub, deb and rpm keep Kerberos (F-112). libgssapi has no dlopen mode. Installing krb5.conf from meson keeps every installed file in one install definition. - -**Rejected**: dlopen shim: hack. Always-on: F-112. Default-on in the driver crate: integration CI needs krb5 again. Flatpak post-install shell line for krb5.conf: bypasses meson install. - -##### FD-D12 · sqlx 0.9, system SQLite, gettext-rs 0.8, dependency hygiene - -sqlx 0.9: -- sqlx::AssertSqlSafe only where user-authored SQL enters a driver -- sqlite-unbundled in storage and the sqlite driver (freedesktop-sdk 25.08 SQLite 3.50.4 with FTS5, Fedora 44 3.51.2, Ubuntu 26.04 3.46.1-9ubuntu0.2); meson dependency('sqlite3', version >= 3.46) -- mysql-rsa in the mysql driver - -gettext-rs = { 0.8, features [gettext-system] }. - -Removed: -- crates/app thiserror (re-added with StartupError), async-channel, anyhow -- crates/ssh async-trait, futures, serde -- crates/storage tablepro-ssh -- crates/drivers/sqlite rust_decimal, serde_json, uuid -- libc (with single_instance.rs) - -cargo update: anyhow >= 1.0.103 (dev chain), gtk4 0.11.4, libadwaita 0.9.2, sourceview5 0.11.2, clickhouse 0.15.2. testcontainers stays 0.27 because testcontainers-modules 0.15.0 (latest) requires ^0.27.0. cargo shear in CI. - -Release profile keeps lto fat and codegen-units 1, and drops strip, so flatpak-builder, dh_strip and find-debuginfo produce debug packages. - -**Why**: Bundled SQLite 3.46.0 carries CVEs fixed in 3.49.1-3.50.3 (F-114 verifier); unbundling hands SQLite security to the platform. Stripping in cargo defeats distro debuginfo. - -**Rejected**: sqlite-bundled: app-owned SQLite patching. Floor 3.50.3: rejects Ubuntu's patched 3.46.1. unused_crate_dependencies: noisy on test crates. - -##### FD-D13 · Third-party license notices and generated package license metadata - -linux/about.toml accepted list mirrors deny.toml [licenses].allow. `cargo about generate --locked --offline --format json -o data/licenses/third-party.json` is committed, and CI regenerates it and diffs. - -Uses: -- embedded in the GResource (licenses/third-party.json) -- AdwAboutDialog::add_legal_section per license (SPDX id mapped to gtk::License MitX11, Apache20, Bsd, Bsd3, Mpl20, Lgpl21, else Custom) -- installed with data/licenses/THIRD-PARTY-NOTICE.md and the full AGPL linux/LICENSE to datadir/licenses/@app-id@/ - -Package metadata generator build-aux/packaging/license_metadata.py (python3 stdlib, unit-tested) reads cargo-about's data model (licenses[].id, licenses[].used_by[].crate name/version, per cargo-about output docs): -- `spdx-expression`: AGPL-3.0-or-later followed by the sorted unique third-party ids joined with ' AND ', rendered into the RPM License field at release -- `debian-copyright`: DEP-5 build-aux/debian/copyright, with a Files: vendor/-/* paragraph per crate (cargo vendor --versioned-dirs names) whose License joins that crate's ids with ' and '; committed and drift-checked - -cargo-about fails on crates it cannot resolve, so any needed [crate.clarify] entry is added in the same commit and the committed JSON proves full resolution. - -**Why**: F-115: Flathub requires licenses under share/licenses/$FLATPAK_ID. A committed generated file avoids cargo-about in the SDK. Generating the RPM License and debian/copyright from the same JSON gives the review's missing SPDX definition one source. - -**Rejected**: cargo-about during meson builds: not in the GNOME SDK. One text blob in About: unreadable. Hand-written License field: drifts with Cargo.lock. Fedora %cargo_license_summary: RPM-only, needs the Fedora cargo macros this build does not use. - -##### FD-D14 · Flatpak manifests: one committed Devel manifest, a rendered Flathub manifest - -Committed build-aux/flatpak/app.tablepro.TablePro.Devel.json (CI, GNOME Builder): -- dir source path ../../ with skip [target, _build, _flatpak, .flatpak-builder, flatpak_app, repo] -- config-opts -Dprofile=development -Dflatpak=true -Dcargo_config=cargo/config.toml; run-tests true -- runtime org.gnome.Platform//50, sdk org.gnome.Sdk, sdk-extensions rust-stable and llvm22 -- append-path rust-stable/bin:llvm22/bin, prepend-ld-library-path llvm22/lib, env CARGO_NET_OFFLINE=true and LIBCLANG_PATH=/usr/lib/sdk/llvm22/lib, test-args [--socket=x11] -- sources: the dir, build-aux/flatpak/cargo-sources.json (flatpak-cargo-generator writes an inline cargo/config; verified), and shell source `mv cargo/config cargo/config.toml` (cargo --config accepts only .toml) - -The Flathub manifest is never committed. build-aux/flatpak/render_flathub_manifest.py (python3 stdlib, unit-tested) renders it from the Devel manifest: -- id = build-aux/APP_ID -- -Dprofile=development becomes -Dprofile=default, plus -Dscreenshots_base_url= when given -- run-tests and test-args removed -- the dir source replaced by {type: archive, path|url, sha256} -- cargo-sources.json copied beside the output (and the tarball in path mode) - -PR CI renders with --archive-path (the job's meson dist tarball) and the commit's screenshot URL. Release renders with --archive-url and --sha256. No x-checker-data: it would bump the archive without regenerating cargo-sources.json. - -llvm22 is permanent. Verified that org.gnome.Sdk//50 ships no libclang: gnome-build-meta gnome-50 elements/sdk.bst and sdk-platform.bst and freedesktop-sdk 25.08 elements/sdk.bst and platform.bst list no llvm/clang element. libgssapi-sys runs bindgen, which needs libclang. No krb5 module: sdk-platform.bst lists krb5.bst and gssproxy.bst. - -**Why**: F-007. Runtime, extensions, finish-args and build options have one source (the Devel manifest), and the Flathub form is a tested transformation, so no placeholder url/sha256 is ever committed and the release-shaped manifest is built and linted on every PR. - -**Rejected**: Committed Flathub manifest with placeholder url/sha256: invalid until release (review). type git of the 211 MB monorepo: slow clone of macOS history. Vendored crates in the dist tarball: Flathub asks for dependency manifests. GNOME Sdk libclang: absent per element lists. Conditional removal of llvm22: owner standard forbids planned follow-ups, and the evidence settles it. Relying on /.cargo/config.toml discovery: build-dir dependent (FD-D1). - -##### FD-D15 · Flatpak finish-args and linter exceptions - -finish-args: -- --share=network -- --share=ipc, --socket=wayland, --socket=fallback-x11, --device=dri -- --socket=ssh-auth (agent auth for tunnels) -- --filesystem=home, --filesystem=/media, --filesystem=/run/media, --filesystem=/mnt -- --filesystem=/run/.heim_org.h5l.kcm-socket and --env=KRB5_CONFIG=/app/etc/krb5.conf (FD-D16) - -Removed: --filesystem=/etc/krb5.conf:ro (reserved, never granted, no effect) and --talk-name=org.freedesktop.secrets (oo7 0.6 uses the Secret portal when sandboxed). - -Filesystem justification: SQLite opens databases read-write and creates -journal, -wal and -shm siblings, which single-file document-portal grants cannot provide; portal temp-file handling can strand a hot journal (audit gap-sqlite-sandbox-file-access-01). Open With files under these paths reach the app as real paths. - -Linter exceptions are per app ID and granted only through Flathub's exceptions file (docs.flathub.org linter). Committed build-aux/flatpak/lint-exceptions.json = {"app.tablepro.TablePro": ["finish-args-home-filesystem-access", "finish-args-has-socket-ssh-auth"]}. Every local and CI lint passes it with --user-exceptions (flatpak-builder-lint CLI, verified format). Its content equals exactly what maintainers request from Flathub with written justifications (human-written, per Flathub policy). Flathub's builders apply the granted list. - -**Why**: Minimal static permissions with portals wherever a portal covers the use case. The committed exceptions file makes the expected findings a named mechanism instead of prose, and any other finding fails CI (F-033). - -**Rejected**: FileChooser/document portal for SQLite: unsafe for WAL and journals. --filesystem=host: broader. ~/.ssh:ro only: cannot open SQLite files. Keeping org.freedesktop.secrets: unused and exposes the whole keyring. Linting only with --exceptions (remote): fails every PR until Flathub reviews the submission. - -##### FD-D16 · Kerberos inside the Flatpak - -build-aux/flatpak/krb5.conf, installed by meson to /app/etc/krb5.conf (-Dflatpak=true) and selected with --env=KRB5_CONFIG=/app/etc/krb5.conf: -[libdefaults] -default_ccache_name = KCM: -kcm_socket = /run/.heim_org.h5l.kcm-socket -dns_lookup_realm = true -dns_lookup_kdc = true -dns_canonicalize_hostname = fallback -rdns = false - -Plus --filesystem=/run/.heim_org.h5l.kcm-socket (not reserved, no lint rule). - -Works with KCM ccaches (sssd-kcm, Fedora/RHEL default) when the realm is discoverable via DNS _kerberos TXT and KDCs via SRV. - -Verified limits, documented in the README Kerberos section: -- FILE ccaches in host /tmp are invisible (private /tmp) -- KEYRING ccaches fail (flatpak-run.c seccomp EPERM for add_key/keyctl/request_key) -- host /etc/krb5.conf cannot be consumed (flatpak#4841 open; MIT krb5 fails on missing includedir, prof_parse.c PROF_FAIL_INCLUDE_DIR) -- tiberius imports MSSQLSvc/host:port as GSS_NT_KRB5_PRINCIPAL, resolved with the default realm - -Those hosts use the .deb/.rpm, which read /etc/krb5.conf. Consumer contract: the MSSQL driver maps KRB5_CONFIG_NODEFREALM and KRB5_FCC_NOFILE/KG_EMPTY_CCACHE to distinct error variants. - -**Why**: The only shape that verifiably works in the sandbox without host config; follows the Firefox BaseApp's app-owned krb5.conf with KCM. - -**Rejected**: host-etc:ro with KRB5_CONFIG=/run/host/etc/krb5.conf: breaks on includedir (flatpak#4841) and needs an exception. /etc/krb5.conf:ro: reserved. std::env::set_var at runtime: unsafe env mutation. Dropping Kerberos from Flatpak: loses the working KCM+DNS case. - -##### FD-D17 · Metainfo: releases and screenshot URLs that always resolve - -data/app.tablepro.TablePro.metainfo.xml.in.in, merged with i18n.merge_file and validated by test validate-appstream (appstreamcli validate --no-net --explain). Fields: -- desktop-application, id @app-id@, CC0-1.0, AGPL-3.0-or-later -- summary; description of all five drivers -- launchable; developer id app.tablepro -- URLs: homepage, bugtracker, vcs-browser, help, and translate/contribute pinned to the tag linux-v@version@ -- translation gettext; branding #99c1f1 light / #1a5fb4 dark -- 4 captioned screenshots; oars-1.1; keyboard and pointing; display_length >= 360 -- provides binary and mediatype - -Screenshots use @screenshots-base-url@/NN-name.png. The meson option screenshots_base_url defaults to https://raw.githubusercontent.com/TableProApp/TablePro/linux-v/linux/data/screenshots (release, deb, rpm); it must be https. PR CI renders the Flathub manifest with https://raw.githubusercontent.com///linux/data/screenshots, a commit that exists in that repository at PR time, so --mirror-screenshots-url can fetch every image. - -Releases: Flathub treats releases-info-missing, release-time-missing and any appstream warning as fatal (linter docs), so the list always has an entry for the current VERSION with a date and no development snapshot is injected. Both profiles share the list. test metainfo-release-version (build-aux/check-metainfo-release.py) fails when the newest version differs from meson.project_version() or lacks an ISO date. The release commit sets the newest entry's date to the tag date, and the release workflow checks it. Screenshots in data/screenshots use default settings. The list is the single source of Linux release notes. - -**Why**: Screenshots are a hard Flathub requirement (metainfo-missing-screenshots). A commit-addressed URL resolves at PR time, and the tag URL is immutable for releases. Dropping the git-derived snapshot removes the empty version/date the review found in git-less trees (F-038). - -**Rejected**: Injected snapshot release from git show: empty in dir sources and dist tarballs. Branch URLs: move after publication. Skipping screenshot mirroring in PR CI: never exercises appstream-external-screenshot-url and appstream-screenshots-not-mirrored-in-ostree, which Flathub never excepts. Generating releases from the monorepo CHANGELOG.md: that is the macOS log. - -##### FD-D18 · Icons for removed emblem glyphs - -emblem-default-symbolic (editor.rs:793) and emblem-ok-symbolic (history_dialog.rs:747) become object-select-symbolic (Adwaita 50). emblem-shared-symbolic (structure_tab/mod.rs:531) and emblem-synchronizing-symbolic (editor.rs:854) become app icons data/resources/icons/scalable/actions/table-relation-symbolic.svg and statement-pending-symbolic.svg. meson test icon-names (build-aux/check-icon-names.py) fails when a "*-symbolic" literal in crates/app/src exists neither under adwaita_icon_dir nor data/resources/icons. It runs in ubuntu:26.04 (adwaita-icon-theme 50.0) and inside the GNOME 50 SDK. - -**Why**: F-039. Checking against the runtime theme catches regressions. - -**Rejected**: Keeping emblem names: broken glyphs. Bundling old emblems: stale and semantically wrong. - -##### FD-D19 · CI layout and supply-chain gates - -.github/workflows/build-linux.yml is an identical file on main and linux (FD-C22). Jobs: -- detect: sparse checkout; output linux=true when linux/meson.build exists. Every build job needs it and runs only when true, so the main copy (no linux/ before the merge) skips build jobs for workflow-only PRs and manual dispatches. -- fast: ubuntu:26.04 container; meson setup -Dprofile=development; fmt; clippy default and --no-default-features (CARGO_HOME/CARGO_TARGET_DIR pointed into _build so meson reuses them); meson compile; xvfb-run -a meson test; meson dist --no-tests; render the Flathub manifest twice (path form for building, url form for manifest lint); upload artifacts. -- supply-chain: cargo-deny, cargo shear, cargo-about JSON drift, debian/copyright drift, cargo-sources.json drift (flatpak-builder-tools pinned by commit SHA in the workflow env). -- i18n: fedora:44 with the full package list; setup with --fatal-meson-warnings; POTFILES.in, then tablepro-pot, then protected msgids, then git diff. -- flatpak-devel: gnome-50 image, matrix x86_64 (ubuntu-24.04) and aarch64 (ubuntu-24.04-arm); Devel manifest with run-tests. A dir source has no .git, so validate-appstream and every other meson test run in a git-less tree. -- flatpak-flathub: needs fast; builds the rendered app.tablepro.TablePro manifest from the dist tarball with mirror-screenshots-url https://dl.flathub.org/media; then flatpak-builder-lint --user-exceptions build-aux/flatpak/lint-exceptions.json in manifest mode (url-form manifest) and repo mode. -- integration: needs fast; ubuntu-24.04 with docker; driver suites for postgres, mysql, clickhouse, mssql (no kerberos). - -Schedule: GitHub schedules only the default-branch file. weekly-dispatch (schedule only, actions: write) runs `gh workflow run build-linux.yml --ref ${{ vars.LINUX_BRANCH }}`; the variable is switched to main at merge. - -**Why**: F-060: a supported container, the release-shaped Flatpak built and linted on every PR with screenshots mirrored, advisory/license/ban scans with dev edges, all driver suites, i18n drift gating, and a schedule that fires. The detect job fixes the main-copy failure without diverging file content. - -**Rejected**: ubuntu:25.10: EOL. Schedule in the linux copy: never fires. Restricting main's triggers to schedule and dispatch: the copies would differ and conflict at merge. hashFiles in a job-level if: unavailable before checkout. Lint of the Devel repo: that ID is never published, so Flathub screenshot rules test nothing real. cargo-audit: no license, ban or source checks. - -##### FD-D20 · Non-Flatpak channels - -release-linux.yml (tag linux-vX.Y.Z, FD-C23) first checks that the tag equals VERSION and that the newest metainfo release has that version and the tag commit's date. It produces: -- (a) tablepro-X.Y.Z.tar.xz from meson dist --no-tests -- (b) tablepro-X.Y.Z-vendor.tar.xz, a single top-level vendor/ containing the crates plus vendor/cargo-config.toml. It is written with `cargo vendor --locked --versioned-dirs vendor > $RUNNER_TEMP/cargo-config.toml` then moved in, since cargo vendor prints the config to stdout (Cargo docs) and may prune its output dir. Cargo's directory source skips entries without Cargo.toml (cargo 0.99 sources/directory.rs), so the config file is inert inside vendor/. -- (c) .deb: build-aux/packaging/assemble_debian.py writes debian/ (control from build-aux/debian/control.in with Maintainer from repository variable LINUX_PACKAGE_MAINTAINER, rules, source/format 3.0 (quilt), copyright, README.source, changelog for resolute) into the unpacked dist tree. tablepro_X.Y.Z.orig.tar.xz and tablepro_X.Y.Z.orig-vendor.tar.xz (the same vendor tarball) sit beside it, and vendor/ is extracted into the tree. dpkg-source renames a single top-level directory to the component name (Dpkg/Source/Archive.pm, verified), so source-package rebuilds match. dpkg-buildpackage -us -uc runs in ubuntu:26.04 with the rustup toolchain from rust-toolchain.toml. Resolute ships rustc 1.93.1 and rustc-1.91/1.92/1.93 only (packages.ubuntu.com, verified), below rust-version 1.98, so rustup is the permanent toolchain source for this GitHub Release artifact. lintian runs. -- (d) .rpm: build-aux/rpm/render_spec.py renders tablepro.spec.in (Version, License from license_metadata.py; %find_lang and -f tablepro.lang only when po/LINGUAS lists a language). %autosetup -a1; %meson -Dprofile=default -Dkerberos=true -Dcargo_config=vendor/cargo-config.toml. COPR project tablepro/tablepro builds fedora-44-x86_64 and aarch64 offline; rpmlint runs. - -Package tests: meson's gtk suite needs a display. debian/rules overrides dh_auto_test with `xvfb-run -a dh_auto_test` (Build-Depends xvfb, xauth). The spec runs `xvfb-run -a %meson_test` (BuildRequires /usr/bin/xvfb-run, shipped by Fedora's xorg-x11-server Xvfb subpackage). The gtk suite sets GSK_RENDERER=cairo and GTK_A11Y=none. - -Both packages are installed in fresh ubuntu:26.04 and fedora:44 containers, which run desktop-file-validate, appstreamcli validate --no-net, glib-compile-schemas --strict --dry-run and `tablepro --help`. - -AppImage is dropped: it must build on the oldest target base, no base older than Ubuntu 26.04/Fedora 44 has GLib 2.88/GTK 4.22, and it forbids the compiled-in LOCALEDIR, schema and D-Bus paths of FD-D1/D3/D5. - -**Why**: Concrete packages from the same meson install and lockfile (F-136). One vendor tarball serves both formats. The explicit cargo_config option removes the review's missing-config failure. xvfb-run removes the missing-display failure. - -**Rejected**: cargo-deb/cargo-generate-rpm: bypass meson data and translations. Launchpad PPA: archive toolchain too old. Fedora %cargo_prep vendoring macros: not verifiable here. debian/ committed inside the upstream tarball with a hardcoded Maintainer: puts packaging identity into upstream sources. A separate meson suite excluded from package %check: packages would skip GTK tests. AppImage via sharun: see above. - -##### FD-D21 · Stylesheet - -data/resources/style.css replaces the inline CSS at ui/app/mod.rs:716-764. It uses libadwaita CSS variables with color-mix(): warning, success and error backgrounds for modified, inserted and deleted rows; var(--accent-color) for the focus ring; and a prefers-contrast: more block. textview.sql-editor scopes var(--monospace-font-family/--monospace-font-size). When use-system-font is false, the editor consumer applies the custom font through one app-level gtk::CssProvider targeting .sql-editor, updated on changed::custom-font and changed::use-system-font. - -**Why**: F-091 and shell-16: automatic loading, theme-aware colours, no leakage into cell editors. - -**Rejected**: @warning_color named colours: miss overrides. style-dark.css/style-hc.css: deprecated in libadwaita 1.9. - -##### FD-D22 · Window geometry restored per window, persisted on close - -crates/app/src/ui/window_geometry.rs exposes restore(window, settings) and persist(window, settings). - -Each session window (W1) calls restore after construction: -- set_default_size(width, height) from AppSettings::window_geometry() -- maximize() when maximized - -persist runs inside that window's close-request handler once the unsaved-changes flow proceeds. It keeps the measurement at ui/app/mod.rs:900-904 (default size while maximized, width/height otherwise) and writes through AppSettings::set_window_geometry. No Settings::bind touches window properties. The most recently closed window sets the size of the next new window (GNOME Text Editor and the gtk-rs book 'Saving Window State' pattern). - -**Why**: Owner's window-per-session model: setting GtkWindow:maximized is gtk_window_maximize (docs.gtk.org). A bound is-maximized would maximize or unmaximize every session window together, and every resize of any window would write the shared keys. - -**Rejected**: Settings::bind on default-width/height/maximized: couples windows (review). Per-session keys via relocatable schemas: sessions are transient, and GNOME apps restore one default geometry. - -##### FD-D23 · RUSTSEC-2023-0071 risk acceptance with a correct scope - -deny.toml ignores RUSTSEC-2023-0071. Per the advisory, private key information leaks through network-observable timing (CVSS AV:N/AC:H/C:H, patched = []). - -Affected: SSH public-key authentication with an RSA identity file loaded into the process. russh 0.63.3's default rsa feature signs with rsa =0.10.0-rc.18, and the SSH server or a precise on-path observer sees one private-key operation per authentication. - -Not affected: -- sqlx-mysql RSA password encryption (public-key operation) -- SSH host-key verification (public-key operation) -- ssh-agent authentication (the agent signs) - -Why acceptable: operations are bounded by user-initiated connects, the rsa crate blinds private-key operations (RustCrypto/RSA README), no patched version exists, and disabling russh's rsa feature would make existing RSA identities unusable. - -Product decision: RSA identity files stay supported, and the SSH section tells users when a key is RSA. tablepro_ssh::identity_algorithm(path) reads the key's public algorithm through russh::keys::ssh_key::PrivateKey::from_openssh without decrypting. When it returns Rsa, the key-file row (ui/ssh_section.rs:72-84) shows the subtitle 'RSA key. An Ed25519 key or the SSH agent avoids RSA signing timing leaks.' The F-065 consumer makes ssh-agent the default method whenever SSH_AUTH_SOCK is present. - -Recorded in linux/docs/decisions/0009-supply-chain-policy.md (single provider, git pin, advisory acceptances) and the deny.toml reason text. - -**Why**: Security first requires a suppressed advisory to state its exposure correctly. The previous text wrongly dismissed the SSH case. Scope, bound and user guidance are now explicit and testable. - -**Rejected**: Dropping RSA identity support: breaks existing bastion access. A blocking warning dialog: alarmist for a bounded, high-complexity attack. Silent ignore with the old reason: misstates exposure. - -#### Contracts - -##### FD-C1 · `Build values file inputs` - -**Location**: `linux/VERSION, linux/build-aux/APP_ID` · **Kind**: data file · **Consumers**: meson.build, tablepro-build-support, release workflow, spec/debian rendering, config readers - -- VERSION: one line, MAJOR.MINOR.PATCH, no leading v. -- APP_ID: one line, app.tablepro.TablePro. - -No other file holds either as a value. Template names under data/ and the GResource prefix embed the base ID by GNOME convention; drift fails meson configure and the resource tests. - -The newest metainfo version must equal VERSION (meson test metainfo-release-version). The release tag is linux-v (release workflow check). - -##### FD-C2 · `tablepro-build-support crate` - -**Location**: `linux/crates/build-support/src/{lib.rs, profile.rs, build_driver.rs, identity.rs, inputs.rs, rendered_config.rs, error.rs, tools.rs}` · **Kind**: Rust library (build-time) · **Consumers**: crates/app/build.rs - -lib.rs: module declarations and re-exports only. - -profile.rs: -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Profile { Default, Development } -impl Profile { pub fn from_meson_option(value: &str) -> Result } - -build_driver.rs: -#[derive(Debug, Clone, PartialEq, Eq)] pub enum BuildDriver { Meson { profile: Profile, localedir: PathBuf, version_suffix: Option }, Cargo } -impl BuildDriver { pub fn from_env(profile: Option<&str>, localedir: Option<&str>, version_suffix: Option<&str>) -> Result } - -identity.rs: -pub struct AppIdentity { pub base_app_id: String, pub version: String } -impl AppIdentity { pub fn read(linux_root: &Path) -> Result } - -inputs.rs: -pub struct BuildInputs { pub identity: AppIdentity, pub driver: BuildDriver, pub out_dir: PathBuf } - -rendered_config.rs: -pub struct RenderedConfig { pub app_id: String, pub base_app_id: String, pub resource_base_path: String, pub profile: Profile, pub version: String, pub localedir: PathBuf, pub development_schema_dir: Option, pub storage_dir_name: String, pub secret_schema: String } -impl RenderedConfig { pub fn resolve(inputs: &BuildInputs) -> Result; pub fn to_rust_source(&self) -> Result } - -error.rs: -#[derive(Debug, thiserror::Error)] pub enum BuildConfigError { Io { path: PathBuf, source: std::io::Error }, InvalidAppId(String), InvalidVersion(String), UnknownProfile(String), IncompleteMesonEnvironment(&'static str), RelativeLocaledir(PathBuf), NonUtf8Path(PathBuf), ToolFailed { tool: &'static str, status: std::process::ExitStatus, stderr: String } } - -tools.rs (functions): -pub fn compile_development_schemas(data_dir: &Path, target_dir: &Path) -> Result<(), BuildConfigError> (glib-compile-schemas --strict --targetdir) -pub fn compile_translations(po_dir: &Path, locale_dir: &Path, domain: &str) -> Result<(), BuildConfigError> (msgfmt --check per LINGUAS code) - -from_env rules: -- profile None and others None -> Cargo -- profile None with localedir or suffix set -> IncompleteMesonEnvironment("TABLEPRO_PROFILE") -- profile Some -> Meson; missing localedir -> IncompleteMesonEnvironment("TABLEPRO_LOCALEDIR"); relative -> RelativeLocaledir -- suffix Some("") -> None - -resolve: -- Meson{Default}: app_id=base, version=VERSION[-suffix], localedir=given, schema dir None, storage 'tablepro' -- Meson{Development}: base+'.Devel', VERSION-suffix, given, None, 'tablepro-devel' -- Cargo: Development, base+'.Devel', VERSION-devel, out_dir/locale, Some(out_dir/schemas), 'tablepro-devel' -- resource_base_path='/'+base with '.' replaced by '/' -- secret_schema=app_id+'.Password' - -AppIdentity validates: >= 3 dot-separated elements of [A-Za-z0-9_-], not starting with a digit, last element not 'desktop' or 'linux'; version is three decimal integers. to_rust_source writes string literals with {:?} and rejects non-UTF-8 paths. - -##### FD-C3 · `crates/app/build.rs` - -**Location**: `linux/crates/app/build.rs` · **Kind**: cargo build script · **Consumers**: cargo build, meson cargo-build target, cargo test - -``` -#![expect(clippy::print_stdout, reason = "cargo build script protocol")] -fn main() -> std::process::ExitCode - -1. Read TABLEPRO_PROFILE, TABLEPRO_LOCALEDIR, TABLEPRO_VERSION_SUFFIX (non-Unicode -> NonUtf8Path) and OUT_DIR. -2. BuildDriver::from_env; AppIdentity::read(CARGO_MANIFEST_DIR/../..); RenderedConfig::resolve; write $OUT_DIR/config.rs. -3. glib_build_tools::compile_resources(&["../../data/resources"], "../../data/resources/resources.gresource.xml", "tablepro.gresource"). It panics on failure (0.22.8 unwraps OUT_DIR and asserts glib-compile-resources succeeded), which aborts the build with the tool's stderr; it emits rerun-if-changed for the manifest and each dependency. -4. Cargo driver only: compile_development_schemas(../../data, $OUT_DIR/schemas) and compile_translations(../../po, $OUT_DIR/locale, "tablepro"). -5. cargo::rerun-if-changed for ../../VERSION, ../../build-aux/APP_ID, ../../data/app.tablepro.TablePro.gschema.xml, ../../po; cargo::rerun-if-env-changed for the three TABLEPRO_* vars. -6. Any BuildConfigError: print cargo::error= (respected since Cargo 1.84) and return ExitCode::FAILURE. -``` - -##### FD-C4 · `config module` - -**Location**: `linux/crates/app/src/config.rs` · **Kind**: Rust module · **Consumers**: About dialog, application builder, i18n, logging, settings, StoragePaths, SecretStore - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Profile { Default, Development } -pub const GETTEXT_PACKAGE: &str = env!("CARGO_PKG_NAME"); -include!(concat!(env!("OUT_DIR"), "/config.rs")); - -Generated: pub const APP_ID, BASE_APP_ID, RESOURCE_BASE_PATH: &str; PROFILE: Profile; VERSION, LOCALEDIR: &str; DEVELOPMENT_SCHEMA_DIR: Option<&str>; STORAGE_DIR_NAME, SECRET_SCHEMA: &str. - -Values: -- meson default: app.tablepro.TablePro, 0.1.0, /share/locale, None, tablepro, app.tablepro.TablePro.Password -- meson development: app.tablepro.TablePro.Devel, 0.1.0-, /share/locale, None, tablepro-devel, app.tablepro.TablePro.Devel.Password -- plain cargo: .Devel, 0.1.0-devel, $OUT_DIR/locale, Some($OUT_DIR/schemas), tablepro-devel, .Devel.Password - -No other module reads CARGO_PKG_VERSION, hardcodes an app ID, or reads TABLEPRO_* at runtime. status_pages.rs:88 (application_icon), main.rs:11 and the secrets schema (through SecretStore) switch to these constants. - -##### FD-C5 · `Root meson.build and options` - -**Location**: `linux/meson.build, linux/meson_options.txt` · **Kind**: meson · **Consumers**: flatpak-builder, release workflow deb/rpm builds, CI, developers - -``` -project('tablepro', 'rust', version: files('VERSION'), meson_version: '>= 1.4', license: 'AGPL-3.0-or-later') -fs = import('fs'); gnome = import('gnome'); i18n = import('i18n') -base_id = fs.read('build-aux/APP_ID').strip() -dependency('glib-2.0', version: '>= 2.88'); dependency('gio-2.0', version: '>= 2.88'); dependency('gtk4', version: '>= 4.22'); dependency('libadwaita-1', version: '>= 1.9'); dependency('gtksourceview-5', version: '>= 5.18'); dependency('sqlite3', version: '>= 3.46') -dependency('mit-krb5-gssapi', required: get_option('kerberos')) -cargo = find_program('cargo'); git = find_program('git', required: false); python = find_program('python3') -glib_compile_schemas = find_program('glib-compile-schemas') -desktop_file_validate = find_program('desktop-file-validate', required: false); appstreamcli = find_program('appstreamcli', required: false) -is_devel = get_option('profile') == 'development' -application_id = is_devel ? base_id + '.Devel' : base_id -version_suffix = '' -if is_devel - version_suffix = 'devel' - if git.found() - vcs_hash = run_command(git, '-C', meson.project_source_root(), 'rev-parse', '--short', 'HEAD', check: false).stdout().strip() - if vcs_hash != '' - version_suffix = vcs_hash - endif - endif -endif -cargo_config = get_option('cargo_config') -cargo_global_options = [] -if cargo_config != '' - if not cargo_config.endswith('.toml') or not fs.is_file(cargo_config) - error('cargo_config must name an existing .toml file relative to the source root: ' + cargo_config) - endif - cargo_global_options = ['--config', meson.project_source_root() / cargo_config] -endif -screenshots_base_url = get_option('screenshots_base_url') -if screenshots_base_url == '' - screenshots_base_url = 'https://raw.githubusercontent.com/TableProApp/TablePro/linux-v@0@/linux/data/screenshots'.format(meson.project_version()) -endif -if not screenshots_base_url.startswith('https://') - error('screenshots_base_url must be an https URL') -endif -prefix = get_option('prefix'); bindir = prefix / get_option('bindir'); datadir = prefix / get_option('datadir'); localedir = prefix / get_option('localedir') -cargo_env_vars = {'CARGO_HOME': meson.project_build_root() / 'cargo-home', 'CARGO_TARGET_DIR': meson.project_build_root() / 'cargo-target', 'TABLEPRO_PROFILE': get_option('profile'), 'TABLEPRO_LOCALEDIR': localedir, 'TABLEPRO_VERSION_SUFFIX': version_suffix} -cargo_env = environment(cargo_env_vars) -meson.add_devenv(cargo_env) -subdir('data'); subdir('po'); subdir('build-aux'); subdir('crates/app') -gnome.post_install(gtk_update_icon_cache: true, glib_compile_schemas: true, update_desktop_database: true) - -meson_options.txt: -option('profile', type: 'combo', choices: ['default', 'development'], value: 'default') -option('kerberos', type: 'boolean', value: true) -option('flatpak', type: 'boolean', value: false, description: 'Install Flatpak-only files such as /app/etc/krb5.conf') -option('cargo_config', type: 'string', value: '', description: 'Extra cargo .toml config relative to the source root, e.g. vendored sources') -option('screenshots_base_url', type: 'string', value: '', description: 'https base URL for metainfo screenshots; empty uses the release tag') -option('adwaita_icon_dir', type: 'string', value: '/usr/share/icons/Adwaita') -``` - -##### FD-C6 · `Cargo meson targets and test suites` - -**Location**: `linux/crates/app/meson.build` · **Kind**: meson · **Consumers**: meson compile/test, Flatpak run-tests, deb dh_auto_test, rpm %check - -cargo_manifest_options = ['--manifest-path', meson.project_source_root() / 'Cargo.toml', '--locked'] -app_feature_options = get_option('kerberos') ? [] : ['--no-default-features'] -library_feature_options = get_option('kerberos') ? ['--features', 'tablepro-driver-mssql/kerberos'] : [] -rust_target = is_devel ? 'debug' : 'release' -build_profile_options = is_devel ? [] : ['--release'] -custom_target('cargo-build', build_by_default: true, build_always_stale: true, console: true, output: meson.project_name(), install: true, install_dir: bindir, env: cargo_env, command: [cargo, cargo_global_options, 'build', cargo_manifest_options, '-p', meson.project_name(), app_feature_options, build_profile_options, '&&', 'cp', meson.project_build_root() / 'cargo-target' / rust_target / meson.project_name(), '@OUTPUT@']) -gtk_test_env = environment(cargo_env_vars) -gtk_test_env.set('GSK_RENDERER', 'cairo') -gtk_test_env.set('GTK_A11Y', 'none') -test('cargo-test-libraries', cargo, args: cargo_global_options + ['test'] + cargo_manifest_options + ['--workspace', '--exclude', meson.project_name()] + library_feature_options, env: cargo_env, suite: 'unit', is_parallel: false, timeout: 3600) -test('cargo-test-app', cargo, args: cargo_global_options + ['test'] + cargo_manifest_options + ['-p', meson.project_name()] + app_feature_options, env: gtk_test_env, suite: 'gtk', is_parallel: false, timeout: 3600) -if get_option('kerberos') - krb5_env = environment(cargo_env_vars) - krb5_env.set('KRB5_CONFIG', meson.project_source_root() / 'build-aux/flatpak/krb5.conf') - test('krb5-profile', cargo, args: cargo_global_options + ['test'] + cargo_manifest_options + ['-p', 'tablepro-driver-mssql', '--features', 'kerberos', '--test', 'krb5_profile'], env: krb5_env, suite: 'kerberos', is_parallel: false, timeout: 3600) -endif - -Display contract: suite gtk needs a display. Every caller provides one: fast job xvfb-run -a, flatpak-builder run-tests with test-args --socket=x11, override_dh_auto_test with xvfb-run -a, %check with xvfb-run -a. - -##### FD-C7 · `data/meson.build` - -**Location**: `linux/data/meson.build` · **Kind**: meson · **Consumers**: meson install for Flatpak, deb, rpm; meson devenv - -conf = configuration_data({'app-id': application_id, 'bindir': bindir, 'gettext-package': meson.project_name(), 'version': meson.project_version(), 'screenshots-base-url': screenshots_base_url}) -desktop_file = i18n.merge_file(type: 'desktop', input: configure_file(input: base_id + '.desktop.in.in', output: '@BASENAME@', configuration: conf), output: application_id + '.desktop', po_dir: '../po', install: true, install_dir: datadir / 'applications') -metainfo_file = i18n.merge_file(type: 'xml', input: configure_file(input: base_id + '.metainfo.xml.in.in', output: '@BASENAME@', configuration: conf), output: application_id + '.metainfo.xml', po_dir: '../po', install: true, install_dir: datadir / 'metainfo') -configure_file(input: base_id + '.service.in', output: application_id + '.service', configuration: conf, install: true, install_dir: datadir / 'dbus-1' / 'services') -install_data(base_id + '.gschema.xml', install_dir: datadir / 'glib-2.0' / 'schemas') -gnome.compile_schemas(build_by_default: true, depend_files: files(base_id + '.gschema.xml')) -install_data('icons/hicolor/scalable/apps' / (is_devel ? base_id + '.Devel.svg' : base_id + '.svg'), rename: application_id + '.svg', install_dir: datadir / 'icons/hicolor/scalable/apps') -install_data('icons/hicolor/symbolic/apps' / base_id + '-symbolic.svg', rename: application_id + '-symbolic.svg', install_dir: datadir / 'icons/hicolor/symbolic/apps') -install_data('../LICENSE', 'licenses/third-party.json', 'licenses/THIRD-PARTY-NOTICE.md', install_dir: datadir / 'licenses' / application_id) -if get_option('flatpak') and get_option('kerberos') - install_data('../build-aux/flatpak/krb5.conf', install_dir: prefix / get_option('sysconfdir')) -endif -if desktop_file_validate.found() - test('validate-desktop', desktop_file_validate, args: [desktop_file], suite: 'data') -endif -if appstreamcli.found() - test('validate-appstream', appstreamcli, args: ['validate', '--no-net', '--explain', metainfo_file], suite: 'data') -endif -test('validate-gschema', glib_compile_schemas, args: ['--strict', '--dry-run', meson.current_source_dir()], suite: 'data') -test('metainfo-release-version', python, args: [meson.project_source_root() / 'build-aux/check-metainfo-release.py', metainfo_file, meson.project_version()], suite: 'data') -test('icon-names', python, args: [meson.project_source_root() / 'build-aux/check-icon-names.py', meson.project_source_root() / 'crates/app/src', meson.current_source_dir() / 'resources/icons', get_option('adwaita_icon_dir')], suite: 'data') - -##### FD-C8 · `GSettings schema` - -**Location**: `linux/data/app.tablepro.TablePro.gschema.xml` · **Kind**: GSchema XML · **Consumers**: storage::settings, preferences dialog, window_geometry, editor font, CSV export - - - nicks comma=0 semicolon=1 tab=2 pipe=3 - always=0 if-needed=1 never=2 - lf=0 crlf=1 cr=2 - period=0 comma=1 - -Window and preference keys: -- window-width i 1200 -- window-height i 760 -- is-maximized b false -- default-page-size u range 100..100000, default 1000 -- confirm-destructive b true -- history-retention-days u range 0..365, default 30 (0 keeps forever) -- query-timeout-secs u range 0..3600, default 60 (0 disables) -- use-system-font b true -- custom-font s 'Monospace 12' -- style-scheme s 'Adwaita' - -CSV keys: -- csv-null-to-empty b true -- csv-line-break-to-space b false -- csv-header-row b true -- csv-sanitize-formulas b true -- csv-delimiter enum CsvDelimiter 'comma' -- csv-quote enum CsvQuote 'if-needed' -- csv-line-break enum CsvLineBreak 'lf' -- csv-decimal enum CsvDecimal 'period' - -Every key has a

. - -Ranges copy the AdwSpinRow ranges in ui/preferences.rs:26,46,147. Defaults copy Preferences::default, CsvOptions::default and WindowState::default. - -##### FD-C9 · `AppSettings typed wrapper` - -**Location**: `linux/crates/storage/src/settings/{mod.rs, error.rs, editor_font.rs, window_geometry.rs, keys.rs}` · **Kind**: Rust API · **Consumers**: preferences dialog, window_geometry (W1), editor, browse tabs, history pruning, query timeout, CSV export - -``` -mod.rs: -pub struct AppSettings { settings: gio::Settings } -impl AppSettings { - pub fn open(schema_id: &str, development_schema_dir: Option<&Path>) -> Result; - pub fn with_backend(schema_id: &str, schema_dir: &Path, backend: &gio::SettingsBackend) -> Result; - pub fn gio(&self) -> &gio::Settings; - pub fn default_page_size(&self) -> u64; pub fn set_default_page_size(&self, value: u64) -> Result<(), SettingsError>; - pub fn confirm_destructive(&self) -> bool; - pub fn history_retention_days(&self) -> u32; - pub fn query_timeout(&self) -> Option; - pub fn editor_font(&self) -> EditorFont; - pub fn csv_options(&self) -> tablepro_core::export::CsvOptions; pub fn set_csv_options(&self, options: &CsvOptions) -> Result<(), SettingsError>; - pub fn window_geometry(&self) -> WindowGeometry; pub fn set_window_geometry(&self, geometry: WindowGeometry) -> Result<(), SettingsError>; -} - -error.rs: #[derive(Debug, thiserror::Error)] pub enum SettingsError { SchemaNotFound(String), SchemaDirectory { path: PathBuf, source: glib::Error }, Write { key: &'static str, source: glib::BoolError } } -editor_font.rs: pub enum EditorFont { System, Custom(String) } -window_geometry.rs: #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct WindowGeometry { pub width: i32, pub height: i32, pub maximized: bool } with Default 1200x760, false. -keys.rs: pub const DEFAULT_PAGE_SIZE, CONFIRM_DESTRUCTIVE, HISTORY_RETENTION_DAYS, QUERY_TIMEOUT_SECS, USE_SYSTEM_FONT, CUSTOM_FONT, STYLE_SCHEME: &str. Window keys stay pub(crate), so nothing outside storage can bind them. - -open: from_directory only for Some, else SettingsSchemaSource::default(); lookup(id, true); Settings::new_full(&schema, None::<&SettingsBackend>, None). Rows bind with settings.gio().bind(keys::X, &widget, prop).build(). One AppSettings is created in main and cloned. services/preferences.rs and services/window_state.rs are deleted. -``` - -##### FD-C10 · `StoragePaths, private durable I/O, SecretStore` - -**Location**: `linux/crates/storage/src/{paths.rs, fs.rs, secrets.rs, error.rs}` · **Kind**: Rust API · **Consumers**: connection service, history, workspace persistence, column widths, filters, SSH tunnel, drafts (F-063), F-061 - -``` -paths.rs: -#[derive(Debug, Clone, PartialEq, Eq)] pub struct StoragePaths { pub config: PathBuf, pub state: PathBuf, pub data: PathBuf, pub cache: PathBuf } -impl StoragePaths { pub fn from_glib(dir_name: &str) -> StoragePaths; pub fn under(root: &Path, dir_name: &str) -> StoragePaths; pub fn connections_file(&self) -> PathBuf; known_hosts_file; history_database; workspace_state_file; column_widths_file; filter_settings_file; drafts_dir } -from_glib joins dir_name to glib user_config/state/data/cache dirs; under uses root/{config,state,data,cache}/dir_name. - -fs.rs: -pub fn ensure_private_dir(dir: &Path) -> Result<(), StorageError> -pub async fn write_private(path: PathBuf, contents: Vec) -> Result<(), StorageError> -pub fn write_private_blocking(path: &Path, contents: &[u8]) -> Result<(), StorageError> -pub fn create_private_file_if_missing(path: &Path) -> Result<(), StorageError> -write_private_blocking = ensure_private_dir(parent) + glib::file_set_contents_full(path, contents, CONSISTENT | DURABLE, 0o600). - -secrets.rs: -pub struct SecretStore { schema: &'static str } -impl SecretStore { pub fn new(schema: &'static str) -> SecretStore } with the existing store/load/delete password, ssh_password and ssh_passphrase functions as async methods. - -error.rs: StorageError gains Write { path: PathBuf, source: glib::Error } and Settings(#[from] SettingsError). - -Stores hold paths: ConnectionStore::new(paths) with load/save/delete/touch_last_opened; QueryHistory::open(&paths) replaces the POOL OnceLock and pre-creates history.db 0600. -tablepro-ssh: SshConnector::new(known_hosts: PathBuf); default_known_hosts_path deleted. -main: StoragePaths::from_glib(config::STORAGE_DIR_NAME), SecretStore::new(config::SECRET_SCHEMA). -``` - -##### FD-C11 · `i18n API` - -**Location**: `linux/crates/app/src/i18n.rs` · **Kind**: Rust API · **Consumers**: every UI workstream - -pub use gettextrs::{gettext, ngettext, npgettext, pgettext}; -#[derive(Debug, thiserror::Error)] pub enum I18nError { #[error("binding text domain: {0}")] Bind(std::io::Error) } -pub fn init() -> Result<(), I18nError> -pub fn gettext_f(msgid: &str, args: &[(&str, &str)]) -> String -pub fn ngettext_f(msgid: &str, msgid_plural: &str, n: u32, args: &[(&str, &str)]) -> String -pub fn pgettext_f(msgctxt: &str, msgid: &str, args: &[(&str, &str)]) -> String - -fn substitute replaces each {name} in one pass. Values are never re-expanded; unknown {x} stays literal. - -init: `unsafe { gettextrs::setlocale(LocaleCategory::LcAll, "") }` with SAFETY 'first statement of main, before any thread exists', then bindtextdomain(config::GETTEXT_PACKAGE, config::LOCALEDIR), bind_textdomain_codeset UTF-8, textdomain. - -Call sites: -- gettext("literal") -- gettext_f("Statement {n} executed", &[("n", &n.to_string())]) -- ngettext_f("{count} row", "{count} rows", count, ...) -- pgettext("filter operator", "is empty") - -'TablePro', SQL keywords and operator symbols are never passed to gettext. - -##### FD-C12 · `po pipeline` - -**Location**: `linux/po/meson.build, linux/po/POTFILES.in, linux/po/LINGUAS, linux/build-aux/generate-potfiles.sh, linux/build-aux/check-protected-msgids.py` · **Kind**: meson + scripts · **Consumers**: i18n CI job, translators, meson install - -po/meson.build: -xgettext = find_program('xgettext', required: false) -if xgettext.found() and xgettext.version().version_compare('< 0.24') - warning('xgettext @0@ cannot parse Rust sources; the tablepro-pot target needs gettext >= 0.24 (Fedora 44 or org.gnome.Sdk//50)'.format(xgettext.version())) -endif -i18n.gettext(meson.project_name(), preset: 'glib', args: ['--keyword=gettext', '--keyword=ngettext:1,2', '--keyword=pgettext:1c,2', '--keyword=npgettext:1c,2,3', '--keyword=gettext_f', '--keyword=ngettext_f:1,2', '--keyword=pgettext_f:1c,2']) - -generate-potfiles.sh (POSIX sh, run from linux/) writes sorted: -- data/app.tablepro.TablePro.desktop.in.in -- data/app.tablepro.TablePro.gschema.xml -- data/app.tablepro.TablePro.metainfo.xml.in.in -- data/resources/shortcuts-dialog.ui -- `git ls-files 'crates/app/src/*.rs' | xargs grep -lE '\b(n?p?gettext(_f)?)\('` - -check-protected-msgids.py : exits 1 when any *.po maps msgid "TablePro" to a msgstr other than "" or "TablePro"; also exits 1 if po/tablepro.pot lacks the translator comment on that msgid. - -LINGUAS: one code per line, no comments (the current comment lines are removed). po/README.md documents running tablepro-pot on Fedora 44 or in org.gnome.Sdk//50. - -##### FD-C13 · `Logging API` - -**Location**: `linux/crates/app/src/logging.rs` · **Kind**: Rust API · **Consumers**: all crates via tracing, journald identifier - -``` -#[derive(Debug, thiserror::Error)] pub enum LoggingError { #[error("installing the tracing subscriber: {0}")] Install(#[from] tracing_subscriber::util::TryInitError) } -pub fn init(profile: config::Profile) -> Result<(), LoggingError> -pub fn filter_spec(rust_log: Option<&str>, g_messages_debug: Option<&str>, app_id: &str, profile: config::Profile) -> String -pub fn install_panic_hook() - -filter_spec order: -1. non-empty RUST_LOG verbatim -2. "debug" when G_MESSAGES_DEBUG split on ',' or ' ' contains all, app_id or tablepro -3. "debug" for Development -4. "info" otherwise - -init: registry().with(EnvFilter::try_new(spec), falling back to EnvFilter::new("info")). Adds tracing_journald::layer()?.with_syslog_identifier(APP_ID) when glib::log_writer_is_journald(std::io::stderr()) and the layer is Ok; else fmt::layer().with_writer(std::io::stderr). Then try_init. - -install_panic_hook: take_hook, then error!(message, location, backtrace = Backtrace::force_capture(), "panic") and the previous hook. - -Dependency tracing-journald = "0.3". -``` - -##### FD-C14 · `Application construction and startup` - -**Location**: `linux/crates/app/src/application.rs, linux/crates/app/src/startup_error.rs, linux/crates/app/src/main.rs` · **Kind**: Rust API · **Consumers**: W1, F-069 open flow, shell workstreams - -application.rs: -pub fn build_application() -> Result -Body: gio::resources_register_include!("tablepro.gresource")?; then adw::Application::builder().application_id(config::APP_ID).resource_base_path(config::RESOURCE_BASE_PATH).flags(gio::ApplicationFlags::HANDLES_OPEN).build(). -Startup actions: -- app.quit (q) -- app.new-window (n) -- app.about -- app.preferences (comma) -- app.shortcuts, provided by AdwApplication from shortcuts-dialog.ui -connect_open delegates to W1's open_files. Development windows add the 'devel' CSS class. - -startup_error.rs: -#[derive(Debug, thiserror::Error)] pub enum StartupError { Logging(#[from] LoggingError), Settings(#[from] SettingsError), Storage(#[from] StorageError), Runtime(#[source] std::io::Error), Resources(#[source] glib::Error) } - -main.rs: fn main() -> glib::ExitCode runs run(). -run order: -1. i18n::init (result kept) -2. logging::init -3. log a warning for any i18n error -4. install_panic_hook -5. AppSettings::open(config::APP_ID base schema id, config::DEVELOPMENT_SCHEMA_DIR.map(Path::new)) -6. StoragePaths::from_glib -7. tokio runtime build (map_err Runtime) -8. build_application (map_err Resources) -9. RelmApp::from_app - -A Logging error is reported with glib::g_critical!; other errors with tracing::error!; both return ExitCode::FAILURE. - -##### FD-C15 · `Desktop entry and D-Bus service templates` - -**Location**: `linux/data/app.tablepro.TablePro.desktop.in.in, linux/data/app.tablepro.TablePro.service.in` · **Kind**: desktop entry · **Consumers**: GNOME Shell, Files Open With, flatpak export, deb/rpm - -``` -[Desktop Entry] -# Translators: Do NOT translate or transliterate this text (this is the application name)! -Name=TablePro -GenericName=Database Client -Comment=Browse and edit PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse databases -# Translators: Search terms to find this application. Keep the database product names. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! -Keywords=SQL;Database;PostgreSQL;MySQL;MariaDB;SQLite;SQL Server;ClickHouse; -Exec=tablepro %U -# Translators: Do NOT translate or transliterate this text (this is an icon file name)! -Icon=@app-id@ -Terminal=false -Type=Application -Categories=Development;Database; -MimeType=application/vnd.sqlite3; -StartupNotify=true -DBusActivatable=true -Actions=new-window; - -[Desktop Action new-window] -Name=New Window - -The MimeType line lands with the open handler (FD-C14). - -service.in: -[D-BUS Service] -Name=@app-id@ -Exec=@bindir@/tablepro --gapplication-service -``` - -##### FD-C16 · `GResource manifest and style.css` - -**Location**: `linux/data/resources/resources.gresource.xml, linux/data/resources/style.css, linux/data/resources/shortcuts-dialog.ui` · **Kind**: GResource XML + CSS · **Consumers**: grid, editor, window shell, About dialog, structure tab icons - - -style.css -shortcuts-dialog.ui -icons/scalable/actions/table-relation-symbolic.svg -icons/scalable/actions/statement-pending-symbolic.svg -../licenses/third-party.json - - -style.css: -.tp-cell-modified { background: color-mix(in srgb, var(--warning-bg-color) 18%, transparent); } -.tp-row-pending-insert { background: color-mix(in srgb, var(--success-bg-color) 16%, transparent); } -.tp-row-pending-delete { text-decoration: line-through; color: color-mix(in srgb, var(--error-color) 70%, transparent); background: color-mix(in srgb, var(--error-bg-color) 10%, transparent); } -label.tp-null-sentinel { font-style: italic; } -columnview > listview > row > cell:focus-within > label, columnview > listview > row > cell:focus-within > .tp-cell-editor { box-shadow: inset 0 0 0 2px var(--accent-color); border-radius: 2px; } -@keyframes tp-flash-error { from { background: color-mix(in srgb, var(--error-bg-color) 55%, transparent); } to { background: transparent; } } -.tp-row-leftmost-error-flash { animation: tp-flash-error 1.8s ease-out; } -textview.sql-editor { font-family: var(--monospace-font-family); font-size: var(--monospace-font-size); } -@media (prefers-contrast: more) { .tp-cell-modified { background: color-mix(in srgb, var(--warning-bg-color) 40%, transparent); } } - -shortcuts-dialog.ui: an AdwShortcutsDialog with id shortcuts_dialog, whose sections mirror build_shortcuts_window at ui/app/mod.rs:1548. - -##### FD-C17 · `Metainfo template` - -**Location**: `linux/data/app.tablepro.TablePro.metainfo.xml.in.in` · **Kind**: AppStream XML · **Consumers**: Flathub, GNOME Software, AdwAboutDialog, linter CI - - -@app-id@CC0-1.0AGPL-3.0-or-later -TableProWork with your databases - -

TablePro connects to PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse databases. Browse tables, edit rows in place, change table structure, and run SQL with syntax highlighting.

-
  • Edit cells and rows, then review and save the changes together
  • Write and run SQL in tabs, with query history and search
  • Connect through SSH tunnels and encrypted connections
  • Export results as CSV, JSON or SQL
-
-@app-id@.desktop -TablePro Authors -https://tablepro.app -https://github.com/TableProApp/TablePro/issues -https://github.com/TableProApp/TablePro -https://docs.tablepro.app -https://github.com/TableProApp/TablePro/blob/linux-v@version@/linux/po/README.md -https://github.com/TableProApp/TablePro/blob/linux-v@version@/linux/CONTRIBUTING.md -@gettext-package@ -#99c1f1#1a5fb4 - -@screenshots-base-url@/01-table.pngBrowse and edit table rows -@screenshots-base-url@/02-editor.pngRun SQL in the editor -@screenshots-base-url@/03-structure.pngChange a table's columns and indexes -@screenshots-base-url@/04-connect.pngConnect through an SSH tunnel - - -keyboardpointing -360 -tableproapplication/vnd.sqlite3 -DevelopmentDatabase -

First release.

-
- -Rules: -- the newest release equals VERSION (meson test) -- the release commit rewrites its date to the tag date (release workflow check) -- no injected snapshot release -- display_length follows the F-035 shell floor - -##### FD-C18 · `Workspace Cargo manifest` - -**Location**: `linux/Cargo.toml, linux/rust-toolchain.toml, linux/clippy.toml` · **Kind**: TOML · **Consumers**: all crates, CI, packaging - -[workspace] resolver = "2"; members = [app, build-support, core, ssh, storage, drivers/clickhouse, drivers/mssql, drivers/mysql, drivers/postgres, drivers/sqlite] -[workspace.package] edition = "2024"; rust-version = "1.98"; license = "AGPL-3.0-or-later"; publish = false -[workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" -[workspace.lints.clippy] unwrap_used, expect_used, panic, todo, unimplemented, dbg_macro, print_stdout, print_stderr, allow_attributes, allow_attributes_without_reason = "deny" - -[workspace.dependencies]: -- russh = "0.63" -- sqlx = { 0.9, default-features false, features [runtime-tokio, tls-rustls-aws-lc-rs, chrono, rust_decimal, uuid, json] } -- tiberius = { 0.12, default-features false, features [tds73, rustls, chrono, rust_decimal] } -- clickhouse = { 0.15, default-features false, features [rustls-tls-aws-lc, rustls-tls-webpki-roots] } -- testcontainers = { 0.27, default-features false, features [aws-lc-rs] } -- testcontainers-modules = { 0.15, default-features false, features [aws-lc-rs] } -- rustls = { 0.23, default-features false, features [std] } -- gtk4 = "0.11"; libadwaita = "0.9"; sourceview5 = { 0.11, features [v5_18] } -- glib = "0.22"; gio = "0.22"; glib-build-tools = "0.22" -- relm4 = { 0.11, default-features false, features [macros, libadwaita, gnome_50] } -- gettext-rs = { 0.8, features [gettext-system] } -- tracing-journald = "0.3" -- existing async-trait, thiserror, serde, serde_json, tokio, tokio-util, secrecy, tracing, tracing-subscriber, chrono, rust_decimal, futures, uuid, tempfile, oo7 unchanged -- anyhow and async-channel removed - -[profile.release] lto = "fat"; codegen-units = 1 -[patch.crates-io] tiberius = { git = "https://github.com/tiberius-rs/tiberius", rev = "c1d2e741ee6fe0935091826d91fb5886b6a4082a" } - -Per crate: -- storage: sqlx [sqlite-unbundled]; glib, gio [v2_72] -- drivers/sqlite: sqlx [sqlite-unbundled] -- drivers/mysql: sqlx [mysql, mysql-rsa]; dev testcontainers-modules [mysql] -- drivers/postgres: dev testcontainers-modules [postgres], dev rustls -- drivers/mssql: [features] kerberos = ["tiberius/integrated-auth-gssapi"]; dev testcontainers-modules [mssql_server]; dev libgssapi 0.8 for krb5_profile -- drivers/clickhouse: dev testcontainers [http_wait_plain] -- app: package tablepro, [[bin]] tablepro, default = ["kerberos"], thiserror, build-dependencies glib-build-tools + tablepro-build-support -- every crate: [lints] workspace = true - -rust-toolchain.toml: channel 1.98, components rustfmt and clippy. clippy.toml: allow-{unwrap,expect,panic,dbg,print}-in-tests = true. - -##### FD-C19 · `Supply-chain policy` - -**Location**: `linux/deny.toml, linux/about.toml, linux/docs/decisions/0009-supply-chain-policy.md` · **Kind**: TOML + ADR · **Consumers**: supply-chain CI, About dialog, packaging license generators - -``` -deny.toml: -[graph] targets = [x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu]; all-features = true; exclude-dev = false -[advisories] version = 2; yanked = "deny"; unmaintained = "workspace" -ignore = [{ id = "RUSTSEC-2023-0071", reason = "rsa private-key timing: affects in-process SSH RSA identity signatures (one per authentication, blinded); MySQL RSA password encryption, host-key verification and ssh-agent auth are public-key or out-of-process; accepted in ADR 0009 with an RSA key hint in the SSH section" }] -[licenses] version = 2; allow = [AGPL-3.0-or-later, MIT, Apache-2.0, Apache-2.0 WITH LLVM-exception, BSD-2-Clause, BSD-3-Clause, ISC, Zlib, Unicode-3.0, CC0-1.0, MPL-2.0, OpenSSL, CDLA-Permissive-2.0, LGPL-2.1-or-later, 0BSD]; confidence-threshold = 0.9; private = { ignore = true } -[bans] multiple-versions = "warn"; wildcards = "deny" -deny = [ - { crate = "ring", reason = "single crypto provider: aws-lc-rs" }, - { crate = "openssl-sys", reason = "rustls only" }, - { crate = "native-tls", reason = "rustls only" }, - { crate = "rustls:<0.23", reason = "one rustls major" } -] -[sources] unknown-registry = "deny"; unknown-git = "deny"; allow-git = ["https://github.com/tiberius-rs/tiberius"] - -A new license needs a reviewed deny.toml change. - -about.toml: accepted = the same list in priority order, plus any [crate.clarify] entries cargo-about needs. -Command: cargo about generate --locked --offline --format json -o data/licenses/third-party.json. - -ADR 0009 records the single provider, the tiberius git pin and the RSA acceptance scope. -``` - -##### FD-C20 · `Flatpak manifest, renderer, lint exceptions, krb5.conf` - -**Location**: `linux/build-aux/flatpak/{app.tablepro.TablePro.Devel.json, render_flathub_manifest.py, lint-exceptions.json, cargo-sources.json, krb5.conf}` · **Kind**: Flatpak manifest JSON + script · **Consumers**: CI flatpak jobs, GNOME Builder, release workflow, Flathub submission - -app.tablepro.TablePro.Devel.json: -{"id": "app.tablepro.TablePro.Devel", "runtime": "org.gnome.Platform", "runtime-version": "50", "sdk": "org.gnome.Sdk", - "sdk-extensions": ["org.freedesktop.Sdk.Extension.rust-stable", "org.freedesktop.Sdk.Extension.llvm22"], - "command": "tablepro", - "finish-args": ["--share=network", "--share=ipc", "--socket=wayland", "--socket=fallback-x11", "--device=dri", "--socket=ssh-auth", "--filesystem=home", "--filesystem=/media", "--filesystem=/run/media", "--filesystem=/mnt", "--filesystem=/run/.heim_org.h5l.kcm-socket", "--env=KRB5_CONFIG=/app/etc/krb5.conf"], - "build-options": {"append-path": "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm22/bin", "prepend-ld-library-path": "/usr/lib/sdk/llvm22/lib", "env": {"CARGO_NET_OFFLINE": "true", "LIBCLANG_PATH": "/usr/lib/sdk/llvm22/lib"}, "test-args": ["--socket=x11"]}, - "cleanup": ["/include", "/lib/pkgconfig", "/share/man"], - "modules": [{"name": "tablepro", "buildsystem": "meson", "run-tests": true, - "config-opts": ["-Dprofile=development", "-Dflatpak=true", "-Dcargo_config=cargo/config.toml"], - "sources": [{"type": "dir", "path": "../../", "skip": ["target", "_build", "_flatpak", ".flatpak-builder", "flatpak_app", "repo"]}, "cargo-sources.json", {"type": "shell", "commands": ["mv cargo/config cargo/config.toml"]}]}]} - -render_flathub_manifest.py (python3 stdlib): -args --devel-manifest (default: the file above), --app-id-file (build-aux/APP_ID), exactly one of --archive-path FILE or (--archive-url URL --sha256 HEX), optional --screenshots-base-url https-URL, --output-dir DIR. -Writes DIR/app.tablepro.TablePro.json: -- id = APP_ID -- -Dprofile=development becomes -Dprofile=default -- -Dscreenshots_base_url appended when given -- run-tests and build-options.test-args removed -- sources[0] must be the dir source and becomes {"type": "archive", "path": basename, "sha256": computed} or {"type": "archive", "url": URL, "sha256": HEX} -Copies cargo-sources.json (and the archive in path mode) into DIR. Exits 2 with a message on any structural mismatch or a non-https screenshot URL. - -lint-exceptions.json: {"app.tablepro.TablePro": ["finish-args-home-filesystem-access", "finish-args-has-socket-ssh-auth"]} -Lint commands: -flatpak run --command=flatpak-builder-lint org.flatpak.Builder --user-exceptions linux/build-aux/flatpak/lint-exceptions.json manifest /app.tablepro.TablePro.json -flatpak run --command=flatpak-builder-lint org.flatpak.Builder --user-exceptions linux/build-aux/flatpak/lint-exceptions.json repo repo - -krb5.conf: [libdefaults] with default_ccache_name = KCM:, kcm_socket = /run/.heim_org.h5l.kcm-socket, dns_lookup_realm = true, dns_lookup_kdc = true, dns_canonicalize_hostname = fallback, rdns = false. Installed by meson (FD-C7). - -Exception requests filed by maintainers (human-written) in flathub-infra/flatpak-builder-lint carry the FD-D15 justifications. - -##### FD-C21 · `Debian and RPM packaging` - -**Location**: `linux/build-aux/debian/{control.in, rules, source/format, copyright, README.source}, linux/build-aux/packaging/{assemble_debian.py, license_metadata.py}, linux/build-aux/rpm/{tablepro.spec.in, render_spec.py}` · **Kind**: packaging files · **Consumers**: release workflow deb and COPR jobs - -control.in: -Source: tablepro -Section: database -Priority: optional -Maintainer: @MAINTAINER@ -Build-Depends: debhelper-compat (= 13), meson (>= 1.4), pkgconf, python3, gettext, desktop-file-utils, appstream, libglib2.0-bin, libglib2.0-dev (>= 2.88), libgtk-4-dev (>= 4.22), libadwaita-1-dev (>= 1.9), libgtksourceview-5-dev (>= 5.18), libsqlite3-dev, libkrb5-dev, libclang-dev, xvfb, xauth -Standards-Version: 4.7.2 -Rules-Requires-Root: no -Homepage: https://tablepro.app - -Package: tablepro -Architecture: any -Depends: ${shlibs:Depends}, ${misc:Depends} -Description: Database client for GNOME - -rules: -#!/usr/bin/make -f -export CARGO_NET_OFFLINE = true -%: - dh $@ --buildsystem=meson -override_dh_auto_configure: - dh_auto_configure -- -Dprofile=default -Dkerberos=true -Dcargo_config=vendor/cargo-config.toml -override_dh_auto_test: - xvfb-run -a dh_auto_test - -source/format: 3.0 (quilt). README.source: Rust >= 1.98 comes from rustup via rust-toolchain.toml; the vendor component is tablepro_X.Y.Z.orig-vendor.tar.xz. - -assemble_debian.py --version X.Y.Z --maintainer " " --dest /debian: copies the files, renders control, writes changelog (tablepro (X.Y.Z-1) resolute; urgency=medium) with the maintainer and the tag date. - -license_metadata.py spdx-expression|debian-copyright (FD-D13). - -tablepro.spec.in (rendered by render_spec.py --version --license-json --linguas): -Name: tablepro; Version: @VERSION@; Release: 1%{?dist}; Summary: Database client for GNOME; License: @LICENSE_EXPRESSION@; URL: https://tablepro.app -Source0/Source1: release asset URLs for tablepro-%{version}.tar.xz and tablepro-%{version}-vendor.tar.xz -BuildRequires: meson >= 1.4, rust >= 1.98, cargo, gcc, clang-devel, python3, gettext, desktop-file-utils, /usr/bin/appstreamcli, /usr/bin/xvfb-run, pkgconfig(glib-2.0) >= 2.88, pkgconfig(gtk4) >= 4.22, pkgconfig(libadwaita-1) >= 1.9, pkgconfig(gtksourceview-5) >= 5.18, pkgconfig(sqlite3), pkgconfig(mit-krb5-gssapi) -%prep: %autosetup -a1 -%build: export CARGO_NET_OFFLINE=true; %meson -Dprofile=default -Dkerberos=true -Dcargo_config=vendor/cargo-config.toml; %meson_build -%install: %meson_install; @FIND_LANG@ -%check: export CARGO_NET_OFFLINE=true; xvfb-run -a %meson_test -%files @FILES_LANG@: -- %license LICENSE -- %{_bindir}/tablepro -- the desktop, metainfo, dbus-1 service and gschema files -- %{_datadir}/icons/hicolor/scalable/apps/app.tablepro.TablePro.svg and symbolic/apps/app.tablepro.TablePro-symbolic.svg -- %{_datadir}/licenses/app.tablepro.TablePro/ - -@FIND_LANG@ = '%find_lang %{name}' and @FILES_LANG@ = '-f %{name}.lang' only when LINGUAS lists a language, otherwise empty. - -##### FD-C22 · `CI workflow` - -**Location**: `.github/workflows/build-linux.yml (identical on main and linux)` · **Kind**: GitHub Actions YAML · **Consumers**: all workstreams' PRs - -on: push (branches main, linux, linux/**, feat/linux-**; paths linux/**, .github/workflows/build-linux.yml), pull_request (same paths), workflow_dispatch, schedule cron '13 14 * * 1' - -weekly-dispatch: if schedule; ubuntu-24.04; permissions actions: write; env GH_TOKEN github.token; run `gh workflow run build-linux.yml --repo ${{ github.repository }} --ref "${{ vars.LINUX_BRANCH }}"` - -detect: if github.event_name != 'schedule'; actions/checkout@v5 sparse-checkout linux/meson.build; step writes linux=true|false to GITHUB_OUTPUT. All jobs below: needs detect, if needs.detect.outputs.linux == 'true'. - -fast: ubuntu-24.04, container ubuntu:26.04, working-directory linux, env CARGO_HOME=$GITHUB_WORKSPACE/linux/_build/cargo-home and CARGO_TARGET_DIR=$GITHUB_WORKSPACE/linux/_build/cargo-target. -- apt: build-essential pkgconf meson ninja-build gettext desktop-file-utils appstream libglib2.0-bin libgtk-4-dev libadwaita-1-dev libgtksourceview-5-dev libsqlite3-dev libkrb5-dev libclang-dev adwaita-icon-theme xvfb xauth python3 git curl ca-certificates -- dtolnay/rust-toolchain@1.98 (rustfmt, clippy); Swatinem/rust-cache@v2 with workspaces 'linux -> _build/cargo-target' and cache-directories linux/_build/cargo-home -- steps: - 1. meson setup _build -Dprofile=development - 2. cargo fmt --all --check - 3. cargo clippy --locked --workspace --all-targets -- -D warnings - 4. same with --no-default-features - 5. meson compile -C _build - 6. xvfb-run -a meson test -C _build --print-errorlogs - 7. meson dist -C _build --no-tests --formats xztar - 8. python3 build-aux/flatpak/render_flathub_manifest.py --archive-path _build/meson-dist/tablepro-$(cat VERSION).tar.xz --screenshots-base-url https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.full_name || github.repository }}/${{ github.event.pull_request.head.sha || github.sha }}/linux/data/screenshots --output-dir _flatpak/build - 9. render again with --archive-url https://github.com/TableProApp/TablePro/releases/download/linux-v$(cat VERSION)/tablepro-$(cat VERSION).tar.xz --sha256 $(sha256sum of the tarball) --output-dir _flatpak/lint - 10. upload artifact flathub-shape (linux/_flatpak) - -supply-chain: -- EmbarkStudios/cargo-deny-action@v2 (manifest-path linux/Cargo.toml, command check advisories bans licenses sources) -- cargo install --locked cargo-shear cargo-about; cargo shear; cargo fetch --locked -- cargo about generate --locked --offline --format json -o data/licenses/third-party.json -- python3 build-aux/packaging/license_metadata.py debian-copyright data/licenses/third-party.json > build-aux/debian/copyright -- flatpak-cargo-generator from flatpak/flatpak-builder-tools at env FLATPAK_BUILDER_TOOLS_REV (commit SHA): Cargo.lock -o build-aux/flatpak/cargo-sources.json -- git diff --exit-code - -i18n: container fedora:44. First step: dnf install -y git meson ninja-build gcc rust cargo python3 pkgconf-pkg-config gettext desktop-file-utils appstream glib2-devel gtk4-devel libadwaita-devel gtksourceview5-devel sqlite-devel krb5-devel. Then checkout; meson setup _build --fatal-meson-warnings; sh build-aux/generate-potfiles.sh; meson compile -C _build tablepro-pot; python3 build-aux/check-protected-msgids.py po; git diff --exit-code po/. - -flatpak-devel: container ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 --privileged; matrix {x86_64: ubuntu-24.04, aarch64: ubuntu-24.04-arm}; flatpak/flatpak-github-actions/flatpak-builder@v6 with manifest-path linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json, run-tests true, arch, cache-key flatpak-devel-${{ matrix.arch }}-${{ hashFiles('linux/Cargo.lock') }}. - -flatpak-flathub: needs [detect, fast]; same container; x86_64; download flathub-shape into linux/_flatpak. -- flatpak-builder@v6 with manifest-path linux/_flatpak/build/app.tablepro.TablePro.json, mirror-screenshots-url https://dl.flathub.org/media, run-tests false, build-bundle false -- flatpak install -y flathub org.flatpak.Builder -- both FD-C20 lint commands (manifest mode on linux/_flatpak/lint/app.tablepro.TablePro.json, repo mode on repo) - -integration: needs [detect, fast]; ubuntu-24.04; apt build-essential pkg-config; rust 1.98; cargo test --locked --test integration -p tablepro-driver-{postgres,mysql,clickhouse,mssql} -- --include-ignored --test-threads=1. - -Repository variable LINUX_BRANCH=linux. scripts/ci-local.sh mirrors fast; scripts/smoke-postgres.sh stays. - -##### FD-C23 · `Release workflow` - -**Location**: `.github/workflows/release-linux.yml` · **Kind**: GitHub Actions YAML · **Consumers**: release process, Flathub, COPR, GitHub Releases - -on: push tags linux-v*. Secrets: COPR_CONFIG, FLATHUB_TOKEN. Variables: LINUX_PACKAGE_MAINTAINER. - -1. verify: tag == 'linux-v' + VERSION; build-aux/check-metainfo-release.py confirms the newest release version equals VERSION and its date equals the tag commit date (UTC). -2. sources (ubuntu:26.04): meson setup + meson dist --no-tests --formats xztar; `cargo vendor --locked --versioned-dirs vendor > "$RUNNER_TEMP/cargo-config.toml"`, then mv into vendor/cargo-config.toml; tar -cJf tablepro-X.Y.Z-vendor.tar.xz vendor. -3. deb (ubuntu:26.04, rustup from rust-toolchain.toml): - - extract the dist tarball to tablepro-X.Y.Z/ - - copy tarballs to tablepro_X.Y.Z.orig.tar.xz and tablepro_X.Y.Z.orig-vendor.tar.xz - - extract vendor into the tree - - assemble_debian.py --version --maintainer "$LINUX_PACKAGE_MAINTAINER" --dest tablepro-X.Y.Z/debian - - dpkg-buildpackage -us -uc; lintian -4. rpm: render_spec.py; rpmbuild -bs with Source0/1; copr-cli build tablepro/tablepro --chroot fedora-44-x86_64 --chroot fedora-44-aarch64; rpmlint on spec and SRPM. -5. smoke: install the .deb in fresh ubuntu:26.04 and the COPR rpm in fresh fedora:44; run desktop-file-validate, appstreamcli validate --no-net, glib-compile-schemas --strict --dry-run on the installed schema dir, and tablepro --help. -6. github-release: upload the dist tarball, vendor tarball, .deb and SHA256SUMS. -7. flathub (gnome-50 container): - - render_flathub_manifest.py --archive-url --sha256 --output-dir out (default tag screenshot URLs, which now exist) - - build with mirror-screenshots-url; lint manifest and repo with --user-exceptions - - copy out/app.tablepro.TablePro.json and out/cargo-sources.json into flathub/app.tablepro.TablePro and open a PR with FLATHUB_TOKEN - - the initial Flathub submission uses the same two files, submitted by a maintainer to flathub/flathub new-pr - -##### FD-C24 · `Window geometry helper` - -**Location**: `linux/crates/app/src/ui/window_geometry.rs` · **Kind**: Rust API · **Consumers**: W1 window-per-session model - -``` -pub fn restore(window: &adw::ApplicationWindow, settings: &AppSettings) -Sets set_default_size(geometry.width, geometry.height), then maximize() when geometry.maximized. - -pub fn persist(window: &adw::ApplicationWindow, settings: &AppSettings) -> Result<(), SettingsError> -When window.is_maximized(): (default_width(), default_height()); else (width(), height()). Writes WindowGeometry { width, height, maximized } via set_window_geometry. - -Contract for W1: restore after constructing each session window; persist inside that window's connect_close_request when the handler returns Propagation::Proceed. Replaces ui/app/mod.rs:767-771 and 900-909. -``` - -##### FD-C25 · `SSH identity algorithm inspection` - -**Location**: `linux/crates/ssh/src/identity.rs, linux/crates/app/src/ui/ssh_section.rs` · **Kind**: Rust API + UI contract · **Consumers**: SSH section, F-065 agent-default consumer, ADR 0009 - -``` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IdentityAlgorithm { Ed25519, Ecdsa, Rsa, Other } -pub fn identity_algorithm(path: &Path) -> Result -Reads the file and parses it with russh::keys::ssh_key::PrivateKey::from_openssh, taking algorithm() from the unencrypted public section (no passphrase needed). Read and parse failures map to the existing SshError::Key. - -ssh_section.rs: when the key-file row (key_path, lines 72-84) changes and identity_algorithm returns Rsa, the row subtitle becomes gettext("RSA key. An Ed25519 key or the SSH agent avoids RSA signing timing leaks."). Otherwise the subtitle is cleared. -``` - -##### FD-C26 · `build-aux checks and script tests` - -**Location**: `linux/build-aux/{meson.build, check-metainfo-release.py, check-icon-names.py, check-protected-msgids.py, tests/}` · **Kind**: python3 stdlib scripts + meson · **Consumers**: meson test, CI, release workflow - -check-metainfo-release.py [--tag-date YYYY-MM-DD]: exit 1 unless the first has version == and an ISO date (equal to --tag-date when given). -check-icon-names.py : exit 1 listing unresolved '*-symbolic' literals. -check-protected-msgids.py: FD-C12. -render_flathub_manifest.py, license_metadata.py, assemble_debian.py, render_spec.py: FD-C20/C21. - -build-aux/meson.build: test('build-aux-scripts', python, args: ['-m', 'unittest', 'discover', '-s', meson.current_source_dir() / 'tests', '-t', meson.current_source_dir()], suite: 'packaging'). tests/ holds fixtures: a Devel manifest copy, a cargo-about JSON sample, po files, and metainfo files with matching, mismatching and undated releases. - -#### Test strategy - -- tablepro-build-support unit tests: -- from_env: absent profile gives Cargo; a stray LOCALEDIR without profile gives IncompleteMesonEnvironment; profile without LOCALEDIR gives the TABLEPRO_LOCALEDIR error; a relative localedir is rejected; suffix Some("") becomes None -- resolve Meson development: development_schema_dir None, app ID .Devel, VERSION-hash, storage tablepro-devel -- resolve Meson development with an empty suffix: version exactly VERSION, never '0.1.0-' -- resolve Meson default: base ID, no suffix, None schema dir, storage tablepro -- resolve Cargo: Some(out_dir/schemas), out_dir/locale, VERSION-devel -- resource_base_path never has a Devel segment; secret_schema = app_id + .Password -- AppIdentity rejects fewer than 3 elements, a leading digit, trailing .desktop/.linux, and non-numeric versions -- to_rust_source output compiles in a doctest -- compile_development_schemas and compile_translations produce gschemas.compiled and .mo files from fixtures and return ToolFailed on invalid input -- App config and resources: gio::resources_lookup_data for RESOURCE_BASE_PATH/style.css, shortcuts-dialog.ui, both action icons and licenses/third-party.json succeeds after build_application() returns Ok. build_application has HANDLES_OPEN and resource_base_path == RESOURCE_BASE_PATH. -- #[gtk::test] (meson suite gtk, run under xvfb-run with GSK_RENDERER=cairo and GTK_A11Y=none in the fast job, Flatpak run-tests, dh_auto_test and %check): -- Builder::from_resource(shortcuts-dialog.ui) yields adw::ShortcutsDialog shortcuts_dialog -- IconTheme has table-relation-symbolic after the resource path is added -- window_geometry with a memory backend: two ApplicationWindows restored from the same settings; maximize() on window A leaves the is-maximized key unchanged and window B's maximized property false; persist(A) after set_default_size(800, 600) writes 800x600 while B's default size stays 1200x760 -- AppSettings tests: compile the gschema into a TempDir, open with_backend and gio::memory_settings_backend_new(). -- defaults equal the pre-migration Preferences, CsvOptions and WindowState defaults -- csv_options and window_geometry round-trip -- range_check rejects page size 99/100001, retention 366 and timeout 3601 -- query_timeout maps 0 to None -- a missing schema returns SchemaNotFound; open(Some(nonexistent dir)) returns SchemaDirectory -- StoragePaths, fs and SecretStore tests (StoragePaths::under): -- from_glib and under join the given dir name, and tablepro vs tablepro-devel give disjoint paths -- write_private_blocking creates 0700 dirs and 0600 files; rewriting a 0644 file results in 0600 -- a write into a read-only dir returns StorageError::Write and leaves the previous bytes intact -- history.db and history.db-wal are 0600 after QueryHistory::open -- ConnectionStore round-trips -- SshConnector uses the injected known_hosts -- SecretStore builds attributes with the injected schema name -- i18n tests: -- gettext_f named substitution with reordered translations from a fixture .mo compiled by msgfmt into a TempDir -- values containing {x} are not re-expanded; unknown placeholders stay literal -- ngettext_f plural selection for n=1/2 -- the i18n CI job: meson setup --fatal-meson-warnings on fedora:44, POTFILES then POT regeneration, protected-msgid check, git diff; check-protected-msgids.py unit tests with passing and failing po fixtures -- Logging tests: filter_spec precedence (RUST_LOG over G_MESSAGES_DEBUG over profile; all, app ID and tablepro raise to debug; empty RUST_LOG ignored). The panic hook test uses tracing::subscriber::with_default and catch_unwind, asserting one error event with message and location and that the previous hook ran. -- Meson suites: unit (cargo-test-libraries), gtk (cargo-test-app), data (validate-desktop, validate-appstream, validate-gschema, metainfo-release-version, icon-names), packaging (build-aux-scripts unittest), kerberos (krb5-profile: libgssapi Cred::acquire with KRB5_CONFIG=build-aux/flatpak/krb5.conf fails with a missing-credentials GSS status, not a profile parse error). All run in the fast job and inside the GNOME 50 SDK. -- Git-less verification: flatpak-devel builds from a dir source without .git on x86_64 and aarch64 and runs every meson suite, so the Devel metainfo, desktop file and version resolve without git. flatpak-flathub builds from the meson dist tarball (also git-less) with the default profile. -- Flathub shape on every PR: render_flathub_manifest.py unit tests (profile swap, test options removed, archive path/url forms, sha256, cargo-sources.json copied, rejection of non-https URLs and missing dir sources). The rendered manifest builds with --mirror-screenshots-url against the PR commit's raw URLs. flatpak-builder-lint with --user-exceptions passes in manifest and repo modes, and any finding beyond the two exceptions fails. -- Supply chain: cargo deny check with all-features and dev edges (fails on ring, rustls <0.23, openssl-sys, native-tls, unknown git, new license, new advisory); tls_provider.rs builds rustls::ClientConfig::builder() in the unified workspace test graph; cargo shear; drift checks for third-party.json, build-aux/debian/copyright and cargo-sources.json; license_metadata.py unit tests against a cargo-about JSON fixture. -- Drivers: docker integration suites for PostgreSQL, MySQL (including non-TLS caching_sha2_password via mysql-rsa), ClickHouse and SQL Server without Kerberos. The file-backed SQLite integration test (TEMP table visibility, concurrent read during a write) runs against system libsqlite3. A storage test creates the FTS5 history table. Clippy runs with and without default features. identity_algorithm tests parse ed25519, ecdsa, rsa and passphrase-protected rsa fixtures without a passphrase. -- Packaging (release workflow): assemble_debian.py and render_spec.py unit tests (control rendering, changelog, conditional %find_lang); dpkg-buildpackage -us -uc from the dist tree plus vendor component, with xvfb-run dh_auto_test; lintian; COPR fedora-44 offline builds with xvfb-run %meson_test; rpmlint; install smoke tests running desktop-file-validate, appstreamcli validate --no-net, glib-compile-schemas --strict --dry-run and tablepro --help; check-metainfo-release.py --tag-date gate. - -#### Risks - -- GNOME 51 releases 2026-09-16 on freedesktop-sdk 26.08, two days after this plan, while the manifest targets org.gnome.Platform//50 per the owner's decision. Mitigation: GNOME 50 remains a supported runtime after 51 ships. Flathub's runtime-is-eol check becomes an error only after three consecutive newer releases, so //50 is valid for submission and updates. The crate floor gnome_50 runs unchanged on 51. The weekly CI run lints the rendered manifest with the current flatpak-builder-lint, so any Flathub runtime policy change fails that run with the exact check name. -- Ubuntu 26.04's archive provides only rustc 1.93.1 and rustc-1.91/1.92/1.93 (verified), below sqlx 0.9's 1.94 and the workspace rust-version 1.98. Mitigation: Decided permanently in FD-D20: the .deb is a GitHub Release artifact built in ubuntu:26.04 with the rustup toolchain from rust-toolchain.toml, documented in build-aux/debian/README.source and ADR 0008. No archive upload depends on the archive toolchain. -- Flathub may deny the finish-args-home-filesystem-access or finish-args-has-socket-ssh-auth exceptions. Mitigation: The requests carry the technical justification (SQLite -journal/-wal/-shm siblings that document portals cannot provide; ssh-agent tunnel authentication). ADR 0007 records the evaluated portal-only design and why it loses rollback and WAL safety, so the review discussion starts from that analysis. The committed lint-exceptions.json names exactly what is requested. -- Kerberos in the Flatpak works only with KCM ccaches and DNS-discoverable realms; FILE and KEYRING ccaches and /etc/krb5.conf-only realms fail in the sandbox. Mitigation: Limits are verified (private /tmp, seccomp EPERM for keyctl, krb5 default-realm import, flatpak#4841) and documented in the README. MSSQL error mapping names the missing realm or ticket. The krb5-profile test proves the shipped config parses. Native packages read /etc/krb5.conf. -- Without an xdg-desktop-portal Secret backend, the Flatpak cannot store passwords (oo7 0.6 has no host keyring fallback); the app ID change strands developer secrets under com.tablepro.linux.Password. Mitigation: The connection consumer surfaces the store failure and prompts at connect. The clean break affects only developer machines and is documented in the PR description. -- Without a session bus, GApplication cannot enforce uniqueness, so two processes of one profile can run. Mitigation: State files are written atomically and durably with file_set_contents_full, so concurrent writes cannot corrupt a file (last writer wins). Different profiles use disjoint storage dirs and secret schemas. The workspace model keeps per-connection state in separate files (F-070). -- Ubuntu 26.04's xgettext 0.23.2 would extract Rust strings with the C parser if someone runs tablepro-pot there. Mitigation: po/meson.build warns at configure time with the required version, po/README.md documents Fedora 44 or org.gnome.Sdk//50, and the fedora:44 i18n job runs meson setup --fatal-meson-warnings and fails on any POT drift. -- RUSTSEC-2023-0071 has no patched rsa version; in-process SSH RSA signatures remain timing-exposed. Mitigation: FD-D23 states the correct scope and bound (one blinded signature per user-initiated authentication; MySQL RSA encryption, host-key verification and agent auth unaffected). The SSH section recommends Ed25519 or ssh-agent for RSA keys, and the F-065 consumer makes the agent the default when available. cargo-deny flags the ignore as unused once the advisory is withdrawn or patched. -- tiberius is a git pin to tiberius-rs main (c1d2e741) because no release carries the rustls 0.23 migration. Mitigation: cargo-deny [sources] allows only that URL. flatpak-cargo-generator and cargo vendor include the git source for offline builds. The SQL Server integration suite and krb5-profile test run against the pin on every PR. ADR 0009 records the pin. -- Committed generated artifacts (tablepro.pot, POTFILES.in, third-party.json, build-aux/debian/copyright, cargo-sources.json) can drift from their sources. Mitigation: Each is regenerated in CI and gated by git diff --exit-code. Commits that change Cargo.lock or strings regenerate them in the same commit (atomic change rule). -- The release-shaped Flatpak job compiles the workspace with fat LTO on every PR, lengthening CI. Mitigation: flatpak-builder caches are keyed by Cargo.lock, and the job runs in parallel with flatpak-devel after fast. The cost buys a real Flathub-shape build, screenshot mirroring and repo lint before the first release, which the review required. -- PR screenshot mirroring depends on raw.githubusercontent.com serving the PR head commit from the head repository. Mitigation: The URL is built from github.event.pull_request.head.repo.full_name and head.sha, a commit that exists in that repository when the workflow runs. Pushes use github.repository and github.sha. Releases use tag URLs mirrored after the tag exists, and the linter's screenshot checks fail visibly on any unreachable image. -- Profile-scoped storage means cargo run builds (tablepro-devel) no longer see data under ~/.config/tablepro from earlier developer builds. Mitigation: Intended isolation, documented as part of the clean break with one-off mv commands in the PR description; no migration code. - -## Reconciliation - -### Conflicts between foundations - -- **FA-C6, FB-C9, FC-C8**: Connection is defined three times. FA keeps today's trait (connection.rs:62-114) with ping renamed to probe(&self) and close(&self). FB keeps list_tables/query/execute, adds dialect() and changes the bound methods to &Statement/&[GuardedStatement]. FC replaces it wholesale with CallOptions, list_objects, write_batch, open_session, and close(&self) that returns nothing. Resolution: One Connection v2 (canonical CONN). It takes FC's surface and adds three things: FB's required dialect(); FA's rule that probe never waits for a pool slot; and FA's close(&self) -> Result<(), DriverError>, which is idempotent. query_bound, count_rows and write_batch take FB's Statement types. These methods are deleted: list_tables, fetch_rows, query, query_params, execute, execute_params, execute_in_transaction, ping and close(self: Box). -- **FA-C6, FB-C9, FC-C11**: DriverError has three shapes. FA adds Busy plus is_connection_lost/is_permanent_connect_failure over Disconnected/ConnectionRefused/AuthFailed. FB adds RowGuardFailed and GuardUnsupported to the old enum. FC rewrites it as a #[non_exhaustive] v2 with category(), TimeoutPhase::PoolWait and SessionBusy. Resolution: FC v2 is canonical (ERR), extended as follows. It gains FB's RowGuardFailed (category WriteOutcome) and GuardUnsupported (Internal). FA's two predicates are deleted; callers match DriverError::category(). PostgreSQL 53300 and MySQL/MariaDB 1040 map to Busy (from FA). A deadpool Timeout(Wait) also maps to Busy, because the deadpool 0.13.1 wait is a semaphore that times out only when every slot is taken (verified in managed/pool.rs), so TimeoutPhase::PoolWait is removed. SessionBusy is renamed EditorSessionBusy. -- **FA-C6, FC-D18**: FA classifies PoolTimedOut through PoolPressure using sqlx Pool::size/num_idle/try_acquire, and holds MSSQL in a tokio Mutex> probed with try_lock. FC moves PostgreSQL, MySQL and MSSQL onto deadpool managers, keeps a sqlx pool only for SQLite, and deletes the MSSQL mutex. Resolution: FC's pools are canonical. PoolPressure is deleted from core. For PostgreSQL, MySQL and MSSQL, probe calls pool.timeout_get(&Timeouts { wait: Some(Duration::ZERO), .. }), which is deadpool's verified try_acquire path. When no slot is free, probe opens a standalone physical connection through the same establish path, runs SELECT 1 and closes it. SQLite (SqlitePool with max 1) keeps a driver-private pool-pressure classifier: size()==1 && num_idle()==0 means Busy. The MSSQL tokio mutex and the plan to mark a client for replacement are both dropped. A superseded MSSQL request is stopped with the fork's cancel_pending attention instead. -- **FA-C5, FA-C9, FC-C5, FC-C16**: The name Transport collides. FA's session::Transport is a struct { Box, Option }. FC's core::Transport is a trait and deletes SshTunnel. FA also moves redirect_through_tunnel into driver_connector.rs, while FC deletes service_endpoint rewriting. Resolution: FA's struct becomes EstablishedConnection { connection: Arc, ssh_session: Option> }, and FA's RetiredTransport becomes RetiredConnection. core::Transport stays FC's trait. redirect_through_tunnel and ConnectOptions::service_address are deleted; tunnels are SshTransport streams (FC-D6). -- **FA-C4, FC-C9**: The name Session collides. FA's app Session is the per-window session. FC's core::Session trait is the per-tab editor connection, and it comes with SessionId, SessionState, open_session and SessionBusy. FC's SessionSettings is really workspace settings. Resolution: FA keeps Session. FC's trait is renamed EditorSession, with EditorSessionId, EditorSessionState, Connection::open_editor_session and DriverError::EditorSessionBusy. SessionSettings is renamed WorkspaceSettings, and ConnectOptions.workspace_settings keeps its field name. -- **FA-C7, FC-C2**: FA defines NetworkEndpoint as an enum { Local, Remote{host,port} } for reachability. FC defines core::NetworkEndpoint as a validated host:port struct. FA's private LinkState struct also shadows FC's public core::LinkState enum. Resolution: FA's enum becomes ReachabilityTarget { Local, Remote(core::NetworkEndpoint) }, built by ReachabilityTarget::for_request(&ConnectRequest), and Session::endpoint() becomes Session::reachability(). FA's private LinkState becomes LinkSlot. -- **FA-C10, FB-C10, FC-C12**: There are three table identities and two listing types: FB's borrowed TableRef<'a> in dialect signatures, FC's owned TableRef, FA's TableKey, and today's TableInfo (query.rs:7). FC deletes TableInfo and list_tables in favour of ObjectInfo and list_objects(ObjectScope). FA's SchemaCatalog caches Vec. Resolution: There is one owned core::TableRef { schema: Option, name: String } deriving Clone, Eq, Hash. Every FB dialect and builder method takes &TableRef. TableKey, TableRef<'a> and TableInfo are deleted. SchemaCatalog caches list_schemas, objects per ObjectScope (ObjectListSnapshot) and columns per TableRef, with the same shared-fetch and never-clear rules. PendingSession carries schemas plus DefaultSchema objects. -- **FA-C9, FA-C7, FC-C21**: There are two app connect APIs. FA has the Connector trait, ConnectRequest { options: ConnectOptions(old), ssh, read_only }, PendingSession, ConnectDeadlines and a ConnectError with Ssh/TimedOut(ConnectStage)/UnsupportedAuthMode. FC has ConnectRecipe/RecipeTarget, a free fn establish, EstablishedConnection and ConnectServices, plus a connection_monitor that reconnects on its own. Resolution: FA's Connector trait stays the DI seam, and DriverConnector implements FC's establish algorithm while holding ConnectServices. ConnectRequest carries RequestTarget (FC's RecipeTarget) and WorkspaceSettings, with read_only inside WorkspaceSettings. ConnectRecipe, the free establish, ConnectDeadlines, ConnectStage and FC's connection_monitor.rs are deleted. FC's monitor behaviour (wake on lost() and SshSession::closed(), stop on Authentication/Tls/Configuration/host-key) moves into FA's run_monitor. ConnectError loses Ssh, UnsupportedAuthMode and TimedOut; those arrive as Driver(Transport/Config/Timeout). -- **FA-D4, FA-D14, FC-C4, FB-C9**: Deadline numbers and ownership conflict. FA's DeadlinePolicy wraps operations in tokio::time::timeout (Probe 10 s, Close 5 s, ssh 15 s, driver 20 s) and has OperationError::TimedOut. FC passes deadlines inside CallOptions so the driver's owned task can stop the server (probe 5 s, ssh 20 s, connect 10 s + login 15 s, cancel_grace 5 s). FA's Save deadline of 120 s would cancel legitimate long DDL, which FC deliberately leaves unbounded on the server. Resolution: LivenessPolicy (FC values, plus a new close_timeout of 10 s) owns every connect-time bound. DefaultDeadlines derives the per-operation deadlines: Metadata 15 s, Page and Count 60 s, Save(RowChanges) 120 s, Save(SchemaChanges) None, Probe = probe_timeout, Close = close_timeout. OperationRunner turns them into CallOptions and adds no timer of its own; Connection::close is the one exception, bounded by RetiredConnection with close_timeout. OperationError::TimedOut is removed, and timeouts arrive as DriverError::Timeout{phase}. ProbeOutcome::classify takes Result<(), DriverError>. -- **FA-C4, FC-D12, FC-C9**: FA routes every connection use through OperationRunner, including OperationKind::Query for the SQL editor. FC gives each editor tab its own physical connection (EditorSession) whose loss is never silently repaired. It is also undefined what happens to editor sessions when FA replaces or closes the workspace link, and how open editor transactions interact with FA's CloseGuard. Resolution: OperationKind::Query is removed. Session::open_editor_session(options, cancel) leases the current connection once and returns Arc, which the editor tab owns. An editor session holds its own Transport Arc (and so its SSH session) and is not closed by link replacement or RetiredConnection::close. An editor ConnectionLost posts MonitorSignal::ProbeRequested { generation } and leaves the tab Lost with an explicit reopen action. Workspace::shutdown closes editor sessions through the spawner under app.hold(). DirtyKind gains OpenTransaction for tabs whose last RunSummary.transaction_state is InTransaction or Failed, or Unknown after at least one run. -- **FA-C4, FB-C18**: FA's op closure is Fn(ConnectionLease) -> Result, so SQL build errors (BindError, BuildSqlError, BrowseSqlError, BuildDdlError) have no way out. FB C18 also demands a Session::connection() accessor plus a SessionEvent::Reconnected event that FA does not provide. Resolution: The op is Fn(ConnectionLease, CallOptions) -> Fut>. OperationError gains Sql(SqlBuildError), plus From impls for DriverError and every builder error. SQL is built inside the op from lease.dialect(), so building and executing always use the same connection, and a replay rebuilds on the new lease. FB's connection() accessor and SessionEvent::Reconnected are replaced by Session::current_dialect() and Session::link_changes() -> watch::Receiver, which capability-driven UI subscribes to. -- **FB-D11, FB-C13, FC-D1**: FB binds PostgreSQL through sqlx: a PgParam Encode newtype, PgTypeInfo::with_oid and decode over PgValueRef. FC moves PostgreSQL to tokio-postgres and binds app SQL as Type::UNKNOWN text parameters. FC's untyped NULLs bring back the 42804/42P18 failures FB fixes. Resolution: PostgreSQL runs on the tokio-postgres 0.7.18 fork (FC), and FB's ParamType-to-OID contract is kept on that stack. PgParam implements postgres_types::ToSql and encodes binary through postgres_protocol::types codecs. Each call passes (PgParam, Type::from_oid(oid)) to Client::query_typed_raw or Client::execute_typed (both verified in client.rs); ServerText binds TEXT with $n::cast_target. Decode goes through a RawCell FromSql that accepts every type, where from_sql_null gives Value::Null. FC's UNKNOWN text parameters and the rust_decimal and with-* features are dropped. -- **FB-D20, FB-C13, FC-D21**: Verified in tokio-postgres 0.7.18: query_typed calls get_type for every RowDescription OID, and a non-builtin OID is resolved through a named, cached typeinfo statement (prepare builds the name s{n}). FB projects PostgreSQL enums, arrays of enums and similar types natively. Behind PgBouncer transaction pooling with max_prepared_statements=0, that named statement is missing on the next server connection, which breaks FC's pooler-safety guarantee. Resolution: PgDialect sets read_form = ServerText ("c"::text AS "c") for every column whose RowDescription OID is not a postgres_types built-in: Enum, Range, Composite, Other, Spatial, and arrays whose innermost element is any of those. Domains stay Native, because printtup.c sends the domain's base type OID (verified). Enum labels come from the catalog ColumnType (FB's authority rule). Driver metadata SQL selects only built-in types. A PgBouncer conformance test with max_prepared_statements=0 asserts that no Parse with a non-empty statement name is ever sent. -- **FB-C12, FC-D2, F-056**: FB pins MySqlConnectOptions::timezone(Some("+00:00")) so TIMESTAMP decodes and binds as exact instants. FC and F-056 require timezone(None), so NOW() and TIMESTAMP match the mysql CLI and objects created from the editor behave the same as elsewhere. Resolution: The workspace pool (the deadpool MysqlManager, which runs app-generated SQL) pins +00:00. There, TIMESTAMP classifies as Timestamp { zone: Offset } and decodes as TimestampTz with offset +00:00, and the grid shows the offset, so identity is lossless across DST folds. Editor sessions use timezone(None) and decode TIMESTAMP as naive Timestamp in the session zone (F-056). Both use pipes_as_concat(false) and no_engine_substitution(false). -- **FB-D12, FC-C8, FC-C18, FD-D10**: ClickHouse writes conflict. FB uses CAST( AS ) with no parameters, while FC uses Query::param on {pN:Type}. Crate TLS also conflicts: FD enables rustls-tls-aws-lc and webpki roots, while FC turns crate TLS off and supplies its own hyper-rustls connector. Resolution: Generated DML and filters use FB's CAST literals and emit no BoundParam. Driver-internal SELECTs on system tables (FC's resolve_commit query_log lookup) may use Query::param. KILL QUERY renders the app-generated UUID through the dialect literal. The union of client settings applies: output_format_json_quote_denormals=1, output_format_json_validate_utf8=0, mutations_sync=1, plus readonly=2 and allow_ddl=0 when read-only, and product info. FD's crate TLS features stand only until the contract switch, which moves ClickHouse to with_http_client (FC). -- **FA-C16, FB-D19, FB-C19, FC-D13, FC-C10**: There are three write contracts. FB has execute_in_transaction(&[GuardedStatement]) -> Vec with RowGuardFailed. FC has write_batch(&WriteBatch{BoundStatement}) -> WriteReport with RolledBack, CommitOutcomeUnknown{token} and PartiallyApplied, plus resolve_commit. FA's SaveOutcome::classify(result, transactional) guesses outcomes from TimedOut and lost-connection errors. Resolution: write_batch(&WriteBatch { kind: WriteKind, steps: Vec }, CallOptions) -> WriteReport. The driver evaluates FB guards inside the transaction, or runs every probe first for StatementByStatement engines, and reports outcomes with FC's variants. SaveOutcome::classify(Result) takes no transactional flag: -- Ok -> Committed. -- RolledBack, RowGuardFailed or ReadOnly -> RolledBack. -- CommitOutcomeUnknown -> Unknown(CommitAckLost { token }). -- PartiallyApplied -> Unknown(PartiallyApplied). -- Panicked or Closed -> Unknown. -When a token exists, the Workspace runs resolve_commit before CloseGuard shows the unknown-outcome dialog. Applied settles as Committed and reloads, NotApplied as RolledBack, and anything else keeps the tracker frozen. -- **FB-C10, FB-C11, FC-C7, FD-D11, FB-D9**: Capabilities overlap. FB's DialectCapabilities has transactional_dml, exact_row_counts and unique_row_keys, and DdlCapabilities has transactional; FB also deletes the driver's ddl_is_transactional and reports_rows_affected. FC's DriverCapabilities has dml_writes, ddl_writes and reports_rows_affected, and deletes supports_integrated_auth. FD keeps supports_integrated_auth() = cfg!(feature). Resolution: Static, pre-connect facts live on DriverCapabilities (FC) without reports_rows_affected, because WriteReport.rows_affected is already Vec>. Version-dependent row semantics live on DialectCapabilities { exact_row_counts } only. Atomicity comes from DriverCapabilities.dml_writes and ddl_writes, key uniqueness comes from RowIdentity, and DdlCapabilities loses transactional. The mssql auth_modes constant is cfg-selected: [Password, Kerberos] with feature kerberos, [Password] without. -- **FB-C14, FB-C19, FC-D15, FC-C12**: FB builds UPDATE/DELETE keys from primary-key values only (NoPrimaryKey otherwise). FC computes RowIdentity (UniqueKey, EngineRowId, SortingKey, Unordered) and two incompatible page builders: FB's build_page(dialect, PageRequest{offset}) and FC's build_page_select(Dialect, …) -> PageSpec. Resolution: There is one browse::build_page(&dyn SqlDialect, &PageRequest) -> Result. PageRequest carries TableRef, columns, FilterSet, sort, RowIdentity, PagePosition and limit. The dialect gains row_value_after(keys, markers, direction) and engine_row_id(EngineRowId) -> Option projections. DML keys come from RowIdentity: -- UniqueKey: AffectedExactly(1) when exact_row_counts, otherwise ProbeExactly. -- EngineRowId (projected hidden column): AffectedExactly(1). -- SortingKey: ProbeExactly. -- Unordered: rows are read-only. -build_count feeds Connection::count_rows. -- **FB-C14, FC-C10, FB-C8, FC-C9**: Statement types collide: FB's Statement { sql, Vec } against FC's BoundStatement { GeneratedSql, Vec }. Result columns collide too: FB's ResultColumn { name, column_type } in a Vec against FC's { name, data_type, native_type: NativeTypeId } in an Arc<[..]>. Resolution: FB's Statement, BoundParam and ParamType are canonical; BoundStatement and GeneratedSql are deleted, and FC's UserSql stays for editor text. ResultColumn is { name, column_type: ColumnType } and QueryResult.columns is Arc<[ResultColumn]>. NativeTypeId is dropped, because drivers keep native type info private and ColumnType carries the kind. -- **FA-C6, FB-C9, FC-D9**: FA and FB delegate probe, close and dialect through ReadOnlyConnection. FC deletes it (read_only.rs) and enforces read-only per engine. Resolution: ReadOnlyConnection is deleted. Connection::read_only() reports the setting, drivers enforce it (FC-D9), and Session::read_only() reads it from the lease. The UI's SaveRejection::ReadOnly stays as a pre-flight guard. -- **FB-D1, FC-C12, core/query.rs**: FB rules that value and metadata transport types carry no serde. FC derives serde on ObjectKind and lists TableRef as a persistence consumer. Today query.rs:6-65 derives Serialize/Deserialize on TableInfo, IndexInfo, ColumnInfo and Value. Resolution: No core value, metadata or result type derives serde. Persistence uses its own record structs (FA's TabRecord, storage SavedConnection). Configuration types that are persisted by design (TlsConfig, TlsMode, ClientIdentity, AuthMode) keep serde. -- **FB-D16, FB-C20, FC-D4, FC-C19, FD-D10, FD-C19**: There are three tiberius plans. FB forks prisma a6b4fcd (rustls 0.21 and ring) with an opt-in money_as_numeric. FC forks tiberius-rs c1d2e74 and changes money unconditionally, adding attention and TLS config injection. FD pins plain tiberius-rs c1d2e741 with no fork, and each design has a different deny.toml allow-git. Resolution: There is one fork, TableProApp/tiberius on branch tablepro, based on c1d2e741ee6fe0935091826d91fb5886b6a4082a. At that commit it uses tokio-rustls 0.26.4 and libgssapi 0.8.1 (verified), so it keeps the Kerberos fix. It carries five commits: -1. Server-reachable panics become Error::Protocol (FC's list, a superset of FB's). -2. sql_variant and UDT decoding, plus Column::udt_type_name. -3. An opt-in Config::money_as_numeric(bool), so default f64 FromSql is unchanged and the patch stays upstreamable (FB). -4. Client::cancel_pending. -5. Config::tls_client_config. -deny.toml allow-git lists exactly TableProApp/tiberius and TableProApp/rust-postgres. -- **FA-C6, FC-D3, FD-D8, FD-D12**: Toolchain and sqlx conflict. FA verifies against sqlx 0.8.6 pool APIs. FC moves to sqlx 0.9 with a ring-webpki interim backend and MSRV 1.94. FD sets Rust 1.98, and moves sqlx 0.8.6 to aws-lc before 0.9. FC says AssertSqlSafe goes on every dynamic string, FD only at user-SQL entry. Resolution: Rust 1.98 everywhere: rust-toolchain, rust-version and CI. There is no msrv key; clippy reads rust-version (verified). sqlx goes to 0.9.0 (rust-version 1.94, verified) in one commit after provider unification, with tls-rustls-aws-lc-rs, sqlite-unbundled and mysql-rsa (verified features) and no ring interim. sqlx::query requires SqlSafeStr (verified), so AssertSqlSafe wraps text exactly once per driver entry point: where Statement::sql() or UserSql reaches sqlx, and on driver-internal format! SQL. -- **FC-D8, FD-D10**: FC installs aws-lc-rs as the process default CryptoProvider in main. FD forbids install_default and relies on a graph-wide single provider plus a regression test. Resolution: There is no install_default. Every ClientConfig the app builds uses builder_with_provider(aws_lc_rs::default_provider()) (FC-C15). Crates that call ClientConfig::builder() themselves (sqlx-mysql) resolve unambiguously because ring is banned across normal, build and dev edges (FD). The tls_provider.rs regression test stays. -- **FC-C20, FD-C19**: deny.toml differs. FC sets exclude-dev = true, allows two forks and bans ring, rustls<0.23 and openssl-sys. FD sets exclude-dev = false and all-features = true, allows tiberius-rs, adds a native-tls ban and ignores RUSTSEC-2023-0071. Resolution: deny.toml is the union: [graph] all-features = true, exclude-dev = false (the testcontainers ring chain is dev-only); bans on ring, openssl-sys, native-tls and rustls:<0.23; allow-git for the two TableProApp forks only; FD's advisories block with the scoped RSA ignore; FD's licenses block. -- **FA-D7, FA-C12, FD-D8, FC-C20**: FA adds crates/app/clippy.toml with msrv 1.93 and disallowed-methods. Clippy uses the nearest config file with no merge (FA verified), so that file would silently drop FD's allow-unwrap/expect/panic/dbg/print-in-tests for the app crate. FC and FD disagree on msrv. Resolution: No clippy.toml sets msrv. crates/app/clippy.toml repeats the five allow-*-in-tests keys and adds FA's disallowed-methods; a CI step fails when the shared keys differ from linux/clippy.toml. The bans stay app-only: drivers legitimately use tokio::spawn with a TaskTracker, and an unresolvable disallowed path is a clippy error unless allow-invalid is set (verified). -- **FA-D19, FD-C2, FD-C3, FD-C4**: FA's build.rs reads TABLEPRO_APP_ID, TABLEPRO_PROFILE and TABLEPRO_LOCALEDIR, and its resource paths are relative to data/resources. FD's tablepro-build-support takes the app ID from build-aux/APP_ID and adds TABLEPRO_VERSION_SUFFIX, DEVELOPMENT_SCHEMA_DIR, STORAGE_DIR_NAME and SECRET_SCHEMA. Resolution: FD owns build configuration (FD-C2 to C4). TABLEPRO_APP_ID does not exist. FA consumes config::{APP_ID, BASE_APP_ID, RESOURCE_BASE_PATH, PROFILE, VERSION, LOCALEDIR, DEVELOPMENT_SCHEMA_DIR, STORAGE_DIR_NAME, SECRET_SCHEMA}. -- **FA-D18, FD-D1**: FA's library is tablepro_app and its tests use CARGO_BIN_EXE_tablepro-app. FD renames the package and binary to tablepro, with GETTEXT_PACKAGE = CARGO_PKG_NAME. A lib and bin with the same name collide on cargo doc output. Resolution: The package is tablepro, with [lib] name = "tablepro_app" (src/lib.rs) and [[bin]] name = "tablepro" (src/main.rs calling tablepro_app::run). Process tests use env!("CARGO_BIN_EXE_tablepro"), and GETTEXT_PACKAGE stays "tablepro". -- **FA-D1, FA-D11, FD-C14**: FD builds a plain adw::Application via build_application(), hands it to RelmApp::from_app, and builds a startup tokio runtime (StartupError::Runtime). FA subclasses adw::Application, bans RelmApp, and deletes the startup runtime. The startup orders and the two panic-hook modules also differ. Resolution: FA's TableProApplication subclass is canonical, with no RelmApp and no second runtime. StartupError::Runtime is removed. One panic hook lives in logging.rs, with FA's fields (thread, location, message) plus FD's forced backtrace, and it chains the previous hook. The startup order is canonical APPLICATION. -- **FA-D11, FD-C11**: FA's configure_runtime_threads is phrased as setting RELM_THREADS like an environment variable, while FD requires setlocale to run before any thread exists. In relm4 0.11.0, RELM_THREADS is a pub static OnceCell that defaults to 1 (lib.rs:73,185, verified). Resolution: configure_runtime_threads calls relm4::RELM_THREADS.set(available_parallelism clamped to 2..=4) after i18n::init and logging::init and before any relm4 spawn. It mutates no environment, so the single-threaded setlocale invariant holds. -- **FA-D12, FA-C3, FD-D4, FD-C10, FC-C1**: Persistence roots conflict. FA's AppPaths puts state under user_state_dir()/APP_ID/workspaces with per-key files. FD's StoragePaths uses tablepro or tablepro-devel dirs with one workspace_state.json. FC puts the relay socket dir under user_runtime_dir()/APP_ID. Resolution: FD's StoragePaths is canonical and gains runtime (user_runtime_dir()/STORAGE_DIR_NAME) and workspaces_dir() = state/workspaces. FA's per-key workspace files live under workspaces_dir(), and AppPaths and workspace_state_file are deleted. ConnectEnvironment.private_socket_dir = paths.runtime. -- **FA-D11, FA-C17, FD-C10**: FD's storage::fs::write_private wraps tokio spawn_blocking internally, which bypasses FA's injected TaskSpawner and its spawn bans. FA's coalescing writer list still includes preferences.rs and window_state.rs, which FD deletes in favour of GSettings. Resolution: Storage exposes only write_private_blocking and create_private_file_if_missing; write_private is deleted. App code dispatches both through TaskSpawner::spawn_blocking. FA's coalescing config writes cover column_widths and filter_settings only. WorkspaceStore's writer uses write_private_blocking. -- **FA-C18, FD-C10**: FA's HistoryService::start(retention_days, spawner) initialises history itself, while FD's QueryHistory::open(&paths) replaces the POOL OnceLock and pre-creates a 0600 file. Resolution: HistoryService::start(paths: StoragePaths, retention_days, spawner) runs QueryHistory::open(&paths) and pruning through the spawner and publishes HistoryAvailability. -- **FA-C13, FD-D22, FD-C24**: FA references a WindowGeometryStore from 'the settings foundation'. FD provides window_geometry::restore/persist, with persist called in close-request when the handler proceeds, but FA's close-request returns Proceed only when close_approved is set. Resolution: SessionWindow::init calls window_geometry::restore(&window, &services.settings). The close-request handler calls window_geometry::persist in the close_approved branch that returns Proceed, and logs a SettingsError without blocking the close. WindowGeometryStore does not exist. -- **FA-D16, FC-C22**: FC's connect dialog runs establish on Connect with an in-dialog Cancel. FA's dialog never establishes on Connect; it emits ConnectIntent, and the SessionWindow claims first and owns Cancel on the Connecting page. Resolution: On Connect, the dialog runs FC validation (TlsConfig::validate, PEM parse, database_required, auth modes) and emits ConnectIntent (FA). Test runs Connector::establish plus PendingSession::discard with an in-dialog cancel token (FC). All of FC's Security and SQLite rows stay. -- **FC-C16, FD-C10, FD-C25, FD-D23**: FD introduces SshConnector::new(known_hosts), which FC's crate does not have. FD's identity_algorithm returns SshError::Key, but FC moves SSH errors to core::SshFailure. FD's F-065 plan makes ssh-agent the default, yet FC's SshAuth has only Password and PrivateKey. Resolution: SshAuth gains Agent, which uses russh::keys::agent::client::AgentClient::connect_env and Handle::authenticate_publickey_with with the agent as Signer (verified in 0.63.3). SshConnector is dropped. KnownHostsStore takes paths.known_hosts_file(), and KnownHostsVerifier takes that file plus the files chosen by the F-009 security foundation. identity_algorithm returns SshFailure::PrivateKeyUnreadable on failure. -- **FA-D8, FA-C11, FC-D10**: FA defers server-side cancel-on-drop to a 'network-loss workstream' and plans MSSQL client replacement and sqlite3_interrupt. FC implements cancel from connection-owned tasks in the foundation itself, using TDS attention and the SQLite progress handler. Resolution: FC-D10 and FC-C18 are the implementation, and FA's forward references are removed. Because drivers spawn owned tasks with tokio::spawn, every Connection and EditorSession future is polled only inside a TaskSpawner or GuardedCommand future (tokio context), never directly by a glib future. -- **FA-C7, FC-C21**: FA's FailureCause is { AuthFailed, Tls, IntegratedAuth }, derived from old DriverError variants. FC's monitor also parks on Configuration errors and on host-key Unknown or Changed. Resolution: FailureCause is { Authentication, Tls, Configuration, HostKeyUnknown, HostKeyChanged }, derived from ErrorCategory and TransportError::Ssh(SshFailure). ProbeOutcome maps: -- Busy category -> Busy. -- Timeout, Server or Internal -> Unresponsive. -- ConnectionLost or Network -> Lost. -- Authentication, Tls or Configuration -> Rejected. -- **FC-C20, FD-D11, FD-C22**: FC's integration job installs libkrb5-dev and clang to run SQL Server tests. FD's integration job builds without Kerberos (the driver's default feature is off) and needs neither. Resolution: FD's rule applies: integration suites run without kerberos. The suites are postgres, pgbouncer, mysql, mariadb, sqlite, mssql, clickhouse, ssh and tls, at --test-threads=1. Kerberos compiles and its krb5-profile test runs in the fast job and inside Flatpak. -- **FA-D18, FD-C6, FD-C21**: FA's GTK and process tests need a session bus (xvfb-run -a dbus-run-session, plus a dbus-daemon for gio::TestDBus). FD's meson gtk suite and the deb/rpm %check provide only xvfb-run. Resolution: The fast job runs xvfb-run -a dbus-run-session -- meson test and installs dbus-daemon. Debian Build-Depends and RPM BuildRequires add dbus-daemon, and the gtk suite env keeps GSK_RENDERER=cairo and GTK_A11Y=none. -- **FA-D18, FB-C8, FC-C1**: Test doubles are defined in three places: FA's crates/test-support (FakeDriver, FakeConnection), FC's test_support (network_target, FixedTrustSource, FaultTransport), and FB's per-crate #[cfg(test)] builders. Resolution: There is one crates/test-support crate (publish = false, dev-dependency only, depending on tablepro-core and tokio). It holds FakeDriver, FakeConnection, FakeEditorSession, FakeTransport, FaultTransport, FixedTrustSource, network_target(), and ColumnInfo, ColumnType and Value builders. -- **FA-migration, FB-migration, FC-migration, FD-migration**: The migration orders cannot all hold. FA step 1 and FB steps 5-8, 10-12, 14 and 15 rewrite sqlx-based drivers that FC's atomic switch replaces. FA step 5 rewrites connection_service/monitor, which FC's switch also rewrites. FC step 1 uses a ring interim that FD's step 4 forbids. FD's build_application and RelmApp contradict FA step 11. Resolution: One dependency order, given in global_decisions. FA step 1, FA step 5, FB steps 5-8 and FB's per-driver decode steps (10-12, 14, 15) all fold into the single contract switch, because the drivers are rewritten there anyway. The switch keeps sql_ddl.rs's per-driver code, which FB's DDL step then moves. Every other step keeps its design's content. -- **FA-C2, FD-C14**: Accelerators are spelled in FD and in FA, and only FA reserves question. Resolution: Use in every accel. App accels are new-window n, quit q, preferences comma, and shortcuts question, which AdwApplication installs from shortcuts-dialog.ui. -- **FD-D12, FA-D18, FC-C9, FB-D2**: FD removes async-channel and keeps rust_decimal in the workspace. FA (WorkspaceStore writer, ResumeDetector) and FC (the editor ResultSink) use async-channel. FB's SqlDecimal and driver codecs replace every rust_decimal use, including the sqlx and tiberius rust_decimal features. Resolution: A dependency enters Cargo.toml in the commit that first uses it: async-channel returns with the spawner/WorkspaceStore commit. The contract switch removes rust_decimal and the sqlx and tiberius rust_decimal features, the tokio-postgres with-* features, and FC's rust_decimal db-tokio-postgres. - -### Canonical shared contracts - -#### CONN · `core::Connection v2` (FC) - -``` -#[async_trait] pub trait Connection: Send + Sync { - fn server_info(&self) -> &ServerInfo; - fn dialect(&self) -> Arc; - fn read_only(&self) -> bool; - fn link_state(&self) -> LinkState; - fn lost(&self) -> WaitForCancellationFutureOwned; - async fn probe(&self, o: CallOptions) -> Result<(), DriverError>; - async fn list_schemas(&self, o: CallOptions) -> Result, DriverError>; - async fn list_objects(&self, scope: &ObjectScope, o: CallOptions) -> Result, DriverError>; - async fn fetch_columns(&self, t: &TableRef, o: CallOptions) -> Result, DriverError>; - async fn fetch_indexes(&self, t: &TableRef, o: CallOptions) -> Result, DriverError>; - async fn fetch_foreign_keys(&self, t: &TableRef, o: CallOptions) -> Result, DriverError>; - async fn row_identity(&self, t: &TableRef, o: CallOptions) -> Result; - async fn table_estimate(&self, t: &TableRef, o: CallOptions) -> Result; - async fn query_bound(&self, s: &Statement, limit: RowLimit, o: CallOptions) -> Result; - async fn count_rows(&self, s: &Statement, o: CallOptions) -> Result; - async fn write_batch(&self, b: &WriteBatch, o: CallOptions) -> Result; - async fn resolve_commit(&self, token: &CommitToken, o: CallOptions) -> Result; - async fn open_editor_session(&self, options: EditorSessionOptions, cancel: CancellationToken) -> Result, DriverError>; - async fn close(&self) -> Result<(), DriverError>; -} -pub struct CallOptions { pub deadline: Option, pub cancel: CancellationToken } // constructor new(deadline, cancel) only -pub enum LinkState { Open, Lost } -ServerInfo is as in FC-C8; the dialect is built from it at physical connect. - -Rules: -1. Execution: methods run on connection-owned tasks (TaskTracker) and hold cancel.drop_guard(); server stop follows FC-C18. -2. Deadline: a call returns within deadline + liveness.cancel_grace; a conformance test per driver enforces this. -3. probe never waits for a pool slot. It uses deadpool timeout_get(wait: Some(ZERO)), or SQLite try_acquire. When none is free, it opens a standalone physical connection, runs SELECT 1 and closes it. -4. close is idempotent. It cancels owned tasks, waits at most cancel_grace, and never closes EditorSessions (each owns its transport). -5. Deleted: list_tables, fetch_rows, query, query_params, execute, execute_params, execute_in_transaction, ping, MAX_QUERY_ROWS, ReadOnlyConnection, TableInfo, PoolPressure. -``` - -#### DRIVER · `core::DatabaseDriver and DriverCapabilities` (FC) - -``` -#[async_trait] pub trait DatabaseDriver: Send + Sync { fn id(&self) -> &'static str; fn display_name(&self) -> &'static str; fn capabilities(&self) -> &'static DriverCapabilities; async fn connect(&self, options: ConnectOptions, cancel: CancellationToken) -> Result, DriverError>; } -pub struct DriverCapabilities { pub endpoint: EndpointKind, pub auth_modes: &'static [AuthMode], pub tls: Option, pub read_only: ReadOnlyEnforcement, pub dml_writes: WriteAtomicity, pub ddl_writes: WriteAtomicity, pub script_execution: ScriptExecution, pub commit_resolution: CommitResolutionSupport, pub row_count_estimate: EstimateQuality, pub object_kinds: &'static [ObjectKind], pub database_required: bool } - -Per-engine values are FC-C7's, minus reports_rows_affected. mssql auth_modes is a cfg-selected const: [Password, Kerberos] with feature "kerberos", [Password] without. - -Deleted: default_port, is_file_based, ddl_is_transactional, reports_rows_affected, supports_integrated_auth. - -UI uses: the connect dialog hides Kerberos when auth_modes lacks it. Structure UI reads ddl_writes for atomicity, and row_ops reads dml_writes. -``` - -#### ERR · `core::DriverError v2, ErrorCategory, TimeoutPhase` (FC) - -#[non_exhaustive] pub enum DriverError { Transport(TransportError), Auth { diagnostics: Option }, IntegratedAuth(String), Tls { failure: TlsFailure, detail: String }, Server(ServerDiagnostics), ReadOnly(ReadOnlyRefusal), Cancelled, Timeout { phase: TimeoutPhase, server_cancelled: bool }, ConnectionLost { during: LossPhase }, RolledBack { statement_index: usize, source: Box }, RowGuardFailed { statement_index: usize, expected: u64, actual: u64 }, GuardUnsupported { statement_index: usize }, CommitOutcomeUnknown { token: Option, source: Box }, PartiallyApplied { applied: usize, statement_index: usize, failed_outcome: StatementOutcome, source: Box }, FileNotFound { path: PathBuf }, FileAccessDenied { path: PathBuf }, NotADatabase { path: PathBuf }, FileExists { path: PathBuf }, Busy, EditorSessionBusy, Config(ConfigError), Decode { column: String, detail: String }, Protocol(String), Unsupported { feature: &'static str } } -pub enum TimeoutPhase { Connect, Login, SshHandshake, SshChannelOpen, LockWait, Statement, Probe, CancelAcknowledgement } -pub enum ErrorCategory { Configuration, Network, Tls, Authentication, ReadOnly, Server, Interrupted, Timeout, ConnectionLost, WriteOutcome, File, Busy, Internal } -impl DriverError { pub fn category(&self) -> ErrorCategory } // exhaustive match inside core - -Category mapping: as FC-C11, plus RowGuardFailed -> WriteOutcome and GuardUnsupported -> Internal. - -Classification additions: -- Busy: PG 53300, MySQL/MariaDB 1040, deadpool PoolError::Timeout(Wait), SQLite 5/6 and SQLite pool saturation. -- Timeout(Create) keeps the phase reached. -- is_connection_lost and is_permanent_connect_failure are deleted; callers match category(). - -RowGuardFailed means nothing was applied: the transaction was rolled back, or on ClickHouse no mutation was sent. - -#### EDITOR · `core::EditorSession, RunOptions, ResultSink` (FC) - -FC-C9 applies unchanged except for these renames: -- Session -> EditorSession -- SessionId -> EditorSessionId -- SessionState -> EditorSessionState { Ready, Running, Lost } -- Connection::open_session -> open_editor_session -- DriverError::SessionBusy -> EditorSessionBusy -ResultColumn is the RESULT contract, and NativeTypeId is removed. - -pub struct RunOptions { pub row_limit: RowLimit, pub deadline: Option, pub cancel: CancellationToken } -#[async_trait] pub trait ResultSink: Send + 'static { async fn accept(&mut self, event: ResultEvent) -> SinkFlow; } -The row-limit rules are FC-C25. - -Ownership: an editor tab owns Arc, obtained through app Session::open_editor_session. Its lifetime is independent of workspace link replacement. On ConnectionLost the tab shows Lost with an explicit reopen action and posts MonitorSignal::ProbeRequested. - -MySQL editor sessions use MySqlConnectOptions::timezone(None) and decode TIMESTAMP as naive Timestamp in the session zone. - -#### STMT · `core::statement (Statement, BoundParam, ParamType, UserSql, SqlBuildError)` (FB) - -The FB-C14 statement types are canonical: -#[derive(Debug, Clone, PartialEq)] pub struct Statement { sql: String, params: Vec } -pub struct BoundParam { value: Value, param_type: ParamType } -pub enum ParamType { Bool, Int16, Int32, Int64, UInt64, Float32, Float64, Decimal, Text, FixedText, Bytes, Uuid, Json, JsonBinary, Date, Time, TimeTz, Timestamp, TimestampTz, Interval, ServerText(SqlTypeExpr) } -pub struct StatementBuilder; -pub struct UserSql(Arc) // FC; editor text only -#[derive(Debug, thiserror::Error)] pub enum SqlBuildError { Dml(BuildSqlError), Browse(BrowseSqlError), Ddl(BuildDdlError) } - -FC's BoundStatement and GeneratedSql are deleted. - -Driver binding: -- PostgreSQL: PgParam implements postgres_types::ToSql and encodes binary through postgres_protocol::types. Each value goes with Type::from_oid(FB OID) into Client::query_typed_raw or execute_typed. ServerText binds TEXT with $n::cast_target. -- MySQL and SQLite: sqlx binds by ParamType, and AssertSqlSafe(statement.sql()) appears once per driver entry point. -- MSSQL: typed Option::None NULLs. -- ClickHouse: no params (CAST literals). - -#### RESULT · `core::QueryResult and ResultColumn` (FB) - -``` -#[derive(Debug, Clone, PartialEq, Eq)] pub struct ResultColumn { pub name: String, pub column_type: ColumnType } -#[derive(Debug, Clone)] pub struct QueryResult { pub columns: Arc<[ResultColumn]>, pub rows: Vec>, pub truncated: bool } - -No serde. ColumnInfo, ColumnType, ColumnKind and Value are exactly FB-C1 to C8. IndexInfo is FC-C12 (keys: Vec, predicate). ForeignKeyInfo uses Option. - -Authority rule: browse uses ColumnInfo from fetch_columns; ResultColumn serves editor results and export. -``` - -#### META · `core metadata, identity and paging` (FC) - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct TableRef { pub schema: Option, pub name: String } -SchemaInfo, ObjectScope, ObjectInfo, IndexInfo, IndexKey, RowIdentity, EngineRowId, RowCountEstimate, TableEstimate, PagePosition, PageStability and PageCost are as in FC-C12. ObjectKind is #[non_exhaustive] and has no serde. - -Deleted: TableInfo, TableKey (FA), TableRef<'a> (FB). - -Mutation keys come from RowIdentity: -- UniqueKey (PK, or unique non-partial all-NOT-NULL index): guard AffectedExactly(1) when DialectCapabilities.exact_row_counts, else ProbeExactly. -- EngineRowId (PostgresCtid, PostgresTableoidCtid, SqliteRowid; projected as a hidden column in the page SELECT): AffectedExactly(1). -- SortingKey (ClickHouse): ProbeExactly. -- Unordered: rows are read-only in the grid. - -#### DIALECT · `core::SqlDialect (reconciled)` (FB) - -FB-C10 applies with these changes: -1. Every TableRef<'_> parameter becomes &TableRef. -2. DialectCapabilities is { exact_row_counts: bool }. transactional_dml and unique_row_keys are removed, replaced by DriverCapabilities.dml_writes and RowIdentity. -3. New method: fn row_value_after(&self, key_sql: &[String], markers: &[String], direction: KeysetDirection) -> String. It renders a row-value comparison on PostgreSQL, MySQL and SQLite, and the expanded OR form on MSSQL. -4. New method: fn engine_row_id(&self, id: EngineRowId) -> Option. PostgreSQL gives ctid or tableoid,ctid; SQLite gives rowid. - -DdlDialect (FB-C11): DdlCapabilities loses transactional. - -PostgreSQL read_form: ServerText for any column whose RowDescription OID is not a postgres_types built-in (Enum, Range, Composite, Other, Spatial, and arrays whose innermost element is one of those). ServerNumeric for money. Native otherwise, domains included. - -MySQL workspace dialect: TimestampTz binds and decodes as UTC under the pinned +00:00 workspace session. - -ClickHouse: CAST( AS ) placeholders with no BoundParam. Driver-internal system-table SELECTs may use Query::param. - -The dialect is reachable only through Connection::dialect(), or Session::current_dialect() for UI affordances. - -#### WRITE · `WriteBatch, GuardedStatement, RowGuard, WriteReport, CommitToken` (FC) - -pub enum WriteKind { RowChanges, SchemaChanges } -#[derive(Debug, Clone, PartialEq)] pub struct GuardedStatement { pub statement: Statement, pub guard: RowGuard } -pub enum RowGuard { None, AffectedExactly(u64), ProbeExactly { probe: Statement, rows: u64 } } -pub struct WriteBatch { pub kind: WriteKind, pub steps: Vec } -pub struct WriteReport { pub rows_affected: Vec> } -CommitToken, CommitResolution and StatementOutcome are as in FC-C10, with no serde. - -save::plan_save(dialect, table: &TableRef, columns: &[ColumnInfo], identity: &RowIdentity, changes: &ChangeSet) -> Result. SavePlan { batch: WriteBatch, origins: Vec }. - -Driver semantics: -- Transactional engines run probe, statement and affected-rows check per step inside the transaction. The first mismatch rolls back and returns RowGuardFailed. -- StatementByStatement engines (ClickHouse) run every probe before any mutation, and reject AffectedExactly with GuardUnsupported. -- PostgreSQL write_batch follows FC-D21: START TRANSACTION READ WRITE, SET LOCAL statement_timeout (RowChanges = settings, SchemaChanges = 0), then pg_current_xact_id, then COMMIT. - -#### CONNECT-OPTS · `ConnectOptions, ConnectTarget, NetworkTarget, WorkspaceSettings, LivenessPolicy` (FC) - -``` -pub struct ConnectOptions { pub target: ConnectTarget, pub workspace_settings: WorkspaceSettings, pub liveness: LivenessPolicy, pub environment: ConnectEnvironment } -ConnectTarget, NetworkTarget, Credentials, NetworkEndpoint, FileEndpoint, TlsConfig, TrustSource and Transport are as in FC-C1 to C6. -pub struct WorkspaceSettings { pub read_only: bool, pub statement_timeout: Duration, pub lock_timeout: Duration } // renamed from SessionSettings; workspace(read_only) = 60 s / 10 s -pub struct LivenessPolicy { /* FC-C4 fields */ pub close_timeout: Duration } - -LivenessPolicy::DESKTOP: connect 10 s, login 15 s, keepalive 30/10/3, tcp_user_timeout 45 s, happy_eyeballs 250 ms, probe 5 s, pool_wait 5 s, cancel_grace 5 s, ssh_handshake 20 s, ssh_channel_open 10 s, ssh_keepalive 15 s x 3, close_timeout 10 s. - -ConnectEnvironment.private_socket_dir = StoragePaths.runtime. - -MySQL workspace connections add timezone(Some("+00:00")), pipes_as_concat(false) and no_engine_substitution(false). ClickHouse client settings add output_format_json_quote_denormals=1, output_format_json_validate_utf8=0 and mutations_sync=1. - -Deleted: the old ConnectOptions fields, service_address, and Default. -``` - -#### APP-CONNECT · `Connector, ConnectRequest, RequestTarget, PendingSession, ConnectError` (FA) - -``` -#[derive(Clone)] pub struct ConnectRequest { pub metadata: ConnectionMetadata, pub driver: Arc, pub target: RequestTarget, pub workspace_settings: WorkspaceSettings } // Debug prints id and name only -pub enum RequestTarget { Network { endpoint: NetworkEndpoint, ssh: Option, credentials: Credentials, database: Option, tls: TlsConfig }, File(FileEndpoint) } -pub trait Connector: Send + Sync { fn establish(&self, request: ConnectRequest, cancel: CancellationToken) -> BoxFuture<'static, Result>; } -pub struct ConnectServices { pub trust: Arc, pub environment: ConnectEnvironment, pub host_keys: Arc, pub liveness: LivenessPolicy } -pub struct DriverConnector { pub services: ConnectServices, pub deadlines: Arc } -pub trait CredentialSource: Send + Sync { fn refresh(&self, request: ConnectRequest) -> BoxFuture<'static, Result>; } // replaces only secrets -pub struct KeyringCredentials { pub secrets: SecretStore, pub connection_id: Uuid } -pub struct NoStoredCredentials; -#[must_use] pub struct PendingSession { request: ConnectRequest, established: EstablishedConnection, schemas: Vec, objects: Vec } -impl PendingSession { pub fn metadata(&self) -> &ConnectionMetadata; pub async fn discard(self) -> CloseReport; } -#[derive(Debug, thiserror::Error)] pub enum ConnectError { Driver(DriverError), Keyring(tablepro_storage::StorageError), UnknownDriver(String), Cancelled, Panicked(TaskPanic) } - -DriverConnector::establish does five things in order: -1. Validate the auth mode against capabilities, then TlsConfig::validate and database_required. -2. Build the transport: SshSession::connect plus SshTransport, or DirectTcpTransport. -3. driver.connect(options, cancel). -4. Fetch list_schemas and list_objects(DefaultSchema) under the Metadata deadline. -5. Close its own objects if cancelled between stages. - -Deleted: ConnectDeadlines, ConnectStage, ConnectRecipe, free fn establish, connection_service.rs, connection_monitor.rs, redirect_through_tunnel. -``` - -#### LINK · `EstablishedConnection, ConnectionLink, ConnectionLease, RetiredConnection` (FA) - -``` -pub struct EstablishedConnection { pub connection: Arc, pub ssh_session: Option> } -#[derive(Clone)] pub struct ConnectionLease { generation: LinkGeneration, established: Arc } -impl ConnectionLease { pub fn generation(&self) -> LinkGeneration; pub fn ssh_closed(&self) -> Option; } -impl Deref for ConnectionLease { type Target = dyn Connection; } -pub struct ConnectionLink { slot: watch::Sender } -struct LinkSlot { generation: LinkGeneration, established: Option> } -impl ConnectionLink { pub fn new(e: EstablishedConnection) -> Self; pub fn lease(&self) -> Option; pub fn generation(&self) -> LinkGeneration; pub fn subscribe(&self) -> watch::Receiver; pub fn replace(&self, e: EstablishedConnection) -> (LinkGeneration, Option); pub fn take(&self) -> Option; } -pub struct RetiredConnection { generation: LinkGeneration, established: Arc } -impl RetiredConnection { pub async fn close(self, close_timeout: Duration) -> CloseReport; } // Ok→Closed, Err→CloseFailed, elapsed→Abandoned -pub enum CloseReport { Closed, AlreadyClosed, CloseFailed(DriverError), Abandoned } -``` - -#### SESSION · `app Session, OperationRunner, OperationKind, OperationError, DeadlinePolicy` (FA) - -pub enum OperationKind { Metadata, Page, Count, Save(WriteKind), Probe, Close } // is_replayable: Metadata, Page, Count -pub trait DeadlinePolicy: Send + Sync { fn deadline(&self, kind: OperationKind) -> Option; } -pub struct DefaultDeadlines { pub liveness: LivenessPolicy } // Metadata 15s, Page/Count 60s, Save(RowChanges) 120s, Save(SchemaChanges) None, Probe=probe_timeout, Close=close_timeout -#[derive(Debug, thiserror::Error)] pub enum OperationError { Driver(#[from] DriverError), Sql(#[from] SqlBuildError), Panicked(#[from] TaskPanic), Cancelled, Closed } -impl OperationRunner { pub async fn run_cancellable(&self, kind: OperationKind, cancel: &CancellationToken, op: F) -> Result where F: Fn(ConnectionLease, CallOptions) -> Fut + Send + Sync, Fut: Future> + Send, T: Send; } - -The runner: -- builds CallOptions { deadline: policy.deadline(kind), cancel: a child of the session token that is also cancelled by the caller token }; -- adds no timer; -- maps DriverError::Cancelled to Cancelled when the caller token fired, and to Closed when the session token fired; -- posts ConnectionLost for category ConnectionLost, ProbeRequested for a panic, and OperationSucceeded; -- replays replayable kinds once on a newer lease. - -Session keeps FA-C4 minus endpoint() and plus: -- pub fn reachability(&self) -> &ReachabilityTarget; -- pub fn current_dialect(&self) -> Option>; -- pub fn link_changes(&self) -> watch::Receiver; -- pub async fn open_editor_session(&self, options: EditorSessionOptions, cancel: &CancellationToken) -> Result, OperationError>; -- pub async fn schemas / objects(ObjectScope) / refresh_objects / columns(TableRef). - -SessionDeps { connector, credentials, deadlines, spawner }. - -#### HEALTH · `MonitorSignal, ProbeOutcome, FailureCause, ReachabilityTarget` (FA) - -pub enum FailureCause { Authentication, Tls, Configuration, HostKeyUnknown, HostKeyChanged } -pub enum ReachabilityTarget { Local, Remote(NetworkEndpoint) } -impl ReachabilityTarget { pub fn for_request(r: &ConnectRequest) -> Self; } // ssh bastion endpoint when tunnelled, else service; Local for File targets, loopback, 'localhost', '.', unix sockets -pub enum MonitorSignal { RetryNow, ConnectionLost { generation }, ProbeRequested { generation }, OperationSucceeded { generation }, Reachability(Reachability), Resumed { slept } } // OperationPanicked renamed ProbeRequested; also posted by editor-session loss -impl ProbeOutcome { pub fn classify(result: Result<(), DriverError>) -> Self; } - -ProbeOutcome::classify maps DriverError::category(): -- Busy -> Busy -- Timeout, Server, Internal, Interrupted -> Unresponsive -- ConnectionLost, Network -> Lost -- Authentication, Tls, Configuration -> Rejected(cause) -TransportError::Ssh(HostKeyUnknown or HostKeyChanged) -> Rejected(HostKey*). - -run_monitor extends FA-D5 by also selecting on the current lease's lost() and ssh_closed(), posting ConnectionLost at that lease's generation. The HealthPolicy constants and transitions are FA-C7's. - -#### CATALOG · `SchemaCatalog` (FA) - -``` -pub struct ObjectListSnapshot { pub scope: ObjectScope, pub generation: u64, pub objects: Arc<[ObjectInfo]>, pub last_error: Option> } -impl SchemaCatalog { - pub fn new(schemas: Vec, objects: Vec, runner: OperationRunner, spawner: Arc) -> Self; - pub async fn schemas(&self) -> Result, Arc>; - pub async fn objects(&self, scope: ObjectScope) -> Result, Arc>; - pub async fn refresh_objects(&self, scope: ObjectScope) -> Result, Arc>; - pub async fn columns(&self, table: TableRef) -> Result, Arc>; - pub fn commit(&self, schemas: Vec, objects: Vec); - pub fn invalidate_table(&self, table: &TableRef); - pub fn subscribe(&self, scope: ObjectScope) -> watch::Receiver; -} -Shared spawned fetches, one rerun on refresh, and a failure only sets last_error (FA-D9). ObjectScope derives Hash and Eq. -``` - -#### SAVE · `SaveOutcome, DirtyKind` (FA) - -pub enum SaveOutcome { Committed { warning: Option }, RolledBack(DriverError), Unknown(UnknownCause) } -pub enum UnknownCause { CommitAckLost { token: Option }, PartiallyApplied, Panicked, Closed } -impl SaveOutcome { pub fn classify(result: Result) -> Self; } - -classify maps: -- Ok -> Committed -- RolledBack, RowGuardFailed, ReadOnly -> RolledBack -- CommitOutcomeUnknown -> CommitAckLost -- PartiallyApplied -> PartiallyApplied -- Panicked, Cancelled, Closed -> Unknown -- Sql(_) -> RolledBack (nothing sent) - -On CommitAckLost with a token, the Workspace first runs session.run(Metadata, resolve_commit). Applied reloads the tab as Committed. NotApplied settles as RolledBack. Other resolutions freeze the tracker. - -pub enum DirtyKind { Rows, Structure, OpenTransaction } -OpenTransaction marks an editor tab whose last transaction_state is InTransaction or Failed, or Unknown after at least one run. - -#### SSH · `tablepro-ssh v2` (FC) - -FC-C16 applies, with these changes: -- pub enum SshAuth { Password { password: SecretString }, PrivateKey { path: PathBuf, passphrase: Option }, Agent } - Agent uses russh::keys::agent::client::AgentClient::connect_env() and, for each identity from request_identities(), Handle::authenticate_publickey_with(user, key, None, &mut agent). -- KnownHostsStore::new(paths.known_hosts_file()). -- KnownHostsVerifier::new(files): the app file first, then the F-009 read-only files. -- pub fn identity_algorithm(path: &Path) -> Result, where failures are PrivateKeyUnreadable { path, detail }. - -russ 0.63.3. SshConnector, SshTunnel, SshError and default_known_hosts_path are deleted. The SSH section defaults to Agent when SSH_AUTH_SOCK is set. - -#### TIBERIUS · `TableProApp/tiberius fork` (FC) - -``` -[patch.crates-io] tiberius = { git = "https://github.com/TableProApp/tiberius", rev = <40-hex head of branch tablepro> }. The branch is based on tiberius-rs/tiberius c1d2e741ee6fe0935091826d91fb5886b6a4082a (tokio-rustls 0.26.4, libgssapi 0.8.1). - -Commits, each filed upstream with tests: -(a) Every server-reachable todo!, panic!, unimplemented! or expect becomes Error::Protocol (pre_login returns Error::Tls). -(b) sql_variant decodes to its base ColumnData; UDT decodes to ColumnData::Binary, with Column::udt_type_name(). -(c) Config::money_as_numeric(&mut self, bool), default false. true decodes MONEY and SMALLMONEY as Numeric scale 4. -(d) Client::cancel_pending(). -(e) Config::tls_client_config(Arc). - -Workspace dep: tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "chrono"] }. The mssql driver feature kerberos = ["tiberius/integrated-auth-gssapi"]. build_target calls money_as_numeric(true). -``` - -#### PG-DRIVER · `PostgreSQL driver stack` (FC) - -Dependencies: tokio-postgres 0.7.18 plus the TableProApp/rust-postgres fork (FC-C24), default-features = false, features = ["runtime"]; postgres-types; postgres-protocol; deadpool 0.13.1; tablepro-net. Neither rust_decimal nor any sqlx-postgres. - -App SQL: an FC-D21 pipelined flight using query_typed_raw or execute_typed, with PgParam binary params typed by FB OIDs. - -Decode: a RawCell FromSql (accepts every type) feeds FB-C13's codecs. Editor sessions use the text portal or simple protocol with the fork's type_oid and a per-session pg_type cache. - -Pooler rule: app SQL never makes tokio-postgres resolve a non-builtin OID, which would prepare its named typeinfo statement. This is guaranteed by DIALECT read_form and built-in-only metadata columns, and tested against PgBouncer with max_prepared_statements=0. Client::prepare is never called. - -#### CONFIG · `Build config and identity` (FD) - -FD-C1 to C5 apply unchanged: -- values come from linux/VERSION and linux/build-aux/APP_ID; -- tablepro-build-support renders config.rs; -- env vars are TABLEPRO_PROFILE, TABLEPRO_LOCALEDIR and TABLEPRO_VERSION_SUFFIX only; -- config::{APP_ID, BASE_APP_ID, RESOURCE_BASE_PATH, PROFILE, VERSION, LOCALEDIR, DEVELOPMENT_SCHEMA_DIR, STORAGE_DIR_NAME, SECRET_SCHEMA, GETTEXT_PACKAGE}. - -crates/app/Cargo.toml: package name "tablepro", [lib] name = "tablepro_app" at src/lib.rs, [[bin]] name = "tablepro" at src/main.rs, default = ["kerberos"]. Process tests use env!("CARGO_BIN_EXE_tablepro"). GResource tablepro.gresource is compiled from ../../data/resources with prefix /app/tablepro/TablePro/. - -#### APPLICATION · `TableProApplication and startup` (FA) - -``` -TableProApplication is as in FA-C1, constructed with application-id = config::APP_ID, flags = HANDLES_OPEN and resource-base-path = config::RESOURCE_BASE_PATH. - -fn main() -> glib::ExitCode calls tablepro_app::run() -> Result. run order: -1. i18n::init (unsafe setlocale while single-threaded; result kept) -2. logging::init(config::PROFILE) -3. warn on the i18n error -4. logging::install_panic_hook -5. runtime::configure_runtime_threads (relm4::RELM_THREADS.set(2..=4)) -6. gio::resources_register_include!("tablepro.gresource") -7. AppSettings::open(config::BASE_APP_ID, config::DEVELOPMENT_SCHEMA_DIR) -8. StoragePaths::from_glib(config::STORAGE_DIR_NAME) -9. TableProApplication::new(AppServices::production(drivers, settings, paths)) -10. app.run() - -pub enum StartupError { Logging(LoggingError), Settings(SettingsError), Storage(StorageError), Resources(glib::Error) } - -A Logging error goes to glib::g_critical!; the rest go to tracing::error!, then ExitCode::FAILURE. - -AppServices gains settings: AppSettings, paths: StoragePaths, secrets: SecretStore and connections: ConnectionStore. AppPaths is deleted. FD's build_application, RelmApp::from_app and the startup tokio runtime are deleted. - -The development profile adds the 'devel' CSS class to each SessionWindow. Accels use : new-window n, quit q, preferences comma, shortcuts question. -``` - -#### STORAGE · `StoragePaths, private I/O, SecretStore, WorkspaceStore location` (FD) - -pub struct StoragePaths { pub config: PathBuf, pub state: PathBuf, pub data: PathBuf, pub cache: PathBuf, pub runtime: PathBuf } -from_glib(dir_name) joins dir_name to the user config, state, data, cache and runtime dirs. under(root, dir_name) serves tests. -Accessors: connections_file, known_hosts_file, history_database, workspaces_dir (state/workspaces), column_widths_file, filter_settings_file, drafts_dir. - -fs exposes ensure_private_dir, write_private_blocking (glib::file_set_contents_full CONSISTENT|DURABLE, 0o600) and create_private_file_if_missing. There is no async wrapper; the app calls these through TaskSpawner::spawn_blocking. - -Other storage types: -- SecretStore::new(config::SECRET_SCHEMA) -- ConnectionStore::new(paths); SavedConnection.tls: TlsConfig is required, with no migration -- QueryHistory::open(&paths) -- HistoryService::start(paths, retention_days, spawner) -- WorkspaceStore::start(paths.workspaces_dir(), spawner) with FA per-key file names -- window_geometry::{restore, persist} persist runs in SessionWindow's close_approved Proceed branch - -#### RUNTIME · `TaskSpawner boundary and tokio-context rule` (FA) - -TaskSpawner, TaskSpawnerExt, TaskResult, TaskPanic, GuardedCommand and catch_panic are as in FA-C12. The panic hook moves to logging.rs. - -Rule: every future that polls a Connection, EditorSession, SshSession or Transport runs inside TaskSpawner::spawn/spawn_task or GuardedCommand. GTK-thread code builds only runtime-free primitives. Drivers may call tokio::spawn with a TaskTracker, because they always run under that context. - -crates/app/clippy.toml carries FA's disallowed-methods plus a copy of linux/clippy.toml's allow-{unwrap,expect,panic,dbg,print}-in-tests. No msrv key exists anywhere. CI diffs the shared keys between the two files. - -#### DEPS · `Workspace dependencies and supply-chain policy` (FD) - -Rust 1.98 (rust-toolchain.toml, workspace rust-version 1.98, dtolnay/rust-toolchain@1.98). - -Workspace dependencies: -- sqlx 0.9.0: default-features = false, features [runtime-tokio, chrono, uuid, json]. Per crate: mysql [mysql, mysql-rsa, tls-rustls-aws-lc-rs]; sqlite and storage [sqlite-unbundled]. -- russh 0.63.3. -- deadpool 0.13.1 [managed, rt_tokio_1]. -- clickhouse 0.15.2 (default-features = false; uuid, chrono) plus clickhouse-types 0.1. -- hyper-rustls 0.27 (aws-lc-rs), rustls 0.23 (aws_lc_rs, std, tls12), tokio-rustls 0.26. -- tokio-util 0.7 [compat, rt]. -- testcontainers 0.27 and testcontainers-modules 0.15, both default-features = false with aws-lc-rs. -- sqlparser 0.63 [std]. -- relm4 gnome_50, sourceview5 v5_18. - -No rust_decimal after the switch. No CryptoProvider::install_default. - -[patch.crates-io]: the TableProApp/tiberius and TableProApp/rust-postgres forks. - -deny.toml: -- [graph] all-features = true, exclude-dev = false -- bans: ring, openssl-sys, native-tls, rustls:<0.23 -- sources: allow-git for the two forks only -- advisories: version 2, yanked deny, RUSTSEC-2023-0071 ignore with FD-D23's scope -- licenses: FD's list - -#### TEST-SUPPORT · `crates/test-support` (FA) - -A workspace crate with publish = false, used only as a dev-dependency, depending on tablepro-core and tokio. - -Provides: -- FakeDriver and FakeConnection: scripted probe outcomes, close delay or hang, write_batch outcomes, call counters. -- FakeEditorSession. -- FakeTransport and FaultTransport (black-hole after N bytes). -- FixedTrustSource. -- network_target(endpoint). -- ColumnInfo, ColumnType and Value builders. - -Replaces FB's per-crate #[cfg(test)] builders and FC's test_support module. - -## Tasks - -### W1 · Persistence, secrets and SSH tunnel - -W1 fixes its 14 findings in 16 commit-sized tasks. This revision resolves every point from the review. The external APIs were re-checked against docs.rs and the upstream sources, and the OpenSSH behaviour was re-checked by running OpenSSH_10.2p1 on this machine. - -SSH lands in five tasks before the contract switch (global step 25). Task IDs are kept, but W1-04 and W1-13 swapped content, so W1-13 now lands before W1-11. -- **W1-01 (after step 6): destinations.** Resolved by `ssh -G`, including jump chains passed with `-J`. IdentityFile and CertificateFile values are expanded per ssh_config(5), because `ssh -G` prints them unexpanded (seen locally: `identityfile ~/.ssh/%h_key`). Destination checks follow OpenSSH's valid_hostname and valid_ruser rules, plus pattern characters. -- **W1-02 (step 7): host keys.** russh 0.63.3. Host keys are decided once over all known_hosts files with OpenSSH's rules, using `ssh-keygen -F` for matching (tested locally for wildcard, negated, hashed, @cert-authority, @revoked and malformed lines; exit codes 0/1/255) and `ssh-keygen -Q` for RevokedHostKeys lists. Host certificates are checked the way OpenSSH's check_host_key does: a valid CA or else retry as a plain key. The app known_hosts file is written through an injected private writer behind one mutex. -- **W1-03: host-key dialog.** -- **W1-04: authentication.** One sequence for every hop, in OpenSSH order: agent identities, then identity files (missing ones skipped, passphrases prompted), then keyboard-interactive, then password. It follows PreferredAuthentications and the server's remaining methods, through a core CredentialPrompter. -- **W1-13: ProxyJump.** Chains are rebuilt the way ssh.c builds its jump ProxyCommand. - -Storage: -- W1-05: StoragePaths plus 0700/0600 durable writes. -- W1-06: the connection list can no longer be wiped. -- W1-07: typed keyring errors, secrets stored before a single list write. -- W1-08: history migrations. -- W1-09: coalesced config documents. It lands after FA 4 and owns persist_debouncer.rs. -- W1-10: untruncated drafts. - -W1-11 is W1's part of step 25: -- SshSession and SshTransport replace the loopback tunnel, with an injected first-hop transport. -- It pins the final shapes W4 compiles against: `Connector::establish` and `CredentialSource::refresh` gain `CredentialInteraction`, and `SshServices` replaces `ConnectServices.host_keys`. -- It adds `SshFailure::category`. -- It disables sqlx statement logging for MySQL and SQLite. - -After step 28: -- W1-12: password prompts. -- W1-14: host-key review from a parked session. -- W1-15: ordered deletion. -- W1-16: the history switch. Credential-carrying statements are classified per dialect and not recorded at all. - -Every change to a canonical contract is listed as a numbered amendment in cross_workstream_needs (A1 to A9), each with its final signature and the commit that introduces it. Flatpak needs are now explicit: `~/.ssh:ro`, `ssh-auth`, and keeping the secrets talk-name because oo7 0.6.0 falls back to the host Secret Service when the portal is missing (keyring.rs:39-55). - -Review points kept with a different mechanism: -- Hashed host names are not written, because HashKnownHosts is defined only for ~/.ssh/known_hosts, and the app file is 0600 in a 0700 directory. -- gssapi-with-mic and hostbased are skipped. F-065 does not name them, and hostbased needs the setuid ssh-keysign helper, which an app cannot use. - -#### W1-01 · Resolve SSH destinations and jump chains through OpenSSH's client configuration - -**Step**: 5 · **Findings**: F-009, F-065 -**Depends on**: W2-09 -**Contracts**: FC-C2, FC-C5, SSH, FC-C16 -**Commit**: `feat(ssh): resolve SSH destinations through OpenSSH client configuration` - -**Files**: `linux/crates/ssh/Cargo.toml`, `linux/crates/ssh/src/lib.rs`, `linux/crates/ssh/src/ssh_destination.rs (new)`, `linux/crates/ssh/src/jump_spec.rs (new)`, `linux/crates/ssh/src/resolved_host.rs (new)`, `linux/crates/ssh/src/identity_agent_setting.rs (new)`, `linux/crates/ssh/src/auth_preferences.rs (new)`, `linux/crates/ssh/src/strict_host_key_checking.rs (new)`, `linux/crates/ssh/src/local_identity.rs (new)`, `linux/crates/ssh/src/token_expansion.rs (new)`, `linux/crates/ssh/src/ssh_config_resolver.rs (new)`, `linux/crates/ssh/src/openssh_config_resolver.rs (new)`, `linux/crates/ssh/src/ssh_g_output.rs (new)`, `linux/crates/ssh/tests/openssh_resolution.rs (new)`, `linux/crates/core/src/ssh_failure.rs`, `linux/docs/decisions/0010-openssh-client-configuration.md (new)` - -Additive ssh-crate layer, after global step 6. - -SshDestination { host, port: Option, user: Option }. `new` trims the host and returns `SshFailure::InvalidDestination(DestinationProblem)` for these cases: -- Empty host, host over 255 bytes, or port 0. -- A leading '-' in host or user. -- In the host: control characters, whitespace, or any of ' ` " $ \ ; & < > | ( ) { } , * ? ! @ / [ ]. This is OpenSSH 10.2 ssh.c valid_hostname plus pattern, list, URI and user separators. -- In the user: control characters, whitespace, or any of ' ` " $ \ ; & < > | ( ) { } * ?. This is valid_ruser tightened. '@' stays allowed for realm logins. -These checks matter because `ssh -G` evaluates `Match exec` through /bin/sh with %h and %r. - -`JumpSpec::parse` accepts `[user@]host[:port]`, `[v6]:port` and `ssh://[user@]host[:port]`, each checked through SshDestination. - -`ResolvedHost` fields: -- original: SshDestination -- endpoint: NetworkEndpoint -- user -- host_key_alias: Option -- identity_files and certificate_files: Vec -- identities_only -- identity_agent: IdentityAgentSetting { Environment, Disabled, Socket(PathBuf) } -- proxy_jump: Vec -- user_known_hosts_files and global_known_hosts_files: Vec -- revoked_host_keys: Option -- strict_host_key_checking: StrictHostKeyChecking { Yes, Ask, AcceptNew, No } -- auth: AuthPreferences { preferred: Vec, publickey, keyboard_interactive, password: bool, password_prompts: u32 } -- `known_hosts_lookup()` returns the alias verbatim, otherwise OpenSSH put_host_port: `host`, or `[host]:port` when the port is not 22 (sshconnect.c get_hostfile_hostname_ipaddr, verified). - -Trait: `SshConfigResolver: Send + Sync + Debug { fn resolve(&self, destination: &SshDestination, jump: JumpOverride) -> BoxFuture<'static, Result>; }`, with `JumpOverride::{FromConfig, Chain(Vec)}`. - -`OpenSshConfigResolver { program, local: LocalIdentity, timeout }`, production ("ssh", LocalIdentity::current(), 5 s): -- Runs `ssh -G`, adding `-J ` for Chain and `-p` / `-l` when set, then `--` and the host. -- stdin is null and the child uses kill_on_drop, all under tokio::time::timeout. -- NotFound maps to OpenSshClientMissing. -- A non-zero exit maps to ConfigResolution { destination, detail: stderr cut to 512 bytes }. Stderr is ignored on exit 0, because ssh prints a pseudo-terminal notice there. - -`parse_ssh_g_output(stdout, &LocalIdentity)` reads the lowercase keyword lines: -- hostname, port, user, identitiesonly, hostkeyalias, revokedhostkeys, stricthostkeychecking, numberofpasswordprompts. -- identityagent and userknownhostsfile/globalknownhostsfile arrive already expanded (verified). For identityagent, `none` means Disabled and `SSH_AUTH_SOCK` means Environment. For the known-hosts lists, `none` entries are dropped. -- identityfile and certificatefile arrive raw and go through token expansion. -- proxyjump, split on ','. -- A proxycommand other than none gives ProxyCommandUnsupported. A config that sets both keeps the first, as OpenSSH does (verified). -- preferredauthentications, when absent, defaults to gssapi-with-mic,hostbased,publickey,keyboard-interactive,password. -- pubkeyauthentication yes/true/unbound/host-bound means on; kbdinteractiveauthentication and passwordauthentication are read as yes/no. - -`token_expansion::expand_path(raw, &TokenValues)` follows ssh_config(5) TOKENS: -- `~` and `~/` use the passwd home; `~name/` uses that user's passwd entry. -- `${VAR}` comes from glib::getenv; an unset variable gives ConfigResolution. -- %% gives '%'. %C is the lowercase hex of glib::compute_checksum_for_string(ChecksumType::Sha1, %l%h%p%r%j). %d is passwd pw_dir, as OpenSSH uses (verified: HOME is ignored). -- %h hostname, %i uid, %j the last ProxyJump host (ssh.c jmphost, empty when unset), %k alias or original host, %L short local host, %l gethostname, %n original host, %p port, %r user, %u passwd name. -- Any other token gives ConfigResolution { detail: "unknown token %x in IdentityFile" }. - -`LocalIdentity { uid, user_name, home, host_name }` is built by `current()` from nix 0.29 `unistd::{getuid, User::from_uid, gethostname}` (features user and hostname, verified in nix-0.29.0). - -Core SshFailure (additive, #[non_exhaustive]): InvalidDestination(DestinationProblem), OpenSshClientMissing, ConfigResolution { destination, detail }, ProxyCommandUnsupported { host }. Also DestinationProblem { EmptyHost, HostTooLong, LeadingDash(DestinationField), ForbiddenCharacter { field, character }, PortZero } and DestinationField { Host, User, Port }. - -Dependencies: crates/ssh gains tokio `process`, nix 0.29 and glib 0.22. - -ADR 0010 records ssh -G over russh-config 0.58 (which has no Include or Match), openssh in freedesktop-sdk 25.08 platform.bst (verified; ssh and ssh-keygen are outside the vm-only split rules), and the packaging dependency on OpenSSH >= 9.6. - -**APIs**: OpenSSH ssh -G and -J (evaluates Host/Match, Include, jump chains; no connection); tokio::process::Command::kill_on_drop (tokio 1.52); glib::compute_checksum_for_string(ChecksumType::Sha1) and glib::getenv (glib 0.22.5); nix 0.29 unistd::{getuid, User::from_uid, User::from_name, gethostname} - -**Tests**: Unit tests (crates/ssh), with fixtures captured from OpenSSH 10.2: -- `parse_reads_alias_block`: hostname, user, port, alias, identitiesonly, identityagent socket, two expanded userknownhostsfile entries, preferred methods, kbdinteractive off. -- `identityfile_tokens_expand`: `~/.ssh/%h_key`, `${HOME}/.ssh/%r_%p_key` and `%d/.ssh/%C_%n_%l_%u_%i_%k_%j_%L` against a fixed LocalIdentity. -- `unknown_token_is_config_resolution`. -- `unset_env_var_is_config_resolution`. -- `tilde_user_uses_passwd_entry`. -- `proxyjump_uri_ipv6_and_user_port_forms`. -- `proxyjump_none_is_empty`. -- `proxycommand_is_refused`. -- `lookup_is_alias_or_bracketed_port`. -- `destination_rejects_shell_metacharacters`: one case per forbidden character for host and user. -- `destination_accepts_ipv6_zone_and_realm_user`. - -Tokio tests with a script as the program: `resolver_maps_exit_status`, `resolver_times_out_and_kills_child` (200 ms), `resolver_passes_jump_port_user_flags`, `missing_program_is_openssh_client_missing`. - -Integration test `crates/ssh/tests/openssh_resolution.rs` runs the real ssh with `-F `: -- `expansion_matches_openssh_controlpath`: our expansion of a template equals `ssh -G -o ControlPath=