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

+74 -80
View File
@@ -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 <glm-5.3-flash@alk.dev>`). 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-<name>-spec.md` / `poc-<name>-findings.md` follows the
alkblobs precedent). A POC that needs this repo's code runs in a
worktree (`.worktrees/research/<task-id>/` 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-<name>-spec.md`, findings in
`poc-<name>-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 <set>` 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 <crate>)
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.
or decision rejected, the reference is stale — remove it and align;
do not implement the rejected design.
Generated
+3
View File
@@ -5,6 +5,9 @@ version = 4
[[package]]
name = "alkstore"
version = "0.1.0"
dependencies = [
"thiserror",
]
[[package]]
name = "alkstore-postgres"
+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)
}
+54 -3
View File
@@ -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<dyn std::error::Error + Send + Sync>` — 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<Box<dyn Error + Send + Sync>>`.
- `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
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<T>` 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.