feat(primitives): kei-artifact typed handoff pipeline (BMAD-style doc passthrough)

- kei-artifact Rust crate (25th): schema registry + artifact store + SHA-256 id + chain walker
- 5 schemas (JSON Schema 2020-12 strict): spec / plan / patch / review / research
- Manifest extension: optional produces_artifact + expects_artifact per handoff (non-breaking)
- Validator extension: KNOWN_ARTIFACT_SCHEMAS whitelist check + 4 new tests
- 3 kei-* manifests updated with typed handoff (architect→code-implementer→critic chain)
- compose-solution phase-5 cross-ref to kei-artifact

Tests: 189 Rust workspace (was 167, +22 artifact tests) + 24 assembler (was 20, +4 validator tests)
This commit is contained in:
Parfii-bot 2026-04-22 14:10:08 +08:00
parent 4b0185a3d1
commit 537589e6a7
27 changed files with 1517 additions and 69 deletions

View file

@ -7,6 +7,7 @@
mod assembler;
mod manifest;
mod placeholders;
mod validator;
use manifest::Manifest;

View file

@ -19,12 +19,22 @@ pub struct Manifest {
pub memory_project: Option<String>,
pub project_claudemd: Option<String>,
pub references: Option<References>,
/// v0.15: optional typed-artifact schema this agent emits on completion.
/// Must be one of the names in `artifact_schemas::KNOWN`.
#[serde(default)]
pub produces_artifact: Option<String>,
}
#[derive(Deserialize)]
pub struct Handoff {
pub target: String,
pub trigger: String,
/// v0.15: optional schema name the target consumes from this handoff.
#[serde(default)]
pub expects_artifact: Option<String>,
/// v0.15: optional schema name this agent produces for the target.
#[serde(default)]
pub produces_artifact: Option<String>,
}
#[derive(Deserialize)]

View file

@ -0,0 +1,124 @@
//! Placeholder check — reject unsubstituted `{{PLACEHOLDER}}` tokens.
//!
//! Constructor Pattern: one cube = one validation concern.
//! Extracted from `validator.rs` to keep that file under 200 LOC.
use crate::manifest::Manifest;
/// Reject manifests that still carry `{{PLACEHOLDER}}` tokens — the wizard
/// should have substituted them. Matches `{{...}}` conservatively (not
/// single braces).
pub fn check(m: &Manifest) -> Result<(), String> {
let check = |field: &str, value: &str| -> Result<(), String> {
if contains_placeholder(value) {
Err(format!(
"Unsubstituted template placeholder in field '{field}': {value}. Did the wizard skip a substitution?"
))
} else {
Ok(())
}
};
check("name", &m.name)?;
check("description", &m.description)?;
check("model", &m.model)?;
check("role", &m.role)?;
for (i, t) in m.tools.iter().enumerate() {
check(&format!("tools[{i}]"), t)?;
}
for (i, b) in m.blocks.iter().enumerate() {
check(&format!("blocks[{i}]"), b)?;
}
for (i, d) in m.domain_in.iter().enumerate() {
check(&format!("domain_in[{i}]"), d)?;
}
for (i, d) in m.forbidden_domain.iter().enumerate() {
check(&format!("forbidden_domain[{i}]"), d)?;
}
for (i, h) in m.handoff.iter().enumerate() {
check(&format!("handoff[{i}].target"), &h.target)?;
check(&format!("handoff[{i}].trigger"), &h.trigger)?;
}
for (i, o) in m.output_extra_fields.iter().enumerate() {
check(&format!("output_extra_fields[{i}]"), o)?;
}
if let Some(v) = &m.memory_project {
check("memory_project", v)?;
}
if let Some(v) = &m.project_claudemd {
check("project_claudemd", v)?;
}
if let Some(r) = &m.references {
for (i, e) in r.extra.iter().enumerate() {
check(&format!("references.extra[{i}]"), e)?;
}
}
Ok(())
}
fn contains_placeholder(s: &str) -> bool {
if let Some(start) = s.find("{{") {
if s[start + 2..].contains("}}") {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{Handoff, Manifest};
fn base() -> Manifest {
Manifest {
name: "test".into(),
description: "d".into(),
tools: vec!["Read".into()],
model: "opus".into(),
role: "r".into(),
blocks: vec!["baseline".into(), "evidence-grading".into(), "memory-protocol".into()],
domain_in: vec!["x".into()],
forbidden_domain: vec!["y".into()],
handoff: vec![Handoff {
target: "a".into(),
trigger: "b".into(),
expects_artifact: None,
produces_artifact: None,
}],
output_extra_fields: vec![],
memory_project: None,
project_claudemd: None,
references: None,
produces_artifact: None,
}
}
#[test]
fn rejects_placeholder_in_memory_project() {
let mut m = base();
m.memory_project = Some("{{MEMORY_PROJECT}}".into());
let err = check(&m).unwrap_err();
assert!(err.contains("memory_project"), "err = {err}");
assert!(err.contains("{{MEMORY_PROJECT}}"), "err = {err}");
}
#[test]
fn accepts_single_braces() {
let mut m = base();
m.description = "hello {world}".into();
assert!(check(&m).is_ok());
}
#[test]
fn accepts_empty_manifest() {
assert!(check(&base()).is_ok());
}
#[test]
fn rejects_placeholder_in_role() {
let mut m = base();
m.role = "do {{THING}}".into();
assert!(check(&m).is_err());
}
}

View file

@ -1,11 +1,21 @@
//! Manifest validator. Enforces Constructor Pattern invariants.
//! Hard-fails on missing obligatory blocks, missing handoffs, unknown blocks.
//!
//! Detailed sub-checks live in their own cubes:
//! - `placeholders::check` — {{PLACEHOLDER}} substitution guard
//! - this file — structural checks + artifact-schema names
use crate::manifest::Manifest;
use crate::placeholders;
use std::path::Path;
pub const OBLIGATORY: &[&str] = &["baseline", "evidence-grading", "memory-protocol"];
/// v0.15: canonical artifact schema names shipped by `kei-artifact`.
/// Mirror of `kei_artifact::schema::KNOWN_SCHEMAS` — kept here as a string
/// literal to avoid coupling the assembler crate to the runtime primitive.
pub const KNOWN_ARTIFACT_SCHEMAS: &[&str] = &["spec", "plan", "patch", "review", "research"];
pub fn validate(m: &Manifest, blocks_dir: &Path) -> Result<(), String> {
for required in OBLIGATORY {
if !m.blocks.iter().any(|b| b == required) {
@ -34,70 +44,38 @@ pub fn validate(m: &Manifest, blocks_dir: &Path) -> Result<(), String> {
return Err("role must not be empty".into());
}
check_no_placeholders(m)?;
placeholders::check(m)?;
check_artifact_schemas(m)?;
Ok(())
}
/// Reject manifests that still carry `{{PLACEHOLDER}}` tokens — the wizard
/// should have substituted them. Emitted literally into generated .md they
/// produce broken agents. Matches `{{...}}` conservatively (not single braces).
fn check_no_placeholders(m: &Manifest) -> Result<(), String> {
let check = |field: &str, value: &str| -> Result<(), String> {
if contains_placeholder(value) {
Err(format!(
"Unsubstituted template placeholder in field '{field}': {value}. Did the wizard skip a substitution?"
))
} else {
Ok(())
}
};
check("name", &m.name)?;
check("description", &m.description)?;
check("model", &m.model)?;
check("role", &m.role)?;
for (i, t) in m.tools.iter().enumerate() {
check(&format!("tools[{i}]"), t)?;
}
for (i, b) in m.blocks.iter().enumerate() {
check(&format!("blocks[{i}]"), b)?;
}
for (i, d) in m.domain_in.iter().enumerate() {
check(&format!("domain_in[{i}]"), d)?;
}
for (i, d) in m.forbidden_domain.iter().enumerate() {
check(&format!("forbidden_domain[{i}]"), d)?;
/// v0.15: if a manifest references artifact schema names, they must be in the
/// known whitelist shipped with `kei-artifact`. Missing fields are allowed
/// (non-breaking extension).
fn check_artifact_schemas(m: &Manifest) -> Result<(), String> {
if let Some(name) = &m.produces_artifact {
check_known(name, "produces_artifact")?;
}
for (i, h) in m.handoff.iter().enumerate() {
check(&format!("handoff[{i}].target"), &h.target)?;
check(&format!("handoff[{i}].trigger"), &h.trigger)?;
}
for (i, o) in m.output_extra_fields.iter().enumerate() {
check(&format!("output_extra_fields[{i}]"), o)?;
}
if let Some(v) = &m.memory_project {
check("memory_project", v)?;
}
if let Some(v) = &m.project_claudemd {
check("project_claudemd", v)?;
}
if let Some(r) = &m.references {
for (i, e) in r.extra.iter().enumerate() {
check(&format!("references.extra[{i}]"), e)?;
if let Some(name) = &h.expects_artifact {
check_known(name, &format!("handoff[{i}].expects_artifact"))?;
}
if let Some(name) = &h.produces_artifact {
check_known(name, &format!("handoff[{i}].produces_artifact"))?;
}
}
Ok(())
}
fn contains_placeholder(s: &str) -> bool {
// Look for a `{{` with a matching `}}` later in the same string.
if let Some(start) = s.find("{{") {
if s[start + 2..].contains("}}") {
return true;
}
fn check_known(name: &str, field: &str) -> Result<(), String> {
if KNOWN_ARTIFACT_SCHEMAS.iter().any(|s| *s == name) {
return Ok(());
}
false
Err(format!(
"unknown artifact schema '{name}' in field '{field}' — must be one of {:?}",
KNOWN_ARTIFACT_SCHEMAS
))
}
#[cfg(test)]
@ -115,39 +93,50 @@ mod tests {
blocks: vec!["baseline".into(), "evidence-grading".into(), "memory-protocol".into()],
domain_in: vec!["x".into()],
forbidden_domain: vec!["y".into()],
handoff: vec![Handoff { target: "a".into(), trigger: "b".into() }],
handoff: vec![Handoff {
target: "a".into(),
trigger: "b".into(),
expects_artifact: None,
produces_artifact: None,
}],
output_extra_fields: vec![],
memory_project: None,
project_claudemd: None,
references: None,
produces_artifact: None,
}
}
#[test]
fn rejects_placeholder_in_memory_project() {
let mut m = base();
m.memory_project = Some("{{MEMORY_PROJECT}}".into());
let err = check_no_placeholders(&m).unwrap_err();
assert!(err.contains("memory_project"), "err = {err}");
assert!(err.contains("{{MEMORY_PROJECT}}"), "err = {err}");
fn artifact_schemas_absent_passes() {
let m = base();
assert!(check_artifact_schemas(&m).is_ok());
}
#[test]
fn accepts_single_braces() {
fn artifact_schemas_known_names_pass() {
let mut m = base();
m.description = "hello {world}".into();
assert!(check_no_placeholders(&m).is_ok());
m.produces_artifact = Some("spec".into());
m.handoff[0].expects_artifact = Some("plan".into());
m.handoff[0].produces_artifact = Some("patch".into());
assert!(check_artifact_schemas(&m).is_ok());
}
#[test]
fn accepts_empty_manifest() {
assert!(check_no_placeholders(&base()).is_ok());
fn artifact_schemas_reject_unknown_produces() {
let mut m = base();
m.produces_artifact = Some("not-a-schema".into());
let err = check_artifact_schemas(&m).unwrap_err();
assert!(err.contains("not-a-schema"), "err: {err}");
assert!(err.contains("produces_artifact"), "err: {err}");
}
#[test]
fn rejects_placeholder_in_role() {
fn artifact_schemas_reject_unknown_expects_in_handoff() {
let mut m = base();
m.role = "do {{THING}}".into();
assert!(check_no_placeholders(&m).is_err());
m.handoff[0].expects_artifact = Some("zzz".into());
let err = check_artifact_schemas(&m).unwrap_err();
assert!(err.contains("zzz"), "err: {err}");
assert!(err.contains("handoff[0].expects_artifact"), "err: {err}");
}
}

View file

@ -64,10 +64,15 @@ output_extra_fields = [
"Decisive verdict: <ONE recommended approach with justification — no \"it depends\">",
]
# v0.15: typed-artifact handoff — kei-architect emits a `spec` artifact
# consumable by downstream agents via kei-artifact store. Optional, non-breaking.
produces_artifact = "spec"
# Handoffs MUST come after all top-level keys (TOML array-of-tables scope rule)
[[handoff]]
target = "kei-code-implementer"
trigger = "structural finding implies a concrete refactor / extraction / module split"
produces_artifact = "spec"
[[handoff]]
target = "kei-critic"

View file

@ -63,6 +63,10 @@ output_extra_fields = [
"Checkpoints: <commit-sha or stash> — <description>",
]
# v0.15: typed-artifact handoff — implementer consumes `spec` from
# kei-architect and emits a `patch` manifest for downstream review.
produces_artifact = "patch"
# Handoffs MUST come after all top-level keys (TOML array-of-tables scope rule)
[[handoff]]
target = "kei-ml-implementer"
@ -75,6 +79,7 @@ trigger = "task involves deploy / CI/CD / secrets / IaC / credentials / public-s
[[handoff]]
target = "kei-critic"
trigger = "anti-pattern sweep / code smell review on large diff (>500 LOC) or long function chains"
produces_artifact = "patch"
[[handoff]]
target = "kei-security-auditor"

View file

@ -51,10 +51,16 @@ output_extra_fields = [
"Categories covered: security | bugs | anti-patterns | performance | tech-debt",
]
# v0.15: typed-artifact handoff — critic consumes `patch` from code-implementer
# and emits a `review` artifact with severity-sorted findings.
produces_artifact = "review"
# Handoffs MUST come after all top-level keys (TOML array-of-tables scope rule)
[[handoff]]
target = "kei-code-implementer"
trigger = "confirmed findings need code edits (user approves fix plan first)"
expects_artifact = "patch"
produces_artifact = "review"
[[handoff]]
target = "kei-security-auditor"

View file

@ -20,9 +20,9 @@ minimal = []
core = ["tomd", "genesis-scan"]
frontend = ["mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode"]
ops = ["kei-ledger", "ssh-check", "firewall-diff", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship"]
dev = ["kei-migrate", "kei-changelog", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store"]
dev = ["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"]
mcp = ["kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth"]
full = ["tomd", "genesis-scan", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "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"]
full = ["tomd", "genesis-scan", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "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"]
# --- shell primitives (13) -------------------------------------------------
@ -251,3 +251,11 @@ kind = "rust"
crate = "kei-auth"
deps = ["rusqlite bundled", "hmac", "sha2"]
desc = "Multi-tenant session tokens with scopes + HMAC-signed expiry (rewrite, not port)"
# --- v0.15 artifact handoff pipeline (1) -----------------------------------
[primitive.kei-artifact]
kind = "rust"
crate = "kei-artifact"
deps = ["rusqlite bundled"]
desc = "Typed artifact handoff pipeline — schema-validated content pass-between agents (BMAD-style)"

View file

@ -909,6 +909,20 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kei-artifact"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"rusqlite",
"serde",
"serde_json",
"sha2",
"tempfile",
]
[[package]]
name = "kei-auth"
version = "0.1.0"

View file

@ -26,6 +26,8 @@ members = [
"kei-social-store",
"kei-curator",
"kei-auth",
# v0.15 artifact handoff pipeline
"kei-artifact",
]
[workspace.package]

View file

@ -0,0 +1,26 @@
[package]
name = "kei-artifact"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Typed artifact handoff pipeline — BMAD-style document pass-between agents with JSON Schema validation"
[[bin]]
name = "kei-artifact"
path = "src/main.rs"
[lib]
name = "kei_artifact"
path = "src/lib.rs"
[dependencies]
rusqlite = { version = "0.31", features = ["bundled"] }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
anyhow = "1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,48 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "kei-artifact://patch",
"title": "File-Changes Manifest",
"description": "Implementer output — manifest of changed files with ops.",
"type": "object",
"additionalProperties": false,
"required": ["summary", "changes"],
"properties": {
"summary": {
"type": "string",
"minLength": 1
},
"parent_plan_id": {
"type": "string",
"minLength": 1
},
"changes": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["path", "op", "summary"],
"properties": {
"path": {"type": "string", "minLength": 1},
"op": {"type": "string", "enum": ["add", "mod", "del"]},
"summary": {"type": "string", "minLength": 1},
"loc_delta": {"type": "integer"},
"diff": {"type": "string"}
}
}
},
"tests": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "status"],
"properties": {
"name": {"type": "string", "minLength": 1},
"status": {"type": "string", "enum": ["pass", "fail", "skip"]},
"reproduce": {"type": "string"}
}
}
}
}
}

View file

@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "kei-artifact://plan",
"title": "Karpathy Goal-Driven Plan",
"description": "Ordered plan steps, each with a verify-criterion.",
"type": "object",
"additionalProperties": false,
"required": ["goal", "steps"],
"properties": {
"goal": {
"type": "string",
"minLength": 1
},
"parent_spec_id": {
"type": "string",
"minLength": 1
},
"steps": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["step", "verify"],
"properties": {
"step": {"type": "string", "minLength": 1},
"verify": {"type": "string", "minLength": 1},
"est_minutes": {"type": "integer", "minimum": 0}
}
}
},
"risks": {
"type": "array",
"items": {"type": "string", "minLength": 1}
}
}
}

View file

@ -0,0 +1,46 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "kei-artifact://research",
"title": "Research Claims",
"description": "Researcher output — claims + evidence grades + sources.",
"type": "object",
"additionalProperties": false,
"required": ["question", "claims"],
"properties": {
"question": {
"type": "string",
"minLength": 1
},
"claims": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["claim", "evidence_grade"],
"properties": {
"claim": {"type": "string", "minLength": 1},
"evidence_grade": {"type": "string", "enum": ["E1", "E2", "E3", "E4", "E5", "E6"]},
"confidence": {"type": "string", "enum": ["100", "80", "50", "30", "0"]},
"sources": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["url"],
"properties": {
"url": {"type": "string", "minLength": 1},
"title": {"type": "string"},
"verified": {"type": "boolean"}
}
}
}
}
}
},
"gaps": {
"type": "array",
"items": {"type": "string", "minLength": 1}
}
}
}

View file

@ -0,0 +1,41 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "kei-artifact://review",
"title": "Review Findings",
"description": "Critic / security / validator output — severity-sorted findings.",
"type": "object",
"additionalProperties": false,
"required": ["reviewer", "findings"],
"properties": {
"reviewer": {
"type": "string",
"enum": ["kei-critic", "kei-security-auditor", "kei-validator", "kei-architect"]
},
"parent_patch_id": {
"type": "string",
"minLength": 1
},
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["severity", "title", "file", "line"],
"properties": {
"severity": {"type": "string", "enum": ["critical", "high", "medium", "low", "info"]},
"category": {"type": "string", "enum": ["security", "bug", "anti-pattern", "performance", "tech-debt", "style"]},
"title": {"type": "string", "minLength": 1},
"file": {"type": "string", "minLength": 1},
"line": {"type": "integer", "minimum": 0},
"problem": {"type": "string"},
"impact": {"type": "string"},
"fix": {"type": "string"}
}
}
},
"verdict": {
"type": "string",
"enum": ["approve", "request_changes", "reject"]
}
}
}

View file

@ -0,0 +1,40 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "kei-artifact://spec",
"title": "Agent Specification",
"description": "Architect output — what to build and under which constraints.",
"type": "object",
"additionalProperties": false,
"required": ["goal", "constraints", "invariants"],
"properties": {
"goal": {
"type": "string",
"minLength": 1
},
"constraints": {
"type": "array",
"items": {"type": "string", "minLength": 1}
},
"invariants": {
"type": "array",
"items": {"type": "string", "minLength": 1}
},
"tradeoffs": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["choice", "rejected", "because"],
"properties": {
"choice": {"type": "string", "minLength": 1},
"rejected": {"type": "string", "minLength": 1},
"because": {"type": "string", "minLength": 1}
}
}
},
"evidence_grade": {
"type": "string",
"enum": ["E1", "E2", "E3", "E4", "E5", "E6"]
}
}
}

View file

@ -0,0 +1,191 @@
//! Artifact CRUD — register_schema / emit / get / list / chain / validate.
//!
//! Constructor Pattern: one concern per public fn, each < 30 LOC.
//! Every write path uses `artifact_id` for idempotency.
use crate::hash::artifact_id;
use crate::store::Store;
use crate::validate::validate_content;
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use rusqlite::{params, OptionalExtension};
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Serialize, Clone)]
pub struct Artifact {
pub id: String,
pub schema_name: String,
pub source_agent: String,
pub content: Vec<u8>,
pub meta_json: Option<String>,
pub parent_artifact_id: Option<String>,
pub created_at: i64,
}
#[derive(Debug, Default, Clone)]
pub struct ArtifactFilter {
pub schema_name: Option<String>,
pub source_agent: Option<String>,
pub since: Option<i64>,
}
/// Insert a schema under `name`. Overwrite if present (idempotent registry).
pub fn register_schema(store: &Store, name: &str, json_schema: &str) -> Result<()> {
let parsed: Value = serde_json::from_str(json_schema).context("schema is not valid JSON")?;
if !parsed.is_object() {
return Err(anyhow!("schema must be a JSON object"));
}
let now = Utc::now().timestamp();
store.conn().execute(
"INSERT INTO schemas (name, json_schema, registered_at) VALUES (?1, ?2, ?3)
ON CONFLICT(name) DO UPDATE SET json_schema=excluded.json_schema,
registered_at=excluded.registered_at",
params![name, json_schema, now],
)?;
Ok(())
}
pub fn list_schemas(store: &Store) -> Result<Vec<String>> {
let mut stmt = store.conn().prepare("SELECT name FROM schemas ORDER BY name")?;
let rows = stmt
.query_map([], |r| r.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
fn load_schema(store: &Store, name: &str) -> Result<Value> {
let raw: String = store
.conn()
.query_row(
"SELECT json_schema FROM schemas WHERE name = ?1",
params![name],
|r| r.get(0),
)
.optional()?
.ok_or_else(|| anyhow!("unknown schema '{name}' — register it first"))?;
serde_json::from_str(&raw).context("stored schema is not valid JSON")
}
/// Emit a typed artifact. Returns the id. Idempotent (same bytes → same id).
pub fn emit(
store: &Store,
schema_name: &str,
source_agent: &str,
content: &[u8],
meta_json: Option<&str>,
parent: Option<&str>,
) -> Result<String> {
if let Some(pid) = parent {
if !has_artifact(store, pid)? {
return Err(anyhow!("parent artifact '{pid}' not found"));
}
}
let schema = load_schema(store, schema_name)?;
let value: Value = serde_json::from_slice(content).context("artifact content not JSON")?;
validate_content(&schema, &value).map_err(|e| anyhow!("schema-validation: {e}"))?;
let id = artifact_id(schema_name, content);
let now = Utc::now().timestamp();
store.conn().execute(
"INSERT OR IGNORE INTO artifacts
(id, schema_name, source_agent, content, meta_json, parent_artifact_id, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![id, schema_name, source_agent, content, meta_json, parent, now],
)?;
Ok(id)
}
fn has_artifact(store: &Store, id: &str) -> Result<bool> {
let n: i64 = store.conn().query_row(
"SELECT COUNT(*) FROM artifacts WHERE id = ?1",
params![id],
|r| r.get(0),
)?;
Ok(n > 0)
}
pub fn get(store: &Store, id: &str) -> Result<Option<Artifact>> {
let row = store
.conn()
.query_row(
"SELECT id, schema_name, source_agent, content, meta_json,
parent_artifact_id, created_at
FROM artifacts WHERE id = ?1",
params![id],
row_to_artifact,
)
.optional()?;
Ok(row)
}
fn row_to_artifact(r: &rusqlite::Row) -> rusqlite::Result<Artifact> {
Ok(Artifact {
id: r.get(0)?,
schema_name: r.get(1)?,
source_agent: r.get(2)?,
content: r.get(3)?,
meta_json: r.get(4)?,
parent_artifact_id: r.get(5)?,
created_at: r.get(6)?,
})
}
/// Re-validate a stored artifact against its schema. Useful after schema
/// revision to detect rows that no longer satisfy the contract.
pub fn validate_by_id(store: &Store, id: &str) -> Result<()> {
let a = get(store, id)?.ok_or_else(|| anyhow!("artifact '{id}' not found"))?;
let schema = load_schema(store, &a.schema_name)?;
let value: Value = serde_json::from_slice(&a.content).context("artifact content not JSON")?;
validate_content(&schema, &value).map_err(|e| anyhow!("schema-validation: {e}"))?;
Ok(())
}
/// Filter-based listing; ORDER BY created_at DESC.
pub fn list(store: &Store, filter: &ArtifactFilter) -> Result<Vec<Artifact>> {
let (sql, args) = build_list_sql(filter);
let mut stmt = store.conn().prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(args.iter()), row_to_artifact)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
fn build_list_sql(f: &ArtifactFilter) -> (String, Vec<String>) {
let mut sql = String::from(
"SELECT id, schema_name, source_agent, content, meta_json, \
parent_artifact_id, created_at FROM artifacts",
);
let mut args: Vec<String> = Vec::new();
let mut clauses: Vec<String> = Vec::new();
if let Some(s) = &f.schema_name {
clauses.push(format!("schema_name = ?{}", clauses.len() + 1));
args.push(s.clone());
}
if let Some(a) = &f.source_agent {
clauses.push(format!("source_agent = ?{}", clauses.len() + 1));
args.push(a.clone());
}
if let Some(since) = f.since {
clauses.push(format!("created_at >= ?{}", clauses.len() + 1));
args.push(since.to_string());
}
if !clauses.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&clauses.join(" AND "));
}
sql.push_str(" ORDER BY created_at DESC");
(sql, args)
}
/// Walk the parent chain upward from `id`. Root first, youngest last.
pub fn chain(store: &Store, id: &str) -> Result<Vec<Artifact>> {
let mut out: Vec<Artifact> = Vec::new();
let mut current = Some(id.to_string());
while let Some(cid) = current {
let a = get(store, &cid)?.ok_or_else(|| anyhow!("artifact '{cid}' not found"))?;
current = a.parent_artifact_id.clone();
out.push(a);
}
out.reverse();
Ok(out)
}

View file

@ -0,0 +1,54 @@
//! sha256-based artifact id.
//!
//! Id = sha256(schema_name || 0x00 || content_bytes). Including the schema
//! name prevents trivial collisions across different content types with the
//! same payload bytes. Hex-encoded 64-char string.
use sha2::{Digest, Sha256};
/// Deterministic artifact id from schema name + content bytes.
pub fn artifact_id(schema_name: &str, content: &[u8]) -> String {
let mut h = Sha256::new();
h.update(schema_name.as_bytes());
h.update([0u8]);
h.update(content);
let out = h.finalize();
hex_encode(&out)
}
fn hex_encode(bytes: &[u8]) -> String {
const TABLE: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(TABLE[(*b >> 4) as usize] as char);
s.push(TABLE[(*b & 0x0f) as usize] as char);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_is_deterministic_for_same_input() {
let a = artifact_id("spec", b"hello");
let b = artifact_id("spec", b"hello");
assert_eq!(a, b);
assert_eq!(a.len(), 64);
}
#[test]
fn id_changes_with_schema_name() {
let a = artifact_id("spec", b"hello");
let b = artifact_id("plan", b"hello");
assert_ne!(a, b);
}
#[test]
fn id_changes_with_content() {
let a = artifact_id("spec", b"hello");
let b = artifact_id("spec", b"world");
assert_ne!(a, b);
}
}

View file

@ -0,0 +1,24 @@
//! kei-artifact — typed artifact handoff store.
//!
//! Constructor Pattern: one concern per file.
//! - `schema` — SQL DDL + schema registry table.
//! - `store` — `Store` cube (Connection wrapper).
//! - `hash` — sha256 artifact id helper.
//! - `schemas` — built-in schema registration (spec/plan/patch/review/research).
//! - `validate` — minimal JSON Schema (strict subset of draft 2020-12).
//! - `artifact` — CRUD on `artifacts` table (emit / get / list / chain).
//!
//! Storage path (CLI default): `~/.claude/artifacts/artifacts.sqlite` or
//! `$KEI_ARTIFACT_DB`.
pub mod artifact;
pub mod hash;
pub mod schema;
pub mod schemas;
pub mod store;
pub mod validate;
pub use artifact::{Artifact, ArtifactFilter};
pub use hash::artifact_id;
pub use store::Store;
pub use validate::validate_content;

View file

@ -0,0 +1,198 @@
//! kei-artifact CLI — register-schema / emit / get / list / validate / chain.
//!
//! Constructor Pattern: main.rs = dispatch only. Each `cmd_*` fn < 30 LOC.
use clap::{Parser, Subcommand};
use kei_artifact::artifact::{chain, emit, get, list, register_schema, validate_by_id, ArtifactFilter};
use kei_artifact::schemas::register_builtins;
use kei_artifact::Store;
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Parser)]
#[command(name = "kei-artifact", version, about = "Typed artifact handoff store")]
struct Cli {
#[arg(long)]
db: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Initialise the db and register the 5 built-in schemas.
Init,
/// Register a JSON Schema file under a name.
RegisterSchema {
#[arg(long)] name: String,
#[arg(long)] path: PathBuf,
},
/// Emit an artifact. Content file must be JSON.
Emit {
#[arg(long)] schema: String,
#[arg(long)] from: String,
#[arg(long)] content: PathBuf,
#[arg(long)] meta: Vec<String>,
#[arg(long)] parent: Option<String>,
},
/// Fetch an artifact by id.
Get {
id: String,
#[arg(long, default_value = "typed")] format: String,
},
/// List artifacts; filter by schema / source / since-seconds.
List {
#[arg(long)] schema: Option<String>,
#[arg(long)] from: Option<String>,
#[arg(long)] since: Option<String>,
},
/// Re-validate a stored artifact against its schema.
Validate { id: String },
/// Walk the parent-handoff chain.
Chain { id: String },
}
fn db_path(o: Option<PathBuf>) -> PathBuf {
if let Some(p) = o {
return p;
}
if let Ok(e) = std::env::var("KEI_ARTIFACT_DB") {
return PathBuf::from(e);
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".claude/artifacts/artifacts.sqlite")
}
fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
let store = Store::open(&db_path(cli.db))?;
dispatch(&store, cli.cmd)
}
fn dispatch(store: &Store, cmd: Cmd) -> anyhow::Result<()> {
match cmd {
Cmd::Init => cmd_init(store),
Cmd::RegisterSchema { name, path } => cmd_register(store, &name, &path),
Cmd::Emit { schema, from, content, meta, parent } => {
cmd_emit(store, &schema, &from, &content, &meta, parent.as_deref())
}
Cmd::Get { id, format } => cmd_get(store, &id, &format),
Cmd::List { schema, from, since } => {
cmd_list(store, schema.as_deref(), from.as_deref(), since.as_deref())
}
Cmd::Validate { id } => validate_by_id(store, &id),
Cmd::Chain { id } => cmd_chain(store, &id),
}
}
fn cmd_init(store: &Store) -> anyhow::Result<()> {
register_builtins(store)?;
println!("registered 5 built-in schemas: spec, plan, patch, review, research");
Ok(())
}
fn cmd_register(store: &Store, name: &str, path: &std::path::Path) -> anyhow::Result<()> {
let text = std::fs::read_to_string(path)?;
register_schema(store, name, &text)?;
println!("registered schema '{name}'");
Ok(())
}
fn cmd_emit(
store: &Store,
schema: &str,
from: &str,
content_path: &std::path::Path,
meta: &[String],
parent: Option<&str>,
) -> anyhow::Result<()> {
let bytes = std::fs::read(content_path)?;
let meta_json = if meta.is_empty() { None } else { Some(encode_meta(meta)?) };
let id = emit(store, schema, from, &bytes, meta_json.as_deref(), parent)?;
println!("{id}");
Ok(())
}
fn encode_meta(kvs: &[String]) -> anyhow::Result<String> {
let mut obj = serde_json::Map::new();
for kv in kvs {
let (k, v) = kv
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("--meta expects key=value: {kv}"))?;
obj.insert(k.to_string(), serde_json::Value::String(v.to_string()));
}
Ok(serde_json::Value::Object(obj).to_string())
}
fn cmd_get(store: &Store, id: &str, format: &str) -> anyhow::Result<()> {
let a = get(store, id)?.ok_or_else(|| anyhow::anyhow!("artifact not found: {id}"))?;
match format {
"json" => print_json(&a)?,
"md" | "typed" => print_typed(&a)?,
other => return Err(anyhow::anyhow!("unknown format '{other}'")),
}
Ok(())
}
fn print_json(a: &kei_artifact::Artifact) -> anyhow::Result<()> {
println!("{}", serde_json::to_string_pretty(a)?);
Ok(())
}
fn print_typed(a: &kei_artifact::Artifact) -> anyhow::Result<()> {
let text = std::str::from_utf8(&a.content).unwrap_or("<binary>");
println!("# artifact {} (schema={}, from={})", a.id, a.schema_name, a.source_agent);
println!("{text}");
Ok(())
}
fn cmd_list(
store: &Store,
schema: Option<&str>,
from: Option<&str>,
since: Option<&str>,
) -> anyhow::Result<()> {
let filter = ArtifactFilter {
schema_name: schema.map(str::to_string),
source_agent: from.map(str::to_string),
since: since.and_then(parse_since),
};
for a in list(store, &filter)? {
println!("{}\t{}\t{}\t{}", a.id, a.schema_name, a.source_agent, a.created_at);
}
Ok(())
}
fn parse_since(s: &str) -> Option<i64> {
// Accept raw epoch ints, or "1d" / "2h" / "30m" shorthands.
if let Ok(n) = s.parse::<i64>() {
return Some(n);
}
let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit())?);
let n: i64 = num.parse().ok()?;
let secs = match unit {
"s" => n,
"m" => n * 60,
"h" => n * 3600,
"d" => n * 86400,
_ => return None,
};
Some(chrono::Utc::now().timestamp() - secs)
}
fn cmd_chain(store: &Store, id: &str) -> anyhow::Result<()> {
for a in chain(store, id)? {
println!("{}\t{}\t{}", a.id, a.schema_name, a.source_agent);
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("kei-artifact: {e:#}");
ExitCode::from(1)
}
}
}

View file

@ -0,0 +1,50 @@
//! SQL schema DDL + migrations for the artifact store.
//!
//! Two tables:
//! - `schemas` — registered JSON Schemas by name (SSoT for validation).
//! - `artifacts` — typed content + metadata + parent pointer for handoff chain.
use rusqlite::{Connection, Result};
/// Ordered migrations. Index = schema version. Append only; never reorder.
pub const MIGRATIONS: &[&str] = &[
// v1 — initial schema
"CREATE TABLE IF NOT EXISTS schemas (
name TEXT PRIMARY KEY,
json_schema TEXT NOT NULL,
registered_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS artifacts (
id TEXT PRIMARY KEY,
schema_name TEXT NOT NULL,
source_agent TEXT NOT NULL,
content BLOB NOT NULL,
meta_json TEXT,
parent_artifact_id TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (schema_name) REFERENCES schemas(name),
FOREIGN KEY (parent_artifact_id) REFERENCES artifacts(id)
);
CREATE INDEX IF NOT EXISTS idx_schema ON artifacts(schema_name);
CREATE INDEX IF NOT EXISTS idx_source ON artifacts(source_agent);
CREATE INDEX IF NOT EXISTS idx_created ON artifacts(created_at);",
];
/// Apply pending migrations. Uses pragma `user_version` as the version cursor.
pub fn migrate(conn: &Connection) -> Result<()> {
let current: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap_or(0);
for (i, sql) in MIGRATIONS.iter().enumerate() {
let target = (i + 1) as i64;
if current < target {
conn.execute_batch(sql)?;
conn.pragma_update(None, "user_version", target)?;
}
}
Ok(())
}
/// Canonical list of artifact schema names shipped with this primitive.
/// Also the whitelist the _assembler validator checks.
pub const KNOWN_SCHEMAS: &[&str] = &["spec", "plan", "patch", "review", "research"];

View file

@ -0,0 +1,27 @@
//! Built-in schemas — 5 shipped schemas, embedded at compile time.
//!
//! Chain: architect(spec) → code-implementer(plan → patch) →
//! critic/security(review) → researcher(research) feeds back.
//! Each file lives in `kei-artifact/schemas/*.json` and is embedded via
//! `include_str!` so the CLI `--self-register` path needs no filesystem.
use crate::artifact::register_schema;
use crate::store::Store;
use anyhow::Result;
/// (name, schema JSON text). Keep in sync with `schemas/*.json`.
pub const BUILTIN: &[(&str, &str)] = &[
("spec", include_str!("../schemas/spec.json")),
("plan", include_str!("../schemas/plan.json")),
("patch", include_str!("../schemas/patch.json")),
("review", include_str!("../schemas/review.json")),
("research", include_str!("../schemas/research.json")),
];
/// Register all 5 built-in schemas. Idempotent.
pub fn register_builtins(store: &Store) -> Result<()> {
for (name, text) in BUILTIN {
register_schema(store, name, text)?;
}
Ok(())
}

View file

@ -0,0 +1,32 @@
//! `Store` — Connection wrapper. Single responsibility: open/migrate sqlite.
use crate::schema::migrate;
use anyhow::{Context, Result};
use rusqlite::Connection;
use std::path::Path;
pub struct Store {
conn: Connection,
}
impl Store {
pub fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let conn = Connection::open(path).context("open sqlite")?;
conn.pragma_update(None, "journal_mode", "WAL").ok();
migrate(&conn)?;
Ok(Self { conn })
}
pub fn open_memory() -> Result<Self> {
let conn = Connection::open_in_memory()?;
migrate(&conn)?;
Ok(Self { conn })
}
pub fn conn(&self) -> &Connection {
&self.conn
}
}

View file

@ -0,0 +1,198 @@
//! Minimal JSON Schema validator — strict subset of draft 2020-12.
//!
//! Keyword support (chosen for the 5 built-in schemas):
//! - `type` (object, array, string, integer, number, boolean, null)
//! - `required` (array of property names)
//! - `properties` (object → sub-schema)
//! - `additionalProperties` (bool; default true, we set false on ours)
//! - `enum` (array of allowed scalar values)
//! - `items` (sub-schema for array elements)
//! - `minLength` (integer) / `minItems` (integer) / `minimum` (number)
//!
//! Intentionally NOT supported: $ref, oneOf/anyOf/allOf, patternProperties,
//! format validation, conditional schemas. The 5 built-in schemas are written
//! to avoid needing those — keeps the validator under 200 LOC and removes the
//! 40+ transitive-dep `jsonschema` crate.
//!
//! RULE 0.4 note: draft 2020-12 is the current JSON Schema standard
//! [VERIFIED: https://json-schema.org/draft/2020-12 — spec page].
//! This implementation is a strict subset — any schema author sticking to
//! the keywords above gets draft-2020-12-compatible semantics.
use serde_json::Value;
/// Top-level entry. Returns `Ok(())` on pass, `Err(msg)` with a path-style
/// location on first failure.
pub fn validate_content(schema: &Value, content: &Value) -> Result<(), String> {
check(schema, content, "$")
}
fn check(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
if let Some(t) = schema.get("type") {
check_type(t, value, path)?;
}
if let Some(e) = schema.get("enum") {
check_enum(e, value, path)?;
}
match value {
Value::Object(_) => check_object(schema, value, path)?,
Value::Array(_) => check_array(schema, value, path)?,
Value::String(s) => check_min_length(schema, s, path)?,
Value::Number(n) => check_minimum(schema, n, path)?,
_ => {}
}
Ok(())
}
fn check_type(schema_type: &Value, value: &Value, path: &str) -> Result<(), String> {
let want = schema_type
.as_str()
.ok_or_else(|| format!("{path}: schema 'type' must be string"))?;
let ok = match (want, value) {
("object", Value::Object(_)) => true,
("array", Value::Array(_)) => true,
("string", Value::String(_)) => true,
("boolean", Value::Bool(_)) => true,
("null", Value::Null) => true,
("integer", Value::Number(n)) => n.is_i64() || n.is_u64(),
("number", Value::Number(_)) => true,
_ => false,
};
if !ok {
return Err(format!(
"{path}: expected type '{want}', got {}",
type_of(value)
));
}
Ok(())
}
fn type_of(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn check_enum(enum_schema: &Value, value: &Value, path: &str) -> Result<(), String> {
let allowed = enum_schema
.as_array()
.ok_or_else(|| format!("{path}: 'enum' must be array"))?;
if !allowed.iter().any(|a| a == value) {
return Err(format!("{path}: value {value} not in enum"));
}
Ok(())
}
fn check_object(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
let obj = value.as_object().unwrap();
if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
for r in required {
if let Some(name) = r.as_str() {
if !obj.contains_key(name) {
return Err(format!("{path}: missing required property '{name}'"));
}
}
}
}
let props = schema.get("properties").and_then(|v| v.as_object());
let additional = schema
.get("additionalProperties")
.and_then(|v| v.as_bool())
.unwrap_or(true);
for (k, v) in obj {
match props.and_then(|p| p.get(k)) {
Some(sub) => check(sub, v, &format!("{path}.{k}"))?,
None if !additional => {
return Err(format!("{path}: unexpected property '{k}'"));
}
None => {}
}
}
Ok(())
}
fn check_array(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
let arr = value.as_array().unwrap();
if let Some(min) = schema.get("minItems").and_then(|v| v.as_u64()) {
if (arr.len() as u64) < min {
return Err(format!("{path}: array has {} items, min {min}", arr.len()));
}
}
if let Some(items) = schema.get("items") {
for (i, el) in arr.iter().enumerate() {
check(items, el, &format!("{path}[{i}]"))?;
}
}
Ok(())
}
fn check_min_length(schema: &Value, s: &str, path: &str) -> Result<(), String> {
if let Some(min) = schema.get("minLength").and_then(|v| v.as_u64()) {
if (s.chars().count() as u64) < min {
return Err(format!("{path}: string shorter than minLength {min}"));
}
}
Ok(())
}
fn check_minimum(schema: &Value, n: &serde_json::Number, path: &str) -> Result<(), String> {
if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
if let Some(v) = n.as_f64() {
if v < min {
return Err(format!("{path}: number {v} below minimum {min}"));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn type_mismatch_rejected() {
let schema = json!({"type": "string"});
let err = validate_content(&schema, &json!(42)).unwrap_err();
assert!(err.contains("expected type 'string'"), "got: {err}");
}
#[test]
fn missing_required_rejected() {
let schema = json!({
"type": "object",
"required": ["goal"],
"properties": {"goal": {"type": "string"}}
});
let err = validate_content(&schema, &json!({})).unwrap_err();
assert!(err.contains("goal"));
}
#[test]
fn unknown_additional_rejected() {
let schema = json!({
"type": "object",
"additionalProperties": false,
"properties": {"a": {"type": "string"}}
});
let err = validate_content(&schema, &json!({"a":"x","b":"y"})).unwrap_err();
assert!(err.contains("unexpected property 'b'"));
}
#[test]
fn enum_and_array_items_enforced() {
let schema = json!({
"type": "array",
"items": {"type": "string", "enum": ["add", "mod", "del"]}
});
assert!(validate_content(&schema, &json!(["add", "mod"])).is_ok());
let err = validate_content(&schema, &json!(["nope"])).unwrap_err();
assert!(err.contains("enum"));
}
}

View file

@ -0,0 +1,132 @@
//! Integration tests — core CRUD (register_schema / emit / get / list / chain).
//!
//! Constructor Pattern: each test = one scenario, one assertion focus.
//! Companion file `validation.rs` tests schema-validation edge cases.
use kei_artifact::artifact::{chain, emit, get, list, register_schema, ArtifactFilter};
use kei_artifact::hash::artifact_id;
use kei_artifact::schemas::{register_builtins, BUILTIN};
use kei_artifact::Store;
use serde_json::json;
fn seed() -> Store {
let s = Store::open_memory().unwrap();
register_builtins(&s).unwrap();
s
}
fn spec_bytes(goal: &str) -> Vec<u8> {
serde_json::to_vec(&json!({
"goal": goal, "constraints": [], "invariants": []
}))
.unwrap()
}
#[test]
fn register_builtin_schemas_and_list_them() {
let s = seed();
let names = kei_artifact::artifact::list_schemas(&s).unwrap();
for (n, _) in BUILTIN {
assert!(names.iter().any(|x| x == n), "missing {n}");
}
assert_eq!(names.len(), 5);
}
#[test]
fn register_schema_custom_and_query_back() {
let s = seed();
let custom = r#"{"type":"object","additionalProperties":false,"properties":{}}"#;
register_schema(&s, "custom", custom).unwrap();
let names = kei_artifact::artifact::list_schemas(&s).unwrap();
assert!(names.iter().any(|n| n == "custom"));
}
#[test]
fn emit_get_roundtrip_for_spec_schema() {
let s = seed();
let bytes = spec_bytes("ship v0.15");
let id = emit(&s, "spec", "kei-architect", &bytes, None, None).unwrap();
let got = get(&s, &id).unwrap().unwrap();
assert_eq!(got.schema_name, "spec");
assert_eq!(got.source_agent, "kei-architect");
assert_eq!(got.content, bytes);
assert_eq!(got.id, artifact_id("spec", &bytes));
}
#[test]
fn chain_walks_parent_handoff_up_the_graph() {
let s = seed();
let spec = spec_bytes("g");
let spec_id = emit(&s, "spec", "kei-architect", &spec, None, None).unwrap();
let plan = serde_json::to_vec(&json!({
"goal": "g",
"steps": [{"step": "s1", "verify": "v1"}]
}))
.unwrap();
let plan_id = emit(&s, "plan", "kei-architect", &plan, None, Some(&spec_id)).unwrap();
let patch = serde_json::to_vec(&json!({
"summary": "first cut",
"changes": [{"path": "a.rs", "op": "add", "summary": "new"}]
}))
.unwrap();
let patch_id = emit(&s, "patch", "kei-code-implementer", &patch, None, Some(&plan_id)).unwrap();
let walk = chain(&s, &patch_id).unwrap();
assert_eq!(walk.len(), 3);
assert_eq!(walk[0].id, spec_id);
assert_eq!(walk[1].id, plan_id);
assert_eq!(walk[2].id, patch_id);
}
#[test]
fn list_filters_by_schema_source_and_since() {
let s = seed();
let a = spec_bytes("a");
let b = spec_bytes("b");
emit(&s, "spec", "kei-architect", &a, None, None).unwrap();
emit(&s, "spec", "kei-researcher", &b, None, None).unwrap();
let by_source = list(&s, &ArtifactFilter {
source_agent: Some("kei-architect".into()),
..Default::default()
})
.unwrap();
assert_eq!(by_source.len(), 1);
assert_eq!(by_source[0].source_agent, "kei-architect");
let by_schema = list(&s, &ArtifactFilter {
schema_name: Some("spec".into()),
..Default::default()
})
.unwrap();
assert_eq!(by_schema.len(), 2);
let none = list(&s, &ArtifactFilter {
schema_name: Some("plan".into()),
..Default::default()
})
.unwrap();
assert!(none.is_empty());
}
#[test]
fn duplicate_emit_is_idempotent_same_id() {
let s = seed();
let content = spec_bytes("g");
let id1 = emit(&s, "spec", "kei-architect", &content, None, None).unwrap();
let id2 = emit(&s, "spec", "kei-architect", &content, None, None).unwrap();
assert_eq!(id1, id2);
let all = list(&s, &ArtifactFilter::default()).unwrap();
assert_eq!(all.len(), 1);
}
#[test]
fn missing_parent_rejected() {
let s = seed();
let content = spec_bytes("g");
let err = emit(&s, "spec", "kei-architect", &content, None, Some("deadbeef")).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("parent"), "unexpected: {msg}");
}

View file

@ -0,0 +1,139 @@
//! Integration tests — schema validation edge cases.
//!
//! Constructor Pattern: one scenario per test, one assertion focus.
use kei_artifact::artifact::{emit, get, register_schema, validate_by_id};
use kei_artifact::schemas::register_builtins;
use kei_artifact::Store;
use serde_json::json;
fn seed() -> Store {
let s = Store::open_memory().unwrap();
register_builtins(&s).unwrap();
s
}
#[test]
fn invalid_content_rejected_at_emit_time() {
let s = seed();
// spec requires goal, constraints, invariants — give only goal.
let bad = serde_json::to_vec(&json!({"goal": "x"})).unwrap();
let err = emit(&s, "spec", "kei-architect", &bad, None, None).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("constraints") || msg.contains("invariants"),
"unexpected error: {msg}"
);
}
#[test]
fn validate_by_id_passes_for_conforming_content() {
let s = seed();
let content = serde_json::to_vec(&json!({
"summary": "ok",
"changes": [{"path": "x", "op": "mod", "summary": "tiny"}]
}))
.unwrap();
let id = emit(&s, "patch", "kei-code-implementer", &content, None, None).unwrap();
assert!(validate_by_id(&s, &id).is_ok());
}
#[test]
fn validate_by_id_detects_drift_after_schema_override() {
let s = seed();
let content = serde_json::to_vec(&json!({
"summary": "ok",
"changes": [{"path": "x", "op": "mod", "summary": "t"}]
}))
.unwrap();
let id = emit(&s, "patch", "kei-code-implementer", &content, None, None).unwrap();
let stricter = r#"{
"type": "object",
"additionalProperties": false,
"required": ["summary", "changes", "must_have"],
"properties": {
"summary": {"type": "string"},
"changes": {"type": "array"},
"must_have": {"type": "string"}
}
}"#;
register_schema(&s, "patch", stricter).unwrap();
let err = validate_by_id(&s, &id).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("must_have"), "unexpected: {msg}");
}
#[test]
fn unknown_schema_name_rejected_at_emit() {
let s = seed();
let err = emit(&s, "not-a-real-schema", "x", b"{}", None, None).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("unknown schema"), "unexpected: {msg}");
}
#[test]
fn review_schema_accepts_canonical_critic_output() {
let s = seed();
let content = serde_json::to_vec(&json!({
"reviewer": "kei-critic",
"findings": [
{
"severity": "high",
"category": "bug",
"title": "off-by-one",
"file": "src/x.rs",
"line": 42
}
],
"verdict": "request_changes"
}))
.unwrap();
let id = emit(&s, "review", "kei-critic", &content, None, None).unwrap();
let back = get(&s, &id).unwrap().unwrap();
assert_eq!(back.schema_name, "review");
}
#[test]
fn research_schema_accepts_claims_with_evidence_grade() {
let s = seed();
let content = serde_json::to_vec(&json!({
"question": "Does Rust's cargo support offline builds?",
"claims": [
{
"claim": "cargo --offline works with pre-cached deps",
"evidence_grade": "E1",
"confidence": "100",
"sources": [{"url": "https://doc.rust-lang.org/cargo/", "verified": true}]
}
]
}))
.unwrap();
let id = emit(&s, "research", "kei-researcher", &content, None, None).unwrap();
assert!(validate_by_id(&s, &id).is_ok());
}
#[test]
fn plan_schema_rejects_empty_steps() {
let s = seed();
let bad = serde_json::to_vec(&json!({
"goal": "g",
"steps": []
}))
.unwrap();
let err = emit(&s, "plan", "kei-architect", &bad, None, None).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("array"), "unexpected: {msg}");
}
#[test]
fn patch_schema_rejects_invalid_op_enum() {
let s = seed();
let bad = serde_json::to_vec(&json!({
"summary": "ok",
"changes": [{"path": "x", "op": "RENAME", "summary": "t"}]
}))
.unwrap();
let err = emit(&s, "patch", "kei-code-implementer", &bad, None, None).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("enum"), "unexpected: {msg}");
}

View file

@ -35,6 +35,7 @@ via Phase 3 grep, but surface it explicitly so the user sees the option:
- Test matrix (unit / integration / e2e / visual) → `/test-matrix`
- Frontend site / UI WYSIWYD loop → `/site-create` + `mock-render` + `visual-diff` + `tokens-sync`
- Multi-agent project bootstrap → `/new-project` + `kei-ledger` (RULE 0.12 fork tracking)
- Typed artifact handoff between agents → `kei-artifact` (v0.15: schema-validated spec→plan→patch→review chain instead of prose hints). If your architecture spans multiple agents and the output of one is the input of another, declare `produces_artifact` / `expects_artifact` in the manifest and emit via `kei-artifact emit`.
One-line per reference, click-discoverable, no duplication of pipeline logic.