Skip to content

feat(python): check what a model's declaration means (#1387) - #1437

Open
yujiyokoo wants to merge 2 commits into
feat/python-sdk-declaration-readerfrom
feat/python-sdk-declaration-meaning
Open

yujiyokoo wants to merge 2 commits into
feat/python-sdk-declaration-readerfrom
feat/python-sdk-declaration-meaning

Conversation

@yujiyokoo

Copy link
Copy Markdown
Contributor

Summary

@stackencrypt.model now checks what a declaration means, at the class statement. It refuses a field type that does not match its marker, a constructor decrypt could not call, and a plan the engine refuses. So a wrong model fails at import, before any database call.

A marker such as TextEq names an EQL type. EQL is CipherStash's encrypted column format in PostgreSQL.

Changes

Decorator checks (_declaration.py), after rule A:

  • declaration_unproducible: the engine cannot produce the marker yet.
  • declaration_type_mismatch: the value type is not exactly the kind's Python type, such as str for TextEq.
  • declaration_not_outermost: the decorator ran below @dataclass, so the class had no constructor yet.
  • declaration_constructor_missing_field: the constructor does not take a field's name, such as with init=False or a Pydantic alias.
  • declaration_ignore_required: an Ignore field has no default.
  • An engine refusal of the plan becomes a DeclarationError with the engine's code, such as stack_encrypt_dynamic_plan.

Engine check. crates/core/src/plan.rs builds the plan frame and runs the engine's plan_with. The EQL resolver is the Go guest's targets.rs, compiled in place through #[path], so both SDKs run one resolver. eql-bindings is a new path dependency. Its lock entries match the EQL workspace's versions.

CI. tests-python.yml also runs on changes to eql-bindings and targets.rs. python-ci.test.mjs checks that the filter selects every #[path] file.

Verification

  • pytest: 195 passed and 2 skipped, on Python 3.14 and 3.9.
  • cargo test, clippy, cargo fmt --check, mypy strict, and pyright are clean.
  • pnpm run test:scripts: 83 of 83 files pass.
  • The python-ci checks fail when the new filter entries are removed.
  • A build without the eql feature stops with the intended compile_error!.

Related

Closes #1387. Part of #1341.

Review notes

  • Start with _check_value_type and _check_constructor, then plan.rs.
  • #[path] means the Python crate must list every dependency of targets.rs. A Go change that adds one fails tests-python.yml at once.
  • Python refuses an unproducible marker before the engine does, as the spec asks.
  • JsonSearch has no Python type in v1, so it fails with declaration_type_mismatch.
  • A passthrough field reaches the engine with no "type" key. The encrypt ticket can add one.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 89830a6

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 Oct 29, 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: 0768fc57-dd6f-4d17-b040-1e3c076a75c7

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

@yujiyokoo
yujiyokoo added this pull request to stack #1520 November 4, 2026 02:58
yujiyokoo and others added 2 commits November 5, 2026 00:19
The decorator now checks each field's type against its marker, the
constructor, and the plan with the engine's own reader. The EQL
resolver is the Go guest's targets.rs, compiled in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A constructor that lacks a field is now its own error, apart from the
decorator order. Pydantic aliases are read from Pydantic. A test covers
a relationship to a class defined later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yujiyokoo
yujiyokoo force-pushed the feat/python-sdk-declaration-meaning branch from 2dc6d3d to 89830a6 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 89830a6 · posted from Claude Code · claude-sonnet-5-5 · effort medium
Sources: code-review (medium, manual diff read) · claude-sonnet-5-5
Skills: code-review, review-cross-check, review-post

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

Existing feedback on the PR (two bot comments, no review threads) raised nothing that overlaps. Findings were checked by reading the code at head; the Pydantic alias case was not executed.


Generated by Claude Code

for field in read:
parameter = parameters.get(field.name)
if field.marker is Ignore:
if parameter is not None and _required(parameter):

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 A required Ignore field on a Pydantic model with an alias (Field(alias="X")) is not refused: the signature lists the alias, so parameters.get(field.name) is None and declaration_ignore_required is skipped. Decrypt then omits the field and Pydantic fails at construction. Look the Ignore field up through _takes' alias logic, or check info.is_required() for Pydantic fields.

Source: code-review (medium) · claude-sonnet-5-5


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 after changes (1 of 8 review job(s) failed)

Two constructor-check cases must change before merging: a Pydantic dataclass init=False field, and a required attrs Ignore field with a private name. Both let a wrong model pass the class statement. The other items are optional and can wait.

A Codex finding said python-ci.test.mjs resolves #[path] from the wrong directory. I dropped it. The recursive walk passes the current directory as dir, so the path resolves from the file's own directory.

Other findings not posted as comments

  • Optional: add tests for three untested branches in _declaration.py. They are a positional-only constructor parameter, the Pydantic validate_by_name and validation_alias branches, and Optional[T] on a Passthrough field.
  • Optional: no test covers the inspect.signature(cls) failure fallback (_declaration.py:404), or Refusal.field in plan.rs.
  • Optional: if the frame encoder in plan.rs:48 returns an error, check panics. pyo3 raises PanicException, which except Exception does not catch. Return a Refusal instead.
  • Optional: #[allow(dead_code)] in lib.rs:25 covers all of targets.rs. Only HOLDS_EQL is unused. Use HOLDS_EQL in a const assertion, or use #[expect(dead_code)].
  • Fix in a follow-up: the re-seed procedure in AGENTS.md:96 (copy the root Cargo.lock, then cargo metadata --offline) no longer gives this lock. The root lock has no eql-bindings dependencies such as schemars. Document that those crates follow packages/eql/Cargo.lock.
  • Optional: lazy_static is 1.5.0 in languages/python/Cargo.lock, but 1.5.1 in the root lock. Pick one rule and write it down.
  • Optional: _takes refuses a Pydantic validation_alias of AliasChoices("email", "Email"), although the constructor takes email by name.
  • Optional: update the comments in .github/dependabot.yml:267 and the header of tests-python.yml to mention eql-bindings and the #[path] check.
How this review was made
Agent Model Review type Result
claude claude-opus-5-5 test-gap 3 found, 0 posted
claude claude-opus-5-5 rust 2 found, 0 posted
claude claude-opus-5-5 python 2 found, 2 posted
claude claude-opus-5-5 infracode 2 found, 0 posted
codex gpt-5.6-terra test-gap 0 found, 0 posted
codex gpt-5.6-terra rust 0 found, 0 posted
codex gpt-5.6-terra python failed
codex gpt-5.6-terra infracode 1 found, 0 posted

Synthesis: claude-sonnet-5-5 merged the findings, removed duplicates and dropped findings it could not confirm in the code. 0 posted finding(s) were raised by two or more models.

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

Stack: position 6 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, 2 linked issue(s) and 4 discussion entries.

# says whether the name is taken too. With extra="allow" the signature
# has **kwargs, and a name that is not taken lands in the extras.
alias = getattr(info, "validation_alias", None) or getattr(info, "alias", None)
if alias is None or alias == name:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Change before merge: _takes accepts a Pydantic dataclass field declared with init=False, but the constructor does not take that field.

Impact: A Pydantic dataclass sets __pydantic_fields__, so _takes uses the Pydantic branch. The field has no alias, so the function returns True and never reads the signature. The class declares with no error. Then decrypt passes the field by name, and construction fails or drops the value. The spec wants declaration_constructor_missing_field at the class statement.

Evidence: _takes returns at the alias is None line without reading parameter. Pydantic's generated signature leaves out init=False fields. A plain-dataclass init=False case is tested. The Pydantic dataclass case is not. A Pydantic dataclass also keeps its config in __pydantic_config__, not model_config.

Fix: When the field has no alias, use the signature as the answer. Read the config from either attribute. Add a Pydantic dataclass case to the init=False tests.

if alias is None or alias == name:
    return _keyword(parameter) or any_keyword
config = getattr(cls, "model_config", None) or getattr(cls, "__pydantic_config__", None) or {}
return bool(config.get("populate_by_name") or config.get("validate_by_name"))

Found by 1 model: claude

if base is not object
)
for field in read:
parameter = parameters.get(field.name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Change before merge: a required Ignore field with an attrs private name is not refused.

Impact: attrs names the constructor argument without the leading underscore. For _cache: Annotated[dict, Ignore] with no default, the parameter is cache. So parameters.get("_cache") returns None, and the declaration_ignore_required check does not run. The class declares with no error. The first decrypt then fails with TypeError: missing 1 required argument.

Evidence: The Ignore branch uses parameter = parameters.get(field.name). The existing test passes only because it uses a public name. The plan-field path handles private names through _takes, so only the Ignore path misses it.

Fix: Look up the parameter by the attrs init name, and add a private-name case to the Ignore test.

def _init_name(cls: type, name: str) -> str:
    for attribute in getattr(cls, "__attrs_attrs__", ()):
        if attribute.name == name:
            return getattr(attribute, "alias", None) or name.lstrip("_")
    return name

parameter = parameters.get(_init_name(cls, field.name))

Found by 1 model: claude

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.

3 participants