Repository navigation
Statement log: every operation statement, with secrets redacted #765
Description
Parent
What to build
Every statement an operation sends is in the statement log, with its parameters, and a secret never is. After this ticket:
class Account(Model):
id: int = Field(primary_key=True)
email: str = Field(unique=True)
token: SecretStr
logging.getLogger("ferro.sql").setLevel(logging.DEBUG)
async with engines.session():
await Account(id=1, email="ada@example.com", token=SecretStr("sk_live_1")).save()
# ferro.sql DEBUG INSERT INTO "account" ("id", "email", "token") VALUES ($1, $2, $3) params=(1, 'ada@example.com', ***) 0.6ms
await Account.where(lambda account: account.token == "sk_live_1").first()
# ferro.sql DEBUG SELECT ... WHERE "account"."token" = $1 LIMIT $2 params=(***, 1) 0.3ms
await raw.execute("UPDATE account SET token = $1", SecretStr("sk_live_2"))
# ferro.sql DEBUG UPDATE account SET token = $1 params=(***,) 0.2msEvery FFI operation entry opens the statement context the logger ticket built, naming its operation (fetch, count, exists, save, update, delete, bulk_create, m2m_add, m2m_remove, m2m_clear, raw, transaction, settings, catalog) and its connection (the route's connection name). Transaction control (BEGIN, COMMIT, ROLLBACK, SAVEPOINT, RELEASE, ROLLBACK TO), the session-settings SET LOCAL batch and the Postgres catalog probe are logged under their own operation names; a chunked bulk_create or a many-to-many add leaves one record per statement under the same name. rows carries the driver's affected-row count for execute statements and None for fetches.
Redaction is one rule applied at emit time: a bound value is masked when it is in the operation's secret-value set, which is decided once per operation from two facts Python owns. Python collects the values of every secret-typed literal and field value it unwraps (compile_query for predicate literals, the bind-payload helpers for save/update/bulk_create values, raw for its arguments) and passes them as secret_values; Rust, at entry, adds the values bound to columns whose field the model declares SecretStr/SecretBytes (read from the hydration-converter registry #760 added, for the root model and every hop class). The engine layer masks a bind that equals any value in the set. This is where the ADR's "per-bind flag" lives: a set of values rather than a positional list, because the binds are made by the query builder in Rust and never pass through Python one by one. Session settings are statement text (SET LOCAL takes no parameters) and are not masked.
.sql() does not exist yet; this ticket is complete when the log tells the whole truth for every operation and no secret reaches it.
Acceptance criteria
- Each of these leaves records with the expected
operation, on SQLite and Postgres: awhere/order_by/includefetch (fetch, one statement),countandexists,savefor a new and a fetched instance (save),bulk_createacross a chunk boundary (bulk_create, one record per chunk),Query.update()andQuery.delete(),Model.delete(), a many-to-manyadd/remove/clear,raw.execute/fetch_all/fetch_one(raw). - A
transaction()block with a nestedtransaction()leavesBEGIN,SAVEPOINT …,RELEASE …/ROLLBACK TO …andCOMMITrecords withoperation == "transaction", in order, between the statements of the block. - A session with settings on Postgres leaves the
SET LOCALrecords (operation == "settings") before the operation's statement inside the same transaction; the catalog probe leaves acatalogrecord the first time a table is touched and none the second time. - Every record's
connectionis the route's connection name anddialectthe backend's;rowsis the affected-row count forexecutestatements andNonefor fetches. - A
SecretStr/SecretBytesfield value insave,bulk_createandupdate, a plain literal compared against a secret column, a secret-typed literal compared against any column, and a secret-typedrawargument each appear asREDACTED(by identity) inparams, and the real value appears in no record's message orparams. - A non-secret value is never masked unless it equals a secret value of the same operation (documented false-positive direction).
- With
ferro.sqlnot enabled forDEBUG, no operation calls the emit callable and no secret-value set is built (asserted with a spy and a counter on the collecting helpers). - A statement that fails (a unique violation on
save) leaves a record witherrorset; theIntegrityErrorstill propagates. -
compile_query's artifact carriessecret_valuesand the wire JSON is unchanged (golden vectors untouched); the compiled-query pin covers the new field.
Files owned
- Modify:
src/operations.rs,src/session_settings.rs,src/sql_log.rs,src/hydration.rs,src/ferro/query/wire.py,src/ferro/query/builder.py,src/ferro/_bind_payload.py,src/ferro/models.py,src/ferro/raw.py,src/ferro/_core.pyi - Test:
tests/test_statement_log.py(the operation, transaction, settings, catalog and redaction cases),tests/test_compiled_query.py(thesecret_valuesfield)
Interfaces
- Consumes:
sql_log::{scope, StatementContext, observe}andferro._sql_log.{emit, REDACTED}from Statement log: the ferro.sql logger and the Rust emit path, proven on migration DDL #764 (shipped in PR feat: statement log records on ferro.sql for migration DDL (#764) #768;scopeisasync fn scope<F, T>(ctx: Option<StatementContext>, fut: F) -> PyResult<T> where F: Future<Output = PyResult<T>>, so an emitter error comes back as the operation's result: wrap each FFI entry's future, which already returnsPyResult); thestatement_recordsfixture. - Consumes:
unwrap_secretand the hydration-converter registry (cls._hydration_converters, kind"secret") from SecretStr and SecretBytes fields save as '**********' and hydrate as plain str #760;field_converters_forinsrc/hydration.rs. - Produces:
CompiledQuery.secret_values: tuple[object, ...](the unwrapped values of secret-typed literals, collected bycompile_query; empty when none), used by the rendering ticket. - Produces:
secret_values(instance) -> tuple[object, ...]andsecret_values_of(fields: Mapping[str, object]) -> tuple[object, ...]insrc/ferro/_bind_payload.py, used bysave,bulk_create,update. - Produces: a keyword
secret_values: list[object]on the FFI entriesfetch_filtered,count_filtered,update_filtered,delete_filtered,save_record,update_record,save_bulk_records,raw_execute,raw_fetch_all,raw_fetch_one, declared in_core.pyi; the rendering ticket'srender_*twins take the same keyword. - Produces (Rust):
sql_log::secret_columns_for(py, cls) -> PyResult<HashSet<String>>(reads the converter registry), and aStatementContextopened at every operation entry withoperationandconnectionset andsecret_valuesfilled from the keyword plus the secret-column values of the plan/payload.
Test scenarios
- Happy path: each operation family above → records with the expected
operation,connection,dialect,rows, and one record per statement sent, on both backends. - Happy path: nested
transaction()→BEGIN,SAVEPOINT, inner statement,RELEASE, outer statement,COMMIT, in that order. - Happy path (Postgres): session settings →
SET LOCALrecords before the statement, inside the same transaction; first touch of a table → onecatalogrecord, second touch → none. - Happy path: the four redaction routes (secret field value on save/update/bulk; plain literal against a secret column; secret literal against a plain column; secret raw argument) →
REDACTEDby identity, real value absent from every message. - Edge case:
bulk_createof two rows where one non-secret value equals another row's secret value → both masked (false positive is the safe direction; asserted so the behaviour is pinned). - Edge case:
ferro.sqlatWARNING→ emit never called, secret-value collection never invoked. - Error path:
save()violating a unique index → record witherror,IntegrityErrorpropagates. - Integration:
tests/test_compiled_query.py→CompiledQuery.secret_valuesis()for a query with no secret literal and("hunter2",)for one withSecretStr("hunter2"); wire JSON identical in both cases.
Verify
uv run maturin develop
uv run --no-sync pytest tests/test_statement_log.py tests/test_compiled_query.py tests/test_ir_vectors_contract.py tests/test_secret_fields.py tests/test_session_set_config.py -q --no-cov -p no:cacheprovider
cargo test --no-default-features --features testing
uv run --no-sync ruff check src/ferro tests
uv run --no-sync ty check src/ferro/query tests/test_query_typing.py tests/test_static_contracts.py tests/test_alembic_typing.pySet FERRO_POSTGRES_URL and pass --db-backends=sqlite,postgres so the Postgres cases run rather than skip.
Blocked by
- Statement log: the ferro.sql logger and the Rust emit path, proven on migration DDL #764 (the
ferro.sqllogger and the Rust emit path).
Activity
0x054 commentedon Oct 21, 2026
Status: verify-passed
Worker: fix-765 (panel round 1 on #771)
Branch:
sdd/765-fix-panel-round-1PR: #772 (into
feat/700-sql-inspection; original #769)Verify: passed 06bc945 (author run, reviewer re-run: sqlite + postgres)
Review: Spec PASS, Quality PASS (review)
Every panel finding this PR owns is fixed: F1, F2, F4, F5, F7 and F11. A keyset position on a secret order key is now redacted, both in the log and in
.sql(). The secret set is hashed, so redaction costs one lookup per bind. The engine is the only reader of the log switch. Every operation collects secrets throughsecret_values_in, and a write collects nothing when the log is off. ADR-0057 is amended to match. The reviewer found no Critical or Important findings. The base has not moved, so no trial merge was needed.feat(log): every operation statement in the log, with secrets redacted (
0x054 commentedon Oct 21, 2026
Merged in #769 into
feat/700-sql-inspection(ca92eca).fix(log): panel round 1 for #765/#766 — keyset redaction, hashed secr…
feat: SQL inspection and logging (#700) (#771)