Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 18 additions & 18 deletions packages/core/src/behaviors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,18 +645,18 @@ impl TaskNodeBehavior {
///
/// Returns `ValidationError` if validation fails. Currently validates:
/// - Status is a valid enum value (enforced by TaskStatus type)
/// - Priority is a valid enum value (enforced by TaskPriority type)
/// - Priority is a valid enum value (enforced by Priority type)
///
/// # Examples
///
/// ```rust
/// use nodespace_core::behaviors::TaskNodeBehavior;
/// use nodespace_core::models::{TaskNode, TaskStatus, TaskPriority};
/// use nodespace_core::models::{TaskNode, TaskStatus, Priority};
///
/// let behavior = TaskNodeBehavior;
/// let task = TaskNode::builder("Fix bug".to_string())
/// .with_status(TaskStatus::InProgress)
/// .with_priority(TaskPriority::Medium)
/// .with_priority(Priority::Medium)
/// .build();
///
/// assert!(behavior.validate_task_node(&task).is_ok());
Expand All @@ -665,9 +665,9 @@ impl TaskNodeBehavior {
// Status is already type-safe via TaskStatus enum - no validation needed
// The Rust type system guarantees it's a valid status value
//
// Priority is already type-safe via TaskPriority enum - no validation needed
// Priority is already type-safe via Priority enum - no validation needed
// Core values (highest, high, medium, low, lowest) are enforced by the enum
// User-defined values (TaskPriority::User) are allowed by schema extension
// User-defined values (Priority::User) are allowed by schema extension
//
// Note: Empty content is allowed for tasks - users can add description later
// Note: Due date validation (if present, must be valid DateTime) is enforced by type
Expand Down Expand Up @@ -3165,7 +3165,7 @@ mod tests {
assert!(behavior.validate(&bad_status_type).is_ok());

// Priority is now a string enum (highest, high, medium, low, lowest) with user-extensibility
// All values pass validation - unknown values become TaskPriority::User(value)
// All values pass validation - unknown values become Priority::User(value)
let priority_highest = Node::new(
"task".to_string(),
"Task".to_string(),
Expand Down Expand Up @@ -3682,7 +3682,7 @@ mod tests {

#[test]
fn test_task_node_behavior_validate_task_node() {
use crate::models::{TaskNode, TaskPriority, TaskStatus};
use crate::models::{Priority, TaskNode, TaskStatus};

let behavior = TaskNodeBehavior;

Expand All @@ -3698,19 +3698,19 @@ mod tests {

// Valid task with priority (now a string enum)
let task_with_priority = TaskNode::builder("High priority task".to_string())
.with_priority(TaskPriority::High)
.with_priority(Priority::High)
.build();
assert!(behavior.validate_task_node(&task_with_priority).is_ok());

// Valid task with low priority
let task_low_priority = TaskNode::builder("Low priority".to_string())
.with_priority(TaskPriority::Low)
.with_priority(Priority::Low)
.build();
assert!(behavior.validate_task_node(&task_low_priority).is_ok());

// User-defined priority values are allowed (schema extensibility)
let task_custom_priority = TaskNode::builder("Custom priority".to_string())
.with_priority(TaskPriority::User("critical".to_string()))
.with_priority(Priority::User("critical".to_string()))
.build();
assert!(
behavior.validate_task_node(&task_custom_priority).is_ok(),
Expand Down Expand Up @@ -4543,7 +4543,7 @@ mod tests {

#[test]
fn test_task_node_from_node_nested_format() {
use crate::models::{TaskNode, TaskPriority};
use crate::models::{Priority, TaskNode};

// Test that TaskNode::from_node handles nested format
// Priority now uses string enum format
Expand All @@ -4561,12 +4561,12 @@ mod tests {
let task = TaskNode::from_node(nested_node).unwrap();
assert_eq!(task.content, "Nested format task");
assert_eq!(task.status.as_str(), "in_progress");
assert_eq!(task.priority, Some(TaskPriority::Medium));
assert_eq!(task.priority, Some(Priority::Medium));
}

#[test]
fn test_task_node_from_node_flat_format() {
use crate::models::{TaskNode, TaskPriority};
use crate::models::{Priority, TaskNode};

// Test flat format (old property structure, but string priority)
let flat_node = Node::new(
Expand All @@ -4581,12 +4581,12 @@ mod tests {
let task = TaskNode::from_node(flat_node).unwrap();
assert_eq!(task.content, "Flat format task");
assert_eq!(task.status.as_str(), "done");
assert_eq!(task.priority, Some(TaskPriority::Low));
assert_eq!(task.priority, Some(Priority::Low));
}

#[test]
fn test_task_node_from_node_string_priority() {
use crate::models::{TaskNode, TaskPriority};
use crate::models::{Priority, TaskNode};

// Test string priority - now the canonical format
let string_priority_node = Node::new(
Expand All @@ -4601,7 +4601,7 @@ mod tests {
);

let task = TaskNode::from_node(string_priority_node).unwrap();
assert_eq!(task.priority, Some(TaskPriority::High));
assert_eq!(task.priority, Some(Priority::High));

// Test core string priorities
let low_node = Node::new(
Expand All @@ -4610,7 +4610,7 @@ mod tests {
json!({"priority": "low"}),
);
let low_task = TaskNode::from_node(low_node).unwrap();
assert_eq!(low_task.priority, Some(TaskPriority::Low));
assert_eq!(low_task.priority, Some(Priority::Low));

// Test user-defined priority (schema extensibility)
let urgent_node = Node::new(
Expand All @@ -4621,7 +4621,7 @@ mod tests {
let urgent_task = TaskNode::from_node(urgent_node).unwrap();
assert_eq!(
urgent_task.priority,
Some(TaskPriority::User("urgent".to_string())),
Some(Priority::User("urgent".to_string())),
"Unknown priorities become User-defined"
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/db/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ CREATE INDEX IF NOT EXISTS idx_task_priority ON node (json_extract(properties, '
-- index is cheaper to maintain for the common status-only filter.
CREATE INDEX IF NOT EXISTS idx_task_status_due_date ON node (json_extract(properties, '$.task.status'), json_extract(properties, '$.task.due_date')) WHERE node_type = 'task';
CREATE INDEX IF NOT EXISTS idx_project_status ON node (json_extract(properties, '$.project.status')) WHERE node_type = 'project';
CREATE INDEX IF NOT EXISTS idx_project_priority ON node (json_extract(properties, '$.project.priority')) WHERE node_type = 'project';

-- Holds BOTH instance-level edges (person→task tasks/assignee, has_child, …)
-- and schema relationship DECLARATIONS: a declaration row connects two schema
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/models/conflict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::fmt;
use std::str::FromStr;

/// The kind of convergence conflict a record represents. A closed set —
/// unlike `TaskStatus`/`TaskPriority`, there is no user-defined extension
/// unlike `TaskStatus`/`Priority`, there is no user-defined extension
/// point here, so an unrecognized stored value is a genuine data error, not a
/// legitimate "user-defined kind" the way `TaskStatus::User(_)` is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
Expand Down
119 changes: 72 additions & 47 deletions packages/core/src/models/core_schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,18 +309,23 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
local_only: false,
protection: SchemaProtectionLevel::User,
core_values: Some(vec![
EnumValue::new("low".to_string(), "Low".to_string()),
EnumValue::new("medium".to_string(), "Medium".to_string()),
EnumValue::new("highest".to_string(), "Highest".to_string()),
EnumValue::new("high".to_string(), "High".to_string()),
EnumValue::new("medium".to_string(), "Medium".to_string()),
EnumValue::new("low".to_string(), "Low".to_string()),
EnumValue::new("lowest".to_string(), "Lowest".to_string()),
]),
user_values: Some(vec![]),
indexed: true,
required: Some(false),
extensible: Some(true),
default: None,
description: Some(
"Relative importance for triage across projects (low, medium, high). \
Absent means no priority has been assigned."
"Relative importance for triage across projects (highest, high, \
medium, low, lowest) — the same scale as task priority. Sorting \
follows that order rather than the alphabetical order of the \
values; user-defined values sort after all of them. Absent means \
no priority has been assigned."
.to_string(),
),
item_type: None,
Expand Down Expand Up @@ -2085,58 +2090,78 @@ mod tests {
}
}

/// The `TaskPriority` sibling of the ADR-076 status drift check above.
/// The `Priority` sibling of the ADR-076 status drift check above, run
/// for every core type that shares the scale.
///
/// `task.priority` has the same two-sided consistency requirement: a named
/// variant with no `core_values` entry is a value the type accepts but the
/// schema rejects, and a `core_values` entry with no named variant parses
/// to `TaskPriority::User(_)` — validating fine while reporting
/// Each type's `priority` must list exactly the named variants, in rank
/// order. A named variant with no `core_values` entry is a value the type
/// accepts but the schema rejects; a `core_values` entry with no named
/// variant parses to `Priority::User(_)` — validating fine while reporting
/// `is_core() == false`, so it is silently treated as a user extension of
/// the very field that declares it.
/// the very field that declares it, and sorts after the whole scale. The
/// order is pinned too, so every type's picker lists the scale the same
/// way the query service ranks it.
#[test]
fn test_task_priority_variants_match_core_values_bidirectionally() {
use crate::models::TaskPriority;
fn test_priority_variants_match_core_values_bidirectionally() {
use crate::models::Priority;

let expected: Vec<&str> = [
Priority::Highest,
Priority::High,
Priority::Medium,
Priority::Low,
Priority::Lowest,
]
.iter()
.map(|p| p.as_str())
.collect();

let schemas = get_core_schemas();
let task = schemas.iter().find(|s| s.id == "task").unwrap();
let priority_field = task
.get_field("priority")
.expect("task schema has priority");
let core_value_strings: Vec<&str> = priority_field
.core_values
.as_ref()
.expect("priority field has core_values")
.iter()
.map(|ev| ev.value.as_str())
.collect();
for node_type in Priority::NODE_TYPES {
let schema = schemas
.iter()
.find(|s| s.id == node_type)
.unwrap_or_else(|| panic!("no core schema for '{}'", node_type));
let priority_field = schema
.get_field("priority")
.unwrap_or_else(|| panic!("{} schema has priority", node_type));
let core_value_strings: Vec<&str> = priority_field
.core_values
.as_ref()
.expect("priority field has core_values")
.iter()
.map(|ev| ev.value.as_str())
.collect();

let named_variants = [
TaskPriority::Highest,
TaskPriority::High,
TaskPriority::Medium,
TaskPriority::Low,
TaskPriority::Lowest,
];
for variant in &named_variants {
assert!(
core_value_strings.contains(&variant.as_str()),
"TaskPriority::{:?} (\"{}\") has no matching entry in task.priority's \
core_values ({:?}) — add it to core_schemas.rs's seed definition.",
variant,
variant.as_str(),
core_value_strings
assert_eq!(
core_value_strings, expected,
"{}.priority's core_values must be exactly the named Priority variants \
in rank order — update core_schemas.rs or models/priority.rs so they agree.",
node_type
);
}
}

for value in &core_value_strings {
let parsed: TaskPriority = value.parse().expect("TaskPriority::from_str is infallible");
assert!(
parsed.is_core(),
"task.priority's core_values entry '{}' does not parse to a named TaskPriority \
variant (got TaskPriority::User(_)) — add a matching variant in task_node.rs \
or remove the stray core_values entry.",
value
);
/// The other direction of the check above: a core type that declares a
/// `priority` field must be listed in `Priority::NODE_TYPES`. Otherwise
/// its field would silently sort as text while looking like the shared
/// scale — the divergence `project.priority` once had. Adding a core type
/// with a deliberately different scale is fine, but it has to be a
/// decision made here, not drift.
#[test]
fn test_every_core_priority_field_uses_the_shared_scale() {
use crate::models::Priority;

for schema in get_core_schemas() {
if schema.get_field("priority").is_some() {
assert!(
Priority::applies_to(&schema.id),
"core schema '{}' declares `priority` but is not in \
Priority::NODE_TYPES — add it there (and align its core_values), \
or document why its scale differs.",
schema.id
);
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod conflict;
pub mod core_schemas;
pub mod embedding;
mod node;
mod priority;
pub mod schema;
pub mod time;

Expand Down Expand Up @@ -77,8 +78,9 @@ pub use collection_node::CollectionNode;
pub use date_node::DateNode;
pub use embedding::{ChunkInfo, Embedding, EmbeddingConfig, EmbeddingSearchResult, NewEmbedding};
pub use nodespace_types::{SkillNode, DEFAULT_SKILL_MAX_ITERATIONS, SKILL_NODE_TYPE};
pub use priority::Priority;
pub use schema_node::SchemaNode;
pub use task_node::{TaskNode, TaskNodeUpdate, TaskPriority, TaskStatus};
pub use task_node::{TaskNode, TaskNodeUpdate, TaskStatus};
pub use text_node::TextNode;

// node_to_typed_value and nodes_to_typed_values are the single canonical
Expand Down
Loading