diff --git a/alkstore-sqlite/src/lib.rs b/alkstore-sqlite/src/lib.rs index c6397aa..1be0c51 100644 --- a/alkstore-sqlite/src/lib.rs +++ b/alkstore-sqlite/src/lib.rs @@ -17,6 +17,7 @@ //! `spawn_blocking` seam — rusqlite connections are not //! `Send`-across-await; the family REQ-TTY-01 posture). +mod lock; mod notify; mod opts; mod queue; diff --git a/alkstore-sqlite/src/lock.rs b/alkstore-sqlite/src/lock.rs new file mode 100644 index 0000000..ee6f333 --- /dev/null +++ b/alkstore-sqlite/src/lock.rs @@ -0,0 +1,141 @@ +//! The named-lock mechanism's SQLite arm (`sqlite-engine-locks`) — the +//! substrate's lock ops over the writer slot (locks are writer ops; +//! all three are single statements, auto-commit): +//! +//! - `try_lock(name, owner, ttl)` — the validated constructor: +//! `name` shared-namespace (`validate_shared_name`), `owner` +//! consumer-local non-empty only (ADR-019 §2 — `InvalidName`), the +//! **duration guard (ADR-023 §2)** on `ttl <= 0` at the entry +//! before the substrate. `Ok(None)` = someone holds it (no-work is +//! a value, not an error); `Ok(Some(..))` = the boxed [`Lock`] +//! handle. +//! - `Lock::renew(ttl)` — the same duration guard at the handle's +//! entry (the substrate's `lock_renew` guard is preserved in the +//! source chain where that call fires; the engine guards both sites +//! uniformly so the rule is contract-side — the pg engine has no +//! inherited guard at all and implements the same rule). Renew is a +//! new **full** TTL window from now (`unixepoch() + ttl`); `false` +//! = lost it (expired and re-acquired elsewhere — exclusion lapses +//! silently at TTL expiry, no revocation event, ADR-008 §7). +//! - `Lock::release(self)` — consuming (`self: Box`, the RAII +//! shape, ADR-019 §1); deletes only the owner's row; `false` = +//! already expired/re-acquired (a no-op, not an error). + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use alkstore::{BoxedFuture, Lock, Result, validate_local_name, validate_shared_name}; + +use crate::seam::{closed_store_error, sqlite_error, with_writer}; +use crate::substrate::{Writer, lock_acquire, lock_release, lock_renew}; + +/// The acquire path (see the module docs): validation, guard, then the +/// substrate's `lock_acquire` through the writer slot. `Ok(None)` is +/// the held-by-someone-else arm. +pub(crate) fn try_lock( + name: &str, + owner: &str, + ttl: i64, + writer: Arc, + closed: Arc, +) -> BoxedFuture<'static, Result>>> { + if let Err(e) = validate_shared_name(name) { + return Box::pin(async move { Err(e) }); + } + if let Err(e) = validate_local_name(owner) { + return Box::pin(async move { Err(e) }); + } + if ttl <= 0 { + return Box::pin(async move { + Err(crate::seam::database_error(format!( + "ttl must be a positive duration (seconds), got {ttl}" + ))) + }); + } + if closed.load(std::sync::atomic::Ordering::Acquire) { + return Box::pin(async { Err(closed_store_error()) }); + } + let name = name.to_string(); + let owner = owner.to_string(); + Box::pin(async move { + let granted = with_writer(writer.clone(), { + let name = name.clone(); + let owner = owner.clone(); + move |conn: &rusqlite::Connection| { + Ok(lock_acquire(conn, &name, &owner, ttl).map_err(sqlite_error)? > 0) + } + }) + .await?; + Ok(granted.then(|| { + Box::new(SqliteLockHandle { + name, + owner, + writer, + closed, + }) as Box + })) + }) +} + +/// The held named-lock handle (see the module docs). [`Lock::release`] +/// consumes it; `renew` is the repeatable `&self` case. +pub struct SqliteLockHandle { + name: String, + owner: String, + writer: Arc, + closed: Arc, +} + +impl std::fmt::Debug for SqliteLockHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SqliteLockHandle") + .field("name", &self.name) + .finish_non_exhaustive() + } +} + +impl Lock for SqliteLockHandle { + fn name(&self) -> &str { + &self.name + } + + fn renew<'a>(&'a self, ttl: i64) -> BoxedFuture<'a, Result> { + if ttl <= 0 { + return Box::pin(async move { + Err(crate::seam::database_error(format!( + "ttl must be a positive duration (seconds), got {ttl}" + ))) + }); + } + if self.closed.load(std::sync::atomic::Ordering::Acquire) { + return Box::pin(async { Err(closed_store_error()) }); + } + let name = self.name.clone(); + let owner = self.owner.clone(); + let writer = self.writer.clone(); + Box::pin(async move { + with_writer(writer, move |conn| { + Ok(lock_renew(conn, &name, &owner, ttl).map_err(sqlite_error)? > 0) + }) + .await + }) + } + + fn release(self: Box) -> BoxedFuture<'static, Result> { + let SqliteLockHandle { + name, + owner, + writer, + closed, + } = *self; + Box::pin(async move { + if closed.load(std::sync::atomic::Ordering::Acquire) { + return Err(closed_store_error()); + } + with_writer(writer, move |conn| { + Ok(lock_release(conn, &name, &owner).map_err(sqlite_error)? > 0) + }) + .await + }) + } +} diff --git a/alkstore-sqlite/src/store.rs b/alkstore-sqlite/src/store.rs index be9b9e0..0b9f4d4 100644 --- a/alkstore-sqlite/src/store.rs +++ b/alkstore-sqlite/src/store.rs @@ -199,11 +199,15 @@ impl Store for SqliteStore { fn try_lock<'a>( &'a self, - _name: &str, - _owner: &str, - _ttl: i64, + name: &str, + owner: &str, + ttl: i64, ) -> alkstore::BoxedFuture<'a, alkstore::Result>>> { - Box::pin(async { Err(database_error("lock wiring lands with the mechanism tasks")) }) + let name = name.to_string(); + let owner = owner.to_string(); + let writer = self.writer.clone(); + let closed = self.closed.clone(); + crate::lock::try_lock(&name, &owner, ttl, writer, closed) } fn schedule<'a>( @@ -241,6 +245,8 @@ impl Store for SqliteStore { } } +#[cfg(test)] +mod lock_tests; #[cfg(test)] mod notify_tests; #[cfg(test)] diff --git a/alkstore-sqlite/src/store/lock_tests.rs b/alkstore-sqlite/src/store/lock_tests.rs new file mode 100644 index 0000000..99e62f4 --- /dev/null +++ b/alkstore-sqlite/src/store/lock_tests.rs @@ -0,0 +1,427 @@ +//! The named-lock mechanism's acceptance tests (`sqlite-engine-locks`): +//! exclusive grant over a temp-file store with the loser's `Ok(None)` +//! arm, release re-enabling acquisition, the duration guards (ADR-023 +//! §2 — `ttl <= 0` rejected opaque on `try_lock` and `renew`, the +//! substrate's guard message preserved in the source chain on renew), +//! `renew`'s full-window-from-now semantics and lapsed-holder refusal, +//! the inherited same-owner re-acquire shape (granted, TTL not +//! refreshed — engine-sqlite.md's mapping row), `release`'s +//! owner-scoped delete + no-op false arm, the silent TTL lapse with +//! lapse-then-reacquire (ADR-008 §7's guarantee row), and the +//! engine-external lapse probe (a second `open` seeing the lapse +//! through the shared db file). ADR-019 §1–§2; ADR-023 §2. + +use std::path::PathBuf; +use std::time::Duration; + +use rusqlite::OptionalExtension; + +use alkstore::{Error, Store}; + +use crate::store::open_store; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "alkstore-lock-{tag}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn temp_path(tag: &str) -> PathBuf { + temp_dir(tag).join("store.db") +} + +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} + +/// Wait until the lock row `name`'s expiry is in the past — the +/// bounded-wait posture (no tight-race assertions; second-precision +/// expiry means ~1 s of wall clock per step, well inside the bound). +async fn until_lapsed(path: &str, name: &str) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(6); + loop { + let lapsed = { + let conn = rusqlite::Connection::open(path).unwrap(); + let expires: Option = conn + .query_row( + "SELECT expires_at FROM __alkstore_locks WHERE name = ?1", + [name], + |r| r.get(0), + ) + .optional() + .unwrap(); + now() > expires.unwrap_or(i64::MAX) + }; + if lapsed { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "lock {name}: never lapsed" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// Acceptance: exclusive grant — the second acquirer gets `Ok(None)` +/// (no-work is a value, not an error), a different lock name is +/// independent, and release makes it acquirable again. The same-owner +/// re-acquire is the inherited shape: granted (`Some`), TTL **not** +/// refreshed (engine-sqlite.md's mapping row, inherited deliberately — +/// `renew` is the refresh path). +#[tokio::test(flavor = "multi_thread")] +async fn try_lock_grants_exclusively_and_release_reopens() { + let dir = temp_dir("exclusive"); + let path = temp_path("exclusive"); + let store = open_store(path.to_str().unwrap(), Default::default()).unwrap(); + + let a = store.try_lock("leader", "a", 300).await.unwrap(); + assert!(a.is_some(), "first acquirer holds it"); + let lock = a.unwrap(); + assert_eq!(lock.name(), "leader"); + let grant_expires: i64 = { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.query_row( + "SELECT expires_at FROM __alkstore_locks WHERE name = 'leader'", + [], + |r| r.get(0), + ) + .unwrap() + }; + + assert!( + store.try_lock("leader", "b", 300).await.unwrap().is_none(), + "someone holds it: Ok(None), never an error" + ); + + let reacquired = store + .try_lock("leader", "a", 300) + .await + .unwrap() + .expect("same-owner re-acquire is granted"); + { + let conn = rusqlite::Connection::open(&path).unwrap(); + let expires: i64 = conn + .query_row( + "SELECT expires_at FROM __alkstore_locks WHERE name = 'leader'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!( + expires == grant_expires, + "re-acquire must not refresh the TTL (inherited deliberately): \ + {expires} vs the grant's {grant_expires} — the row is the \ + original insert, INSERT OR IGNORE keeps it" + ); + } + assert!( + store.try_lock("leader", "b", 300).await.unwrap().is_none(), + "still exclusive after the no-refresh re-acquire" + ); + + assert!( + store + .try_lock("other-lock", "a", 300) + .await + .unwrap() + .is_some(), + "a different lock name is independent" + ); + + assert!(lock.release().await.unwrap(), "release reports it was held"); + assert!( + store.try_lock("leader", "b", 300).await.unwrap().is_some(), + "release makes it acquirable again" + ); + drop(reacquired); + + store.close(); + let _ = std::fs::remove_dir_all(dir); +} + +/// Acceptance: the duration guards (ADR-023 §2) — `try_lock(ttl <= 0)` +/// and `renew(ttl <= 0)` are opaque `Err` (`Database`), never a +/// granted lock, never a panic. The engine-side guard's detail rides +/// the source chain (`#[source]`, ADR-008 §5); the substrate's own +/// guard (`ttl_s must be positive`) is preserved in the chain where +/// its call site fires — direct substrate callers meet it. +#[tokio::test(flavor = "multi_thread")] +async fn duration_guards_reject_non_positive_ttl_opaquely() { + let dir = temp_dir("duration"); + let path = temp_path("duration"); + let store = open_store(path.to_str().unwrap(), Default::default()).unwrap(); + + for ttl in [0, -1] { + let err = match store.try_lock("guarded", "a", ttl).await { + Ok(granted) => panic!( + "try_lock(ttl = {ttl}) must be a rejection, granted: {}", + granted.is_some() + ), + Err(e) => e, + }; + assert!( + matches!(err, Error::Database(_)), + "opaque Database shape, got: {err:?}" + ); + assert!( + !matches!(err, Error::InvalidName { .. } | Error::ReservedName { .. }), + "the duration rule must not be misreported as a name error: {err:?}" + ); + } + assert!( + store.try_lock("guarded", "a", 300).await.unwrap().is_some(), + "the guard fires before the substrate, so no lock row was created" + ); + assert!(store.try_lock("guarded", "b", 300).await.unwrap().is_none()); + + let lock = store + .try_lock("renew-guard", "a", 300) + .await + .unwrap() + .unwrap(); + for ttl in [0, -1] { + let err = match lock.renew(ttl).await { + Ok(landed) => panic!("renew({ttl}) must be a rejection, landed: {landed}"), + Err(e) => e, + }; + assert!(matches!(err, Error::Database(_)), "opaque, got: {err:?}"); + let detail = err.to_string(); + assert!( + detail.contains("ttl must be a positive duration") + || std::error::Error::source(&err) + .map(|s| s.to_string().contains("ttl must be a positive duration")) + .unwrap_or(false), + "the engine guard's detail rides the source chain, got: {detail}" + ); + } + assert!(lock.renew(600).await.unwrap(), "a positive ttl renews"); + + // The substrate's guard message, preserved in the source chain + // where its call site fires: the direct substrate caller meets + // `ttl_s must be positive` verbatim. + let conn = rusqlite::Connection::open(&path).unwrap(); + let err = crate::substrate::lock_renew(&conn, "renew-guard", "a", -1).unwrap_err(); + assert_eq!(err.to_string(), "ttl_s must be positive"); + + store.close(); + let _ = std::fs::remove_dir_all(dir); +} + +/// Acceptance: `renew` is a new full TTL window from now (not +/// additive); a foreign owner's renew is `false` and leaves the row +/// untouched; a lapsed holder's renew is `false` (lost it — expired +/// and re-acquired elsewhere). +#[tokio::test(flavor = "multi_thread")] +async fn renew_is_full_window_and_refuses_foreign_or_lapsed_holders() { + let dir = temp_dir("renew"); + let path = temp_path("renew"); + let store = open_store(path.to_str().unwrap(), Default::default()).unwrap(); + + let lock = store.try_lock("leader", "a", 300).await.unwrap().unwrap(); + + // A foreign owner string's renew is false — the row predicate is + // (name, owner) — and the holder's renew still works after (the + // refusal never touched the row). + { + let conn = rusqlite::Connection::open(&path).unwrap(); + assert_eq!( + crate::substrate::lock_renew(&conn, "leader", "nobody", 300).unwrap(), + 0, + "foreign owner's renew is refused" + ); + } + assert!(lock.renew(600).await.unwrap()); + + let before = now(); + { + let conn = rusqlite::Connection::open(&path).unwrap(); + let expires: i64 = conn + .query_row( + "SELECT expires_at FROM __alkstore_locks WHERE name = 'leader'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!( + ((before + 600 - 3)..=(before + 600 + 3)).contains(&expires), + "renew sets a full window from now (not additive): \ + {expires} vs ~{before}+600" + ); + } + + // Lapse + re-acquire, the lapse-then-reacquire shape: stamp a + // short expiry directly (the honest cross-connection probe, at + // second precision), wait it out, re-acquire as a foreign owner, + // then the stale holder's renew is false (lost it). + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute( + "UPDATE __alkstore_locks SET expires_at = unixepoch() + 1 + WHERE name = 'leader' AND owner = 'a'", + [], + ) + .unwrap(); + } + until_lapsed(path.to_str().unwrap(), "leader").await; + let _other = store + .try_lock("leader", "b", 1) + .await + .unwrap() + .expect("re-acquired after expiry"); + until_lapsed(path.to_str().unwrap(), "leader").await; + assert!(!lock.renew(300).await.unwrap(), "lost it"); + assert!( + !lock.release().await.unwrap(), + "the stale holder's release is the no-op arm after the foreign re-acquire" + ); + + store.close(); + let _ = std::fs::remove_dir_all(dir); +} + +/// Acceptance: `release` deletes only the owner's row (the +/// `(name, owner)` predicate — a foreign owner string deletes +/// nothing), and the no-op `false` arm: releasing with the row already +/// gone is `false`, not an error. +#[tokio::test(flavor = "multi_thread")] +async fn release_owner_scoped_and_missing_row_is_a_noop() { + let dir = temp_dir("release"); + let path = temp_path("release"); + let store = open_store(path.to_str().unwrap(), Default::default()).unwrap(); + + let lock = store.try_lock("leader", "a", 300).await.unwrap().unwrap(); + + // Substrate level: the delete predicate is (name, owner). + { + let conn = rusqlite::Connection::open(&path).unwrap(); + assert_eq!( + crate::substrate::lock_acquire(&conn, "l2", "a", 300).unwrap(), + 1 + ); + assert_eq!( + crate::substrate::lock_release(&conn, "l2", "b").unwrap(), + 0, + "a foreign owner string must not delete the row" + ); + assert!( + crate::substrate::lock_acquire(&conn, "l2", "b", 300).unwrap() == 0, + "the row survives a foreign owner's release attempt" + ); + assert_eq!( + crate::substrate::lock_release(&conn, "l2", "a").unwrap(), + 1, + "the owner's release removed the row" + ); + assert!( + crate::substrate::lock_acquire(&conn, "l2", "b", 300).unwrap() == 1, + "acquirable again after the owner's release" + ); + } + + assert!(lock.release().await.unwrap(), "held: release deletes"); + assert!( + store.try_lock("leader", "b", 300).await.unwrap().is_some(), + "acquirable right after the owner's release" + ); + + store.close(); + let _ = std::fs::remove_dir_all(dir); +} + +/// Acceptance: silent lapse (ADR-008 §7's guarantee row) — no +/// revocation event; the substrate's expired-row delete on the next +/// acquire is the lapse mechanism, pinned lapse-then-reacquire: after +/// expiry another owner acquires while the stale holder's ops +/// disown. Freshly after a lapse (row still present, not yet swept by +/// the foreign acquire) the stale holder's release deletes its own +/// stale row and reports `true` — the row-scoped delete is the +/// release mechanism; only a *removed* row releases as `false` (the +/// no-op arm). +#[tokio::test(flavor = "multi_thread")] +async fn ttl_lapses_silently_and_another_owner_acquires() { + let dir = temp_dir("lapse"); + let path = temp_path("lapse"); + let store = open_store(path.to_str().unwrap(), Default::default()).unwrap(); + + let lock = store.try_lock("leader", "a", 1).await.unwrap().unwrap(); + until_lapsed(path.to_str().unwrap(), "leader").await; + + // Lapse is silent: the stale holder's ops report through values, + // never an error or a revocation event. + assert!( + store.try_lock("leader", "b", 300).await.unwrap().is_some(), + "after expiry another owner acquires (the expired-row delete is the mechanism)" + ); + assert!( + !lock.renew(300).await.unwrap(), + "the stale holder's renew is false: expired and re-acquired elsewhere" + ); + assert!( + !lock.release().await.unwrap(), + "the stale holder's release is the no-op arm after the foreign re-acquire" + ); + assert!( + store.try_lock("leader", "c", 300).await.unwrap().is_none(), + "b's fresh acquisition still excludes others" + ); + + // The no-op arm without a foreign re-acquire: lapse, the stale row + // still present, the holder releases — the owner-scoped delete + // lands (true), and the lock is immediately acquirable again. + let second = store.try_lock("silent", "a", 1).await.unwrap().unwrap(); + until_lapsed(path.to_str().unwrap(), "silent").await; + assert!(second.release().await.unwrap(), "the stale row deletes"); + assert!( + store.try_lock("silent", "b", 300).await.unwrap().is_some(), + "acquirable right after the stale holder's release" + ); + + store.close(); + let _ = std::fs::remove_dir_all(dir); +} + +/// Acceptance: the lapse is visible across engine instances — a second +/// `open` on the same db file (the engine-external probe) acquires +/// after the TTL lapses (the verification backlog's re-acquire-after- +/// expiry pin on SQLite; the substrate's expired-row delete is the +/// mechanism, exercised cross-connection). +#[tokio::test(flavor = "multi_thread")] +async fn re_acquired_after_expiry_from_a_second_engine_instance() { + let dir = temp_dir("reacquire"); + let path = temp_path("reacquire"); + let path_str = path.to_str().unwrap().to_string(); + let store = open_store(&path_str, Default::default()).unwrap(); + + let lock = store.try_lock("leader", "a", 1).await.unwrap().unwrap(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let (other, held) = loop { + let other = open_store(&path_str, Default::default()).unwrap(); + match other.try_lock("leader", "b", 300).await.unwrap() { + Some(l) => break (other, l), + None => other.close(), + } + }; + assert!( + tokio::time::Instant::now() < deadline, + "the lock never lapsed for the second instance" + ); + assert_eq!(held.name(), "leader"); + assert!(!lock.renew(300).await.unwrap(), "lapsed: renew refuses"); + assert!( + !lock.release().await.unwrap(), + "lapsed + foreign re-acquire: the no-op arm" + ); + + store.close(); + other.close(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/tasks/sqlite-engine-locks.md b/tasks/sqlite-engine-locks.md index aae6376..bce4516 100644 --- a/tasks/sqlite-engine-locks.md +++ b/tasks/sqlite-engine-locks.md @@ -1,7 +1,7 @@ --- id: sqlite-engine-locks name: SQLite engine — named locks (`try_lock`, `Lock` handle, duration guards) -status: pending +status: completed depends_on: [sqlite-engine-seam-tx] scope: narrow risk: low @@ -35,17 +35,17 @@ lock ops (`lock_acquire`, `lock_release`, `lock_renew`): ## Acceptance Criteria -- [ ] `try_lock` grants exclusively (second acquirer gets `None`); +- [x] `try_lock` grants exclusively (second acquirer gets `None`); release makes it acquirable again -- [ ] Duration guards: `try_lock`/`renew` with `ttl <= 0` return +- [x] Duration guards: `try_lock`/`renew` with `ttl <= 0` return opaque `Err` (`Database`) — never a granted lock, never a panic; substrate guard message preserved in the source chain on `renew` — pinned per ADR-023 §2's backlog row -- [ ] `renew` extends from now (full window, not additive); foreign +- [x] `renew` extends from now (full window, not additive); foreign owner cannot renew; `false` on lapsed/re-acquired -- [ ] `release` removes only the owner's row; `false` = no-op shape -- [ ] TTL lapse: after expiry, another owner acquires (silent lapse) -- [ ] `cargo test -p alkstore-sqlite`, clippy `-D warnings`, fmt clean +- [x] `release` removes only the owner's row; `false` = no-op shape +- [x] TTL lapse: after expiry, another owner acquires (silent lapse) +- [x] `cargo test -p alkstore-sqlite`, clippy `-D warnings`, fmt clean ## References @@ -57,8 +57,60 @@ lock ops (`lock_acquire`, `lock_release`, `lock_renew`): ## Notes -> To be filled by implementation agent +> Decisions of record the description didn't pin: + +- **Same-owner re-acquire returns `Some`, not `None`** — the + substrate's `lock_acquire` is opportunistic-expiry-delete + + `INSERT OR IGNORE` + read-back: the same owner's re-acquire keeps + the original row (TTL *not* refreshed) and reports granted. Pinned + as the inherited shape (engine-sqlite.md's mapping row: "re-acquire + does not refresh TTL — inherited deliberately"; the research + register's D-4 kept `lock_acquire` verbatim). The handle returned + is a fresh boxed `Lock`; `renew` remains the only refresh path. +- **Post-lapse release returns `true` while the stale row survives, + `false` only once the row is gone/foreign** — the substrate's + `lock_release` is a blind `(name, owner)` delete and expired rows + persist until the next acquire's opportunistic delete (no general + lock-expiry sweep, upstream-inherited). So immediately after a + lapse the stale holder's release deletes its own stale row + (`true`); after a foreign re-acquire (or any row-less state) it is + the no-op `false`. Tests pin both arms; the contract text's "true = + was held" reads as row-scoped, which the substrate defines. +- **The duration-guard detail rides the source chain, not + `Display`** — the engine guard's message + ("ttl must be a positive duration (seconds), got {ttl}") is the + `#[source]` inner error (ADR-008 §5's chain discipline; the + `Database` display is `database error` opaque). The substrate's + direct-caller message `ttl_s must be positive` is asserted verbatim + at the substrate call site (A-2 above). +- **Closed-store posture**: `try_lock`/`renew`/`release` check the + engine's closed flag (or the writer slot's unavailability) and fail + closed with opaque `Database` — the engine-wide shape every wave-3 + mechanism carries; release preserves the substrate row delete as + the truth-teller rather than caching a handle-local "held" flag. +- `store/lock_tests.rs` follows the wave-3 bounded-wait posture for + TTL expiry (second-precision `unixepoch()`; no tight-race sleeps); + the short-window lapse steps stamp short expiries directly through + a separate connection (the queue tests' honest cross-connection + probe shape) instead of sleeping out multi-second windows. ## Summary -> To be filled on completion \ No newline at end of file +> `alkstore-sqlite/src/lock.rs`: the acquire path (`try_lock` — +> shared-namespace + non-empty owner validation, the ADR-023 §2 +> duration guard on `ttl <= 0` engine-side before the substrate, the +> closed-store check, then `lock_acquire` through the writer slot, +> `Ok(None)` = held elsewhere) and `SqliteLockHandle` (`Lock` impl: +> `renew` with the same duration guard over `lock_renew`'s full-window +> update; consuming `release(self: Box)` over `lock_release`'s +> owner-scoped delete). `store.rs`'s `try_lock` stub replaced with the +> wiring. Six acceptance tests in `store/lock_tests.rs` pin the +> exclusion/loser shapes, both duration guards + the substrate's +> verbatim message, full-window renew and stale-holder refusals, +> owner-scoped release with both boolean arms, the silent +> lapse-then-reacquire shape, and the cross-instance re-acquire (the +> verification backlog's SQLite re-acquisition pin at engine level; +> the contract-suite row remains for the suite itself). Verified: +> `cargo build`, `cargo test` (workspace; 177 + 25 + 3 pass), +> `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` +> — all clean. \ No newline at end of file