Skip to content

feat(python): decrypt rows back into models - #1509

Merged
yujiyokoo merged 5 commits into
feat/python-sdk-encryptfrom
feat/python-sdk-decrypt
Nov 13, 2026
Merged

yujiyokoo merged 5 commits into
feat/python-sdk-encryptfrom
feat/python-sdk-decrypt

Conversation

@yujiyokoo

Copy link
Copy Markdown
Contributor

Description:

Summary

Cipher.decrypt now turns rows read from PostgreSQL back into instances of the declared model. EQL (Encrypt Query Language) is CipherStash's set of PostgreSQL types for encrypted values. Each row is a mapping of column names to values, as psycopg's dict_row returns it. All the rows are opened with one key request per 500 sealed values, and the model's own constructor builds each instance.

Changes

  • Python API: Cipher.decrypt(rows) returns a list of instances of the model class. The new DecryptError is a StashError. The README has a new section, "Read the rows back", with a dict_row example.
  • Checks before any key request: a row that is not a mapping is decrypt_row_shape. A missing declared column is decrypt_missing_column. A column value that is not EQL JSON is decrypt_malformed. A value stored under another table or column is decrypt_foreign_row. A passthrough value of the wrong type is an InvalidValueError. Each error names the row. Each error names the field too, except decrypt_row_shape.
  • Checks after the key requests: a value sealed as another type is decrypt_kind_mismatch. An opened date or timestamp that Python cannot hold is decrypt_not_representable. No instance is built when any value fails.
  • Column value forms: an EQL column can be a str, UTF-8 bytes, or a dict that the driver already parsed. Columns the model does not declare are ignored.
  • Keysets: a row sealed under any keyset of the client decrypts. The Go SDK's Client.Open does the same.
  • Errors: stack_encrypt_foreign_keyset and stack_encrypt_context_mismatch are now AuthenticationError.
  • Tests: offline round trips in the core crate, with a fake key source. The live tests now read rows back from postgres:17 for dataclasses, attrs, Pydantic, NamedTuple, and SQLAlchemy models. The credential fixtures are in a new tests/conftest.py.

Verification

  • pytest on Python 3.14 and 3.9: 267 passed, 16 skipped. The skips are the 14 live tests and 2 gate-probe tests.
  • mise run test:cargo: 24 core tests and 5 engine tests passed.
  • clippy, cargo fmt --check, and mypy: no findings.
  • pnpm run test:scripts: 83 of 83 files pass.
  • The live tests have not run on this machine. This machine has no CipherStash credentials. The first run is the live job on this PR.

Related

Closes #1390. Refs #1341 and #1508.

Review notes

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9d5188b

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 2, 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: 4b2e0f97-2039-410d-bf79-b41463e7ed09

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.

@yujiyokoo
yujiyokoo added this pull request to stack #1520 November 4, 2026 02:58
yujiyokoo and others added 5 commits November 5, 2026 00:20
Cipher.decrypt takes the rows psycopg read with dict_row and returns
instances of the declared class, built with its constructor. A sealed
column may be EQL JSON as a str, as UTF-8 bytes, or as a dict. Other
columns are ignored.

Every row is checked before any key request: its shape, its declared
columns, its EQL JSON, and the table and column the JSON's "i" names.
Each passthrough value is checked by exact type, as on encrypt. A
refused row raises the new DecryptError or InvalidValueError, and the
call returns no instance.

The core crate decrypts with the engine's fake key source in its tests,
so the round trip is tested without ZeroKMS. The engine does not say
which field a kind mismatch was in. So decrypt_kind_mismatch names the
field only when the plan has one sealed field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Open rows under the client scope, as Go's Client.Open does. A row
sealed under another keyset of the same client now decrypts.

Map stack_encrypt_foreign_keyset and stack_encrypt_context_mismatch
to AuthenticationError.

Name the row in a malformed-value error, and check for a duplicate
typed null as the scalar path does.

Borrow str and bytes column values, so the EQL text is copied once,
into the engine's frame. Share the frame helpers between encrypt and
decrypt, and move the credential fixtures into conftest.py.

A kind mismatch names its field only when the model has one sealed
field. The engine does not name the field yet. #1508 tracks that.

The README says that a passthrough datetime needs a timestamptz
column, because psycopg reads timestamp as a naive datetime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Encode a str column value with encode_utf8, and use the bytes it
returns. Under abi3-py39, PyString::to_cow encodes the text and then
copies it again, on every Python version. Borrowing the UTF-8 of a str
is in the limited API only from Python 3.10.

Add a test that the engine names the field of a value with only an
identifier, in a model with two sealed fields. So the field of
decrypt_malformed does not depend on #1508.

Move the untouched fixture into conftest.py, so the decrypt and
encrypt tests share one copy.

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

SQLAlchemy finds a relationship's target in the type directly inside
Mapped[...]. It does not look inside Annotated. So a bare relationship()
on Mapped[Annotated[List["Post"], Ignore]] took List["Post"] as the
target, and the first instance failed to configure the mapper. The live
tests on SQLAlchemyUser failed on that.

Naming the target, relationship("Post"), lets the mapper configure.
posts is still not a list under this form. Spec #1341 records that, and
leaves the fix to the SQLAlchemy layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion (#1390)

SQLAlchemy reads a relationship's target and collection type from the
type directly inside Mapped[...]. It does not look inside Annotated.
So Ignore works on a relationship to one object, and a collection
marked Ignore maps as a single object.

ADR-0001 records this in an amendment. The README shows a collection
left without an annotation, one object marked Ignore, and the
Optional form, which must name its target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yujiyokoo
yujiyokoo force-pushed the feat/python-sdk-decrypt branch from e0ddde6 to 9d5188b Compare November 4, 2026 15:24
@yujiyokoo
yujiyokoo marked this pull request as ready for review November 10, 2026 00:40
@yujiyokoo
yujiyokoo requested a review from a team as a code owner November 10, 2026 00:40

@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 9d5188b · posted from Claude Code · model ID claude-sonnet-5-5 · effort medium
Sources: code-review · model unstated
Skills: code-review, review-cross-check, review-post

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

  • languages/python/tests/test_encrypt_live.py:170 minor The SQLAlchemy test model declares posts: Mapped[Annotated[List["Post"], Ignore]], which the README and the ADR amendment in this PR say not to do (SQLAlchemy then maps one object, not a list). This is likely behind the two failing live tests in the Blacksmith comment (comment). Use the documented unannotated posts = relationship("Post") and test that form.

Not verified at head, so not posted: partial construction when a model constructor raises on row N (all-or-nothing docstring), the double JSON parse of EQL payloads, and duplicated Refusal construction.

Source: code-review · model unstated (Claude Code)


Generated by Claude Code

@cipherstash-bot cipherstash-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Recommendation: 🟢 merge as it is (2 of 8 review job(s) failed)

infracode — claude (claude-opus-5-5)

This pull request has no infrastructure-as-code changes. It changes no Terraform, CloudFormation, mise task, workflow or observability config, so this review has no findings. The one manifest change, in languages/python/crates/core/Cargo.toml, moves serde_json from a dev dependency to a normal dependency and enables the test-support feature of stack-kms. That feature has no dependencies, so Cargo.lock does not need to change.


python — claude (claude-opus-5-5)

Nothing must change before merging. The two points below can wait. The earlier review already covers SQL NULL handling, the field name on decrypt_kind_mismatch, conversion errors in python_value, and the failing SQLAlchemy live tests, so this review does not repeat them.

Severity Finding Location
Fix in a follow-up Every exception from json.dumps and from the passthrough read becomes decrypt_malformed crates/engine/src/decrypt.rs:158
Optional A single dict passed to decrypt gives a misleading dict_row message python/stackencrypt/_cipher.py:90
Optional encrypt takes Sequence[object] though Cipher is generic over T python/stackencrypt/_cipher.py:18

I did not run mypy or pytest. This checkout has no built .venv.


rust — claude (claude-opus-5-5)

No Rust finding needs to change before merging. The read path has no unsafe, no unwrap on caller input in library code, and the GIL is released only around the engine call. The three comments below are about error reporting and can wait for a follow-up.

The discussion already covers SQL NULL, the unnamed field of decrypt_kind_mismatch, and python_value errors. This review does not repeat them. cargo is not installed here, so clippy and the tests did not run.

Severity Finding Location
follow-up Engine-found decrypt_malformed and decrypt_kind_mismatch do not name the row languages/python/crates/core/src/decrypt.rs:221
minor A str with a lone surrogate raises a raw UnicodeEncodeError languages/python/crates/engine/src/decrypt.rs:146
minor KeyboardInterrupt and MemoryError become decrypt_malformed languages/python/crates/engine/src/decrypt.rs:157

test-gap — claude (claude-opus-5-5)

Nothing here needs to change before merging. The four gaps below are refusal or error branches in the new decrypt code that no test runs. You can add these tests now or in a follow-up.

I checked the later pull requests in the stack (#1510 to #1582). #1513 adds the tests for decrypt_not_representable, and #1511 adds the round trip for every producible sealed type. So this review does not report those. It also does not repeat what the earlier review raised: NULL handling, the docstring for decrypt_kind_mismatch, and the python_value error wrapping.


infracode — codex (gpt-5.6-terra)

This PR has no infrastructure-as-code changes to review.


rust — codex (gpt-5.6-terra)

I found no new Rust issues in this diff. The existing discussion already covers the current decrypt-path concerns.


Other findings not posted as comments

  • languages/python/crates/core/src/decrypt.rs:231 — Optional: no test covers decrypt_kind_mismatch on a plan with two or more sealed fields, where field is None.
  • languages/python/crates/engine/src/decrypt.rs:66 — Optional: no test checks that a row that is a Mapping but not a dict is accepted.
How this review was made
Agent Model Review type Result
claude claude-opus-5-5 test-gap 4 found, 2 posted
claude claude-opus-5-5 rust 3 found, 3 posted
claude claude-opus-5-5 python 3 found, 3 posted
claude claude-opus-5-5 infracode 0 found, 0 posted
codex gpt-5.6-terra test-gap failed
codex gpt-5.6-terra rust 0 found, 0 posted
codex gpt-5.6-terra python failed
codex gpt-5.6-terra infracode 0 found, 0 posted

Plain language: the reader test did not run; only the word-and-shape check was applied.

Stack: position 9 of 15 (#1405, #1406, #1414, #1415, #1433, #1437, #1453, #1499, *️⃣ #1509, #1510, #1511, #1513, #1516, #1517, #1582). *️⃣ marks this pull request.

Context loaded: the description, 3 linked issue(s) and 7 discussion entries.

@yujiyokoo
yujiyokoo merged commit dffd0f5 into feat/python-sdk-tracer-adr Nov 13, 2026
40 checks passed
@yujiyokoo
yujiyokoo deleted the feat/python-sdk-decrypt branch November 13, 2026 03:22
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.

3 participants