From 74105a16aef86557bd0e8a468b43b8e07f1dbacb Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 7 Oct 2026 15:04:04 +0000 Subject: [PATCH] =?UTF-8?q?Core=20error=20taxonomy=20+=20name=20validation?= =?UTF-8?q?=20(ADR-008=20=C2=A74/=C2=A75,=20ADR-017=20=C2=A73);=20AGENTS.m?= =?UTF-8?q?d=20updated=20to=20Phase=201=20posture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 154 +++++++++++++--------------- Cargo.lock | 3 + alkstore/Cargo.toml | 5 +- alkstore/src/error.rs | 61 +++++++++++ alkstore/src/lib.rs | 17 +++ alkstore/src/tests.rs | 131 +++++++++++++++++++++++ alkstore/src/validation.rs | 69 +++++++++++++ tasks/core-errors-and-validation.md | 57 +++++++++- 8 files changed, 413 insertions(+), 84 deletions(-) create mode 100644 alkstore/src/error.rs create mode 100644 alkstore/src/tests.rs create mode 100644 alkstore/src/validation.rs diff --git a/AGENTS.md b/AGENTS.md index 68058d4..f7fc64f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,27 +7,30 @@ unless their own prompts say otherwise. ## Repo posture -This repo is in **Phase 0 (Exploration)** per `docs/sdd_process.md`. -There is no crate yet — no `Cargo.toml`, no `src/`, no README (the README -gets written just before first publish, so it can be honest about what -shipped). What exists is `docs/research/phase-0.md` (the Phase 0 -document: vision, prior art, open questions, POC register), the SDD -process (`docs/sdd_process.md`), and the agent defs -(`.opencode/agents/`). Do not scaffold implementation code -from this posture unless a task or the user asks for a POC. +This repo is in **Phase 1 (Implementation)** per `docs/sdd_process.md`. +Phase 0 concluded with the converged recommendation and the accepted ADRs +(`docs/architecture/decisions/`, `docs/architecture/README.md`); the +implementation plan (`docs/plans/implementation.md`) decomposes waves of +work into task files in `tasks/`. Three crates exist (ADR-001's Cargo +workspace split): `alkstore` (core — the contract artifact), +`alkstore-sqlite`, `alkstore-postgres`. Work proceeds task-by-task from +`tasks/`; check a task's `status` and `depends_on` before picking it up. -Do not treat `docs/research/phase-0.md` as settled architecture — it is -a working research document whose open questions are genuinely open. -The hedging rules of `docs/sdd_process.md` (no circular deferrals, no -"resolved with escape hatches") still apply to it. +The architecture documents — `docs/architecture/` (core-contract.md, the +ADRs, the engine specs) — are the normative spec for code work. Where +code and ADR disagree, the ADR wins: fix the code, or (if the ADR is +genuinely wrong for a reason no ADR anticipated) raise the discrepancy +with the user before deviating. ADR amendments pre-implementation are +class-1 events under ADR-017 §2 — but that class terminates at first +release, so the window is now, not forever. ## Git Workflow **Commit and push when reasonable.** When a change is complete and verified, commit and push to `origin/main` without asking. This overrides the built-in default of "only commit when explicitly asked." -In Phase 0 "verified" means the doc checks below — there is no build -gate yet. +"Verified" means the Verification Commands below pass for any crate the +change touches. Exceptions — **do not** commit or push without asking: @@ -44,87 +47,78 @@ failed one. Git identity is preconfigured (`glm-5.3-flash `). Do not change `git config`, skip hooks, or use `git commit -i`. -## Phase 0 conventions +## Code conventions -1. **Findings land in `docs/research/`.** Research notes, POC - specifications, and POC findings all go there, named consistently - (`poc--spec.md` / `poc--findings.md` follows the - alkblobs precedent). A POC that needs this repo's code runs in a - worktree (`.worktrees/research//` per the SDD process); a - self-contained POC (like the alkblobs ones) runs as a standalone - crate in the global workspace. Findings land in this repo either - way. -2. **Open questions get OQ-ST-NN IDs** and live in the register in - `docs/research/phase-0.md` until Phase 1 promotes them to - `docs/architecture/open-questions.md`. Numbering is stable — never - renumber; append. POCs live in the register there too (numbered, - spec'd under `docs/research/poc--spec.md`, findings in - `poc--findings.md`). -3. **Cite reference checkouts by path and revision.** The external - references for this crate (`/workspace/honker` @ f4e53c6, - `/workspace/pgboss-rs` @ 98f7d9e) are reference checkouts of - third-party projects — read freely, but never wire the checkout - itself into our code. Use the published version unless we vendor or - fork it (alksocks' fast-socks5 precedent); if a fork is warranted, - forking is normal work. Note the checkout state when your findings - depend on code specifics; licenses and provenance get recorded when - we adopt code, not while only reading. -4. **POC code stays out of this repo** until Phase 1 adopts it. A POC - crate proves the hypothesis and reports findings; it does not - pre-scaffold the real crate layout. -5. **No mock/main-repo documentation.** No README, no CHANGELOG, no - crate-level docs until the shape exists. Docs that describe a thing - that doesn't exist are exactly the kind of dishonest artifact we - write docs to avoid. -6. **No comments in code** (when POC crates get written) unless the - user explicitly asks. Doc comments (`///`, `//!`) are fine on public - API. Inline `//` comments only when a non-obvious safety/correctness - constraint would otherwise be missed, or the user asks. - -## Conventions that will apply once the crate exists - -These are family-standard and pre-committed; they'll be duplicated into -task prompts and tightened by Phase 1 ADRs: +Family-standard and pre-committed; the ADRs carry the load-bearing +decisions: - Rust crate, `tokio` async runtime, `thiserror` error types, no panics in library code, no `unwrap()`/`expect()` outside tests. -- No comments in code (see 6 above). +- No comments in code unless the user explicitly asks. Doc comments + (`///`, `//!`) are fine on public API — and required where a task pins + semantics into doc text (core's docs are part of the contract). + Inline `//` comments only when a non-obvious safety/correctness + constraint would otherwise be missed, or the user asks. - Module-per-file under `src/`, re-exported from `src/lib.rs`; public API surface is the lib re-exports. -- Optional dependencies (the postgres engine likely being the first) - are feature-gated; the base crate compiles lean; both - `cargo test --features ` and default-feature builds pass when - features exist. -- Categorical estimates (`scope`, `risk`, `impact`, `level`) in every - task file — per `docs/sdd_process.md` these are structurally - required, not optional metadata. +- No CI wiring (manual CI per `docs/plans/implementation.md`). +- `#[non_exhaustive]` policy is ADR-017 §3's: on consumer-*read* types + (`Error`, `StreamEvent`, `Job`, `Schedule`, `Wake`), never on opts + structs consumers construct. +- Core (`alkstore`) has no driver dependencies, ever (ADR-001); payload + encoding's `serde`/`serde_json` are the sole core deps (ADR-020 §4). +- Engine crates depend on core with the versioned path pin (ADR-017 + §4.1) — never the reverse. + +## Task files + +Every task file in `tasks/` carries categorical estimates (`scope`, +`risk`, `impact`, `level`) — per `docs/sdd_process.md` these are +structurally required, not optional metadata. On completion: update the +task's `status` to `completed`, and fill its **Notes** (decisions of +record the implementation made that the description didn't pin) and +**Summary** (what landed, verified how) sections — the next agent reads +those instead of re-deriving them. ## Verification Commands -Phase 0 — none (no code yet). The doc conventions above are checked by -reading. When the crate appears, this section gets the standard Rust -gates (`cargo test`, `cargo clippy --all-targets -- -D warnings`, -`cargo fmt --check`, and the fuzz corpus replay if fuzzing is adopted — -the alksocks/alktty/alktunnels pattern) and becomes the merge gate the -coordinator runs. +Standard Rust gates, run from the workspace root; they are the merge +gate: + +``` +cargo build # workspace compiles +cargo test # workspace tests (task-scoped: cargo test -p ) +cargo clippy --all-targets -- -D warnings +cargo fmt --check +``` + +No fuzzing is adopted (no fuzz corpus replay gate — the +alksocks/alktty/alktunnels fuzz posture was not carried into this +crate's plan); revisit only if a task or ADR introduces it. ## Architecture Context -- `docs/research/phase-0.md` — the Phase 0 document: vision, prior - art, open questions (OQ-ST-01..NN), the POC register, and (eventually) - the converged recommendation. Read it before any non-trivial work in - this repo. -- `docs/research/consumer-inventory.md` — the per-feature scope - synthesis answering OQ-ST-01 from the paused consumers' documents. - New consumer needs get a row there *before* any scope decision - assumes them. -- `docs/sdd_process.md` — the SDD process. Phase 0 in progress; - `docs/architecture/` does not exist yet. +- `docs/architecture/README.md` — the decision index; start here. +- `docs/architecture/core-contract.md` — the contract of record for the + core crate's surface (the traits, types, error taxonomy, naming / + reserved namespace, delivery-guarantee table, verification backlog). +- `docs/architecture/decisions/` — the accepted ADRs (ADR-001..021). +- `docs/architecture/open-questions.md` — the open-question register; + resolved OQs cite their ADRs. Numbering is stable — never renumber; + append. +- `docs/plans/implementation.md` — the wave-based implementation plan; + `tasks/` decomposes its waves. +- `docs/research/` — the Phase 0 record (phase-0.md, POC specs and + findings, the honker/pgboss reference reads, + consumer-inventory.md). Reading it is background, not obligation; + the ADRs supersede it where they disagree. New consumer needs still + get a `docs/research/consumer-inventory.md` row *before* any scope + decision assumes them (the row-first gate, ADR-017 §2). - The SDD process and agent defs were copied from downstream projects (alkblobs) in the workspace; project-specific stale references were cleaned at Phase 0 setup (2026-10-03). If you find a section that assumes another crate's machinery, fix it rather than working around it — same discipline as stale TODOs. - If a TODO or doc reference cites a design direction that a later ADR - or Phase 0 decision rejected, the reference is stale — remove it and - align; do not implement the rejected design. \ No newline at end of file + or decision rejected, the reference is stale — remove it and align; + do not implement the rejected design. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a7fdd23..ddb6871 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,6 +5,9 @@ version = 4 [[package]] name = "alkstore" version = "0.1.0" +dependencies = [ + "thiserror", +] [[package]] name = "alkstore-postgres" diff --git a/alkstore/Cargo.toml b/alkstore/Cargo.toml index eea29e7..d215e9d 100644 --- a/alkstore/Cargo.toml +++ b/alkstore/Cargo.toml @@ -3,4 +3,7 @@ name = "alkstore" version.workspace = true edition.workspace = true license.workspace = true -repository.workspace = true \ No newline at end of file +repository.workspace = true + +[dependencies] +thiserror = "2" diff --git a/alkstore/src/error.rs b/alkstore/src/error.rs new file mode 100644 index 0000000..99edb47 --- /dev/null +++ b/alkstore/src/error.rs @@ -0,0 +1,61 @@ +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The payload exceeds the engine's notification payload limit. + /// + /// Universal variant: the Postgres engine produces it (the + /// client-side check on the serde_json serialization of the payload + /// `Value` — the same byte string ADR-020 §4 pins as the stored row + /// bytes); the SQLite engine never produces it (no limit). The + /// documented workaround for large payloads on Postgres is the + /// outbox shape — the id in the notification, the payload in a + /// table row. + #[error("payload exceeds the engine's notification payload limit (limit: {limit} bytes)")] + PayloadTooLarge { limit: usize }, + /// A name carrying the reserved `__alkstore_` prefix was supplied + /// to a shared-namespace entry point. + #[error( + "reserved name `{name}`: names carrying the reserved `__alkstore_` prefix are engine-owned" + )] + ReservedName { name: String }, + /// A name failed entry-point validation. + #[error("invalid name `{name}`")] + InvalidName { name: String }, + /// An operation against a closed receiver/source. + /// + /// The `try_recv`/`recv_timeout` form of wake close (never + /// returned by `recv_timeout`'s timeout expiry, which yields + /// `Ok(None)`); never a timeout signal. + #[error("the receiver or source is closed")] + Closed, + /// Payload serialization or deserialization failure. + /// + /// `payload_as` on stream events and job payloads; wakes carry no + /// payload, so no wake path produces this variant. + #[error("payload codec failure: {0}")] + Codec(String), + /// The opaque fallback: everything else. + /// + /// Driver/connection/SQL errors, opaque to the contract; engine + /// detail is preserved via the error source chain (`#[source]`). No + /// engine-specific variants are ever minted for its contents. + #[error("database error")] + Database(#[source] Box), +} + +impl Error { + /// Convenience constructor for the opaque [`Error::Database`] + /// fallback. + pub fn database(err: E) -> Self + where + E: Into>, + { + Error::Database(err.into()) + } +} + +/// The crate's result type — every fallible operation in the contract +/// surface returns this. `Ok` never carries a "null" sentinel: no-work +/// claims and unlanded job/lock ops are values, not errors (ADR-008 +/// §5). +pub type Result = std::result::Result; diff --git a/alkstore/src/lib.rs b/alkstore/src/lib.rs index 8b13789..9e07daa 100644 --- a/alkstore/src/lib.rs +++ b/alkstore/src/lib.rs @@ -1 +1,18 @@ +//! alkstore — the unified trait surface, value types, error model, and +//! contract documentation of the store contract (ADR-001, ADR-008). +//! +//! This crate is the contract artifact: no driver dependencies, ever. +//! Engine crates (`alkstore-sqlite`, `alkstore-postgres`) depend on +//! this crate and implement its traits. +mod error; +mod validation; + +pub use error::{Error, Result}; +pub use validation::{ + NameKind, RESERVED_LISTENER_RECONNECTED, RESERVED_PREFIX, validate_local_name, validate_name, + validate_shared_name, +}; + +#[cfg(test)] +mod tests; diff --git a/alkstore/src/tests.rs b/alkstore/src/tests.rs new file mode 100644 index 0000000..c1c3682 --- /dev/null +++ b/alkstore/src/tests.rs @@ -0,0 +1,131 @@ +use crate::validation::{ + NameKind, RESERVED_LISTENER_RECONNECTED, RESERVED_PREFIX, validate_local_name, validate_name, + validate_shared_name, +}; +use crate::{Error, Result}; + +fn assert_invalidname(r: Result<()>, expected: &str) { + match r { + Err(Error::InvalidName { name }) => assert_eq!(name, expected), + other => panic!("expected InvalidName({expected}), got {other:?}"), + } +} + +fn assert_reservedname(r: Result<()>, expected: &str) { + match r { + Err(Error::ReservedName { name }) => assert_eq!(name, expected), + other => panic!("expected ReservedName({expected}), got {other:?}"), + } +} + +#[test] +fn shared_name_accepts_legal_names() { + for legal in [ + "orders", + "a", + "names with spaces", + "__not_the_reserved_prefix", + "_alkstore_x", + "__alkstor_e_", + "__ALKSTORE_lower", + "uniße-名前", + ] { + validate_shared_name(legal).unwrap_or_else(|e| panic!("legal {legal:?} rejected: {e}")); + } +} + +#[test] +fn shared_name_rejects_empty() { + assert_invalidname(validate_shared_name(""), ""); +} + +#[test] +fn shared_name_rejects_whitespace_only() { + for ws in [" ", "\t", "\n", " \t \n "] { + assert_invalidname(validate_shared_name(ws), ws); + } +} + +#[test] +fn shared_name_rejects_reserved_prefix() { + for reserved in [ + "__alkstore_", + "__alkstore_outbox:foo", + "__alkstore_listener_reconnected__", + ] { + assert_reservedname(validate_shared_name(reserved), reserved); + } +} + +#[test] +fn local_name_accepts_legal_names() { + for legal in ["worker-1", "__alkstore_my_local_consumer"] { + validate_local_name(legal).unwrap_or_else(|e| panic!("legal {legal:?} rejected: {e}")); + } +} + +#[test] +fn local_name_rejects_empty_and_whitespace_only() { + assert_invalidname(validate_local_name(""), ""); + for ws in [" ", "\t\n "] { + assert_invalidname(validate_local_name(ws), ws); + } +} + +#[test] +fn name_kind_dispatch_via_validate_name() { + assert!(validate_name(NameKind::Shared, "__alkstore_x").is_err()); + assert!(validate_name(NameKind::Local, "__alkstore_x").is_ok()); + assert!(validate_name(NameKind::Shared, "ok").is_ok()); + assert!(validate_name(NameKind::Local, "ok").is_ok()); +} + +#[test] +fn error_display_texts() { + let e = Error::PayloadTooLarge { limit: 8000 }; + assert!(e.to_string().contains("8000")); + assert!( + Error::ReservedName { name: "x".into() } + .to_string() + .contains("x") + ); + assert!( + Error::InvalidName { name: "y".into() } + .to_string() + .contains("y") + ); + assert_eq!( + Error::Closed.to_string(), + "the receiver or source is closed" + ); + assert!( + Error::Codec("bad utf8 at 3".into()) + .to_string() + .contains("bad utf8") + ); + let db = std::io::Error::other("boom"); + let e = Error::Database(Box::new(db)); + assert!(e.to_string().contains("database")); + let src = std::error::Error::source(&e).expect("Database carries #[source]"); + assert_eq!(src.to_string(), "boom"); +} + +#[test] +fn error_database_constructor() { + let e = Error::database(std::io::Error::other("disk gone")); + match e { + Error::Database(source) => { + assert_eq!(source.to_string(), "disk gone"); + } + other => panic!("expected Database, got {other:?}"), + } +} + +#[test] +fn reserved_constants_exact() { + assert_eq!(RESERVED_PREFIX, "__alkstore_"); + assert_eq!( + RESERVED_LISTENER_RECONNECTED, + "__alkstore_listener_reconnected__" + ); +} diff --git a/alkstore/src/validation.rs b/alkstore/src/validation.rs new file mode 100644 index 0000000..971b8ed --- /dev/null +++ b/alkstore/src/validation.rs @@ -0,0 +1,69 @@ +//! Entry-point name validation (ADR-008 §4) and the reserved +//! namespace. +//! +//! Every name-bearing entry point validates before any engine round +//! trip — on the auto-commit and `*_tx` paths alike — via these +//! helpers. + +/// The reserved namespace prefix (ADR-008 §4). Engine-derived names in +/// consumer namespaces (e.g. an outbox's backing queue) carry it; +/// consumer-supplied shared-namespace names carrying it are rejected +/// with [`Error::ReservedName`](crate::Error::ReservedName). +pub const RESERVED_PREFIX: &str = "__alkstore_"; + +/// The one v1-reserved string (ADR-008 §4): the Postgres engine's +/// synthetic reconnect-wake channel, delivered as +/// `Wake { channel: RESERVED_LISTENER_RECONNECTED }` on every +/// subscriber's receiver after a watcher reconnect. Public in core so +/// the pg engine and the contract suite reference the same constant. +pub const RESERVED_LISTENER_RECONNECTED: &str = "__alkstore_listener_reconnected__"; + +/// Which namespace class a name belongs to (ADR-008 §4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NameKind { + /// Shared-namespace kinds — channels, streams, queues, outboxes, + /// locks, schedule names. Non-empty, and the reserved prefix is + /// rejected. + Shared, + /// Consumer-local identifiers — stream-consumer names, `owner` on + /// `try_lock`, `worker_id` on claims. Non-empty only: a leading + /// `__alkstore_` is legal (they are not a shared engine namespace, + /// so there is nothing to collide with). + Local, +} + +/// Validate a name per its [`NameKind`]. +/// +/// - non-empty (after no trimming — whitespace-only counts as +/// non-empty content but is nonetheless rejected below) → +/// [`Error::InvalidName`] when empty +/// - `NameKind::Shared` additionally rejects names carrying +/// [`RESERVED_PREFIX`] with [`Error::ReservedName`] +/// +/// Whitespace-only names are rejected as [`Error::InvalidName`] for +/// both kinds: they are never a meaningful name on any engine. +pub fn validate_name(kind: NameKind, name: &str) -> crate::Result<()> { + if name.is_empty() || name.trim().is_empty() { + return Err(crate::Error::InvalidName { + name: name.to_string(), + }); + } + if kind == NameKind::Shared && name.starts_with(RESERVED_PREFIX) { + return Err(crate::Error::ReservedName { + name: name.to_string(), + }); + } + Ok(()) +} + +/// Validate a shared-namespace name (channels, streams, queues, +/// outboxes, locks, schedule names). +pub fn validate_shared_name(name: &str) -> crate::Result<()> { + validate_name(NameKind::Shared, name) +} + +/// Validate a consumer-local identifier (stream-consumer names, `owner` +/// on `try_lock`, `worker_id` on claims): non-empty only. +pub fn validate_local_name(name: &str) -> crate::Result<()> { + validate_name(NameKind::Local, name) +} diff --git a/tasks/core-errors-and-validation.md b/tasks/core-errors-and-validation.md index 9899f4e..f64380d 100644 --- a/tasks/core-errors-and-validation.md +++ b/tasks/core-errors-and-validation.md @@ -1,7 +1,7 @@ --- id: core-errors-and-validation name: Core error taxonomy and name validation -status: pending +status: completed depends_on: [scaffold-workspace] scope: narrow risk: low @@ -65,8 +65,59 @@ engine and the contract suite reference the same constant. ## Notes -> To be filled by implementation agent +- `thiserror` v2 added to `alkstore` — the crate's first (and, until + wave 2's serde work, only) dependency. +- `Error::Database` carries + `#[source] Box` — the opaque + engine-detail carrier per ADR-008 §5. The `#[error(...)]` text is + deliberately terse ("database error") so it doesn't shadow the + source chain's detail. An `Error::database(err)` convenience + constructor wraps anything `Into>`. +- `Error::PayloadTooLarge { limit: usize }` — the limit is `usize` + (a byte count, not a std::io::ErrorKind-style re-export); doc text + carries the ADR-020 §4 serialization-quantity pin and the + pg-only/never-SQLite asymmetry. +- Validation helpers split by name class exactly per ADR-008 §4's + third-round annotation: `validate_shared_name` (six shared kinds — + non-empty + reserved-prefix rejection) and `validate_local_name` + (stream-consumer names, `try_lock` owner, `worker_id` — non-empty + only). `NameKind::Shared`/`NameKind::Local` + `validate_name(kind, + name)` is the underlying pair; the two named helpers are the + ergonomic surface. Whitespace-only names are rejected as + `InvalidName` for both kinds (never a meaningful name on any + engine); names are not trimmed (the caller's name is stored as + given — engine quoting is the engine's obligation). +- The reserved-string test asserts its shared-name rejection carries + `ReservedName` (it carries the reserved prefix, so it can never be + a legal *shared* name) while `validate_local_name` accepts it — the + two name classes distinguished in tests, mirroring the ADR text. +- `#[non_exhaustive]` was verified on the enum definition, not in + this crate's tests: in-crate matching stays exhaustive (the + mechanical catch-all enforcement binds downstream crates, per the + Rust rules ADR-017 §3 relies on) — a test here asserting a + necessary wildcard is unreachable-code, flagged clippy `warn(unreachable_patterns)`. +- `schedule()`/`run_schedules`' `InvalidSpec`/`LeadershipLost` + variants (core-contract §Errors, ADR-009 §6) are *not* minted here + — the six-variant list is this task's exact scope; the scheduler + variants land with the trait-surface task that pins their + signatures. ## Summary -> To be filled on completion \ No newline at end of file +Core error taxonomy and name validation implemented in `alkstore`. +`src/error.rs`: `Error` — exactly the six v1 variants +(`PayloadTooLarge { limit }`, `ReservedName { name }`, +`InvalidName { name }`, `Closed`, `Codec(String)`, `Database` +with `#[source]`), `thiserror`-derived, `#[non_exhaustive]`, plus +the `Result` alias (the prelude shape downstream crates use). +`src/validation.rs`: `RESERVED_PREFIX = "__alkstore_"`, +`RESERVED_LISTENER_RECONNECTED = +"__alkstore_listener_reconnected__"` (public for the pg engine and +contract suite), `NameKind::{Shared, Local}`, and +`validate_shared_name`/`validate_local_name`/`validate_name` +helpers. All types re-exported from `src/lib.rs`. Ten unit tests +cover empty/whitespace-only/reserved-prefix/reserved-string/legal +names for both classes and the error display/source behavior. +`cargo test -p alkstore` (10 pass), `cargo clippy +--all-targets -- -D warnings`, and `cargo fmt --check` all clean; +workspace `cargo build` compiles all three crates. \ No newline at end of file