Skip to content

feat(python): SQLAlchemy layer, order and match queries, and row-level encrypt and decrypt - #1622

Open
yujiyokoo wants to merge 31 commits into
feat/python-sdk-examplefrom
feat/python-sqlalchemy
Open

yujiyokoo wants to merge 31 commits into
feat/python-sdk-examplefrom
feat/python-sqlalchemy

Conversation

@yujiyokoo

@yujiyokoo yujiyokoo commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR lets a SQLAlchemy app store and search encrypted columns with no encrypt or decrypt calls in the app code. One setup call, configure_encryption([Customer]), does the setup. It also adds the SDK calls that the layer uses: order queries, match search, and encrypt_rows and decrypt_rows for partial rows.

Changes

SDK (stackencrypt):

  • lt, lte, gt, and gte query methods, one for each order operator (ADR-0003).
  • Match search: matches, verify, and target_limit. The heap hygiene test covers verify.
  • encrypt_rows and decrypt_rows, for rows that hold only some of a model's fields.

SQLAlchemy layer (stackencrypt.sqlalchemy, ADR-0004):

  • One hook on the Engine class encrypts every INSERT and UPDATE for the configured tables. It makes one ZeroKMS request for each statement.
  • One hook on Session decrypts the objects and columns that a query loads.
  • ==, !=, <, <=, >, >=, in_, not_in, and .match() work on encrypted columns. An or_ of match searches works too.
  • ORDER BY on an encrypted column sorts by its order term. Without this, PostgreSQL sorts the stored JSON, and the order is wrong.
  • Two Alembic hooks write the EQL column types, their indexes, and the cast that converts an existing column.
  • configure_encryption refuses setups that cannot work. Examples are a unique encrypted column and two classes that map the same table.
  • A field declared Optional stores None as SQL NULL. A relationship attribute needs no marker.

Example:

  • app_sqlalchemy.py now uses configure_encryption in place of the hand-written code.

Verification

  • Offline suite: 499 passed, 95 skipped. The skipped tests are the live tests, which need credentials.
  • Live suite, against ZeroKMS and PostgreSQL with EQL v4: 93 passed. This run was before the last commit (the unique-constraint fix). That commit changes no live path.
  • Customer directory example: 24 passed. CI does not run these tests.

Related

Refs #1341

Review notes

  • Start with ADR-0004. It records the layer's design and its limits.
  • The last 12 commits are fixes found while porting Endurain, a fitness app, to the SDK.
  • between is not included. It waits for an engine change.
  • A Core insert built with sa.table(...) is not encrypted. PostgreSQL refuses it, because the column type does not match.

🤖 Generated with Claude Code

yujiyokoo and others added 29 commits November 10, 2026 18:39
The engine already derives order and match terms, and the Go SDK uses
them. Python's query always asks for equality. ADR-0003 records the
call shape for the other operators: eq, lt, lte, gt, gte, between, and
matches, with query removed. Match answers can contain rows that do not
match, so matches returns an object with target_limit and verify.
ADR-0002 now points to ADR-0003 where its op= plan was replaced.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine already derives order terms, but Python's query always asked
for equality. Cipher now has eq, lt, lte, gt and gte, as ADR-0003
decides, and query is removed. Core passes the operator to the engine.
Cipher checks a field against the operators the engine lists for it,
not against the marker's indexes, so the two cannot disagree.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine refuses a between term for an EQL order type, with the
reason operator_not_answered, and the Go SDK has no Between for these
types either. Python leaves between out and stays at Go's level. A
range search uses gte and lte. Building the range in Python would copy
EQL's range format with nothing to keep the copy correct.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine answers a match on a TextMatch field, but the answer can
hold rows that do not contain the text. Cipher.matches returns a
MatchQuery, as ADR-0003 decides: its term goes in the SQL with @@,
verify removes the false positives after decrypt, and target_limit
sizes the fetch from the stored filters' histogram, or returns None
when no finite fetch can be promised. MatchQuery keeps the search text
for verify, and its repr never shows it.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verify reads the search text and each decrypted value into Rust
before it checks them. Those copies are plaintext. The heap test now
calls the verify binding that Cipher.verify uses, and fails if a block
freed during the call still holds the text.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SQLAlchemy layer will encrypt each statement's parameters in one
batch. An UPDATE contains only the changed columns, plus keys such as
the primary key for the WHERE. encrypt takes whole models, so it cannot
take those rows.

encrypt_rows takes dicts and builds a plan for the fields they contain.
The engine refuses a row that leaves out a field of the plan, and a
value from the smaller plan opens and searches under the full plan.
Every row in one call must contain the same declared fields, so that
all values are checked before the one batch of key requests.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SQLAlchemy layer will decrypt the objects a query loads, in one
batch per class, and write the plaintext back to their attributes. It
needs the values, not new model instances, and a query can load only
some of the columns.

decrypt_rows takes dicts of stored values and returns dicts of
plaintext, with the same rules as encrypt_rows. It passes dict to the
engine in place of the model class, so it needs no change to the
extension. The two methods share the row-shape check and the plan for
each set of fields.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the SDK alone, an app calls encrypt before each insert and decrypt
after each select. configure_encryption(engine, classes) moves both
into SQLAlchemy hooks, so the app reads and writes plaintext
attributes.

A column type would encrypt one value at a time, with one key request
each. The hooks work in batches instead. before_execute encrypts the
parameters of each INSERT or UPDATE with one encrypt_rows call.
do_orm_execute decrypts the objects that the load and refresh events
recorded, with one decrypt_rows call for each class, and sets them
with set_committed_value so that they are not saved again.

A statement with an encrypted value inside its SQL is refused, because
the hook can change only the parameters, and the value would be stored
as plaintext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An app had to build each query term with cipher.eq and write the cast
in the SQL itself. eql_type_map gives every encrypted field an EqlType
column, read from its marker, so a model names no SQL type. The type's
==, <, <=, >, and >= make the term with the Cipher method of the same
meaning and cast it to the marker's query type.

Every other operator that would send a value, such as like, in_, and
!=, raises QueryError when the query is built. The value would reach
the database as plaintext, and could never match an EQL value.

configure_encryption now refuses an encrypted column whose type is not
its marker's EQL type. Without the type map, SQLAlchemy would make it
an ordinary text column, with no error.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An app that moves to encrypted columns needs migrations that create the
EQL columns and the database indexes a search uses. Alembic's
autogenerate could not do either alone.

A mapped class now gets a database index for each EQL index of each
encrypted field, such as hash (eql_v4.eq_term(email)) for TextEq. It is
added when the class is mapped, not when a column joins a table:
Alembic's add_column creates the indexes of a temporary table, so an
index added there was created twice.

Two hooks for env.py. render_item writes EqlType into a migration with
its imports. process_revision_directives writes a change of an existing
column to an EQL type as an ALTER with USING col::jsonb::<type>, which
PostgreSQL needs. The cast changes an empty column and refuses one with
plaintext rows, which keeps them. It also drops the type change Alembic
finds between an EQL type and itself: it compares eql_v4_text_eq from
the database with public.eql_v4_text_eq from the model as text.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SQLAlchemy version of the customer directory called encrypt before
each insert and decrypt after each select, and built the search term by
hand. With configure_encryption, its endpoints use Session as any
SQLAlchemy app does. It is the nearest copy of putting the layer into
an existing app.

The hooks check only the encrypted fields. cipher.encrypt also refused
a created_at with no time zone, so the request model now uses Pydantic's
AwareDatetime to keep that refusal.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The write hook sent only encrypted fields to encrypt_rows, so a
passthrough value was not checked: a datetime with no time zone reached
PostgreSQL, where cipher.encrypt refuses it. Every declared field now
goes through encrypt_rows, which checks a passthrough value and returns
it unchanged. The example no longer needs Pydantic's AwareDatetime.

!= becomes NOT (col = term), and in_ one equality term for each value,
joined with OR. Neither sends a value as plaintext. in_ with a subquery
is still refused.

render_item wrote stackencrypt.Json, a name the package does not
export. A marker that is not exported is written from stackencrypt._eql.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…columns

The SDK added a hash index on eq_term(email) even when the model, like
the example, already had a unique btree index on the same term. Alembic
would then create both. Now an index the model declares on the same
term of the same column replaces the SDK's index for that term.

A unique constraint on an encrypted column compares the stored EQL
values. Each one has new random ciphertext, so the constraint never
finds a duplicate. configure_encryption refuses it, and points to a
unique index on eq_term instead.

The example declares the unique index of schema.sql, and gives plan the
text type, so Alembic finds no index or plan difference with the table.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The layer made several choices that apps will depend on: batch hooks
instead of a column type, an explicit list of classes, refusing
anything that would store or send plaintext, column types and indexes
read from the markers, and two Alembic hooks. ADR-0004 records each
one with the option it was chosen over, and what was run to check it.

ADR-0002 left open whether the SQLAlchemy layer names a field by the
class attribute. It does, and ADR-0002 now points to ADR-0004.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The read hook decrypted only the objects a query loads. A session query
of columns, such as select(Customer.email, Customer.plan), returned
EQL values, though it is ordinary ORM code. The hook now finds the
result columns that are encrypted fields, through attributes and
labels, and decrypts them with one decrypt_rows call for each class
and set of fields.

A query on a connection is still left to the app, which calls
cipher.decrypt_rows: a connection has no hook that can change a result.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A match index is a Bloom filter, so the database can return rows that
do not hold the text. ADR-0003 says the SQLAlchemy layer should hide
this. .match(text) on a field with a match index is col @@ term, and
the session hook runs verify on the rows after the query and removes
those that fail.

Removing rows after the query is right only in a session's select,
with the match as a condition of the WHERE joined with AND, and whole
objects of the class in the result. Anything else, such as or_, not_,
a count, an update, or a query on a connection, raises QueryError
before it is sent: it would return, count, or change rows that do not
hold the text.

A LIMIT is applied before the rows are removed, so a page can be short.
ADR-0004 records the choice and the two other options.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
G6 said the SQLAlchemy layer was a later, separate design. It now
exists, and it is a second way to encrypt and decrypt. The entry goes
through the principle's four tests with the evidence for each: the
need, the shared engine calls, the guarantees kept, and the owners.
It also names the one difference, one decrypt_rows call for each class
instead of one for the whole result, and the untested echo=True log.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The decorator refused Optional, so a nullable column could only be
declared with a wrong type hint, and encrypt still refused None. A row
with a missing value could not be stored. Most columns of an app being
ported are nullable.

Annotated[Optional[T], Marker] and T | None are now accepted. The plan
gives the field "null": "sql", so the engine stores nothing for None
and the column holds SQL NULL; decrypt reads it back as None. A query
term for None is refused, and points to IS NULL. eql_type_map maps the
Optional hints too, so SQLAlchemy makes the column nullable.

"sql" shows which rows have no value. "sealed" would hide that, but
only for fixed-width types with an equality index alone; it can come
later. The ADR-0001 amendment records why.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A search box that looks in several text fields is an or_ of match
searches. The read hook refused every match inside an or_, so an app
had to run one query for each field and join the rows in Python, which
stops the database sorting and paging the combined result.

A row that passes verify always passes @@, so the database returns
every row that any branch would keep. The hook now accepts an or_ whose
branches are all match searches, or and_ of them, and keeps a row when
any branch passes verify. That gives exactly the rows verify would keep
over the whole table. An or_ with any other condition, and a match
under not_, are still refused.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In an outer join, a row with no related object has None in that
object's place. With AND alone such a row never came back, because its
@@ is NULL. With an or_ of matches, another branch can bring it back,
and _verify then read the field from None and raised AttributeError.

A missing object now counts as a NULL value: its search fails for that
row, and another branch can still keep it.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bare ORDER BY on an EQL v4 column sorted in a wrong order, with no
error. EQL v4 gives its column types comparisons but no default sort
order, so PostgreSQL sorted the stored JSON. On 98, 120, 150 and 175 it
gave 98, 150, 175, 120. The EQL v4 on next behaves the same.

The layer now sorts an encrypted column by eql_v4.ord_term(col), or
ord_term_ore for an ore_block marker. That sorts by the plaintext, and
the SDK's index on the term serves the sort. asc, desc, and the nulls
orderings go through the column type. A bare order_by(col) and
sqlalchemy.asc(col) do not, so both hooks rewrite the ORDER BY. A
column with no order index cannot be sorted, and raises QueryError.

ADR-0004 decision 9 records the EQL gap and the options.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rule A asked for a marker on every annotated attribute, relationships
included. A list relationship could not be marked Ignore, because
SQLAlchemy reads the type directly inside Mapped, so an app removed
the annotation and lost the type. Its hint also had to resolve when
the decorator ran, so the target class had to come first.

A relationship is never a column, so leaving it out cannot store
plaintext. The decorator now finds the relationships in the class's
SQLAlchemy mapper, which lists them when the class statement runs,
and leaves them out of the plan without resolving their hints. Every
column still needs a marker, as Rule A says.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine hook rewrote the ORDER BY only for selects without the ORM
mark, and left the rest to the session hook. But SQLAlchemy marks a
select as ORM whenever it names a mapped class or attribute, even when
it runs on a connection, where the session hook does not run. So
conn.execute(select(T.level).order_by(T.level)) sorted the stored JSON.

The engine hook now rewrites every select. A select from a session
reaches it already rewritten, and is left as it is.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ORDER BY rewrite took only columns whose table is a Table, and read
only the top select. So a bare order_by on an aliased class, or in a
subquery, still sorted the stored JSON. It now takes any column of an
EQL type, and rewrites every select in the statement, copying it only
when an ORDER BY changes.

Copying a statement made SQLAlchemy compare columns with ==, which
showed two bugs in the column type. Its None check compared a SQL
expression for truth and raised TypeError, and it treated another
column as a value to encrypt. A comparison with an expression that
holds no value, such as another column, now passes through. One that
holds a value, such as literal("x"), is refused: the value would reach
the database as plaintext.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
order_by(col.label("x")) and order_by("x") sent ORDER BY x, which
sorts by the selected column: the stored JSON, in a wrong order.
SQLAlchemy keeps a label reference in the ORDER BY, not the column, so
the rewrite did not see it.

The rewrite now collects each select's labelled encrypted columns by
name, and replaces a label reference, by object or by name, with the
order term of the column it names.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
order_by("level") and desc("level") sent ORDER BY laps.level, which
sorts the stored JSON. SQLAlchemy matches a string to a column only
when it compiles the statement, so the rewrite did not see a column.

The rewrite now looks the name up as SQLAlchemy does: the labels, then
the selected columns, then the columns of the FROM tables. A name that
belongs to an encrypted column becomes its order term. A name that two
FROM tables share, and that is not selected, raises QueryError,
because the rewrite cannot tell which column is meant.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The name lookup for a string in an ORDER BY kept only encrypted
columns. So a plaintext label or column with the name of an encrypted
FROM column did not replace it, and the rewrite sorted by the
encrypted column instead: select(T.rank.label("level")).order_by("level")
sorted by T.level, with no error.

The lookup now keeps every candidate, plaintext ones included, in
SQLAlchemy's order: FROM columns, then selected columns, then labels.
Only a name that resolves to an encrypted column is rewritten.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
configure_encryption(engine, classes) installed the write hook on one
engine, and refused a second call for a class. An app that writes
through more than one engine, such as an app engine, a test engine,
and a worker run twice in one test, had no way to cover them, and a
missed engine wrote without encryption.

configure_encryption(classes) now installs the write hook once, on
SQLAlchemy's Engine class, so every engine runs it, including one made
later. A table is found by its Table object, not its name, so another
database's table of the same name is not encrypted. A repeat call for
a configured class does nothing; it still refuses a changed class, or
a second class for the same table.

The package is not released, so the signature change breaks no user.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
configure_encryption checked each table constraint with
`column in constraint.columns`. A column collection accepts only a
string key with `in`. So SQLAlchemy raised ArgumentError for a class
with an encrypted field and any UniqueConstraint on its table. That
includes the one that unique=True makes for a plaintext column. The
check now uses contains_column.

An encrypted column in a composite UniqueConstraint is now refused with
declaration_unique_column, the same as a column declared unique=True.
A unique plaintext column beside an encrypted one is accepted.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yujiyokoo
yujiyokoo requested a review from a team as a code owner November 12, 2026 02:54
@yujiyokoo
yujiyokoo added this pull request to stack #1520 November 12, 2026 02:54
@changeset-bot

changeset-bot Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 20291ff

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI; Enforced overrides: Organization
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 46dc184a-1b5f-4088-8ca0-5c62f1ddf887

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review

@blacksmith-sh

This comment has been minimized.

@cipherstash-agent cipherstash-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review attestation

Reviewed 63327b0 · posted from Claude Code · claude-opus-5-5 · effort medium
Sources: code-review · claude-opus-5-5
Skills: code-review, rust-security, review-cross-check, review-post

Blocking (1)

  • languages/python/python/stackencrypt/sqlalchemy.py:579 a sealed value written inline under another bind name reaches PostgreSQL as plaintext

Findings (10): 1 critical, 5 major, 4 minor — 10 inline, 0 below.

Every finding was reproduced at 63327b0 (SQLAlchemy 2.1.8, with a stubbed _engine). rust-security found nothing in the Rust changes under languages/python/crates/.


Generated by Claude Code

as plaintext."""
inline = clause.compile(dialect=conn.dialect).params
for name in sorted(sealed):
if inline.get(name) is not None and not any(name in row for row in rows):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

blocking · critical The check only looks for a compiled param named exactly after the field, and _encrypt_parameters only encrypts row keys equal to field names. So insert(Customer).values([{"email": ...}, ...]) (email_m0), values(email=func.lower(...)), values(email=bindparam("new_email")) with executemany rows, case(..., else_=...), and on_conflict_do_update(set_={"email": ...}) all send the plaintext to the driver with no refusal and no encryption. Decide per sealed column from the statement's own values (_values, _multi_values, _ordered_values, the ON CONFLICT set), and refuse any bound value that is not a field-named parameter the hook encrypts.

Source: code-review · claude-opus-5-5


Generated by Claude Code

state.statement = _sorted_by_terms(state.statement)
# Freezing reads every row, so every load and refresh event has fired.
options = {_VERIFIED: True} if searches else {}
result = state.invoke_statement(execution_options=options).freeze()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

major .freeze() runs on every ORM select in every session, configured class or not. It makes select(Customer, Order).options(joinedload(Customer.orders))).unique().all() raise InvalidRequestError inside the freeze, makes a single-entity joinedload silently return duplicate parents instead of raising, and loads the whole table under yield_per/stream_results. Return early when no configured mapper is involved, and decrypt per chunk instead of freezing.

Source: code-review · claude-opus-5-5


Generated by Claude Code

pending = state.session.info.pop(_PENDING, [])
groups: Dict[Tuple[type, FrozenSet[str]], List[object]] = {}
for target, names in pending:
cipher, sealed = _CONFIGURED[type(target)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

major The load/refresh listeners (lines 448–449) lack propagate=True, and _CONFIGURED[type(target)] here and at 627 is an exact-type lookup. A single-table subclass Vip(Customer) is encrypted on write (matched by Table) but loads with raw EQL JSON in email, and configure_encryption([Vip]) is refused (declaration_not_decorated / declaration_already_configured); _checked_matches (667–672) also refuses .match() on select(Vip). Listen with propagate=True and resolve the class through the mapper's table or the MRO.

Source: code-review · claude-opus-5-5


Generated by Claude Code

if field.marker is Passthrough:
continue
column = mapper.columns[field.name]
unique = column.unique or any(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

major The unique refusal misses Index("ux_email", "email", unique=True) and a primary key on an encrypted column: both are accepted, and with fresh ciphertext per value they never detect a duplicate. Also refuse any index.unique index whose expressions include the bare column, and any PrimaryKeyConstraint containing it.

Source: code-review · claude-opus-5-5


Generated by Claude Code

``order_by(col)``, or ``sqlalchemy.asc(col)``, does not go through the
column type's operators, so the hooks rewrite it here. The statement is
copied only when an ORDER BY changes."""
if not any(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

major Only Select._order_by_clauses is rewritten. A bare encrypted column in union(...).order_by("level"), row_number().over(order_by=Customer.level), aggregate_order_by(...), or percentile_disc(...).within_group(Customer.level) still sorts by the stored JSON with no error. Refuse a bare encrypted column in those positions; a UNION's ORDER BY cannot simply be rewritten to ord_term.

Source: code-review · claude-opus-5-5


Generated by Claude Code

@@ -293,11 +358,19 @@ def _read_fields(cls: type, hints: Dict[str, Any]) -> List[_ReadField]:
names = _library_field_names(cls)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

major Relationships are filtered only when names come from the hints. For a MappedAsDataclass model, dataclasses.fields includes orders = relationship(...), its hint was dropped by _hints, so it fails with declaration_no_marker — even when marked Ignore, which was accepted before this PR. Compute _relationships(cls) before the branch and filter both branches.

Source: code-review · claude-opus-5-5


Generated by Claude Code


probe.__annotations__ = {name: hint}
try:
hints.update(get_type_hints(probe, global_names, dict(vars(base)), include_extras=True))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

minor For a function, get_type_hints(probe, globalns, localns) searches the class dict before the module, the reverse of get_type_hints(cls). With from __future__ import annotations and a relationship present, date: Mapped[Annotated[date, Passthrough]] resolves to the mapped attribute and fails declaration_type_mismatch. Swap to get_type_hints(probe, dict(vars(base)), global_names, ...) here and at line 434.

Source: code-review · claude-opus-5-5


Generated by Claude Code

if kind not in _INDEXES:
continue
function, method = _INDEXES[kind]
name = f"ix_{table.name}_{column.name}_{kind}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

minor The index name is never truncated: table customer_subscription_events with column billing_contact_email_address gives a 64-character name, and CreateIndex raises IdentifierError, failing create_all and the migration. Truncate names over 63 characters with a short hash suffix.

Source: code-review · claude-opus-5-5


Generated by Claude Code

continue
# The model declares an index on this term itself, such as a
# unique btree on eq_term: that one serves the search.
if f"eql_v4.{function}({column.name})" in declared:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

minor _index_term lowercases and strips quotes, but this side keeps column.name as written, so a column Email with its own text('eql_v4.eq_term("Email")') index never matches and a redundant ix_customers_Email_eq is added. Normalise this string the same way.

Source: code-review · claude-opus-5-5


Generated by Claude Code

``insert(table).values(email=...)``: the hook can only change the
parameters passed beside the statement, so the value would be stored
as plaintext."""
inline = clause.compile(dialect=conn.dialect).params

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

minor Every INSERT/UPDATE on a configured table is compiled a second time here, outside SQLAlchemy's compiled cache, just to read .params; and a session select of an unconfigured class walks the statement tree four times (_matches_in and _sorted_by_terms in both hooks). Read the statement's values directly and skip the walks when no configured table is involved.

Source: code-review · claude-opus-5-5


Generated by Claude Code

yujiyokoo and others added 2 commits November 12, 2026 12:36
CI failed in three jobs. Two causes, both in the tests.

The offline jobs have no CipherStash profile. The module-scoped scores
fixture called configure_encryption before the function-scoped
credentials fixture set the fake credentials, and one declaration case
built a Cipher with no credentials fixture. Both now ask for
credentials. They passed locally only because of a ~/.cipherstash
profile.

The live job sets EQL_TEST_DATABASE_URL as a libpq keyword string.
psycopg reads it, but SQLAlchemy's create_engine reads only a URL. A
new helper, sqlalchemy_url, turns either form into a SQLAlchemy URL
for psycopg 3. Both live SQLAlchemy test files use it.

Refs #1341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Optional commit added Value::Null. The fuzz target's match on
(kind, value) had no arm for it, so the fuzz crate no longer compiled.
The target now generates a Null input and expects Scalar::Null back.

stackencrypt-core now depends on zeroize. The fuzz crate's Cargo.lock
did not list it. Nothing keeps that lock in step with the workspace
lock (review thread 1511-F6).

Refs #1341

Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants