From 621415cc47b537874d6586858bb4d442f2a2ac28 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 7 Oct 2026 16:03:00 +0000 Subject: [PATCH] =?UTF-8?q?Contract-suite=20scaffold:=20alkstore-contract-?= =?UTF-8?q?suite=20crate=20+=20ADR-022=20(suite=20layout=20decision),=20en?= =?UTF-8?q?gine=20dev-dep=20edges=20(ADR-017=20=C2=A74.2=20discharged,=20A?= =?UTF-8?q?DR-012=20=C2=A72=20mirror)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 11 + Cargo.toml | 1 + alkstore-contract-suite/Cargo.toml | 14 + alkstore-contract-suite/src/factory.rs | 44 +++ alkstore-contract-suite/src/lib.rs | 40 ++ alkstore-contract-suite/src/properties.rs | 276 +++++++++++++ alkstore-contract-suite/src/version_stamp.rs | 41 ++ .../tests/suite_harness.rs | 369 ++++++++++++++++++ alkstore-postgres/Cargo.toml | 5 +- alkstore-sqlite/Cargo.toml | 5 +- docs/architecture/README.md | 3 +- .../decisions/017-contract-versioning.md | 5 + .../decisions/022-contract-suite-layout.md | 147 +++++++ docs/architecture/overview.md | 3 +- tasks/contract-suite-scaffold.md | 71 +++- 15 files changed, 1028 insertions(+), 7 deletions(-) create mode 100644 alkstore-contract-suite/Cargo.toml create mode 100644 alkstore-contract-suite/src/factory.rs create mode 100644 alkstore-contract-suite/src/lib.rs create mode 100644 alkstore-contract-suite/src/properties.rs create mode 100644 alkstore-contract-suite/src/version_stamp.rs create mode 100644 alkstore-contract-suite/tests/suite_harness.rs create mode 100644 docs/architecture/decisions/022-contract-suite-layout.md diff --git a/Cargo.lock b/Cargo.lock index e86c9ed..2e52a45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,11 +12,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "alkstore-contract-suite" +version = "0.1.0" +dependencies = [ + "alkstore", + "serde_json", + "tokio", +] + [[package]] name = "alkstore-postgres" version = "0.1.0" dependencies = [ "alkstore", + "alkstore-contract-suite", "deadpool-postgres", "tokio", "tokio-postgres", @@ -27,6 +37,7 @@ name = "alkstore-sqlite" version = "0.1.0" dependencies = [ "alkstore", + "alkstore-contract-suite", "rusqlite", ] diff --git a/Cargo.toml b/Cargo.toml index 5816a33..60468df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "alkstore", "alkstore-sqlite", "alkstore-postgres", + "alkstore-contract-suite", ] resolver = "3" diff --git a/alkstore-contract-suite/Cargo.toml b/alkstore-contract-suite/Cargo.toml new file mode 100644 index 0000000..134f7a9 --- /dev/null +++ b/alkstore-contract-suite/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "alkstore-contract-suite" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +alkstore = { version = "0.1", path = "../alkstore" } +serde_json = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } \ No newline at end of file diff --git a/alkstore-contract-suite/src/factory.rs b/alkstore-contract-suite/src/factory.rs new file mode 100644 index 0000000..e2c0a0b --- /dev/null +++ b/alkstore-contract-suite/src/factory.rs @@ -0,0 +1,44 @@ +//! The factory abstraction the suite is parameterized over (ADR-022). +//! +//! One shape, deliberately minimal: produce a fresh store over an +//! isolated backing store, plus teardown. Engines implement this in +//! their dev-dependency wiring — a fresh file (SQLite) or fresh schema +//! (Postgres) per call, so properties never observe one another's +//! rows. +//! +//! The factory shape may evolve as engines adopt the suite (wave 3/4 +//! feedback) — ADR-022 records the *layout* decision, not a frozen +//! API. Changes here are test-side non-events (ADR-017 §2 class 4: +//! the suite tests the contract, it is not contract surface). + +use alkstore::{BoxedFuture, Result, Store}; + +/// Produces fresh, isolated [`Store`]s for the suite's properties. +/// +/// Implementers — engine crates, in their dev-dependency wiring — +/// yield a [`Box`](Store) over an isolated backing store (a +/// fresh file or fresh schema per call, so a property's rows never +/// outlive its run into another's) plus a teardown disposing of the +/// backing store. Implementations must be `Send + Sync` so a factory +/// can be shared across property runs. +/// +/// Rows call [`StoreFactory::open`], drive their assertions, and call +/// [`StoreFactory::teardown`] before returning; a rollback-less early +/// exit (a failing assertion panics the row) is the caller's harness +/// concern, which is why teardown is also required to be safe to run +/// against an abandoned store (drop, close, delete). +/// +/// The async surface rides the same boxed-future posture as the +/// contract traits (`BoxedFuture`), so implementers need no macros. +pub trait StoreFactory: Send + Sync { + /// Open a fresh store over an isolated backing store. + fn open(&self) -> BoxedFuture<'_, Result>>; + + /// Tear down the backing store behind this factory instance. + /// + /// Idempotent per instance. The engine owns what teardown means — + /// deleting a temp file, dropping a schema, closing a pool — and + /// owns the isolated-ness guarantee (no two `open` calls share + /// rows). + fn teardown(&self) -> BoxedFuture<'_, Result<()>>; +} diff --git a/alkstore-contract-suite/src/lib.rs b/alkstore-contract-suite/src/lib.rs new file mode 100644 index 0000000..8cb55ae --- /dev/null +++ b/alkstore-contract-suite/src/lib.rs @@ -0,0 +1,40 @@ +//! alkstore-contract-suite — the contract suite crate (ADR-022). +//! +//! The suite is the [verification +//! backlog](../../docs/architecture/core-contract.md) property set +//! parameterized over a [`StoreFactory`] — one normative owner per +//! property (the one-owner rule, ADR-012 §2 applied to test +//! artifacts); each engine crate consumes the suite as a +//! *dev*-dependency and drives its rows against its own factory. The +//! crate is internal and unpublished (`publish = false`, depends on +//! `alkstore` only); ADR-017 §4.2's "layout decided at implementation" +//! deferral is discharged by +//! [ADR-022](../../docs/architecture/decisions/022-contract-suite-layout.md). +//! +//! # Row structure +//! +//! Each backlog row is one named, independently-runnable property — a +//! public async function taking `&dyn StoreFactory` — with a +//! **version stamp** per the suite's [version-stamp +//! convention](version_stamp): a doc-comment line citing the contract +//! change (ADR §) that added the row (ADR-017 §4.2's no-silent-change +//! discipline applied to the suite itself). Wave 3/4 engine tasks run +//! the existing rows against their engines; wave 5 fills the +//! cross-engine equivalence rows. +//! +//! # Crate posture +//! +//! This is a test-side artifact (ADR-001 §4's posture), not contract +//! surface: rows assert with panics on violation — a failing row is a +//! test failure naming the entry point and expectation — so the +//! family-wide no-panic rule is relaxed here by the crate's purpose. +//! Suite-side changes are contract-non-events (ADR-017 §2 class 4: +//! the suite tests the contract, it is not the contract); a row added +//! for a *contract* addition is stamped with that addition's ADR. + +pub mod factory; +pub mod properties; +pub mod version_stamp; + +pub use factory::StoreFactory; +pub use properties::name_validation_rejects_empty_and_reserved; diff --git a/alkstore-contract-suite/src/properties.rs b/alkstore-contract-suite/src/properties.rs new file mode 100644 index 0000000..46db9cd --- /dev/null +++ b/alkstore-contract-suite/src/properties.rs @@ -0,0 +1,276 @@ +//! The suite's property rows — one named, independently-runnable +//! property per backlog row, version-stamped per the +//! [convention](crate::version_stamp). +//! +//! Assertion posture: rows panic with a label naming the entry point +//! and the broken expectation — a failing row is a test failure, which +//! is the suite's purpose as a test-side artifact (relaxing the +//! family-wide no-panic rule here only). + +use serde_json::json; + +use alkstore::{BoxedFuture, Error, RESERVED_PREFIX, Result, validate_shared_name}; + +use crate::factory::StoreFactory; + +fn expect_invalid(label: &str, out: alkstore::Result) { + match out { + Err(Error::InvalidName { .. }) => {} + Err(other) => { + panic!("{label}: expected `InvalidName`, got {other:?} — entry-point validation broke") + } + Ok(_) => panic!("{label}: expected `InvalidName` rejection, got Ok"), + } +} + +fn expect_reserved(label: &str, out: alkstore::Result) { + match out { + Err(Error::ReservedName { .. }) => {} + Err(other) => { + panic!("{label}: expected `ReservedName`, got {other:?} — entry-point validation broke") + } + Ok(_) => panic!("{label}: expected `ReservedName` rejection, got Ok"), + } +} + +fn expect_ok(label: &str, out: alkstore::Result) { + if let Err(other) = out { + panic!("{label}: expected success (no validation error), got {other:?}"); + } +} + +/// **Exemplar row** — name validation at every entry point, +/// auto-commit and tx paths alike. +/// +/// Every name-bearing entry point on the [`Store`](alkstore::Store) +/// surface — and every name-bearing `*_tx` twin on the +/// [`TxHandle`](alkstore::TxHandle) — rejects, before any engine round +/// trip: the empty (or whitespace-only) name with +/// [`Error::InvalidName`], and the reserved-prefix name +/// (`__alkstore_...`) with [`Error::ReservedName`], on the +/// shared-namespace kinds — channels, streams, queues, outboxes, +/// locks, schedule names, and `schedule()`'s queue argument. +/// Consumer-local identifiers take the non-empty rule only: `owner` +/// reserved-prefixed is legal, `owner` empty is not. The tx path is +/// exercised through `begin_tx` and `with_tx` (the provided wrapper): +/// validation must not be bypassable by the commit-atomic path. +/// +/// Contract stamp: ADR-008 §4 (reserved namespace + entry-point +/// validation); ADR-021 §3 (the schedule queue argument); ADR-015 §2 +/// (the keyed publish's non-empty-key rule). +/// +/// Engine-independent, pure contract: runnable against any factory. +/// The happy-path spot-checks prove the rejections are the +/// validation's doing, not a store-wide failure — valid names +/// construct handles without a validation error, and a +/// reserved-prefixed *local* identifier is accepted. +pub async fn name_validation_rejects_empty_and_reserved(factory: &dyn StoreFactory) { + let store = factory.open().await.expect("factory opens a store"); + + // Auto-commit paths — empty names. + expect_invalid("notify(empty channel)", store.notify("", json!(null)).await); + expect_invalid("listen(empty channel)", store.listen("").await); + expect_invalid("stream(empty name)", store.stream("").await); + expect_invalid( + "queue(empty name)", + store.queue("", alkstore::QueueOpts::default()).await, + ); + expect_invalid("outbox(empty name)", store.outbox("").await); + expect_invalid( + "try_lock(empty name)", + store.try_lock("", "worker", 60).await, + ); + expect_invalid( + "try_lock(empty owner)", + store.try_lock("lock", "", 60).await, + ); + expect_invalid( + "schedule(empty name)", + store + .schedule("", "@every 1s", "work", json!(null), Default::default()) + .await, + ); + expect_invalid( + "schedule(empty queue argument)", + store + .schedule("tick", "@every 1s", "", json!(null), Default::default()) + .await, + ); + + // Auto-commit paths — reserved-prefix names. + expect_reserved( + "notify(reserved channel)", + store.notify(RESERVED_PREFIX, json!(null)).await, + ); + expect_reserved( + "listen(reserved channel)", + store.listen(RESERVED_PREFIX).await, + ); + expect_reserved("stream(reserved name)", store.stream(RESERVED_PREFIX).await); + expect_reserved( + "queue(reserved name)", + store + .queue(RESERVED_PREFIX, alkstore::QueueOpts::default()) + .await, + ); + expect_reserved("outbox(reserved name)", store.outbox(RESERVED_PREFIX).await); + expect_reserved( + "try_lock(reserved name)", + store.try_lock(RESERVED_PREFIX, "worker", 60).await, + ); + expect_reserved( + "schedule(reserved name)", + store + .schedule( + RESERVED_PREFIX, + "@every 1s", + "work", + json!(null), + Default::default(), + ) + .await, + ); + expect_reserved( + "schedule(reserved queue argument)", + store + .schedule( + "tick", + "@every 1s", + RESERVED_PREFIX, + json!(null), + Default::default(), + ) + .await, + ); + + // Happy-path spot-checks: valid names pass validation; a + // reserved-prefixed owner (consumer-local identifier) is legal. + expect_ok("stream(valid name)", store.stream("work").await); + expect_ok( + "queue(valid name)", + store.queue("work", alkstore::QueueOpts::default()).await, + ); + expect_ok( + "try_lock(reserved-prefixed owner is legal)", + store.try_lock("lock", RESERVED_PREFIX, 60).await, + ); + + // Tx paths — the twins validate identically (begin_tx route). + let mut tx = store.begin_tx().await.expect("begin_tx opens"); + expect_invalid( + "enqueue_tx(empty queue)", + tx.enqueue_tx("", Default::default(), json!(null)).await, + ); + expect_reserved( + "enqueue_tx(reserved queue)", + tx.enqueue_tx(RESERVED_PREFIX, Default::default(), json!(null)) + .await, + ); + expect_invalid( + "publish_tx(empty stream)", + tx.publish_tx("", json!(null)).await, + ); + expect_reserved( + "publish_tx(reserved stream)", + tx.publish_tx(RESERVED_PREFIX, json!(null)).await, + ); + expect_invalid( + "notify_tx(empty channel)", + tx.notify_tx("", json!(null)).await, + ); + expect_reserved( + "notify_tx(reserved channel)", + tx.notify_tx(RESERVED_PREFIX, json!(null)).await, + ); + expect_invalid( + "publish_with_key_tx(empty stream)", + tx.publish_with_key_tx("", None, json!(null)).await, + ); + expect_reserved( + "publish_with_key_tx(reserved stream)", + tx.publish_with_key_tx(RESERVED_PREFIX, None, json!(null)) + .await, + ); + expect_invalid( + "publish_with_key_tx(empty key)", + tx.publish_with_key_tx("work", Some(String::new()), json!(null)) + .await, + ); + expect_invalid( + "publish_with_key_tx(whitespace key)", + tx.publish_with_key_tx("work", Some(" ".to_string()), json!(null)) + .await, + ); + expect_invalid( + "save_offset_tx(empty stream)", + tx.save_offset_tx("", "consumer", 0).await, + ); + expect_reserved( + "save_offset_tx(reserved stream)", + tx.save_offset_tx(RESERVED_PREFIX, "consumer", 0).await, + ); + expect_invalid( + "save_offset_tx(empty consumer)", + tx.save_offset_tx("work", "", 0).await, + ); + expect_invalid("get_job_tx(empty queue)", tx.get_job_tx("", 1).await); + expect_reserved( + "get_job_tx(reserved queue)", + tx.get_job_tx(RESERVED_PREFIX, 1).await, + ); + expect_invalid( + "get_offset_tx(empty stream)", + tx.get_offset_tx("", "consumer").await, + ); + expect_reserved( + "get_offset_tx(reserved stream)", + tx.get_offset_tx(RESERVED_PREFIX, "consumer").await, + ); + expect_invalid( + "read_since_tx(empty stream)", + tx.read_since_tx("", 0, 10).await, + ); + expect_reserved( + "read_since_tx(reserved stream)", + tx.read_since_tx(RESERVED_PREFIX, 0, 10).await, + ); + expect_invalid( + "read_from_consumer_tx(empty stream)", + tx.read_from_consumer_tx("", "consumer", 10).await, + ); + expect_reserved( + "read_from_consumer_tx(reserved stream)", + tx.read_from_consumer_tx(RESERVED_PREFIX, "consumer", 10) + .await, + ); + expect_invalid( + "read_from_consumer_tx(empty consumer)", + tx.read_from_consumer_tx("work", "", 10).await, + ); + expect_invalid( + "outbox_enqueue_tx(empty outbox)", + tx.outbox_enqueue_tx("", Default::default(), json!(null)) + .await, + ); + expect_reserved( + "outbox_enqueue_tx(reserved outbox)", + tx.outbox_enqueue_tx(RESERVED_PREFIX, Default::default(), json!(null)) + .await, + ); + + // Tx path via `with_tx` (the provided wrapper): a validation error + // inside the closure propagates as the wrapper's `Err` (and the + // disposition rolls back) — the commit-atomic path cannot bypass + // validation either. + let result = store + .with_tx(Box::new(|tx| { + Box::pin(async move { + validate_shared_name("")?; + tx.notify_tx("work", json!(null)).await + }) as BoxedFuture<'_, Result<()>> + })) + .await; + expect_invalid("with_tx(inner empty-name rejection)", result); + + factory.teardown().await.expect("factory teardown"); +} diff --git a/alkstore-contract-suite/src/version_stamp.rs b/alkstore-contract-suite/src/version_stamp.rs new file mode 100644 index 0000000..fe178bb --- /dev/null +++ b/alkstore-contract-suite/src/version_stamp.rs @@ -0,0 +1,41 @@ +//! The version-stamp convention (ADR-017 §4.2, adopted here as the +//! suite's row discipline — ADR-022). +//! +//! Each property row is version-stamped with the contract change that +//! added it — ADR-017 §4.2's requirement that makes the suite +//! auditable against the core crate's changelog (whose entries cite +//! their ADRs by §2's no-silent-change rule). +//! +//! # The convention +//! +//! A row's stamp is a doc-comment list item beginning +//! **`Contract stamp:`** on the row's doc text, citing the ADR § (or +//! the backlog row) that pinned the behavior being tested: +//! +//! ```text +//! /// +//! /// +//! /// Contract stamp: ADR-008 §4 (reserved namespace, entry-point +//! /// validation); ADR-021 §3 (the schedule queue argument). +//! pub async fn row_name(factory: &dyn StoreFactory) { ... } +//! ``` +//! +//! Rules: +//! +//! - The stamp cites the ADR § that pins the property — never a bare +//! "v1" or a date. A row may carry multiple stamps when it tests +//! several changes' behavior together (the exemplar row does). +//! - A row added to pin a new contract addition is stamped with that +//! addition's ADR; a row whose property predates the suite is +//! stamped with the ADR that pinned the behavior it tests. +//! - When a later contract change *amends* a row's property, the +//! row's stamp list gains the amending ADR (rows accumulate stamps; +//! the ADRs' own texts record the row history). +//! - `Contract stamp:` is greppable: `grep -r "Contract stamp:" +//! alkstore-contract-suite/` lists the suite's stamp inventory. +//! +//! Version 0 of the suite: this module *is* the convention's +//! normative statement; every later row cites its stamp against it. + +/// The doc-comment marker introducing a row's version stamp. +pub const STAMP_MARKER: &str = "Contract stamp:"; diff --git a/alkstore-contract-suite/tests/suite_harness.rs b/alkstore-contract-suite/tests/suite_harness.rs new file mode 100644 index 0000000..d356661 --- /dev/null +++ b/alkstore-contract-suite/tests/suite_harness.rs @@ -0,0 +1,369 @@ +//! The harness: runs the suite's property rows against a supplied +//! [`StoreFactory`]. This test target proves the harness end-to-end +//! with a trivial in-crate mock store — the acceptance criterion's +//! "exemplar property green against a trivial in-crate mock" — and is +//! the shape each engine crate's dev-dependency wiring copies. + +use std::time::Duration; + +use serde_json::json; + +use alkstore::{ + BoxedFuture, Delivery, EnqueueOpts, Error, EventReceiver, Job, JobHandle, Lock, Outbox, Queue, + QueueOpts, Result, Schedule, StopToken, Store, StreamEvent, StreamHandle, TxHandle, Wake, + WakeReceiver, +}; +use alkstore_contract_suite::StoreFactory; +use alkstore_contract_suite::properties::name_validation_rejects_empty_and_reserved; + +/// Trivial mock `TxHandle` — validation is the *engine's* job in real +/// engines; the mock leans on the shared helpers exactly as an engine +/// would, at each entry point. +struct MockTx; + +impl TxHandle for MockTx { + fn enqueue_tx<'a>( + &'a mut self, + queue: &str, + _opts: EnqueueOpts, + _payload: serde_json::Value, + ) -> BoxedFuture<'a, Result> { + let r = alkstore::validate_shared_name(queue).map(|_| 1); + Box::pin(std::future::ready(r)) + } + fn publish_tx<'a>( + &'a mut self, + stream: &str, + _payload: serde_json::Value, + ) -> BoxedFuture<'a, Result> { + let r = alkstore::validate_shared_name(stream).map(|_| 1); + Box::pin(std::future::ready(r)) + } + fn publish_with_key_tx<'a>( + &'a mut self, + stream: &str, + key: Option, + _payload: serde_json::Value, + ) -> BoxedFuture<'a, Result> { + let r = alkstore::validate_shared_name(stream).and_then(|_| match key { + Some(k) if k.trim().is_empty() => Err(Error::InvalidName { name: k }), + _ => Ok(1), + }); + Box::pin(std::future::ready(r)) + } + fn notify_tx<'a>( + &'a mut self, + channel: &str, + _payload: serde_json::Value, + ) -> BoxedFuture<'a, Result<()>> { + Box::pin(std::future::ready(alkstore::validate_shared_name(channel))) + } + fn save_offset_tx<'a>( + &'a mut self, + stream: &str, + consumer: &str, + _offset: i64, + ) -> BoxedFuture<'a, Result<()>> { + let r = alkstore::validate_shared_name(stream) + .and_then(|_| alkstore::validate_local_name(consumer)); + Box::pin(std::future::ready(r)) + } + fn get_job_tx<'a>( + &'a mut self, + queue: &str, + _job_id: i64, + ) -> BoxedFuture<'a, Result>> { + let r = alkstore::validate_shared_name(queue).map(|_| None); + Box::pin(std::future::ready(r)) + } + fn get_offset_tx<'a>( + &'a mut self, + stream: &str, + _consumer: &str, + ) -> BoxedFuture<'a, Result> { + let r = alkstore::validate_shared_name(stream).map(|_| 0); + Box::pin(std::future::ready(r)) + } + fn read_since_tx<'a>( + &'a mut self, + stream: &str, + _offset: i64, + _limit: i64, + ) -> BoxedFuture<'a, Result>> { + let r = alkstore::validate_shared_name(stream).map(|_| Vec::new()); + Box::pin(std::future::ready(r)) + } + fn read_from_consumer_tx<'a>( + &'a mut self, + stream: &str, + consumer: &str, + _limit: i64, + ) -> BoxedFuture<'a, Result>> { + let r = alkstore::validate_shared_name(stream) + .and_then(|_| alkstore::validate_local_name(consumer)) + .map(|_| Vec::new()); + Box::pin(std::future::ready(r)) + } + fn outbox_enqueue_tx<'a>( + &'a mut self, + outbox: &str, + _opts: EnqueueOpts, + _payload: serde_json::Value, + ) -> BoxedFuture<'a, Result> { + let r = alkstore::validate_shared_name(outbox).map(|_| 1); + Box::pin(std::future::ready(r)) + } + fn commit(self: Box) -> BoxedFuture<'static, Result<()>> { + Box::pin(std::future::ready(Ok(()))) + } +} + +struct MockQueue; + +impl Queue for MockQueue { + fn name(&self) -> &str { + "mock" + } + fn enqueue<'a>( + &'a self, + _payload: serde_json::Value, + _opts: EnqueueOpts, + ) -> BoxedFuture<'a, Result> { + Box::pin(std::future::ready(Ok(1))) + } + fn claim_one<'a>( + &'a self, + _worker_id: &str, + ) -> BoxedFuture<'a, Result>>> { + Box::pin(std::future::ready(Ok(None))) + } + fn claim_batch<'a>( + &'a self, + _worker_id: &str, + _n: i64, + ) -> BoxedFuture<'a, Result>>> { + Box::pin(std::future::ready(Ok(Vec::new()))) + } + fn ack_batch<'a>(&'a self, _ids: &[i64]) -> BoxedFuture<'a, Result> { + Box::pin(std::future::ready(Ok(0))) + } + fn cancel<'a>(&'a self, _job_id: i64) -> BoxedFuture<'a, Result> { + Box::pin(std::future::ready(Ok(false))) + } + fn get_job<'a>(&'a self, _job_id: i64) -> BoxedFuture<'a, Result>> { + Box::pin(std::future::ready(Ok(None))) + } + fn sweep_expired<'a>(&'a self) -> BoxedFuture<'a, Result> { + Box::pin(std::future::ready(Ok(0))) + } +} + +struct MockStream; + +impl StreamHandle for MockStream { + fn name(&self) -> &str { + "mock" + } + fn publish<'a>(&'a self, _payload: serde_json::Value) -> BoxedFuture<'a, Result> { + Box::pin(std::future::ready(Ok(1))) + } + fn publish_with_key<'a>( + &'a self, + _key: Option, + _payload: serde_json::Value, + ) -> BoxedFuture<'a, Result> { + Box::pin(std::future::ready(Ok(1))) + } + fn read_since<'a>( + &'a self, + _offset: i64, + _limit: i64, + ) -> BoxedFuture<'a, Result>> { + Box::pin(std::future::ready(Ok(Vec::new()))) + } + fn read_from_consumer<'a>( + &'a self, + _consumer: &str, + _limit: i64, + ) -> BoxedFuture<'a, Result>> { + Box::pin(std::future::ready(Ok(Vec::new()))) + } + fn save_offset<'a>(&'a self, _consumer: &str, _offset: i64) -> BoxedFuture<'a, Result<()>> { + Box::pin(std::future::ready(Ok(()))) + } + fn get_offset<'a>(&'a self, _consumer: &str) -> BoxedFuture<'a, Result> { + Box::pin(std::future::ready(Ok(0))) + } + fn trim_to<'a>(&'a self, _horizon: i64) -> BoxedFuture<'a, Result> { + Box::pin(std::future::ready(Ok(0))) + } + fn subscribe<'a>(&'a self, _consumer: &str) -> BoxedFuture<'a, Result>> { + Box::pin(std::future::ready(Err(Error::Database("mock".into())))) + } +} + +struct MockWakeReceiver; + +impl WakeReceiver for MockWakeReceiver { + fn recv<'a>(&'a mut self) -> BoxedFuture<'a, Option> { + Box::pin(std::future::ready(None)) + } + fn try_recv(&mut self) -> Result> { + Ok(None) + } + fn recv_timeout<'a>(&'a mut self, _timeout: Duration) -> BoxedFuture<'a, Result>> { + Box::pin(std::future::ready(Ok(None))) + } +} + +/// `Schedule` is `#[non_exhaustive]` (ADR-017 §3) — the mock cannot +/// struct-construct it, so the happy-path `schedule()` spot-check is +/// delegated to core's own tests; the mock's `schedule()` returns a +/// `Database` stub and the row only asserts its *validation* outcomes +/// (which precede any construction). +struct MockStore; + +impl Store for MockStore { + fn begin_tx(&self) -> BoxedFuture<'_, Result>> { + Box::pin(std::future::ready(Ok( + Box::new(MockTx) as Box + ))) + } + fn notify<'a>( + &'a self, + channel: &str, + _payload: serde_json::Value, + ) -> BoxedFuture<'a, Result<()>> { + Box::pin(std::future::ready(alkstore::validate_shared_name(channel))) + } + fn listen<'a>(&'a self, channel: &str) -> BoxedFuture<'a, Result>> { + let r = alkstore::validate_shared_name(channel) + .map(|_| Box::new(MockWakeReceiver) as Box); + Box::pin(std::future::ready(r)) + } + fn stream<'a>(&'a self, name: &str) -> BoxedFuture<'a, Result>> { + let r = alkstore::validate_shared_name(name) + .map(|_| Box::new(MockStream) as Box); + Box::pin(std::future::ready(r)) + } + fn queue<'a>( + &'a self, + name: &str, + _opts: QueueOpts, + ) -> BoxedFuture<'a, Result>> { + let r = alkstore::validate_shared_name(name).map(|_| Box::new(MockQueue) as Box); + Box::pin(std::future::ready(r)) + } + fn outbox<'a>(&'a self, name: &str) -> BoxedFuture<'a, Result>> { + let r = + alkstore::validate_shared_name(name).map(|_| Box::new(MockOutbox) as Box); + Box::pin(std::future::ready(r)) + } + fn try_lock<'a>( + &'a self, + name: &str, + owner: &str, + _ttl: i64, + ) -> BoxedFuture<'a, Result>>> { + let r = alkstore::validate_shared_name(name) + .and_then(|_| alkstore::validate_local_name(owner)) + .map(|_| None); + Box::pin(std::future::ready(r)) + } + fn schedule<'a>( + &'a self, + name: &str, + _spec: &str, + queue: &str, + _payload: serde_json::Value, + _opts: alkstore::ScheduleOpts, + ) -> BoxedFuture<'a, Result> { + let r = alkstore::validate_shared_name(name) + .and_then(|_| alkstore::validate_shared_name(queue)) + .and_then(|_| { + Err(Error::Database( + "mock: schedule read-back not exercised".into(), + )) + }); + Box::pin(std::future::ready(r)) + } + fn unschedule<'a>(&'a self, _name: &str) -> BoxedFuture<'a, Result> { + Box::pin(std::future::ready(Ok(false))) + } + fn run_schedules<'a>(&'a self, _stop: StopToken) -> BoxedFuture<'a, Result<()>> { + Box::pin(std::future::ready(Ok(()))) + } +} + +struct MockOutbox; + +impl Outbox for MockOutbox { + fn name(&self) -> &str { + "mock" + } + fn enqueue<'a>( + &'a self, + _payload: serde_json::Value, + _opts: EnqueueOpts, + ) -> BoxedFuture<'a, Result> { + Box::pin(std::future::ready(Ok(1))) + } + fn run_once<'a>( + &'a mut self, + _worker_id: &str, + _delivery: &'a mut dyn Delivery, + ) -> BoxedFuture<'a, Result> { + Box::pin(std::future::ready(Ok(false))) + } +} + +/// The mock factory: yields a fresh store over an isolated (in-memory, +/// stateless) backing store. Isolation is trivially true — the mock +/// holds no rows. +struct MockFactory; + +impl StoreFactory for MockFactory { + fn open(&self) -> BoxedFuture<'_, Result>> { + Box::pin(std::future::ready( + Ok(Box::new(MockStore) as Box), + )) + } + fn teardown(&self) -> BoxedFuture<'_, Result<()>> { + Box::pin(std::future::ready(Ok(()))) + } +} + +#[tokio::test] +async fn exemplar_row_green_against_mock_factory() { + let factory = MockFactory; + name_validation_rejects_empty_and_reserved(&factory).await; +} + +/// Happy-path spot-check behind the exemplar row: valid names construct +/// handles — proving the row's rejections are the validation's doing +/// (the mock's `schedule` happy path is a `Database` stub because +/// `Schedule` is `#[non_exhaustive]`; its validation outcomes are what +/// the row asserts). +#[tokio::test] +async fn mock_store_happy_paths_construct() { + let factory = MockFactory; + let store = factory.open().await.unwrap(); + + store.stream("work").await.expect("stream handle"); + store + .queue("work", QueueOpts::default()) + .await + .expect("queue handle"); + store.outbox("work").await.expect("outbox handle"); + + factory.teardown().await.expect("teardown"); +} + +#[test] +fn stamp_marker_and_json_shape() { + assert_eq!( + alkstore_contract_suite::version_stamp::STAMP_MARKER, + "Contract stamp:" + ); + let _ = json!(null); +} diff --git a/alkstore-postgres/Cargo.toml b/alkstore-postgres/Cargo.toml index 4246bec..b21c47b 100644 --- a/alkstore-postgres/Cargo.toml +++ b/alkstore-postgres/Cargo.toml @@ -9,4 +9,7 @@ repository.workspace = true alkstore = { version = "0.1", path = "../alkstore" } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } tokio-postgres = "0.7" -deadpool-postgres = "0.14" \ No newline at end of file +deadpool-postgres = "0.14" + +[dev-dependencies] +alkstore-contract-suite = { path = "../alkstore-contract-suite" } \ No newline at end of file diff --git a/alkstore-sqlite/Cargo.toml b/alkstore-sqlite/Cargo.toml index b973147..edb6107 100644 --- a/alkstore-sqlite/Cargo.toml +++ b/alkstore-sqlite/Cargo.toml @@ -7,4 +7,7 @@ repository.workspace = true [dependencies] alkstore = { version = "0.1", path = "../alkstore" } -rusqlite = { version = "0.40", features = ["bundled"] } \ No newline at end of file +rusqlite = { version = "0.40", features = ["bundled"] } + +[dev-dependencies] +alkstore-contract-suite = { path = "../alkstore-contract-suite" } \ No newline at end of file diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 1773964..53cd5b8 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-10-07 (ADR-021 + third-round follow-through resolved) +last_updated: 2026-10-07 (ADR-022: contract-suite layout resolved) --- # alkstore — Architecture @@ -56,6 +56,7 @@ pending architecture review and OQ resolution. | [019](decisions/019-mechanism-handle-surfaces.md) | Mechanism-handle surfaces — handle traits (`Queue`/`StreamHandle`/`Outbox`/`Lock`/`JobHandle`), `Job`/`Schedule` shapes, `worker_id` identity, core `StopToken` | Accepted | | [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue-option semantics — delay/run_at precedence, relative `expires`, scheduler stamp source, serde_json payload encoding | Accepted | | [021](decisions/021-tx-reads-and-value-shape-fixes.md) | Third review round — tx-read methods on `TxHandle`, `Job.claimed_at`, `schedule()` queue-argument validation, drop = rollback, receiver close/error arms | Accepted | +| [022](decisions/022-contract-suite-layout.md) | Contract-suite layout — shared internal suite crate, properties parameterized over a store factory (discharges ADR-017 §4.2's deferral) | Accepted | ## Open Questions diff --git a/docs/architecture/decisions/017-contract-versioning.md b/docs/architecture/decisions/017-contract-versioning.md index 1a4a343..a3ef07f 100644 --- a/docs/architecture/decisions/017-contract-versioning.md +++ b/docs/architecture/decisions/017-contract-versioning.md @@ -211,6 +211,11 @@ not a runtime descriptor): shared crate, workspace test target, or per-engine module — is a test-side convenience decided at implementation ([ADR-001](001-crate-split.md) §4's posture for test artifacts). + *(Layout resolved 2026-10-07: + [ADR-022](022-contract-suite-layout.md) — the shared internal + `alkstore-contract-suite` crate; engines take it as a + dev-dependency and drive the rows against their own stores via a + `StoreFactory` the suite defines.)* 3. **Engine-crate docs**: each engine's docs state the contract version(s) it implements — the same standing-statement altitude as its deployment posture (ADR-016 §3's carrier 2). diff --git a/docs/architecture/decisions/022-contract-suite-layout.md b/docs/architecture/decisions/022-contract-suite-layout.md new file mode 100644 index 0000000..275f66a --- /dev/null +++ b/docs/architecture/decisions/022-contract-suite-layout.md @@ -0,0 +1,147 @@ +# ADR-022: Contract-suite layout — a shared internal suite crate, properties parameterized over a store factory + +## Status + +Accepted (2026-10-07, Phase 1 wave-1 — discharges +[ADR-017](017-contract-versioning.md) §4.2's "layout decided at +implementation" deferral) + +## Context + +[ADR-017](017-contract-versioning.md) §4.2 matured the verification +backlog ([core-contract.md](../core-contract.md) +§Verification backlog) into the *contract suite* — the pairing +instrument whose version-stamped rows make "this engine implements +contract vN" an auditable claim. What that ADR deliberately deferred +was the suite's *layout*: shared crate, workspace test target, or +per-engine module — "a test-side convenience decided at +implementation ([ADR-001](001-crate-split.md) §4's posture for test +artifacts)." + +The implementation plan decoupled the decision from wave 5 (where the +suite fills with cross-engine equivalence rows) and pulled the +scaffold into wave 1, with the reasoning recorded in +[implementation.md](../../plans/implementation.md): the harness shape — +a `Store`-factory-parameterized property crate — is easier to grow row +by row as engines land than to retrofit onto two finished engines, and +the engine crates' dev-dependency edge existing early means wave 3/4 +tasks *adopt* the harness rather than debate it. + +The candidate layouts, at implementation: + +- **(a) A shared internal suite crate** — `alkstore-contract-suite`, + `publish = false`, depending on core only; each engine crate takes + it as a dev-dependency and drives the rows against its own factory. +- **(b) A workspace test target** — the rows live as integration + tests inside one workspace member (core, or a dedicated tests-only + member), with engines contributing fixtures. +- **(c) Per-engine modules** — each engine crate carries its own copy + of the property set in its own test tree. + +Shape facts that narrow the choice: + +- The rows are parameterized over a *store factory*, not a store: each + property needs a fresh store over an isolated backing store (fresh + file / fresh schema) so rows never observe one another's state — + something a shared fixture alone does not give. +- The rows are *the same text* for both engines — that is the point of + the equivalence pinning ([ADR-012](012-forked-substrate-design.md) + §2: the engines' arithmetic is "tested into equivalence" by the + suite). Two copies of a property is the classic drift surface: one + copy gets strengthened and the other silently lags. +- The suite is a dev-side instrument; nothing in it belongs on any + published artifact's dependency graph (dev-dependency edges only). +- [ADR-017](017-contract-versioning.md) §4.2 also pinned *per-row + version stamps* (the contract change that added the row — the same + no-silent-change discipline §2), and the version-stamp convention + needed a normative owner decided now, since every later row cites it. + +## Decision + +**Option (a): the suite is a shared internal crate — +`alkstore-contract-suite`**, unpublished (`publish = false`), a +workspace member, depending on `alkstore` only (no driver deps, ever — +the mirroring of [ADR-001](001-crate-split.md) §2's core rule keeps the +suite compilable against any factory). Each engine crate takes it as a +**dev-dependency** and drives the rows in its own test target against +a factory it defines: a fresh store over an isolated backing store per +run, plus teardown. + +- **One normative owner per property** — + [ADR-012](012-forked-substrate-design.md) §2's one-owner rule + applied to test artifacts: each property's text, assertion strength, + and version stamp live in exactly one place. Per-engine copies + (option (c)) double the owners and drift; the suite crate is the + single owner, engines are executors. +- **A crate, not a test target** (over option (b)): the suite's rows + must be *callable from each engine's* test binary — a dev-dependency + is the Cargo mechanism for exactly that shape. It also gives the + suite a compilation gate of its own and a `publish = false` marker + recording its internal posture in the manifest, where + [ADR-005](005-dependency-ownership.md)'s published-by-default + default is answered in the artifact itself. +- **The factory abstraction is the suite's API**: a minimal + `StoreFactory` trait (open/fresh, teardown) over the boxed-future + posture the contract traits use. The exact factory shape is + deliberately *not frozen* — it may evolve as engines adopt the + harness (wave 3/4 feedback); this ADR records the **layout** + decision. Suite-side changes (rows, assertion strength, factory + shape) are contract-non-events ([ADR-017](017-contract-versioning.md) + §2 class 4 — the suite tests the contract, it is not contract + surface); a suite row added to pin a *contract* addition carries + that addition's ADR as its version stamp, and the class-2/3 change + itself rides ADR-017's discipline on the core crate's side. +- **The version-stamp convention is the suite crate's, documented + there** ([version stamp module](../../../alkstore-contract-suite/src/version_stamp.rs)): + a row's doc comment carries one `Contract stamp:` item per contract + change that pinned the behavior tested, citing the ADR §. The + exemplar row (entry-point name validation — empty → `InvalidName`, + reserved prefix → `ReservedName`, auto-commit and tx paths alike) + is stamped `ADR-008 §4` (+ ADR-021 §3, ADR-015 §2 for the rows its + assertions encompass) and proves the harness end-to-end against a + trivial in-crate mock store. + +## Consequences + +**Positive** + +- The equivalence discipline has a mechanical home: one row text, run + on both engines by their factories — the no-ghosts, ordering, and + arithmetic-equality claims (wave 5's rows later) are one property + each, not one per engine. +- The engine/dev-dependency edge existing from wave 1 makes adoption + the default path for waves 3/4 (their backlog-column tasks run the + rows, not re-derive them) — the reason the scaffold was pulled + forward from wave 5. +- `publish = false` plus core-only deps keeps the artifact off every + published graph and compilable anywhere (A mem-shaped engine, if + wave 6 wants one ([ADR-001](001-crate-split.md) §4's deferral), can + implement the same `StoreFactory` and reuse the suite verbatim). + +**Negative** + +- A dev-dependency edge is graph-visible but Cargo-weak: the suite + cannot enforce that an engine actually *ran* it (dev-deps can be + ignored by a careless release). The enforcement is process-side — + the wave-3/4 review gates and [ADR-017](017-contract-versioning.md) + §4.2's release-time suite requirement, the same honesty posture the + rest of the pairing carriers ride. +- The factory shape's latitude is a small standing surface: engines + will each own a factory impl, and a shapes-drift (e.g. someone adds + a pool-reuse knob) needs review discipline to keep the two + implementations comparable. The ADR's stated rule — factory changes + are suite-crate changes first — bounds it. + +## References + +- [ADR-017](017-contract-versioning.md) §4.2 — the deferral this ADR + discharges; the version-stamp duty the convention implements; class + 4's suite-is-not-contract boundary. +- [ADR-001](001-crate-split.md) §4 — the test-artifact posture the + layout defers by; the mem-engine note's possible suite reuse. +- [ADR-012](012-forked-substrate-design.md) §2 — the one-normative- + owner rule the layout mirrors; the "tested into equivalence" framing. +- [core-contract.md](../core-contract.md) §Verification backlog — the + rows the suite matures from. +- docs/plans/implementation.md "Decided points" — the wave-1 pull- + forward rationale and the option-(a) record this ADR formalizes. \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index d9794a0..d26f37f 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-10-07 (ADR-021 — third review round: tx reads, value-shape fixes, schedule queue validation, drop=rollback, receiver arms) +last_updated: 2026-10-07 (ADR-022 — contract-suite layout: shared internal suite crate) --- # alkstore — Overview @@ -85,6 +85,7 @@ Per [ADR-002](decisions/002-feature-scope.md): | [019](decisions/019-mechanism-handle-surfaces.md) | Mechanism-handle surfaces (handle traits, `Job`/`Schedule` shapes, `worker_id`, `StopToken`) | Accepted | | [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue-option semantics (delay/run_at, expires, scheduler stamp source, payload encoding) | Accepted | | [021](decisions/021-tx-reads-and-value-shape-fixes.md) | Third review round (tx-read methods on `TxHandle`, `Job.claimed_at`, `schedule()` queue-argument validation, drop = rollback, receiver close/error arms) | Accepted | +| [022](decisions/022-contract-suite-layout.md) | Contract-suite layout (shared internal suite crate, store-factory parameterization) | Accepted | ## Non-goals diff --git a/tasks/contract-suite-scaffold.md b/tasks/contract-suite-scaffold.md index 4a74bb2..cf78fd4 100644 --- a/tasks/contract-suite-scaffold.md +++ b/tasks/contract-suite-scaffold.md @@ -1,7 +1,7 @@ --- id: contract-suite-scaffold name: Contract-suite crate scaffold + ADR-022 (suite layout decision) -status: pending +status: completed depends_on: [core-trait-surface] scope: narrow risk: low @@ -69,8 +69,73 @@ The crate: ## Notes -> To be filled by implementation agent +- **Factory shape (the implementer's choice)**: a named trait, + `StoreFactory { open() -> BoxedFuture>>; + teardown() -> BoxedFuture> }` — chosen over a function + alias because teardown needs an identity to be idempotent *per + instance* (a bare fn can't carry "this factory's backing store"), + and the async surface rides core's `BoxedFuture` posture so + implementers need no macros. One shape, documented in + `src/factory.rs`; the ADR records the layout, not a frozen API — + wave 3/4 feedback may evolve it. +- **Version-stamp convention (the "decided here" row convention)**: + a row's doc comment carries one `Contract stamp:` item per contract + change pinning the behavior tested, citing ADR § (never bare "v1" + or dates); stamps *accumulate* on amendment; greppable via the + exported `STAMP_MARKER` constant. Normative statement lives in + `src/version_stamp.rs` — every later row cites against it. The + exemplar row is stamped `ADR-008 §4` + `ADR-021 §3` + `ADR-015 §2` + (its assertions span all three changes' behavior). +- **Assertion posture**: rows panic on violation (labeled with the + entry point and expectation) — the test-side-artifact relaxation of + the family no-panic rule, documented in the crate docs as + deliberate. +- **Exemplar scope**: the property runs the full validation surface — + all four `Store` constructor kinds (`listen`/`stream`/`queue`/ + `outbox`), `try_lock` (both name and owner), `schedule` (both name + and queue argument — ADR-021 §3), `notify`, and *every* name-bearing + `*_tx` twin on `TxHandle` (all eleven, incl. ADR-015 §2's + non-empty-key rule on `publish_with_key_tx`), plus a `with_tx` + spot-check proving the commit-atomic path can't bypass validation. + Happy-path spot-checks (`stream`/`queue`/`try_lock` with a + reserved-prefixed owner) prove the rejections are the validation's + doing, not store-wide failure. +- **`Schedule` is `#[non_exhaustive]`** (ADR-017 §3): the mock cannot + struct-construct it, so the mock's `schedule()` returns a `Database` + stub after running the row-validation; the row asserts only the + validation outcomes, and `Schedule` construction stays core's own + tests' business. Same for the mock's `subscribe` (never exercised by + the row). Noted so wave 3/4 factory impls copy the posture knowingly. +- **serde_json is a direct dep of the suite** (not just core's): rows + construct payload `Value`s as contract surface (ADR-020 §4's trait + crossing). No driver deps — the ADR-001 §2 mirror holds. +- **Dev-dep edges are path deps** (`path = "../alkstore-contract-suite"`) + with no version: correct for an unpublished internal crate — a + `version` key would be meaningless. +- ADR-017 §4.2's text gained a layout-resolution annotation (dated, + citing ADR-022) at the deferral site — the discharge is visible from + the deferring document itself. ## Summary -> To be filled on completion \ No newline at end of file +The `alkstore-contract-suite` crate is scaffolded per the option-(a) +layout decision: `publish = false`, workspace member, depends on +`alkstore` only (+ serde_json for payload construction). It defines +the `StoreFactory` trait (open/teardown over an isolated backing +store), the version-stamp convention (`Contract stamp:` doc-comment +items, `STAMP_MARKER` constant, normative statement in +version_stamp.rs), and the exemplar property +`name_validation_rejects_empty_and_reserved` — full auto-commit + tx +validation surface, stamped ADR-008 §4 / ADR-021 §3 / ADR-015 §2. A +`tests/suite_harness.rs` runs it green against a trivial in-crate mock +store + factory (3 tests: the exemplar row, happy-path construction, +stamp-marker check). Engine crates carry the dev-dependency edge +(path-dep). ADR-022 written +(docs/architecture/decisions/022-contract-suite-layout.md): option (a) +chosen, one-normative-owner-per-property rationale (ADR-012 §2 +mirror), factory-shape latitude stated, ADR-017 §4.2 deferral +discharged (with an annotation added at the deferral site); +ADR index tables in docs/architecture/README.md and overview.md +updated. Workspace `cargo build`, `cargo test` (26 tests workspace +wide), `cargo clippy --all-targets -- -D warnings`, `cargo fmt +--check` all clean. \ No newline at end of file