Core error taxonomy + name validation (ADR-008 §4/§5, ADR-017 §3); AGENTS.md updated to Phase 1 posture
This commit is contained in:
1 parent
34e0b9732d
commit
74105a16ae
8 files changed
+413
-84
No files matched your search
@@ -7,27 +7,30 @@ unless their own prompts say otherwise.
|
|||||||
|
|
||||||
## Repo posture
|
## Repo posture
|
||||||
|
|
||||||
This repo is in **Phase 0 (Exploration)** per `docs/sdd_process.md`.
|
This repo is in **Phase 1 (Implementation)** per `docs/sdd_process.md`.
|
||||||
There is no crate yet — no `Cargo.toml`, no `src/`, no README (the README
|
Phase 0 concluded with the converged recommendation and the accepted ADRs
|
||||||
gets written just before first publish, so it can be honest about what
|
(`docs/architecture/decisions/`, `docs/architecture/README.md`); the
|
||||||
shipped). What exists is `docs/research/phase-0.md` (the Phase 0
|
implementation plan (`docs/plans/implementation.md`) decomposes waves of
|
||||||
document: vision, prior art, open questions, POC register), the SDD
|
work into task files in `tasks/`. Three crates exist (ADR-001's Cargo
|
||||||
process (`docs/sdd_process.md`), and the agent defs
|
workspace split): `alkstore` (core — the contract artifact),
|
||||||
(`.opencode/agents/`). Do not scaffold implementation code
|
`alkstore-sqlite`, `alkstore-postgres`. Work proceeds task-by-task from
|
||||||
from this posture unless a task or the user asks for a POC.
|
`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
|
The architecture documents — `docs/architecture/` (core-contract.md, the
|
||||||
a working research document whose open questions are genuinely open.
|
ADRs, the engine specs) — are the normative spec for code work. Where
|
||||||
The hedging rules of `docs/sdd_process.md` (no circular deferrals, no
|
code and ADR disagree, the ADR wins: fix the code, or (if the ADR is
|
||||||
"resolved with escape hatches") still apply to it.
|
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
|
## Git Workflow
|
||||||
|
|
||||||
**Commit and push when reasonable.** When a change is complete and
|
**Commit and push when reasonable.** When a change is complete and
|
||||||
verified, commit and push to `origin/main` without asking. This
|
verified, commit and push to `origin/main` without asking. This
|
||||||
overrides the built-in default of "only commit when explicitly asked."
|
overrides the built-in default of "only commit when explicitly asked."
|
||||||
In Phase 0 "verified" means the doc checks below — there is no build
|
"Verified" means the Verification Commands below pass for any crate the
|
||||||
gate yet.
|
change touches.
|
||||||
|
|
||||||
Exceptions — **do not** commit or push without asking:
|
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
|
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`.
|
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
|
Family-standard and pre-committed; the ADRs carry the load-bearing
|
||||||
specifications, and POC findings all go there, named consistently
|
decisions:
|
||||||
(`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:
|
|
||||||
|
|
||||||
- Rust crate, `tokio` async runtime, `thiserror` error types, no panics
|
- Rust crate, `tokio` async runtime, `thiserror` error types, no panics
|
||||||
in library code, no `unwrap()`/`expect()` outside tests.
|
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
|
- Module-per-file under `src/`, re-exported from `src/lib.rs`; public
|
||||||
API surface is the lib re-exports.
|
API surface is the lib re-exports.
|
||||||
- Optional dependencies (the postgres engine likely being the first)
|
- No CI wiring (manual CI per `docs/plans/implementation.md`).
|
||||||
are feature-gated; the base crate compiles lean; both
|
- `#[non_exhaustive]` policy is ADR-017 §3's: on consumer-*read* types
|
||||||
`cargo test --features <set>` and default-feature builds pass when
|
(`Error`, `StreamEvent`, `Job`, `Schedule`, `Wake`), never on opts
|
||||||
features exist.
|
structs consumers construct.
|
||||||
- Categorical estimates (`scope`, `risk`, `impact`, `level`) in every
|
- Core (`alkstore`) has no driver dependencies, ever (ADR-001); payload
|
||||||
task file — per `docs/sdd_process.md` these are structurally
|
encoding's `serde`/`serde_json` are the sole core deps (ADR-020 §4).
|
||||||
required, not optional metadata.
|
- 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
|
## Verification Commands
|
||||||
|
|
||||||
Phase 0 — none (no code yet). The doc conventions above are checked by
|
Standard Rust gates, run from the workspace root; they are the merge
|
||||||
reading. When the crate appears, this section gets the standard Rust
|
gate:
|
||||||
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
|
cargo build # workspace compiles
|
||||||
coordinator runs.
|
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
|
## Architecture Context
|
||||||
|
|
||||||
- `docs/research/phase-0.md` — the Phase 0 document: vision, prior
|
- `docs/architecture/README.md` — the decision index; start here.
|
||||||
art, open questions (OQ-ST-01..NN), the POC register, and (eventually)
|
- `docs/architecture/core-contract.md` — the contract of record for the
|
||||||
the converged recommendation. Read it before any non-trivial work in
|
core crate's surface (the traits, types, error taxonomy, naming /
|
||||||
this repo.
|
reserved namespace, delivery-guarantee table, verification backlog).
|
||||||
- `docs/research/consumer-inventory.md` — the per-feature scope
|
- `docs/architecture/decisions/` — the accepted ADRs (ADR-001..021).
|
||||||
synthesis answering OQ-ST-01 from the paused consumers' documents.
|
- `docs/architecture/open-questions.md` — the open-question register;
|
||||||
New consumer needs get a row there *before* any scope decision
|
resolved OQs cite their ADRs. Numbering is stable — never renumber;
|
||||||
assumes them.
|
append.
|
||||||
- `docs/sdd_process.md` — the SDD process. Phase 0 in progress;
|
- `docs/plans/implementation.md` — the wave-based implementation plan;
|
||||||
`docs/architecture/` does not exist yet.
|
`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
|
- The SDD process and agent defs were copied from downstream projects
|
||||||
(alkblobs) in the workspace; project-specific stale references were
|
(alkblobs) in the workspace; project-specific stale references were
|
||||||
cleaned at Phase 0 setup (2026-10-03). If you find a section that
|
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
|
assumes another crate's machinery, fix it rather than working around
|
||||||
it — same discipline as stale TODOs.
|
it — same discipline as stale TODOs.
|
||||||
- If a TODO or doc reference cites a design direction that a later ADR
|
- 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
|
or decision rejected, the reference is stale — remove it and align;
|
||||||
align; do not implement the rejected design.
|
do not implement the rejected design.
|
||||||
Generated
+3
@@ -5,6 +5,9 @@ version = 4
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "alkstore"
|
name = "alkstore"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alkstore-postgres"
|
name = "alkstore-postgres"
|
||||||
|
|||||||
+4
-1
@@ -3,4 +3,7 @@ name = "alkstore"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror = "2"
|
||||||
@@ -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>;
|
||||||
@@ -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;
|
||||||
@@ -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__"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: core-errors-and-validation
|
id: core-errors-and-validation
|
||||||
name: Core error taxonomy and name validation
|
name: Core error taxonomy and name validation
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [scaffold-workspace]
|
depends_on: [scaffold-workspace]
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: low
|
risk: low
|
||||||
@@ -65,8 +65,59 @@ engine and the contract suite reference the same constant.
|
|||||||
|
|
||||||
## Notes
|
## 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
|
## 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.
|
||||||
Reference in new issue
Block a user