Skip to content

test(python): verify the two engine facts the spec depends on (#1384) - #1414

Open
yujiyokoo wants to merge 2 commits into
feat/python-sdk-cifrom
feat/python-sdk-gate-facts
Open

yujiyokoo wants to merge 2 commits into
feat/python-sdk-cifrom
feat/python-sdk-gate-facts

Conversation

@yujiyokoo

Copy link
Copy Markdown
Contributor

Summary

The Python SDK spec (#1341) depends on two facts about the engine that nobody had checked. This PR checks both, with a probe for each, and records the results in languages/python/docs/gate-facts/. Both facts hold. No SDK code changes, and the normal wheel build is unchanged.

The engine is the Rust crate stack-encrypt. The engine encrypts and decrypts. The extension is the Rust code compiled into the Python package. Python calls the extension, and the extension calls the engine.

  • Fact 1: the extension can read the record the engine returns after decrypting, and find each field's type. The extension needs only the engine's public API.
  • Fact 2: a Ctrl-C (KeyboardInterrupt) during an engine call is raised only after the call finishes. Also, a request object (Pending) that is dropped early wipes the plaintext it contains.

Changes

  • crates/core/tests/gate_frame_reader.rs: the probe for fact 1. A reader written outside the engine walks a record and records each field's type.
  • crates/engine/src/lib.rs and crates/engine/Cargo.toml: the probe for fact 2. A function that blocks with Python's global interpreter lock (GIL) released, behind a new cargo feature, gate-probe. The feature is off by default, so the function is not in a normal wheel.
  • tests/test_gate_keyboard_interrupt.py: sends Ctrl-C during that call. The tests skip themselves when the feature is not built.
  • docs/gate-facts/frame-reader.md and docs/gate-facts/keyboard-interrupt.md: the findings, how to run each probe again, and what was not tested.
  • Cargo.lock: one line, for tokio as an optional dependency of the probe feature.

Verification

All of this was run on macOS 14 (arm64) with Python 3.14.7:

  • cargo test --locked -p stackencrypt-core --test gate_frame_reader: 2 passed.
  • The Ctrl-C probe, built with --features gate-probe: 2 passed. Ctrl-C was sent 0.2 seconds into a 1-second call. The interrupt was raised after 1.01 seconds. As a control, the same signal interrupted a plain Python time.sleep(1.0) after 0.21 seconds.
  • mise run test: 4 passed and 2 skipped. The 2 skipped tests are the probe tests, in a normal build.
  • mise run test:cargo and cargo fmt --check: both pass.
  • cargo clippy -D warnings, with and without the probe feature: passes.
  • pnpm run test:scripts on Node 22: 83 of 83 test files pass.

Not tested:

  • Linux and Windows were not run for the Ctrl-C probe.
  • The Ctrl-C probe blocks on a tokio sleep, not on a real engine call.
  • The wipe in fact 2 comes from reading the source. Nothing was run for it.
  • CI has not run on this branch yet.

Related

Closes #1384. Refs #1341 (the spec). Stacked on #1406.

Review notes

Start with the two files in docs/gate-facts/. Each one says what was checked, how, and what was not.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 909bd36

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 28, 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: 691ac54e-c59a-4eb0-b672-15370826e414

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 and others added 2 commits November 5, 2026 00:17
The spec in #1341 depends on two facts that had not been checked. The
first is that the extension can read the engine's decrypted record
frame with the public stack-encrypt API. The second is the sync
contract: an interrupt waits for a GIL-released call, and a dropped
Pending wipes its plaintext.

Both facts hold. A Sink written outside the engine reads the frame. A
probe function behind an off-by-default cargo feature is sent SIGINT
during the call. The interrupt arrives after the call returns. The wipe
is from source reading only.

The issue's wording of the first fact is corrected: a sealed value's
kind is inside its encryption, so the kind is read after decrypting.

The findings, how to run each probe again, and what was not tested are
in languages/python/docs/.

Refs #1384

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

The codes follow the amendment on #1341.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yujiyokoo
yujiyokoo force-pushed the feat/python-sdk-gate-facts branch from eeab4bd to 909bd36 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 909bd36 · posted from Claude Code · claude-sonnet-5-5 · effort medium
Sources: code-review · claude-sonnet-5-5
Skills: code-review, review-cross-check, review-post

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

Verified against the PR diff and the head file content only; the engine source was not available in this session, so two further candidate findings (frame passthrough round trip, maturin feature merge) were not confirmed and are not posted.

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


Generated by Claude Code

probe = getattr(_engine, "_probe_detached_sleep", None)

pytestmark = [
pytest.mark.skipif(probe is None, reason="built without the gate-probe feature"),

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 Both tests skip unless the wheel is built with the non-default gate-probe feature, so a normal mise run build plus pytest reports green without running them. A later change to the detach or signal behaviour is never caught, and the gate fact rests on one manual macOS run. Build with the feature in a CI job, or state that this is a one-off check.

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


Generated by Claude Code

# The signal was sent at 0.2 s. If the call had been cut short, the
# interrupt would arrive then. It must arrive after the call's full second.
assert raised_after_s >= CALL_MS / 1000

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 This assertion cannot fail on the engine's behaviour. CPython runs Python signal handlers only on the main thread, between bytecodes, so a KeyboardInterrupt cannot be raised while the main thread is inside probe() whether or not the Rust side releases the GIL. raised_after_s >= 1.0 is guaranteed by the interpreter. Say so in the doc, or add a case that would distinguish (for example a call that checks signals itself), and note the real engine path adds network I/O.

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


Generated by Claude Code

Comment on lines +39 to +41
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
pass

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 while ...: pass loop pegs a core for up to 5 seconds, and with a 0.2s timer against a 1s call there is little margin on a loaded runner. If SIGINT is blocked or the timer thread is starved, the test waits the full 5s and fails with "DID NOT RAISE". Use time.sleep(0.01) inside the loop; it still gives Python its signal check.

Source: code-review · 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

Change before merging: the frame reader probe must cover a typed null and a sequence, and the SIGINT timer in the Python test must be cancelled in a finally block. The other points can wait.

Other findings not posted as comments

  • languages/python/tests/test_gate_keyboard_interrupt.py:30: the tests assume SIGINT raises KeyboardInterrupt. If the launcher ignores SIGINT, both tests fail with DID NOT RAISE. Install signal.default_int_handler in a fixture and restore it afterwards.
  • languages/python/tests/test_gate_keyboard_interrupt.py:21: no CI job builds with gate-probe, so the tests always skip in CI. Add a CI step that builds with the feature and fails when the tests skip.
  • languages/python/docs/gate-facts/keyboard-interrupt.md:12: part 2 of fact 2 (a dropped Pending wipes its plaintext) has no executable test. The doc already says so. Add a heap-hygiene test beside packages/stack-encrypt/tests/heap_hygiene.rs.
  • languages/python/crates/engine/src/lib.rs:32: the probe builds a new tokio runtime on each call and panics if the build fails. Optional: keep one runtime in a OnceLock and return a PyResult.
  • languages/python/crates/core/tests/gate_frame_reader.rs:201: the malformed-frame test checks only truncation. Optional: add a case for trailing bytes and an empty buffer.
How this review was made
Agent Model Review type Result
claude claude-opus-5-5 test-gap 2 found, 1 posted
claude claude-opus-5-5 rust 3 found, 2 posted
claude claude-opus-5-5 python 2 found, 0 posted
claude claude-opus-5-5 infracode 0 found, 0 posted
codex gpt-5.6-terra test-gap 1 found, 0 posted
codex gpt-5.6-terra rust 0 found, 0 posted
codex gpt-5.6-terra python 1 found, 1 posted
codex gpt-5.6-terra infracode 0 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 3 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 6 discussion entries.

Ok(())
}

fn typed_null(self, _: FixedType) -> Result<(), ReadError> {

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: typed_null drops the FixedType, and no test walks a typed null, a sequence or a nested map.

Impact: Fact 1 says the extension can find each field's type. A typed null field is recorded as logical: None, physical: "null", so its type is lost. The decrypt kind-mismatch check needs that type. The test record has only flat, non-null scalars, so no test fails if null or sequence frames read wrongly.

Evidence: User::visit writes only scalar and one scalar passthrough. The typed_null and seq methods of Reader run in no test.

Fix: Keep the type in typed_null, and add a record with a typed null and a sequence.

fn typed_null(self, ty: FixedType) -> Result<(), ReadError> {
    self.scalar(ty.logical(), Physical::Null)
}

Then assert that a null Date field reads back with logical type Date, and that each sequence element is read.

Found by 1 model: claude

//! frame and recover each field's value kind, with only the public API of
//! `stack-encrypt`'s `dynamic` module.
//!
//! The record is built with `ValueFrame::of`, the same type and writer

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fix in a follow-up: the comment says ValueFrame::of uses the same writer as dynamic::decrypt, but decrypt writes its record with a plain FrameWriter.

Impact: Fact 1 is about the record that decrypt returns. This probe reads a frame written through the Modeled writer, so it does not test the bytes of a real decrypt result. It also does not test the batch shape, which is a sequence of maps.

Evidence: packages/stack-encrypt/src/dynamic/value_frame.rs:98 uses Modeled::new(writer). packages/stack-encrypt/src/dynamic/record.rs writes the decrypted row with FrameWriter::new and calls ValueFrame::from_written.

Fix: Correct the comment to say "the same type". Also build the test frame the way decrypt does, a map of per-field ValueFrame values and a sequence of such maps, and assert the reader gives the same result.

Found by 1 model: claude



def test_interrupt_waits_for_the_detached_call_to_return():
timer = send_sigint_later()

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: if the probe or an assertion raises an unexpected error, the SIGINT timer stays active.

Impact: The test skips timer.join(). The timer then sends SIGINT to the whole pytest process, which can interrupt a later, unrelated test.

Evidence: Both tests start the timer before code that can fail. They join it only after the expected KeyboardInterrupt.

Fix: Cancel and join the timer in finally in both tests.

timer = send_sigint_later()
try:
    with pytest.raises(KeyboardInterrupt):
        call_then_wait_for_the_interrupt()
finally:
    timer.cancel()
    timer.join()

Found by 1 model: codex

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