Core error taxonomy + name validation (ADR-008 §4/§5, ADR-017 §3); AGENTS.md updated to Phase 1 posture

This commit is contained in:
glm-5.3-flash committed 2026-10-07 15:04:04 +00:00
1 parent 34e0b9732d
commit 74105a16ae
8 files changed
+413 -84

No files matched your search

+4 -1
View File
@@ -3,4 +3,7 @@ name = "alkstore"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
repository.workspace = true
[dependencies]
thiserror = "2"
+61
View File
@@ -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<dyn std::error::Error + Send + Sync>),
}
impl Error {
/// Convenience constructor for the opaque [`Error::Database`]
/// fallback.
pub fn database<E>(err: E) -> Self
where
E: Into<Box<dyn std::error::Error + Send + Sync>>,
{
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<T> = std::result::Result<T, Error>;
+17
View File
@@ -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;
+131
View File
@@ -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__"
);
}
+69
View File
@@ -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)
}