Skip to content

feat(store): extract SQL-free praxis-ai-store persisted-state contracts crate - #1279

Open
hexfusion wants to merge 11 commits into
praxis-proxy:mainfrom
hexfusion:feat/1258-store-contracts
Open

hexfusion wants to merge 11 commits into
praxis-proxy:mainfrom
hexfusion:feat/1258-store-contracts

Conversation

@hexfusion

@hexfusion hexfusion commented Sep 21, 2026 •

Copy link
Copy Markdown

Summary

Extract the SQL-free persisted-state contracts out of praxis-ai-apis into a new internal praxis-ai-store crate: the ResponseStore and ConversationItemStore traits, the PersistedStateBackend supertrait binding both, the persisted record types, StoreError, the StateOwner data type, owner-scoped handles, the typed registry, an in-memory backend, and a shared contract-test suite. The crate carries no sqlx or SQL-client TLS in its dependency graph, so a downstream build can hold the persistence interface without its cryptography. apis adopts the crate by re-export, so persisted records, schemas, and the Responses/Conversations API surface do not change. This is the foundational slice of the store-layer split (#1257).

This branch also carries one build fix at the bottom of the stack. make test ran cargo test --workspace with default features (store-postgres only), so the sqlite-backed tests in praxis-test-utils constructed a sqlite ResponseStore against a backend that was never compiled and failed at runtime with a backend-unavailable error. make test now passes STORE_ALL_WORKSPACE_FEATURES (the variable coverage-check already uses) so the store crates build with store-all and cargo feature unification enables sqlite where those tests run, while keeping every workspace crate in the run rather than carving test into a subset of dedicated targets.

Related issue

Closes #1258
Part of #1257.

Validation

The contracts crate is crypto-free: cargo tree -p praxis-ai-store (default and --all-features) shows no sqlx, rustls, ring, or openssl. The SQL backends adopt the moved traits with no schema or record change and pass the shared contract suite, and the in-memory backend passes the same suite. make lint is clean, and make test at this tip is 7111 passed / 0 failed with zero sqlite-unavailable failures. The only non-deterministic reds anywhere are pre-existing server-spawning integration examples (port/AddrInUse), unrelated to this change.

  • Unit tests
  • Integration or functional tests
  • make lint

Checklist

  • I reviewed every changed line and can explain the change.
  • No new user-facing capability: a behavior-preserving extraction plus a build fix.
  • No user-facing behavior or generated-doc change.
  • No performance-sensitive change.
  • Commits are signed and include a Signed-off-by trailer.

Breaking changes

No behavior break. apis re-exports the moved items at their prior paths, so existing consumers compile unchanged. The one rename, OwnerScopedStore to OwnerScopedResponseStore, is aliased in apis. The addition is the new praxis-ai-store crate.

@leseb leseb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2 — Conversations cannot use the owner-scoped registry. registry.rs:33 explicitly defers conversation-item access. OwnerScopedStore only exposes Responses operations, so PR #1279 does not satisfy #1258’s requirement that both capabilities be available through typed, owner-scoped handles.

P2 — In-memory resource IDs have different ownership semantics. memory.rs:44 keys responses and conversations by (owner, id). SQL uses globally unique IDs and rejects another owner reusing an existing ID. The test backend therefore accepts ownership collisions production rejects.

P2 — Response plus approval persistence is not atomic. memory.rs:85 inherits the sequential default implementation. A concurrent delete can run after the response write but before approval insertion, leaving orphaned approval data. It also accepts approvals without a corresponding response, unlike SQL. Override the combined operation under one mutex lock.

P2 — In-memory item writes do not enforce SQL invariants. memory.rs:290 and memory.rs:419 accept orphaned or mismatched-owner items. The transactional method also misses duplicate IDs within the same batch, producing records SQL would reject and roll back.

@praxis-bot praxis-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.

PR Review

Clean extraction -- the trait design, owner-scoped facade, blanket PersistedStateBackend impl, contract test suite, and in-memory reference backend are well-structured. One consistency bug in the in-memory backend.

Comment thread store/src/memory.rs
@hexfusion
hexfusion force-pushed the feat/1258-store-contracts branch from 2277633 to 53f671e Compare September 25, 2026 02:52
Extract the persistence contracts from praxis-ai-apis into a new
publish=false praxis-ai-store crate with no sqlx/TLS in its dependency
graph: the StateOwner identity (from_trusted_parts stays the only
constructor, so the crate cannot mint a request-forged owner), the record
types and StoreError, the ResponseStore and ConversationItemStore traits,
and a PersistedStateBackend supertrait with a blanket impl over any type
implementing both halves.

Part of praxis-proxy#1258 (epic praxis-proxy#1257). The SQL backends adopt these in a follow-up.

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
…t suite

StoreRegistry holds Arc<dyn PersistedStateBackend> so a resolved backend
provably implements both trait halves; a Conversations-requiring caller
can never receive a response-only backend. OwnerScopedStore binds every
request-driven access to one validated owner. The registry stays
transport-free (no praxis_filter); the pipeline binding lives in the
transport layer.

InMemoryStore is a deterministic double for service unit tests: a single
mutex makes the multi-step operations (all-or-nothing approval
consumption, compare-and-swap, create/delete-items-and-sync) atomic, as
the SQL backends achieve with a transaction. The test-support
contract_tests suite is the shared conformance harness the in-memory and
SQL backends run against.

Part of praxis-proxy#1258 (epic praxis-proxy#1257).

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
…fter the response

The transport layer (apis) validates an individual owner component as it
parses trusted headers, before it has all three parts for
from_trusted_parts, so validate_component is now public. The contract
suite writes the owning response before recording its pending approvals,
so a backend with a foreign key from approvals to responses accepts them.

Part of praxis-proxy#1258 (epic praxis-proxy#1257).

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
Depend on praxis-ai-store and re-export its traits, records, owner, and
errors from crate::store and crate root, so existing paths keep compiling.
Remove the moved trait_def and types modules; split StateOwner out of
state_owner.rs (the transport filter and projection stay). The SQL
backends implement the moved traits and the ResponseStoreRegistry becomes
a transport-bound wrapper over the SQL-free StoreRegistry, holding a
combined Arc<dyn PersistedStateBackend> so a resolved backend always has
both halves; the PipelineExtension binding lives here, keeping the store
crate transport-free.

Prove adoption by running the shared contract suite against the SQLite
backend. The conversations-filter store path is unchanged (deferred to

Part of praxis-proxy#1258 (epic praxis-proxy#1257).

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
…d test-support

Nothing in non-test src uses tokio (InMemoryStore is std::sync::Mutex;
the tokio::test macros are covered by the dev-dependency), so remove it
from the normal dependencies to keep tokio out of every downstream
consumer's non-dev closure. Gate pub mod memory behind test-support like
contract_tests, so the test double is not built or exported in the
default build.

Part of praxis-proxy#1258 (epic praxis-proxy#1257).

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
…i-store

The registry that used DashMap moved into praxis-ai-store, so apis no
longer uses dashmap. Remove it from the dependencies and the store
feature; cargo machete flagged it as unused.

Part of praxis-proxy#1258 (epic praxis-proxy#1257).

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
The test target ran cargo test --workspace with default features
(store-postgres only), so sqlite-backed tests in praxis-test-utils
constructed a sqlite ResponseStore against a backend that was never
compiled and failed at runtime with backend 'sqlite' is unavailable.
Add STORE_ALL_WORKSPACE_FEATURES (already used by coverage-check) so
the store crates build with store-all across the workspace run; every
crate still runs, none is dropped.

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
…ants

The reference double diverged from what the SQL backends enforce, so the
shared contract suite could pass a backend the SQL side would reject.

- Key responses globally by id; a colliding id from another owner is
  rejected, not shadowed (SQL PRIMARY KEY (id) + owner-guarded upsert).
- Override persist_response_with_pending_approvals to hold one lock across
  the response and approval writes, so a concurrent delete cannot orphan an
  approval (the default impl locks twice and leaves that window open).
- Reject item writes that orphan, cross owners, or reuse an id within one
  batch (SQL parent-scoped insert + item PRIMARY KEY).
- Expose the conversation-item surface owner-scoped on OwnerScopedStore,
  alongside the responses surface.

Extend the shared contract suite to assert each invariant against both the
in-memory and SQLite backends.

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
Relocate PoolConfig, SslMode, and table-name validation from apis/src/store
into a new backend_config module in the SQL-free praxis-ai-store crate, so
the backend-free build no longer links sqlx or its crypto (sqlx-postgres,
sqlite, hkdf, stringprep, and the prohibited md-5).

The sqlx conversions stay behind with the SQL backends: apply_pool_config in
pool.rs and the SslMode-to-PgSslMode mapping in postgres.rs. The mapping
becomes a free function because both types are foreign once SslMode moves, so
the From impl would violate the orphan rule.

praxis-ai-store stays SQL-free and crypto-free: cargo tree shows no sqlx,
rustls, ring, openssl, or md-5 under default or --all-features.

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
…ession

main landed response-store compression (praxis-proxy#1182) inside the store region this
branch extracts. Carry it across the rebase: add the compression argument to
the SQLite contract-suite constructor call, and point the compression benchmark
at a now-public to_pg_ssl_mode. The From<SslMode> for PgSslMode impl became an
orphan once SslMode moved to praxis-ai-store, so the conversion is a function
the benchmark can call.

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
The workspace gained the praxis-ai-store member, but the container and FIPS
Containerfiles COPY crate dirs selectively and omitted it, so cargo could not
load the workspace manifest in the ubi-image (FIPS) build. Copy store/ into
both, matching each file's pattern (manifest+src for the cached build, whole
dir for the FIPS build).

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
@hexfusion
hexfusion force-pushed the feat/1258-store-contracts branch from 144ca20 to 37af7d7 Compare September 25, 2026 07:21
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.

Extract SQL-free persisted-state contracts and typed registries

3 participants