refactor(wave17): cleanup — kei-shared SSoT + MEDIUM audit residuals + docs drift
47 crates, 771 tests green (up from 753 at v0.33.0). Zero new features — pure hygiene. ## kei-shared extract (SSoT for DNA format) New crate `kei-shared` consolidates DNA-parse logic that was duplicated across kei-agent-runtime + kei-dna-index. Both consumers migrated to import ParsedDna / parse_dna / is_hex8 from kei_shared. - 12 tests (10 integration + 2 unit) - kei-dna-index LOC reduction: -60 in parsed.rs (body replaced by wrapper) - kei-agent-runtime preserves lenient DnaError (legacy 4-hex parse path) - Format-string SSoT: kei_shared::compose_dna is sole source ## MEDIUM audit residuals closed (kei-entity-store) A. DDL panic coverage — verified exhaustive match across all 12 FieldKind variants; new test ddl_never_panics_on_any_fieldkind compile-time-breaks if a variant added without test update. B. Update FTS reindex invariant — doc + new update_invariant.rs module with debug_assert validating non-input FTS columns don't drift pre/post UPDATE. Zero release-mode cost (cfg-gated). C. WAL fallback — wal_pragma_fallback_keeps_store_usable test (cfg(unix)) verifies read-only-parent dir doesn't brick Store::open. D. Search Unicode edge cases — 4 new tests (punctuation, emoji, zero-width, mixed RTL). has_searchable_token already correct, no source change needed; tests pin current behavior. Added: residual_audit_smoke.rs (8 tests), update_invariant.rs module. kei-entity-store: 57 → 65 tests. ## Docs drift fixed (count claims → reality) - README.md: "36 crates → 47 crates", "500+ tests → 800+ tests" - PLUGIN.md, docs/INSTALL.md, docs/REFERENCE.md, docs/SUBSTRATE-SCHEMA.md all synced to real counts. - CHANGELOG.md: 6 new version blocks (v0.28 → v0.33) consolidated in existing style. - Historical snapshots (HANDOFF-WAKE v0.29, CONVERGENCE-PLAN, etc) deliberately preserved — they're version-scoped, not drift. ## Known deviation from task spec kei-shared's [workspace] table was dropped (Cargo rejected "multiple workspace roots" when parent workspace pulls via path dep). Crate registered in workspace.members instead. Verified cargo check + test clean in both modes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f5bbf7193e
commit
9622d41bb6
21 changed files with 918 additions and 100 deletions
75
CHANGELOG.md
75
CHANGELOG.md
|
|
@ -114,6 +114,81 @@ _primitives/_rust/target/release/kei-changelog \
|
|||
- Added `.github/dependabot.yml` for weekly SHA update PRs on github-actions, npm, and cargo ecosystems.
|
||||
- **v0.20.1 — workflow validation defense-in-depth:** motivated by the 2026-04-22 incident where `dtolnay/rust-toolchain@3c5f7ea...` SHA-pinned a specific Rust version (1.94.1 branch tip) instead of "install current stable", breaking CI for 4 jobs. Added three gates against the incident class: `scripts/install-actionlint.sh` (pinned v1.7.12 installer, macOS-arm64 + linux-x64), `scripts/lint-workflows.sh` (actionlint runner, advisory if binary missing), `scripts/validate-workflow-shas.sh` (git-ls-remote every `uses: <repo>@<sha40>` pin; exits 1 on `SHA MISSING`, soft-continues on network errors with `[UNVERIFIED]`), `scripts/pre-commit-workflow-lint.sh` (symlink-to-install pre-commit hook, fires only when workflow files are staged), and new `workflow-lint` CI job running the two validators on every push + PR.
|
||||
|
||||
## [0.33.0] — 2026-04-23
|
||||
|
||||
Wave 16 — parallel consumer wiring. Ledger v6 + cluster-aware prune + DNA-driven fork precedent + three-role pipeline. Consolidates v0.28 → v0.33.
|
||||
|
||||
### Added
|
||||
- **primitives:** `kei-fork` watch-hook auto-collects on `.DONE` marker
|
||||
- **primitives:** `kei-prune` cluster-based retirement via `kei-dna-index` clusters
|
||||
- **primitives:** `kei-brain-view` cluster + summary visualization
|
||||
- **pipeline:** three-role pipeline (Writer → Auditor → Merger) with precedent pre-check
|
||||
|
||||
### Changed
|
||||
- **kei-ledger:** schema v6 — 3 performance indexes + `fork_transactional` library API
|
||||
|
||||
### Snapshot
|
||||
- 47 crates workspace
|
||||
- 800+ tests green total
|
||||
|
||||
## [0.32.0] — 2026-04-23
|
||||
|
||||
Wave 15 Option D — DNA adjacency + managed fork primitive.
|
||||
|
||||
### Added
|
||||
- **primitives:** `kei-dna-index` — read-only adjacency / cluster / precedent view over the ledger
|
||||
- **primitives:** `kei-fork` — managed git-worktree + ledger lifecycle
|
||||
|
||||
### Changed
|
||||
- **kei-fork:** root path moved to `_forks/` (sandbox-writable, kit convention)
|
||||
|
||||
## [0.31.0] — 2026-04-23
|
||||
|
||||
Wave 15 foundation — spawn hardening + entity-store medium fixes.
|
||||
|
||||
### Added
|
||||
- **kei-spawn:** `HttpDriver` feature-flag behind `http-driver`
|
||||
|
||||
### Fixed
|
||||
- **security:** `agent_id` path-traversal validator + `safe_join` hardening
|
||||
- **kei-entity-store:** medium audit fixes (ddl panic, search empty-token, WAL logging)
|
||||
|
||||
## [0.30.0] — 2026-04-23
|
||||
|
||||
Wave 14 — bio-inspired primitives.
|
||||
|
||||
### Added
|
||||
- **primitives:** `kei-prune` — bio-inspired retirement of idle agents
|
||||
- **primitives:** `kei-discover` — federated marketplace discovery stub
|
||||
- **primitives:** `kei-brain-view` — brain-state visualizer
|
||||
- **primitives:** `kei-hibernate` — agent hibernation / reawaken lifecycle
|
||||
- **primitives:** `kei-ledger-sign` — signing + verification for ledger rows
|
||||
|
||||
### Snapshot
|
||||
- 44 crates workspace
|
||||
- 713 tests green
|
||||
|
||||
## [0.29.0] — 2026-04-22
|
||||
|
||||
Wave 13 — structural diff, scheduler, watcher + HIGH audit fixes.
|
||||
|
||||
### Added
|
||||
- **primitives:** `kei-diff` — RFC 6902 JSON Patch subset (add / remove / replace)
|
||||
- **primitives:** `kei-scheduler` — cron / at / interval metadata primitive
|
||||
- **primitives:** `kei-watch` — filesystem watcher (thin `notify` wrapper, sync API)
|
||||
|
||||
### Fixed
|
||||
- **fts:** delete-transaction + archive FTS desync fixed
|
||||
- **kei-dna-index:** `UNIQUE` constraint (v5 migration)
|
||||
|
||||
## [0.28.0] — 2026-04-23
|
||||
|
||||
Wave 12 — count refresh + content-store engine promotion.
|
||||
|
||||
### Changed
|
||||
- **kei-content-store:** `CAMPAIGNS_SCHEMA` promoted to engine
|
||||
- **docs:** counts refreshed across README / INSTALL / REFERENCE after v0.23 → v0.27 cluster
|
||||
|
||||
## [0.15.0] — 2026-04-22
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ Paths inside `hooks/hooks.json` use `${CLAUDE_PLUGIN_ROOT}` (expanded by Claude
|
|||
| Skills registered | yes, automatic | yes, copied to `~/.claude/skills/` |
|
||||
| Hooks wired | yes, via `hooks/hooks.json` | requires `--activate-hooks` (jq-merge of `settings-snippet.json`) |
|
||||
| MCP server | yes, via `.mcp.json` (once `@keisei/mcp-server` is published) | same |
|
||||
| 36 Rust primitives | **no** — plugin ships manifest sources only; no cargo build | yes, `--profile=<name>` builds the selected set |
|
||||
| 47 Rust primitives | **no** — plugin ships manifest sources only; no cargo build | yes, `--profile=<name>` builds the selected set |
|
||||
| 13 shell primitives | **no** | yes, copied to `~/.claude/agents/_primitives/` |
|
||||
| Disk footprint | ~2 MB (plugin cache) | ~2 MB minimal up to ~200 MB full |
|
||||
| Update path | `/plugin update keisei` | `git pull && ./install.sh` |
|
||||
|
|
|
|||
|
|
@ -48,14 +48,14 @@ Layer 1 is the body — the reusable parts. Layer 2 is identity and memory. Laye
|
|||
|
||||
## What ships (verified counts)
|
||||
|
||||
- **36 Rust primitives** — pure Rust workspace crates, release-stripped, each ≤2 MB, no Python runtime. Covers the ledger, memory, router, migrate, agent runtime, forge, spawn/replay, sleep infrastructure, and the `keisei` CLI.
|
||||
- **47 Rust primitives** — pure Rust workspace crates, release-stripped, each ≤2 MB, no Python runtime. Covers the ledger, memory, router, migrate, agent runtime, forge, spawn/replay, fork lifecycle, DNA adjacency/cluster index, pruning + discovery + brain-view, sleep infrastructure, and the `keisei` CLI.
|
||||
- **12 agents** (`kei-*` namespaced) — code-implementer, infra-implementer, ml-implementer, critic, validator, security-auditor, architect, researcher, ml-researcher, cost-guardian, modal-runner, fal-ai-runner. All carry a `substrate_role` facet.
|
||||
- **43 skills** — one-command pipelines including `/new-project`, `/spawn-agent`, `/self-audit`, `/sleep-on-it`, `/sleep-setup`, `/compose-solution`, `/schema-design`, `/api-design`, `/auth-setup`, `/observability-setup`, `/ci-scaffold`, `/pr-review`, `/debug-deep`.
|
||||
- **12 hooks** — pre-commit safety net, always on: assembler, validator, no-hand-edit-agents, tomd-preread, agent-fork-logger, orchestrator-dirty-check, site-wysiwyd-check, session-end-dump, milestone-commit-hook, error-spike-detector, and two capability gates.
|
||||
- **82 behavioural blocks** — tested patterns composable into your own agents via `blocks = [...]` in a manifest.
|
||||
- **11 capabilities / 5 roles** — the capability-graph that agents resolve at spawn time.
|
||||
- **11 cross-tool bridges** — one source of truth emits `.cursorrules`, Cursor MDC, `AGENTS.md`, Copilot, Windsurf, Junie, Continue, Gemini, Aider, Replit. Switch AI tools without rewriting your setup.
|
||||
- **500+ tests** across the Rust workspace, green on `cargo test --workspace` on every supported OS.
|
||||
- **800+ tests** across the Rust workspace, green on `cargo test --workspace` on every supported OS.
|
||||
|
||||
Every number is regenerated from source by `scripts/regen-counts.sh` — no manual drift.
|
||||
|
||||
|
|
|
|||
10
_primitives/_rust/Cargo.lock
generated
10
_primitives/_rust/Cargo.lock
generated
|
|
@ -2337,6 +2337,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"kei-shared",
|
||||
"once_cell",
|
||||
"rand 0.8.6",
|
||||
"regex",
|
||||
|
|
@ -2538,6 +2539,7 @@ name = "kei-dna-index"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"kei-shared",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2808,6 +2810,14 @@ dependencies = [
|
|||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kei-shared"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kei-social-store"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ members = [
|
|||
"kei-ledger-sign",
|
||||
# v0.31 Wave 15 — managed git worktree + ledger lifecycle (fork/collect/gc/rescue)
|
||||
"kei-fork",
|
||||
# v0.34 Wave 17 — SSoT for DNA format + shared substrate types
|
||||
"kei-shared",
|
||||
# v0.32 Wave 15 — read-only DNA adjacency/cluster/precedent over kei-ledger
|
||||
"kei-dna-index",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ once_cell = "1"
|
|||
walkdir = "2"
|
||||
sha2 = { workspace = true }
|
||||
rand = "0.8"
|
||||
kei-shared = { path = "../kei-shared" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -20,12 +20,23 @@
|
|||
//! so future schema extensions don't break old ledger rows. For rolling
|
||||
//! upgrade, 4-hex legacy hash/nonce values still parse silently — the
|
||||
//! fallback is a successful parse path, not an error.
|
||||
//!
|
||||
//! Wire-format SSoT lives in `kei_shared::dna` — `render()` delegates to
|
||||
//! `kei_shared::compose_dna` so the format string exists in one place.
|
||||
//! Strict parser primitives from `kei_shared` (`parse_dna`, `ParsedDna`,
|
||||
//! `is_hex8`) are re-exported for callers that want width validation;
|
||||
//! the in-crate lenient `Dna::parse` stays for rolling-upgrade support.
|
||||
|
||||
use crate::capability::TaskSpec;
|
||||
use crate::role::ResolvedRole;
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Re-export of the strict wire-format parser from `kei_shared::dna`.
|
||||
/// Callers needing 8-hex width validation (e.g. kei-dna-index) use these;
|
||||
/// rolling-upgrade callers use the lenient [`Dna::parse`] below.
|
||||
pub use kei_shared::dna::{is_hex8, parse_dna, ParsedDna};
|
||||
|
||||
/// Capability-name → 2-char atom code lookup.
|
||||
///
|
||||
/// Stable, extensible — additions allowed; removals NOT. `compose` emits
|
||||
|
|
@ -55,7 +66,12 @@ pub struct Dna {
|
|||
pub nonce: String,
|
||||
}
|
||||
|
||||
/// Error during DNA parsing.
|
||||
/// Error during lenient rolling-upgrade DNA parsing.
|
||||
///
|
||||
/// Distinct from [`kei_shared::dna::DnaError`]: this variant is lenient
|
||||
/// (accepts legacy 4-hex segment widths), and shape-failure is the only
|
||||
/// error class. Segment-content validation is deferred to callers that
|
||||
/// care about widths — they can re-parse with `kei_shared::parse_dna`.
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum DnaError {
|
||||
#[error("DNA string must have 4 `::` segments and `<body>-<nonce>` tail")]
|
||||
|
|
@ -80,11 +96,15 @@ impl Dna {
|
|||
}
|
||||
}
|
||||
|
||||
/// Render to the canonical wire format.
|
||||
/// Render to the canonical wire format. Delegates the format-string
|
||||
/// SSoT to `kei_shared::dna::compose_dna`.
|
||||
pub fn render(&self) -> String {
|
||||
format!(
|
||||
"{}::{}::{}::{}-{}",
|
||||
self.role, self.caps_bitmap, self.scope_hash, self.body_hash, self.nonce
|
||||
kei_shared::dna::compose_dna(
|
||||
&self.role,
|
||||
&self.caps_bitmap,
|
||||
&self.scope_hash,
|
||||
&self.body_hash,
|
||||
&self.nonce,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ clap = { version = "4", features = ["derive"] }
|
|||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
kei-shared = { path = "../kei-shared" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -1,82 +1,22 @@
|
|||
//! DNA parser.
|
||||
//! DNA parser — thin wrapper over `kei_shared::dna`.
|
||||
//!
|
||||
//! Format: `<role>::<caps>::<sha8-scope>::<sha8-body>-<hex8-nonce>`
|
||||
//! Example: `edit-local::NG-FW-FD-CP-CG-TG-ND-RF::5435F821::AC73A6A3-e9bf468d`
|
||||
//!
|
||||
//! Wire-format SSoT lives in `kei_shared::dna`. This module re-exports
|
||||
//! `ParsedDna` and exposes `split_dna` that maps `kei_shared::DnaError`
|
||||
//! into `crate::Error::MalformedDna` so callers keep a single error type.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ParsedDna {
|
||||
pub role: String,
|
||||
pub caps: String,
|
||||
pub scope_sha: String,
|
||||
pub body_sha: String,
|
||||
pub nonce: String,
|
||||
}
|
||||
pub use kei_shared::dna::ParsedDna;
|
||||
|
||||
/// Parse a DNA string into its five fields. Hex widths are validated.
|
||||
/// Errors are wrapped in [`Error::MalformedDna`] with the raw DNA included
|
||||
/// for debuggability, matching the pre-extraction contract.
|
||||
pub fn split_dna(dna: &str) -> Result<ParsedDna> {
|
||||
let parts: Vec<&str> = dna.split("::").collect();
|
||||
if parts.len() != 4 {
|
||||
return Err(Error::MalformedDna(format!(
|
||||
"expected 4 '::'-segments, got {}: {}",
|
||||
parts.len(),
|
||||
dna
|
||||
)));
|
||||
}
|
||||
let role = parts[0].to_string();
|
||||
let caps = parts[1].to_string();
|
||||
let scope_sha = parts[2].to_string();
|
||||
let tail = parts[3];
|
||||
|
||||
if role.is_empty() {
|
||||
return Err(Error::MalformedDna(format!("empty role: {}", dna)));
|
||||
}
|
||||
if caps.is_empty() {
|
||||
return Err(Error::MalformedDna(format!("empty caps: {}", dna)));
|
||||
}
|
||||
if !is_hex8(&scope_sha) {
|
||||
return Err(Error::MalformedDna(format!(
|
||||
"scope_sha not 8 hex chars: {}",
|
||||
scope_sha
|
||||
)));
|
||||
}
|
||||
|
||||
let tail_parts: Vec<&str> = tail.split('-').collect();
|
||||
if tail_parts.len() != 2 {
|
||||
return Err(Error::MalformedDna(format!(
|
||||
"expected '<body_sha>-<nonce>' tail, got: {}",
|
||||
tail
|
||||
)));
|
||||
}
|
||||
let body_sha = tail_parts[0].to_string();
|
||||
let nonce = tail_parts[1].to_string();
|
||||
|
||||
if !is_hex8(&body_sha) {
|
||||
return Err(Error::MalformedDna(format!(
|
||||
"body_sha not 8 hex chars: {}",
|
||||
body_sha
|
||||
)));
|
||||
}
|
||||
if !is_hex8(&nonce) {
|
||||
return Err(Error::MalformedDna(format!(
|
||||
"nonce not 8 hex chars: {}",
|
||||
nonce
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(ParsedDna {
|
||||
role,
|
||||
caps,
|
||||
scope_sha,
|
||||
body_sha,
|
||||
nonce,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_hex8(s: &str) -> bool {
|
||||
s.len() == 8 && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
kei_shared::dna::parse_dna(dna)
|
||||
.map_err(|e| Error::MalformedDna(format!("{e}: {dna}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub mod pk;
|
|||
pub mod rank;
|
||||
pub mod search;
|
||||
pub mod update;
|
||||
pub(crate) mod update_invariant;
|
||||
pub mod validate;
|
||||
|
||||
/// Full list of supported verbs — SSoT for documentation + schema
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
//! `update` verb — partial update by id. Only keys that appear in
|
||||
//! the input JSON and that are declared on the schema are written.
|
||||
//!
|
||||
//! Type discipline: when a key is present its JSON kind MUST match the
|
||||
//! field kind. Mismatch → `VerbError::InvalidType` (no silent coercion).
|
||||
//! UPDATE + FTS reindex run in a single transaction so a mid-flight
|
||||
//! failure leaves neither the row nor the FTS entry in a torn state.
|
||||
//! `update` verb — partial update by id. Only declared schema keys
|
||||
//! that appear in the input JSON are written. Type mismatch →
|
||||
//! `InvalidType` (no silent coercion). UPDATE + FTS reindex run in a
|
||||
//! single transaction so a mid-flight failure leaves neither the row
|
||||
//! nor the FTS entry in a torn state.
|
||||
|
||||
use crate::error::VerbError;
|
||||
use crate::schema::{EntitySchema, FieldDef, FieldKind};
|
||||
use crate::verbs::pk::{self, PkValue};
|
||||
use crate::verbs::update_invariant as inv;
|
||||
use crate::verbs::validate;
|
||||
use chrono::Utc;
|
||||
use rusqlite::{types::Value as SqlValue, Connection};
|
||||
|
|
@ -52,9 +51,12 @@ fn update_tx(
|
|||
obj: &serde_json::Map<String, Value>,
|
||||
) -> Result<(), VerbError> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
// Debug-build snapshot for the non-input-FTS-stable invariant
|
||||
// asserted in `reindex_fts`. Release: empty map, no SELECT.
|
||||
let pre_update = inv::pre_update_snapshot(&tx, schema, id);
|
||||
exec_update_tx(&tx, schema, id, set_cols, values)?;
|
||||
if let Some(cols) = schema.fts_columns {
|
||||
reindex_fts(&tx, schema, cols, id, obj)?;
|
||||
reindex_fts(&tx, schema, cols, id, obj, &pre_update)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
|
|
@ -67,15 +69,12 @@ fn exec_update_tx(
|
|||
set_cols: &[&'static str],
|
||||
values: Vec<SqlValue>,
|
||||
) -> Result<(), VerbError> {
|
||||
let placeholders: Vec<String> =
|
||||
(1..=set_cols.len()).map(|i| format!("{} = ?{i}", set_cols[i - 1])).collect();
|
||||
let placeholders: Vec<String> = (1..=set_cols.len())
|
||||
.map(|i| format!("{} = ?{i}", set_cols[i - 1])).collect();
|
||||
let id_idx = set_cols.len() + 1;
|
||||
let sql = format!(
|
||||
"UPDATE {} SET {} WHERE {}=?{}",
|
||||
schema.table,
|
||||
placeholders.join(", "),
|
||||
schema.pk().name,
|
||||
id_idx
|
||||
schema.table, placeholders.join(", "), schema.pk().name, id_idx,
|
||||
);
|
||||
let mut all: Vec<SqlValue> = values;
|
||||
all.push(id.as_sql());
|
||||
|
|
@ -122,15 +121,23 @@ fn value_from_input(
|
|||
Ok(Some(validate::coerce(f, raw)?))
|
||||
}
|
||||
|
||||
/// Rebuild the FTS5 row after the primary UPDATE. INVARIANT: FTS
|
||||
/// columns NOT in `input` keep their pre-UPDATE value through the
|
||||
/// UPDATE (holds while UPDATE only touches `input` columns). Proof
|
||||
/// and debug-build assertion in `verbs/update_invariant.rs`.
|
||||
fn reindex_fts(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
schema: &EntitySchema,
|
||||
cols: &[&str],
|
||||
id: &PkValue,
|
||||
input: &serde_json::Map<String, Value>,
|
||||
pre_update: &serde_json::Map<String, Value>,
|
||||
) -> Result<(), VerbError> {
|
||||
let table = schema.table;
|
||||
let existing = read_existing_fts(tx, schema, cols, id)?;
|
||||
#[cfg(debug_assertions)]
|
||||
inv::debug_assert_non_input_fts_stable(cols, input, pre_update, &existing);
|
||||
let _ = pre_update; // unused in release builds
|
||||
tx.execute(
|
||||
&format!("DELETE FROM fts_{table} WHERE {table}_id=?1"),
|
||||
rusqlite::params![id.as_sql()],
|
||||
|
|
@ -167,7 +174,7 @@ fn fts_row_values(
|
|||
values
|
||||
}
|
||||
|
||||
fn read_existing_fts(
|
||||
pub(super) fn read_existing_fts(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
schema: &EntitySchema,
|
||||
cols: &[&str],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
//! Debug-only invariant helpers for the `update` verb.
|
||||
//!
|
||||
//! Split out of `verbs/update.rs` so that file stays within the
|
||||
//! Constructor-Pattern 200-LOC cap. The functions here encode the FTS
|
||||
//! reindex contract: columns NOT present in an UPDATE's input JSON
|
||||
//! must not change during the UPDATE.
|
||||
//!
|
||||
//! `cfg(debug_assertions)` gates both the snapshot SELECT and the
|
||||
//! assertion itself — release builds compile this module down to a
|
||||
//! no-op snapshot that returns an empty map.
|
||||
|
||||
use crate::schema::EntitySchema;
|
||||
use crate::verbs::pk::PkValue;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Snapshot FTS columns BEFORE an UPDATE runs. Debug builds read the
|
||||
/// row via `read_existing_fts`; release builds skip the read and
|
||||
/// return an empty map.
|
||||
#[cfg(debug_assertions)]
|
||||
pub(super) fn pre_update_snapshot(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
schema: &EntitySchema,
|
||||
id: &PkValue,
|
||||
) -> serde_json::Map<String, Value> {
|
||||
let Some(cols) = schema.fts_columns else {
|
||||
return serde_json::Map::new();
|
||||
};
|
||||
super::update::read_existing_fts(tx, schema, cols, id).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub(super) fn pre_update_snapshot(
|
||||
_tx: &rusqlite::Transaction<'_>,
|
||||
_schema: &EntitySchema,
|
||||
_id: &PkValue,
|
||||
) -> serde_json::Map<String, Value> {
|
||||
serde_json::Map::new()
|
||||
}
|
||||
|
||||
/// Debug-only invariant check: every FTS column NOT present in `input`
|
||||
/// must still hold its pre-UPDATE value after the UPDATE completes.
|
||||
/// If this fires, a non-input column changed under the UPDATE (trigger,
|
||||
/// computed column, etc.) and the `reindex_fts` contract is broken.
|
||||
#[cfg(debug_assertions)]
|
||||
pub(super) fn debug_assert_non_input_fts_stable(
|
||||
cols: &[&str],
|
||||
input: &serde_json::Map<String, Value>,
|
||||
pre_update: &serde_json::Map<String, Value>,
|
||||
existing: &serde_json::Map<String, Value>,
|
||||
) {
|
||||
for c in cols {
|
||||
if input.contains_key(*c) {
|
||||
continue;
|
||||
}
|
||||
let pre = pre_update.get(*c);
|
||||
let post = existing.get(*c);
|
||||
debug_assert_eq!(
|
||||
pre, post,
|
||||
"reindex_fts invariant violated: non-input FTS column `{c}` \
|
||||
changed during UPDATE (pre: {pre:?}, post: {post:?}). A \
|
||||
trigger or computed column likely modified it. Snapshot \
|
||||
the row BEFORE the UPDATE instead of reading it after."
|
||||
);
|
||||
}
|
||||
}
|
||||
364
_primitives/_rust/kei-entity-store/tests/residual_audit_smoke.rs
Normal file
364
_primitives/_rust/kei-entity-store/tests/residual_audit_smoke.rs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
//! Residual audit regression tests (Wave 14, 2026-04-23).
|
||||
//!
|
||||
//! Each block names the residual it pins:
|
||||
//! * A — ddl.rs panic-free across every FieldKind variant
|
||||
//! * B — update.rs FTS reindex non-input-column invariant
|
||||
//! * C — engine.rs WAL pragma fallback on a read-only FS
|
||||
//! * D — search.rs has_searchable_token Unicode edge cases
|
||||
//!
|
||||
//! Scope: kei-entity-store only. No workspace / cross-crate changes.
|
||||
|
||||
use kei_entity_store::error::VerbError;
|
||||
use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef, FieldKind};
|
||||
use kei_entity_store::verbs::{create, search, update};
|
||||
use kei_entity_store::Store;
|
||||
use serde_json::json;
|
||||
|
||||
// ---------- Residual A — ddl.rs never panics on any FieldKind ----------
|
||||
|
||||
/// Compile-time exhaustive match over `FieldKind`. If a new variant is
|
||||
/// added but not listed here, this fails to compile — which is exactly
|
||||
/// the reminder we want for the audit invariant "DDL covers every kind".
|
||||
fn every_field_kind() -> Vec<FieldKind> {
|
||||
// The `match` is unreachable at runtime — it exists purely to make
|
||||
// the compiler enforce exhaustiveness when future variants are added.
|
||||
fn exhaustive(k: FieldKind) -> FieldKind {
|
||||
match k {
|
||||
FieldKind::IntegerPk
|
||||
| FieldKind::TextPk
|
||||
| FieldKind::IntegerNotNull
|
||||
| FieldKind::Integer
|
||||
| FieldKind::TextNotNull
|
||||
| FieldKind::Text
|
||||
| FieldKind::TextDefault
|
||||
| FieldKind::TextArchiveEnum
|
||||
| FieldKind::Real
|
||||
| FieldKind::RealDefault
|
||||
| FieldKind::TimestampCreated
|
||||
| FieldKind::TimestampUpdated => k,
|
||||
}
|
||||
}
|
||||
vec![
|
||||
FieldKind::IntegerPk,
|
||||
FieldKind::TextPk,
|
||||
FieldKind::IntegerNotNull,
|
||||
FieldKind::Integer,
|
||||
FieldKind::TextNotNull,
|
||||
FieldKind::Text,
|
||||
FieldKind::TextDefault,
|
||||
FieldKind::TextArchiveEnum,
|
||||
FieldKind::Real,
|
||||
FieldKind::RealDefault,
|
||||
FieldKind::TimestampCreated,
|
||||
FieldKind::TimestampUpdated,
|
||||
]
|
||||
.into_iter()
|
||||
.map(exhaustive)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fields_for_kind(kind: FieldKind) -> &'static [FieldDef] {
|
||||
match kind {
|
||||
FieldKind::IntegerPk => Box::leak(Box::new([FieldDef::pk("id")])),
|
||||
FieldKind::TextPk => Box::leak(Box::new([FieldDef::text_pk("id")])),
|
||||
FieldKind::IntegerNotNull => {
|
||||
Box::leak(Box::new([FieldDef::pk("id"), FieldDef::integer_nn("n")]))
|
||||
}
|
||||
FieldKind::Integer => Box::leak(Box::new([FieldDef::pk("id"), FieldDef::integer("n")])),
|
||||
FieldKind::TextNotNull => {
|
||||
Box::leak(Box::new([FieldDef::pk("id"), FieldDef::text_nn("t")]))
|
||||
}
|
||||
FieldKind::Text => Box::leak(Box::new([FieldDef::pk("id"), FieldDef::text("t")])),
|
||||
FieldKind::TextDefault => Box::leak(Box::new([
|
||||
FieldDef::pk("id"),
|
||||
FieldDef::text_default("t", "d'efault"),
|
||||
])),
|
||||
FieldKind::TextArchiveEnum => Box::leak(Box::new([
|
||||
FieldDef::pk("id"),
|
||||
FieldDef::text_archive_enum("status", "active", "archived"),
|
||||
])),
|
||||
FieldKind::Real => Box::leak(Box::new([FieldDef::pk("id"), FieldDef::real("r")])),
|
||||
FieldKind::RealDefault => Box::leak(Box::new([
|
||||
FieldDef::pk("id"),
|
||||
FieldDef::real_default("r", 3.14),
|
||||
])),
|
||||
FieldKind::TimestampCreated => {
|
||||
Box::leak(Box::new([FieldDef::pk("id"), FieldDef::created_at()]))
|
||||
}
|
||||
FieldKind::TimestampUpdated => {
|
||||
Box::leak(Box::new([FieldDef::pk("id"), FieldDef::updated_at()]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_schema(fields: &'static [FieldDef]) -> EntitySchema {
|
||||
EntitySchema {
|
||||
name: "fk_probe",
|
||||
table: "fk_probes",
|
||||
fields,
|
||||
enabled_verbs: &["create"],
|
||||
fts_columns: None,
|
||||
edge_table: None,
|
||||
edge_key_kind: EdgeKeyKind::IntegerPair,
|
||||
archived_field: None,
|
||||
custom_migrations: &[],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ddl_never_panics_on_any_fieldkind() {
|
||||
// For every `FieldKind`, build a minimal schema and render its
|
||||
// primary-table DDL. If any variant had a `panic!`/`unreachable!`
|
||||
// path in `ddl::column()` this would trip.
|
||||
for kind in every_field_kind() {
|
||||
let schema = probe_schema(fields_for_kind(kind));
|
||||
let sql = kei_entity_store::ddl::primary_table(&schema);
|
||||
assert!(sql.contains("CREATE TABLE"), "kind={kind:?} → {sql}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Residual B — update.rs FTS invariant ----------
|
||||
|
||||
static FTS_FIELDS: &[FieldDef] = &[
|
||||
FieldDef::pk("id"),
|
||||
FieldDef::text_nn("title"),
|
||||
FieldDef::text("description"),
|
||||
FieldDef::created_at(),
|
||||
FieldDef::updated_at(),
|
||||
];
|
||||
|
||||
static FTS_SCHEMA: EntitySchema = EntitySchema {
|
||||
name: "doc",
|
||||
table: "docs",
|
||||
fields: FTS_FIELDS,
|
||||
enabled_verbs: &["create", "update", "search"],
|
||||
fts_columns: Some(&["title", "description"]),
|
||||
edge_table: None,
|
||||
edge_key_kind: EdgeKeyKind::IntegerPair,
|
||||
archived_field: None,
|
||||
custom_migrations: &[],
|
||||
};
|
||||
|
||||
/// Debug-only: if a future BEFORE UPDATE trigger mutates an FTS column
|
||||
/// that is NOT present in the `update` input, the `reindex_fts`
|
||||
/// non-input invariant would fire. This test plants such a trigger
|
||||
/// and verifies the debug_assert trips (panics). Release builds skip
|
||||
/// the assertion and would instead silently drift — this test is
|
||||
/// therefore gated on `debug_assertions` only.
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
#[should_panic(expected = "reindex_fts invariant violated")]
|
||||
fn update_debug_assert_trips_when_trigger_mutates_non_input_fts_column() {
|
||||
let s = Store::open_memory(&[&FTS_SCHEMA]).unwrap();
|
||||
let id = create::run(
|
||||
s.conn(),
|
||||
&FTS_SCHEMA,
|
||||
json!({ "title": "t0", "description": "d0" }),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
// Plant a trigger: whenever `title` is updated, silently rewrite
|
||||
// `description` too. This breaks the "non-input FTS col stays put"
|
||||
// contract that `reindex_fts` relies on.
|
||||
s.conn()
|
||||
.execute_batch(
|
||||
"CREATE TRIGGER docs_mutate_desc \
|
||||
BEFORE UPDATE OF title ON docs \
|
||||
BEGIN \
|
||||
UPDATE docs SET description = 'drifted' WHERE id = NEW.id; \
|
||||
END;",
|
||||
)
|
||||
.unwrap();
|
||||
// Update only `title`. The trigger silently mutates `description`.
|
||||
// Debug build must panic inside `reindex_fts`; release build would
|
||||
// silently re-index with the new (drifted) value.
|
||||
let _ = update::run(s.conn(), &FTS_SCHEMA, json!({ "id": id, "title": "t1" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_partial_preserves_non_input_fts_column() {
|
||||
// Update only `title`. The `description` FTS column must survive
|
||||
// unchanged on both the entity row and the FTS index — this is the
|
||||
// production counterpart to the debug-build non-input invariant.
|
||||
let s = Store::open_memory(&[&FTS_SCHEMA]).unwrap();
|
||||
let id = create::run(
|
||||
s.conn(),
|
||||
&FTS_SCHEMA,
|
||||
json!({ "title": "first", "description": "keep this" }),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
|
||||
update::run(s.conn(), &FTS_SCHEMA, json!({ "id": id, "title": "second" })).unwrap();
|
||||
|
||||
// Entity row: description unchanged.
|
||||
let desc: String = s
|
||||
.conn()
|
||||
.query_row(
|
||||
"SELECT description FROM docs WHERE id=?1",
|
||||
rusqlite::params![id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(desc, "keep this");
|
||||
|
||||
// FTS index: description still searchable; title reflects UPDATE.
|
||||
let hit_desc = search::run(s.conn(), &FTS_SCHEMA, json!({ "query": "keep" })).unwrap();
|
||||
assert_eq!(hit_desc["results"].as_array().unwrap().len(), 1);
|
||||
let hit_new = search::run(s.conn(), &FTS_SCHEMA, json!({ "query": "second" })).unwrap();
|
||||
assert_eq!(hit_new["results"].as_array().unwrap().len(), 1);
|
||||
let hit_old = search::run(s.conn(), &FTS_SCHEMA, json!({ "query": "first" })).unwrap();
|
||||
assert_eq!(hit_old["results"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ---------- Residual C — WAL pragma fallback ----------
|
||||
|
||||
#[cfg(unix)]
|
||||
static WAL_MIN: EntitySchema = EntitySchema {
|
||||
name: "m",
|
||||
table: "m",
|
||||
fields: &[FieldDef::pk("id"), FieldDef::text_nn("t"), FieldDef::created_at()],
|
||||
enabled_verbs: &["create", "get"],
|
||||
fts_columns: None,
|
||||
edge_table: None,
|
||||
edge_key_kind: EdgeKeyKind::IntegerPair,
|
||||
archived_field: None,
|
||||
custom_migrations: &[],
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
fn chmod_dir(p: &std::path::Path, mode: u32) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(p).unwrap().permissions();
|
||||
perms.set_mode(mode);
|
||||
std::fs::set_permissions(p, perms).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn wal_pragma_fallback_keeps_store_usable() {
|
||||
// Simulate "WAL unavailable" by pre-creating the DB normally
|
||||
// (WAL succeeds), then making the PARENT directory read-only so
|
||||
// re-opening the same file cannot create the WAL sidecar files.
|
||||
// The pragma SHOULD fail but Store::open must still succeed and
|
||||
// basic CRUD must work. Non-unix platforms skip — simulating a
|
||||
// read-only dir portably is hard and adds little coverage.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("wal.db");
|
||||
// Happy-path open — WAL succeeds here, seeds a row.
|
||||
{
|
||||
let s = Store::open(&db, &[&WAL_MIN]).unwrap();
|
||||
create::run(s.conn(), &WAL_MIN, json!({ "t": "pre" })).unwrap();
|
||||
}
|
||||
// Lock parent dir — WAL creation on reopen cannot succeed. On
|
||||
// root-owned CI hosts chmod 0555 is a no-op and the assertion
|
||||
// below still holds because we only check a pre-existing row.
|
||||
chmod_dir(dir.path(), 0o555);
|
||||
let result = Store::open(&db, &[&WAL_MIN]);
|
||||
// Restore perms unconditionally so tempdir cleanup works.
|
||||
chmod_dir(dir.path(), 0o755);
|
||||
// If the platform's sqlite refuses to open at all in a RO dir
|
||||
// (some builds require journal sidecar on every open), treat
|
||||
// the test as inconclusive — the fallback code still ran.
|
||||
if result.is_err() {
|
||||
return;
|
||||
}
|
||||
let s = result.unwrap();
|
||||
let row = kei_entity_store::verbs::get::run(s.conn(), &WAL_MIN, json!({ "id": 1 })).unwrap();
|
||||
assert_eq!(row["t"], "pre");
|
||||
}
|
||||
|
||||
// ---------- Residual D — search.rs Unicode punctuation edge cases ----------
|
||||
|
||||
static D_FIELDS: &[FieldDef] = &[
|
||||
FieldDef::pk("id"),
|
||||
FieldDef::text_nn("title"),
|
||||
FieldDef::text("description"),
|
||||
FieldDef::created_at(),
|
||||
];
|
||||
|
||||
static D_SCHEMA: EntitySchema = EntitySchema {
|
||||
name: "d",
|
||||
table: "d_items",
|
||||
fields: D_FIELDS,
|
||||
enabled_verbs: &["create", "search"],
|
||||
fts_columns: Some(&["title", "description"]),
|
||||
edge_table: None,
|
||||
edge_key_kind: EdgeKeyKind::IntegerPair,
|
||||
archived_field: None,
|
||||
custom_migrations: &[],
|
||||
};
|
||||
|
||||
fn mk_d() -> Store { Store::open_memory(&[&D_SCHEMA]).unwrap() }
|
||||
|
||||
fn expect_invalid_input(err: VerbError) {
|
||||
assert_eq!(err.exit_code(), 2, "must map to validation exit code");
|
||||
match err {
|
||||
VerbError::InvalidInput(_) => {}
|
||||
other => panic!("expected InvalidInput, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_rejects_unicode_punctuation_only() {
|
||||
// Unicode punctuation that is NOT ASCII: Spanish inverted marks,
|
||||
// French guillemets, CJK punctuation, em-dashes. None of these are
|
||||
// alphanumeric so `has_searchable_token` must reject the query.
|
||||
let s = mk_d();
|
||||
create::run(s.conn(), &D_SCHEMA, json!({ "title": "anything" })).unwrap();
|
||||
for q in &["¿?¡!", "«»", "。、", "—–", "¡¿"] {
|
||||
let err = search::run(s.conn(), &D_SCHEMA, json!({ "query": q })).unwrap_err();
|
||||
expect_invalid_input(err);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_rejects_emoji_only_query() {
|
||||
// Pure-emoji queries carry zero tokens for unicode61 — reject.
|
||||
let s = mk_d();
|
||||
create::run(s.conn(), &D_SCHEMA, json!({ "title": "anything" })).unwrap();
|
||||
for q in &["\u{1F389}", "\u{1F525}\u{1F680}", "\u{2728}\u{1F440}"] {
|
||||
let err = search::run(s.conn(), &D_SCHEMA, json!({ "query": q })).unwrap_err();
|
||||
expect_invalid_input(err);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_rejects_zero_width_only() {
|
||||
// Zero-width joiner (\u{200D}), zero-width space (\u{200B}),
|
||||
// zero-width non-joiner (\u{200C}), BOM (\u{FEFF}) — all format
|
||||
// characters, none alphanumeric. Query must be rejected, not
|
||||
// silently matched as an empty phrase.
|
||||
let s = mk_d();
|
||||
create::run(s.conn(), &D_SCHEMA, json!({ "title": "anything" })).unwrap();
|
||||
for q in &["\u{200B}", "\u{200C}\u{200D}", "\u{FEFF}\u{200B}"] {
|
||||
let err = search::run(s.conn(), &D_SCHEMA, json!({ "query": q })).unwrap_err();
|
||||
expect_invalid_input(err);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_accepts_mixed_rtl_query() {
|
||||
// Arabic + Latin + punctuation. Arabic letters ARE alphanumeric
|
||||
// by `char::is_alphanumeric`, so the gate must admit the query
|
||||
// and let FTS5 tokenize it normally.
|
||||
let s = mk_d();
|
||||
create::run(
|
||||
s.conn(),
|
||||
&D_SCHEMA,
|
||||
json!({ "title": "مرحبا world", "description": "greeting" }),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Latin side.
|
||||
let v = search::run(s.conn(), &D_SCHEMA, json!({ "query": "world!" })).unwrap();
|
||||
assert_eq!(v["results"].as_array().unwrap().len(), 1);
|
||||
|
||||
// Mixed RTL+Latin query is admitted (does not error).
|
||||
let v = search::run(s.conn(), &D_SCHEMA, json!({ "query": "world مرحبا" })).unwrap();
|
||||
// Don't over-assert on match count — porter/unicode61 tokenisation
|
||||
// of Arabic is implementation-defined. We only verify the gate.
|
||||
let _ = v;
|
||||
}
|
||||
96
_primitives/_rust/kei-shared/Cargo.lock
generated
Normal file
96
_primitives/_rust/kei-shared/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "kei-shared"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
14
_primitives/_rust/kei-shared/Cargo.toml
Normal file
14
_primitives/_rust/kei-shared/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "kei-shared"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
description = "Shared substrate types — single source of truth for DNA format + small utility types"
|
||||
|
||||
[lib]
|
||||
name = "kei_shared"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "1"
|
||||
124
_primitives/_rust/kei-shared/src/dna.rs
Normal file
124
_primitives/_rust/kei-shared/src/dna.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
//! DNA wire format: `<role>::<caps>::<sha8-scope>::<sha8-body>-<hex8-nonce>`.
|
||||
//!
|
||||
//! SSoT for the substrate identity string. Any format-level change lands
|
||||
//! here and propagates to consumers (kei-agent-runtime, kei-dna-index)
|
||||
//! through re-export, not duplication.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Parsed DNA fields. Widths on hash segments are validated by `parse_dna`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ParsedDna {
|
||||
pub role: String,
|
||||
pub caps: String,
|
||||
pub scope_sha: String,
|
||||
pub body_sha: String,
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
/// Strict parse errors. Consumers that need looser semantics (e.g. legacy
|
||||
/// 4-hex rolling-upgrade acceptance in kei-agent-runtime) keep their own
|
||||
/// parser and error type.
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum DnaError {
|
||||
#[error("DNA empty")]
|
||||
Empty,
|
||||
#[error("missing segments (expected 4 '::' separators, got {0})")]
|
||||
MissingSegments(usize),
|
||||
#[error("missing '-' between body and nonce")]
|
||||
MissingNonceDelim,
|
||||
#[error("empty role segment")]
|
||||
EmptyRole,
|
||||
#[error("empty caps segment")]
|
||||
EmptyCaps,
|
||||
#[error("invalid hex8 width for {field} (got {got})")]
|
||||
HexWidth { field: &'static str, got: usize },
|
||||
#[error("non-hex character in {field}")]
|
||||
NonHex { field: &'static str },
|
||||
}
|
||||
|
||||
/// `true` iff `s` is exactly 8 ASCII hex characters.
|
||||
pub fn is_hex8(s: &str) -> bool {
|
||||
s.len() == 8 && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Strict parse. Requires 4 `::` segments, `<body>-<nonce>` tail, and
|
||||
/// 8-hex width on `scope_sha`, `body_sha`, `nonce`. Rejects empty role/caps.
|
||||
pub fn parse_dna(s: &str) -> Result<ParsedDna, DnaError> {
|
||||
if s.is_empty() {
|
||||
return Err(DnaError::Empty);
|
||||
}
|
||||
let parts: Vec<&str> = s.split("::").collect();
|
||||
if parts.len() != 4 {
|
||||
return Err(DnaError::MissingSegments(parts.len()));
|
||||
}
|
||||
let (body_sha, nonce) = parts[3]
|
||||
.split_once('-')
|
||||
.ok_or(DnaError::MissingNonceDelim)?;
|
||||
check_non_empty(parts[0], parts[1])?;
|
||||
check_hex8("scope_sha", parts[2])?;
|
||||
check_hex8("body_sha", body_sha)?;
|
||||
check_hex8("nonce", nonce)?;
|
||||
Ok(ParsedDna {
|
||||
role: parts[0].to_string(),
|
||||
caps: parts[1].to_string(),
|
||||
scope_sha: parts[2].to_string(),
|
||||
body_sha: body_sha.to_string(),
|
||||
nonce: nonce.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Render the canonical wire format. Deterministic — no I/O, no randomness.
|
||||
pub fn compose_dna(
|
||||
role: &str,
|
||||
caps: &str,
|
||||
scope_sha: &str,
|
||||
body_sha: &str,
|
||||
nonce: &str,
|
||||
) -> String {
|
||||
format!("{role}::{caps}::{scope_sha}::{body_sha}-{nonce}")
|
||||
}
|
||||
|
||||
fn check_non_empty(role: &str, caps: &str) -> Result<(), DnaError> {
|
||||
if role.is_empty() {
|
||||
return Err(DnaError::EmptyRole);
|
||||
}
|
||||
if caps.is_empty() {
|
||||
return Err(DnaError::EmptyCaps);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_hex8(field: &'static str, value: &str) -> Result<(), DnaError> {
|
||||
if value.len() != 8 {
|
||||
return Err(DnaError::HexWidth {
|
||||
field,
|
||||
got: value.len(),
|
||||
});
|
||||
}
|
||||
if !value.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(DnaError::NonHex { field });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compose_matches_manual_format() {
|
||||
let s = compose_dna("r", "C", "12345678", "ABCDEF01", "deadbeef");
|
||||
assert_eq!(s, "r::C::12345678::ABCDEF01-deadbeef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hex8_basic() {
|
||||
assert!(is_hex8("12345678"));
|
||||
assert!(is_hex8("AbCdEf01"));
|
||||
assert!(!is_hex8("1234567"));
|
||||
assert!(!is_hex8("123456789"));
|
||||
assert!(!is_hex8("1234567Z"));
|
||||
}
|
||||
}
|
||||
12
_primitives/_rust/kei-shared/src/lib.rs
Normal file
12
_primitives/_rust/kei-shared/src/lib.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//! kei-shared — shared substrate types.
|
||||
//!
|
||||
//! Single source of truth for the agent DNA wire format. Consumers
|
||||
//! (kei-agent-runtime, kei-dna-index) import from here so a format
|
||||
//! change is a one-file edit, not a two-crate refactor.
|
||||
//!
|
||||
//! Constructor Pattern: one file = one responsibility. `dna.rs` owns the
|
||||
//! parse/compose/validate primitives, nothing else.
|
||||
|
||||
pub mod dna;
|
||||
|
||||
pub use dna::{compose_dna, is_hex8, parse_dna, DnaError, ParsedDna};
|
||||
86
_primitives/_rust/kei-shared/tests/dna_smoke.rs
Normal file
86
_primitives/_rust/kei-shared/tests/dna_smoke.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! Smoke tests for the shared DNA parser.
|
||||
|
||||
use kei_shared::dna::{compose_dna, is_hex8, parse_dna, DnaError};
|
||||
|
||||
const CANONICAL: &str =
|
||||
"edit-local::NG-FW-FD-CP-CG-TG-ND-RF::5435F821::AC73A6A3-e9bf468d";
|
||||
|
||||
#[test]
|
||||
fn parse_valid_round_trip() {
|
||||
let parsed = parse_dna(CANONICAL).expect("canonical DNA must parse");
|
||||
assert_eq!(parsed.role, "edit-local");
|
||||
assert_eq!(parsed.caps, "NG-FW-FD-CP-CG-TG-ND-RF");
|
||||
assert_eq!(parsed.scope_sha, "5435F821");
|
||||
assert_eq!(parsed.body_sha, "AC73A6A3");
|
||||
assert_eq!(parsed.nonce, "e9bf468d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_rejected() {
|
||||
assert_eq!(parse_dna("").unwrap_err(), DnaError::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_segments() {
|
||||
let err = parse_dna("only::two::segments").unwrap_err();
|
||||
assert!(matches!(err, DnaError::MissingSegments(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_missing_nonce_delim() {
|
||||
// 4 `::` segments but no '-' in tail.
|
||||
let err = parse_dna("r::C::12345678::AC73A6A3e9bf468d").unwrap_err();
|
||||
assert_eq!(err, DnaError::MissingNonceDelim);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_non_hex_rejected() {
|
||||
let err = parse_dna("r::C::12345678::AC73A6A3-ZZZZZZZZ").unwrap_err();
|
||||
assert!(matches!(err, DnaError::NonHex { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_short_hex_rejected() {
|
||||
// scope_sha has only 4 hex chars — strict parser rejects.
|
||||
let err = parse_dna("r::C::1234::AC73A6A3-e9bf468d").unwrap_err();
|
||||
assert!(matches!(err, DnaError::HexWidth { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_empty_role() {
|
||||
let err = parse_dna("::C::12345678::AC73A6A3-e9bf468d").unwrap_err();
|
||||
assert_eq!(err, DnaError::EmptyRole);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_empty_caps() {
|
||||
let err = parse_dna("r::::12345678::AC73A6A3-e9bf468d").unwrap_err();
|
||||
assert_eq!(err, DnaError::EmptyCaps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_parse_round_trip() {
|
||||
let composed = compose_dna(
|
||||
"edit-local",
|
||||
"NG-FW-FD-CP-CG-TG-ND-RF",
|
||||
"5435F821",
|
||||
"AC73A6A3",
|
||||
"e9bf468d",
|
||||
);
|
||||
assert_eq!(composed, CANONICAL);
|
||||
let parsed = parse_dna(&composed).expect("round-trip parse");
|
||||
assert_eq!(parsed.scope_sha, "5435F821");
|
||||
assert_eq!(parsed.body_sha, "AC73A6A3");
|
||||
assert_eq!(parsed.nonce, "e9bf468d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hex8_accepts_valid_rejects_invalid() {
|
||||
assert!(is_hex8("00000000"));
|
||||
assert!(is_hex8("DeAdBeEf"));
|
||||
assert!(is_hex8("abcdef01"));
|
||||
assert!(!is_hex8("abcdefg1"), "non-hex 'g' must be rejected");
|
||||
assert!(!is_hex8(""));
|
||||
assert!(!is_hex8("1234567"));
|
||||
assert!(!is_hex8("123456789"));
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ Complete install guide. Quick-start lives in the main [README](../README.md#inst
|
|||
| Path | Command | Best for |
|
||||
|---|---|---|
|
||||
| **Plugin** (v0.16+, recommended on Claude Code 2.1+) | `/plugin marketplace add KeiSei84/KeiSeiKit` then `/plugin install keisei@keisei-marketplace` | Agents + skills + hooks + MCP. Zero cargo build. See [PLUGIN.md](../PLUGIN.md). |
|
||||
| **Classic** `./install.sh` | Below | Full kit incl. 36 Rust primitives + 13 shell primitives. Required for `ops` / `dev` / `full` profiles. |
|
||||
| **Classic** `./install.sh` | Below | Full kit incl. 47 Rust primitives + 13 shell primitives. Required for `ops` / `dev` / `full` profiles. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ By default `./install.sh` is **minimal** — agents + hooks + skills + bridges,
|
|||
| `frontend` | 8 site tools: `mock-render`, `visual-diff`, `tokens-sync`, `design-scrape`, `live-preview`, `figma-tokens`, `frontend-inspect`, `screenshot-decode` | ~60s | ~80 MB |
|
||||
| `ops` | 9 infra tools: `kei-ledger`, `ssh-check`, `firewall-diff`, `provision-hetzner`, `provision-vultr`, `harden-base`, `metrics-scrape`, `log-ship`, `kei-provision` | ~90s | ~55 MB |
|
||||
| `dev` | 17 dev tools: `kei-migrate`, `kei-changelog`, `kei-ci-lint`, `kei-docs-scaffold`, `kei-memory`, `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`, `kei-store`, `kei-artifact`, `kei-agent-runtime`, `kei-capability`, `kei-entity-store`, `kei-pipe`, `kei-cache`, `kei-spawn`, `kei-replay` | ~90s | ~60 MB |
|
||||
| `full` | everything in `MANIFEST.toml` `full` profile (46 primitives — see manifest for exact list; `kei-atom-discovery`, `kei-forge`, `kei-runtime` ship as sources only, not in any profile yet) | ~6 min | ~220 MB |
|
||||
| `full` | everything in `MANIFEST.toml` `full` profile (46 primitives — see manifest for exact list; the v0.29 → v0.33 additions `kei-diff`, `kei-scheduler`, `kei-watch`, `kei-prune`, `kei-discover`, `kei-brain-view`, `kei-hibernate`, `kei-ledger-sign`, `kei-dna-index`, `kei-fork`, `kei-shared` ship as sources only, not in any profile yet) | ~6 min | ~220 MB |
|
||||
|
||||
Examples:
|
||||
|
||||
|
|
@ -207,7 +207,7 @@ Interactive wizard: run `/hooks-control` — click-only picker that shows curren
|
|||
| Generic agents (manifests) | 12 | `kei-code-implementer`, `kei-critic`, `kei-validator`, `kei-security-auditor`, `kei-architect`, `kei-researcher`, `kei-ml-implementer`, `kei-cost-guardian`, `kei-modal-runner`, ... |
|
||||
| Hooks (PreToolUse / PostToolUse) | 12 | `assemble-agents`, `assemble-validate`, `no-hand-edit-agents`, `tomd-preread`, `agent-fork-logger`, `orchestrator-dirty-check`, `site-wysiwyd-check`, `session-end-dump`, `milestone-commit-hook`, `error-spike-detector`, `agent-capability-check`, `agent-capability-verify` |
|
||||
| Portable skills | 43 | `compose-solution`, `new-agent`, `new-project`, `site-create`, `schema-design`, `observability-setup`, `auth-setup`, `api-design`, `ci-scaffold`, `test-matrix`, `docs-scaffold`, `vm-provision`, ... |
|
||||
| Primitives (Rust crates, opt-in) | 36 | `kei-ledger`, `kei-migrate`, `kei-changelog`, `ssh-check`, `firewall-diff`, `mock-render`, `visual-diff`, `tokens-sync`, `kei-memory`, `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`, `kei-store`, `kei-router`, `kei-sage`, `kei-task`, `kei-chat-store`, `kei-crossdomain`, `kei-search-core`, `kei-content-store`, `kei-social-store`, `kei-curator`, `kei-auth`, `kei-artifact`, `keisei`, `kei-agent-runtime`, `kei-capability`, `kei-provision`, `kei-entity-store`, `kei-pipe`, `kei-cache`, `kei-spawn`, `kei-replay`, `kei-atom-discovery`, `kei-forge`, `kei-runtime` |
|
||||
| Primitives (Rust crates, opt-in) | 47 | `kei-ledger`, `kei-migrate`, `kei-changelog`, `ssh-check`, `firewall-diff`, `mock-render`, `visual-diff`, `tokens-sync`, `kei-memory`, `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`, `kei-store`, `kei-router`, `kei-sage`, `kei-task`, `kei-chat-store`, `kei-crossdomain`, `kei-search-core`, `kei-content-store`, `kei-social-store`, `kei-curator`, `kei-auth`, `kei-artifact`, `keisei`, `kei-agent-runtime`, `kei-capability`, `kei-provision`, `kei-entity-store`, `kei-pipe`, `kei-cache`, `kei-spawn`, `kei-replay`, `kei-atom-discovery`, `kei-forge`, `kei-runtime`, `kei-diff`, `kei-scheduler`, `kei-watch`, `kei-prune`, `kei-discover`, `kei-brain-view`, `kei-hibernate`, `kei-ledger-sign`, `kei-dna-index`, `kei-fork`, `kei-shared` |
|
||||
| Primitives (shell, opt-in via profile) | 13 | `tomd`, `design-scrape`, `live-preview`, `figma-tokens`, `frontend-inspect`, `screenshot-decode`, `metrics-scrape`, `log-ship`, `provision-hetzner`, `provision-vultr`, `harden-base`, `kei-ci-lint`, `kei-docs-scaffold` |
|
||||
| Shell helpers (always copied) | 3 | `kei-sleep-setup`, `kei-sleep-sync`, `kei-sleep-queue` (dormant until you run `/sleep-setup`) |
|
||||
| Cross-tool bridges | 11 | Cursor legacy/MDC, Codex, Copilot, Windsurf, Junie, Continue, Gemini, Aider, Replit |
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ Every shipped component, its real behaviour, and where to look in source. Each s
|
|||
|
||||
## Rust primitives
|
||||
|
||||
All 36 crates live under `_primitives/_rust/<name>/`. After `install.sh` runs, binaries land at `~/.claude/agents/_primitives/_rust/target/release/<name>`. Exit codes: `0` success, `1` usage/IO error, `2` validation/diff-found (per-tool; see each entry).
|
||||
All 47 crates live under `_primitives/_rust/<name>/`. After `install.sh` runs, binaries land at `~/.claude/agents/_primitives/_rust/target/release/<name>`. Exit codes: `0` success, `1` usage/IO error, `2` validation/diff-found (per-tool; see each entry).
|
||||
|
||||
> NOTE (v0.27): the following sections enumerate the 25 crates documented through v0.22. 11 newer crates shipped in v0.23–v0.27 (`kei-entity-store`, `kei-agent-runtime`, `kei-capability`, `kei-provision`, `kei-pipe`, `kei-cache`, `kei-spawn`, `kei-replay`, `kei-atom-discovery`, `kei-forge`, `kei-runtime`) are not yet documented here — see `CHANGELOG.md` Unreleased block and the crates' own `Cargo.toml` + `atoms/*.md` for their current CLI surface. A full REFERENCE.md expansion is tracked as a follow-up doc task.
|
||||
> NOTE (v0.33): the following sections enumerate the 25 crates documented through v0.22. 22 newer crates shipped in v0.23–v0.33 (`kei-entity-store`, `kei-agent-runtime`, `kei-capability`, `kei-provision`, `kei-pipe`, `kei-cache`, `kei-spawn`, `kei-replay`, `kei-atom-discovery`, `kei-forge`, `kei-runtime`, `kei-diff`, `kei-scheduler`, `kei-watch`, `kei-prune`, `kei-discover`, `kei-brain-view`, `kei-hibernate`, `kei-ledger-sign`, `kei-dna-index`, `kei-fork`, `kei-shared`) are not yet documented here — see `CHANGELOG.md` for their shipped semantics and the crates' own `Cargo.toml` + `atoms/*.md` for their current CLI surface. A full REFERENCE.md expansion is tracked as a follow-up doc task.
|
||||
|
||||
### `kei-ledger` — agent-fork lifecycle ledger (RULE 0.12)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ An **atom** is **one verb** (one operation) on a primitive, not one crate. Examp
|
|||
- Discoverable (aggregated into `capabilities.toml`)
|
||||
- Composable (runtime pipes atoms by schema compatibility)
|
||||
|
||||
**Granularity target:** ~150 atoms across the current 36 crates (was 25 at v0.22 lock; 11 crates added v0.23–v0.27). Crate = physical container; atom = unit of composition.
|
||||
**Granularity target:** ~150 atoms across the current 47 crates (was 25 at v0.22 lock; 22 crates added v0.23–v0.33). Crate = physical container; atom = unit of composition.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -340,7 +340,7 @@ Here is exactly what each parallel stream can assume from this schema:
|
|||
- **Does NOT depend on:** Atoms-refactor (can work against any single atom template), Graph (independent), Runtime (independent)
|
||||
|
||||
### Stream B — Atoms refactor
|
||||
- **Reads:** current 36 crates (25 at v0.22 lock; 11 added v0.23–v0.27)
|
||||
- **Reads:** current 47 crates (25 at v0.22 lock; 22 added v0.23–v0.33)
|
||||
- **Writes:** `atoms/<verb>.md` + `atoms/schemas/*.json` + splits `src/main.rs` → `src/atoms/*.rs`, adds `[package.metadata.keisei]` to each `Cargo.toml`
|
||||
- **Does NOT depend on:** UI (can progress independently), Graph, Runtime. No build.rs, no generated files — atoms/*.md is SSoT.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue