diff --git a/crates/khive-pack-kg/src/entity_type_registry.rs b/crates/khive-pack-kg/src/entity_type_registry.rs index 7a6114e3..fdc1fc3a 100644 --- a/crates/khive-pack-kg/src/entity_type_registry.rs +++ b/crates/khive-pack-kg/src/entity_type_registry.rs @@ -86,6 +86,44 @@ static BUILTIN_DEFS: &[EntityTypeDef] = &[ type_name: "thesis", aliases: &[], }, + // ── Document (ADR-086 workspace-convention governed vocabulary) ─────────── + // `spec` already resolves via the "specification" alias above; `report` is + // already a canonical Document subtype above — neither is re-declared here. + EntityTypeDef { + kind: EntityKind::Document, + type_name: "note", + aliases: &[], + }, + EntityTypeDef { + kind: EntityKind::Document, + type_name: "summary", + aliases: &[], + }, + EntityTypeDef { + kind: EntityKind::Document, + type_name: "handoff", + aliases: &[], + }, + EntityTypeDef { + kind: EntityKind::Document, + type_name: "reference", + aliases: &[], + }, + EntityTypeDef { + kind: EntityKind::Document, + type_name: "adr", + aliases: &[], + }, + EntityTypeDef { + kind: EntityKind::Document, + type_name: "transcript", + aliases: &[], + }, + EntityTypeDef { + kind: EntityKind::Document, + type_name: "other", + aliases: &[], + }, // ── Concept ───────────────────────────────────────────────────────────── EntityTypeDef { kind: EntityKind::Concept, @@ -539,6 +577,82 @@ mod tests { assert_eq!(res.entity_type.as_deref(), Some("specification")); } + // ── ADR-086: document governed vocabulary ──────────────────────────────── + // entity_type ∈ {spec, note, summary, handoff, report, reference, adr, + // transcript, other}. `spec` resolves via the existing "specification" + // alias and `report` was already a canonical Document subtype; both are + // covered by this loop without new registry entries. + + #[test] + fn resolve_all_adr086_document_governed_types() { + let r = reg(); + for raw in [ + "spec", + "note", + "summary", + "handoff", + "report", + "reference", + "adr", + "transcript", + "other", + ] { + let res = r + .resolve(EntityKind::Document, Some(raw)) + .unwrap_or_else(|e| panic!("{raw:?} must be a valid Document entity_type: {e}")); + assert!( + res.entity_type.is_some(), + "{raw:?} must resolve to a canonical entity_type" + ); + } + } + + #[test] + fn resolve_adr086_new_document_types_are_canonical() { + let r = reg(); + for raw in [ + "note", + "summary", + "handoff", + "reference", + "adr", + "transcript", + "other", + ] { + let res = r + .resolve(EntityKind::Document, Some(raw)) + .unwrap_or_else(|e| panic!("{raw:?} must be a valid Document entity_type: {e}")); + assert_eq!( + res.entity_type.as_deref(), + Some(raw), + "{raw:?} must be stored as its own canonical name, not aliased away" + ); + } + } + + #[test] + fn reject_unknown_document_entity_type_lists_adr086_values() { + let r = reg(); + let err = r + .resolve(EntityKind::Document, Some("not_a_real_doc_type")) + .expect_err("unregistered document entity_type must be rejected"); + let msg = format!("{err}"); + for expected in [ + "note", + "summary", + "handoff", + "reference", + "adr", + "transcript", + "other", + ] { + assert!( + msg.contains(expected), + "error must list ADR-086 valid types, missing {expected:?}; got: {msg}" + ); + } + } + // ── Rejection tests ────────────────────────────────────────────────────── #[test] diff --git a/crates/khive-pack-kg/tests/integration.rs b/crates/khive-pack-kg/tests/integration.rs index 78370d7f..7386db1c 100644 --- a/crates/khive-pack-kg/tests/integration.rs +++ b/crates/khive-pack-kg/tests/integration.rs @@ -9732,3 +9732,246 @@ async fn link_depends_on_project_to_project_cross_namespace_infers_dependency_ki "cross-namespace depends_on must still infer dependency_kind=build (#638 round 1 finding 2); got edge: {edge:?}" ); } + +// ── ADR-086: document entity_type governed vocabulary ─────────────────────── +// +// entity_type ∈ {spec, note, summary, handoff, report, reference, adr, +// transcript, other} is OPTIONAL on `document` entities, but when present +// must be one of the governed set (EntityTypeRegistry, ADR-086 §3). These +// tests exercise that through the real `create`/`search`/`list` dispatch +// path, not just the registry's own unit tests. + +/// Every ADR-086 governed value must be accepted on `document` create. +#[tokio::test] +async fn document_create_accepts_each_adr086_governed_entity_type() { + let f = pack(); + for entity_type in [ + "spec", + "note", + "summary", + "handoff", + "report", + "reference", + "adr", + "transcript", + "other", + ] { + let result = f + .dispatch( + "create", + json!({ + "kind": "entity", + "entity_kind": "document", + "entity_type": entity_type, + "name": format!("Adr086Doc-{entity_type}"), + "description": "governed vocabulary body", + }), + ) + .await + .unwrap_or_else(|e| panic!("entity_type {entity_type:?} must be accepted: {e:?}")); + assert_eq!( + result.get("kind").and_then(Value::as_str), + Some("document"), + "created entity must be kind=document; got {result}" + ); + assert!( + result.get("entity_type").and_then(Value::as_str).is_some(), + "created document must carry a resolved entity_type; got {result}" + ); + } +} + +/// An entity_type outside the ADR-086 governed set is rejected fail-closed, +/// with the valid values listed in the error (matches other kind-validation +/// error shapes in this pack). +#[tokio::test] +async fn document_create_rejects_ungoverned_entity_type_with_valid_list() { + let f = pack(); + let err = f + .dispatch( + "create", + json!({ + "kind": "entity", + "entity_kind": "document", + "entity_type": "screenplay", + "name": "Adr086RejectedDoc", + }), + ) + .await + .expect_err("ungoverned document entity_type must be rejected"); + assert!( + is_invalid_input(&err), + "expected InvalidInput, got: {err:?}" + ); + let msg = invalid_input_message(&err); + assert!( + msg.contains("screenplay"), + "error must echo the rejected value; got: {msg}" + ); + for expected in [ + "note", + "summary", + "handoff", + "reference", + "adr", + "transcript", + "other", + ] { + assert!( + msg.contains(expected), + "error must list ADR-086 governed values, missing {expected:?}; got: {msg}" + ); + } +} + +/// entity_type is optional on `document` — omitting it must still succeed +/// (ADR-086 §3: "entity may omit entity_type"). +#[tokio::test] +async fn document_create_omitted_entity_type_accepted() { + let f = pack(); + let result = f + .dispatch( + "create", + json!({ + "kind": "entity", + "entity_kind": "document", + "name": "Adr086NoTypeDoc", + "description": "no entity_type supplied", + }), + ) + .await + .expect("document create without entity_type must succeed"); + assert_eq!( + result.get("kind").and_then(Value::as_str), + Some("document"), + "got {result}" + ); + assert!( + result.get("entity_type").and_then(Value::as_str).is_none() + || result.get("entity_type") == Some(&Value::Null), + "entity_type must be absent/null when omitted at create; got {result}" + ); +} + +/// `search(kind="entity", entity_kind="document", entity_type=...)` scopes +/// results to that governed subtype — the existing entity_type filter path +/// (already wired for `search`, verified here rather than re-invented). +#[tokio::test] +async fn document_search_filters_by_adr086_entity_type() { + let f = pack(); + f.dispatch( + "create", + json!({ + "kind": "entity", + "entity_kind": "document", + "entity_type": "handoff", + "name": "Adr086SearchHandoff", + "description": "unique_marker_adr086_handoff", + }), + ) + .await + .expect("create handoff document"); + f.dispatch( + "create", + json!({ + "kind": "entity", + "entity_kind": "document", + "entity_type": "reference", + "name": "Adr086SearchReference", + "description": "unique_marker_adr086_reference", + }), + ) + .await + .expect("create reference document"); + + let resp = f + .dispatch( + "search", + json!({ + "kind": "entity", + "entity_kind": "document", + "entity_type": "handoff", + "query": "Adr086Search", + }), + ) + .await + .expect("filtered document search must succeed"); + let arr = resp.as_array().expect("search must return an array"); + assert!( + !arr.is_empty(), + "entity_type=handoff filter must return the handoff document; got empty result" + ); + // Search hits do not carry an `entity_type` field (only entity_kind, id, + // score, snippet, title) — the filter narrows which entities are + // eligible to match, so assert on identity (title) instead. + assert!( + arr.iter() + .any(|hit| hit.get("title").and_then(Value::as_str) == Some("Adr086SearchHandoff")), + "entity_type=handoff filter must return the handoff document by title; got {arr:?}" + ); + assert!( + arr.iter() + .all(|hit| hit.get("title").and_then(Value::as_str) != Some("Adr086SearchReference")), + "the reference document must not appear under an entity_type=handoff filter; got {arr:?}" + ); +} + +/// `list(kind="entity", entity_kind="document", entity_type=...)` scopes +/// results the same way as `search` — verified against the existing +/// `list` entity_type filter path (list.rs), not a new filter surface. +#[tokio::test] +async fn document_list_filters_by_adr086_entity_type() { + let f = pack(); + f.dispatch( + "create", + json!({ + "kind": "entity", + "entity_kind": "document", + "entity_type": "transcript", + "name": "Adr086ListTranscript", + }), + ) + .await + .expect("create transcript document"); + f.dispatch( + "create", + json!({ + "kind": "entity", + "entity_kind": "document", + "entity_type": "summary", + "name": "Adr086ListSummary", + }), + ) + .await + .expect("create summary document"); + + let listed = f + .dispatch( + "list", + json!({ + "kind": "entity", + "entity_kind": "document", + "entity_type": "transcript", + }), + ) + .await + .expect("filtered document list must succeed"); + // list returns a JSON array directly (Vec serialized) — not an + // object with an "items" key (see dispatch.rs's kg_oss_default_namespace + // test for the same documented shape). + let items = match listed { + Value::Array(arr) => arr, + other => panic!("list must return a JSON array; got: {other:?}"), + }; + assert!( + !items.is_empty(), + "entity_type=transcript filter must return the transcript document; got {items:?}" + ); + for item in &items { + assert_eq!( + item.get("entity_type").and_then(Value::as_str), + Some("transcript"), + "every listed item must carry entity_type=transcript; got {item}" + ); + } +}