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
4 changes: 2 additions & 2 deletions packages/core/src/behaviors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2266,8 +2266,8 @@ impl NodeBehavior for CustomNodeBehavior {
/// Built-in behavior for person nodes
///
/// Person nodes are pure identity — `first_name` and `last_name` (optional)
/// and `email` (optional, format-validated when present). A person's role in
/// this database is not a PersonNode property: it lives on the `has_role` edge
/// and `email` (optional, format-validated when present). Being this
/// database's owner is not a PersonNode property: it is the `has_role` edge
/// from the person to the `DatabaseSettingsNode` singleton.
///
/// A person can carry child nodes (notes about them). It is not embedded, and
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/db/sqlite_store/relationships.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1778,7 +1778,7 @@ impl SqliteStore {
/// Fetch a single relationship edge (with its properties) by endpoints and type.
///
/// Complements `get_relationship_id` when the caller needs the edge's stored
/// properties (e.g. the `role`/`status` carried on a `has_role` edge), not just
/// properties (e.g. the `order` carried on a `member_of` edge), not just
/// its id. Returns `None` when no such edge exists.
pub async fn get_relationship_record(
&self,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/models/core_schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
//! - **horizontal-line** - Horizontal rule / thematic break
//! - **table** - GFM markdown table
//! - **person** - Identity primitive (name, email)
//! - **database-settings** - Singleton anchor for database-level configuration and the owner role edge
//! - **database-settings** - Singleton anchor for database-level configuration and the owner `has_role` edge
//!
//! ## Usage
//!
Expand Down
211 changes: 81 additions & 130 deletions packages/core/src/services/node_service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1618,9 +1618,8 @@ impl NodeService {
}

/// ADR-037: seed exactly one local PersonNode — the local user.
/// Idempotent: skips when a person already exists, so an existing database
/// is backfilled on next open too. Name/email stay absent until the user
/// fills them in (PersonNodeBehavior allows it).
/// Idempotent: skips when a person already exists. Name/email stay absent
/// until the user fills them in (PersonNodeBehavior allows it).
async fn seed_local_person_if_needed(&self) -> Result<(), NodeServiceError> {
if !self.query_nodes_by_type("person", None).await?.is_empty() {
return Ok(());
Expand All @@ -1632,12 +1631,11 @@ impl NodeService {
}

/// ADR-037: seed the DatabaseSettingsNode singleton — the anchor for
/// database-level configuration — and one `has_role` owner edge from the local
/// PersonNode to it (role `owner`, status `active`). The singleton carries no
/// properties. Idempotent: skips when a
/// database-settings node already exists, so an existing database is
/// backfilled on next open too. Must run after the local person seed so the
/// owner edge always has a person to attach to.
/// database-level configuration — and one `has_role` edge from the local
/// PersonNode to it. The edge records whose database this is and carries no
/// properties (ADR-083 §2). Idempotent: skips when the settings node and
/// its owner edge already exist. Must run after the local person seed so
/// the owner edge always has a person to attach to.
async fn seed_database_settings_if_needed(&self) -> Result<(), NodeServiceError> {
// ADR-069 §1a/S5, closing F13: the idempotency guard checks the
// owner EDGE, not merely the settings node's existence. The node and
Expand Down Expand Up @@ -1695,7 +1693,7 @@ impl NodeService {
}
};

// Attach the owner role edge from the local PersonNode. Seeding order
// Attach the owner edge from the local PersonNode. Seeding order
// guarantees exactly one local person exists at this point.
let local_person_id = self
.query_nodes_by_type("person", None)
Expand All @@ -1712,7 +1710,7 @@ impl NodeService {
&local_person_id,
"has_role",
&settings_id,
serde_json::json!({"role": "owner", "status": "active"}),
serde_json::json!({}),
)
.await?;

Expand All @@ -1724,69 +1722,44 @@ impl NodeService {
Ok(())
}

/// ADR-037: resolve the seeded local-user PersonNode — the
/// person with an outgoing `has_role` edge carrying `role: "owner"` to
/// the DatabaseSettingsNode singleton (the owner edge
/// `seed_database_settings_if_needed` seeds). The DatabaseSettingsNode
/// can hold more than one `has_role` edge, so this filters on
/// `role == "owner"` rather than taking the first `has_role` edge — once
/// a second, non-owner role edge lands there, an unfiltered `.next()`
/// would silently resolve the wrong person.
/// If more than one edge is found with `role == "owner"` — which
/// today's seeding path cannot produce — the first is used and a
/// `tracing::warn!` is emitted, so a future regression surfaces instead
/// of resolving silently to whichever edge SQL returns first.
/// Falls back to the first `person` node when no owner edge is found —
/// a database whose data predates ADR-037 seeding still resolves to *a*
/// local person rather than surfacing "no identity" on an otherwise
/// healthy install. That fallback is silent when there are zero
/// `has_role` edges at all (the legitimate pre-ADR-037 case), but emits a
/// `tracing::warn!` when one or more `has_role` edges exist without any
/// carrying `role == "owner"` — today's only seeding path always seeds
/// "owner", so a non-empty, non-matching edge set signals a
/// data-integrity anomaly rather than expected pre-seeding state.
/// Returns `None` only when there is no person node at all (should not
/// happen post-seed, but this must not panic on data that predates it).
/// ADR-037: resolve the seeded local-user PersonNode — the person whose
/// `has_role` edge points at the DatabaseSettingsNode singleton (the owner
/// edge `seed_database_settings_if_needed` seeds). The edge's existence is
/// the whole fact: it carries no properties (ADR-083 §2), and a database
/// has exactly one.
/// Seeding creates the edge and repairs it on every open, so any other
/// count is an anomaly, and both sides of it emit a `tracing::warn!`:
/// - More than one edge: the earliest-created node holding one is used,
/// so the answer does not depend on the order SQL returns them in.
/// - No edge: falls back to the first `person` node — the one the next
/// open re-attaches the edge to — rather than surfacing "no identity"
/// on an otherwise healthy install.
///
/// Returns `None` only when there is no person node at all.
pub async fn get_local_person(&self) -> Result<Option<Node>, NodeServiceError> {
let owners = self
.get_related_nodes_with_edges(DATABASE_SETTINGS_NODE_ID, "has_role", "in")
.get_related_nodes(DATABASE_SETTINGS_NODE_ID, "has_role", "in")
.await?;
let has_role_edges_exist = !owners.is_empty();
let mut owner_edges = owners.into_iter().filter(|(_, edge_properties)| {
edge_properties
.get("role")
.and_then(|v| v.as_str())
.map(|role| role == "owner")
.unwrap_or(false)
});
let owner = owner_edges.next().map(|(node, _)| node);
if owner_edges.next().is_some() {
// Data-integrity anomaly: today's seeding path creates exactly one
// owner-role edge, so this should be unreachable. Warn rather than
// silently resolving to whichever edge SQL happened to return first.
if owners.len() > 1 {
tracing::warn!(
settings_node_id = DATABASE_SETTINGS_NODE_ID,
"multiple has_role edges with role \"owner\" found on DatabaseSettingsNode; \
resolving to the first one returned"
"multiple has_role edges found on DatabaseSettingsNode; \
resolving to the earliest-created node holding one"
);
}
let owner = owners.into_iter().min_by(|a, b| {
a.created_at
.cmp(&b.created_at)
.then_with(|| a.id.cmp(&b.id))
});
if let Some(owner) = owner {
return Ok(Some(owner));
}
if has_role_edges_exist {
// Data-integrity anomaly: has_role edges exist on the
// DatabaseSettingsNode singleton but none carries role == "owner".
// Today's only seeding path (`seed_database_settings_if_needed`)
// always seeds "owner", so this should be unreachable — warn
// rather than silently falling back to "pick any person node",
// which would mask a real anomaly. Zero edges at all is the
// legitimate pre-ADR-037 fallback case and stays silent.
tracing::warn!(
settings_node_id = DATABASE_SETTINGS_NODE_ID,
"has_role edges found on DatabaseSettingsNode but none has role \"owner\"; \
falling back to the first person node"
);
}
tracing::warn!(
settings_node_id = DATABASE_SETTINGS_NODE_ID,
"no has_role edge found on DatabaseSettingsNode; \
falling back to the first person node"
);
Ok(self
.query_nodes_by_type("person", None)
.await?
Expand Down Expand Up @@ -8815,8 +8788,11 @@ mod tests {
.await
.unwrap()
.expect("owner has_role edge exists");
assert_eq!(edge.properties["role"], "owner");
assert_eq!(edge.properties["status"], "active");
assert_eq!(
edge.properties,
serde_json::json!({}),
"the owner edge records whose database this is and carries no properties"
);

// The owner edge is how the local user is found.
let local = service
Expand Down Expand Up @@ -8985,7 +8961,7 @@ mod tests {
&new_owner_id,
"has_role",
DATABASE_SETTINGS_NODE_ID,
serde_json::json!({"role": "owner", "status": "active"}),
serde_json::json!({}),
)
.await
.unwrap();
Expand All @@ -8997,63 +8973,20 @@ mod tests {
.expect("a local person must resolve when an owner edge exists");
assert_eq!(
local.id, new_owner_id,
"must resolve whoever currently holds the has_role owner edge, \
"must resolve whoever currently holds the has_role edge, \
not just the first person node"
);
}

#[tokio::test]
async fn test_get_local_person_ignores_non_owner_has_role_edges() {
let (service, _temp) = create_test_service().await;

let people = service.query_nodes_by_type("person", None).await.unwrap();
assert_eq!(people.len(), 1);
let seeded_owner_id = people[0].id.clone();

// Add a SECOND has_role edge to the DatabaseSettingsNode singleton,
// from a different person, carrying a non-owner role alongside the
// owner's. A naive "first has_role edge" resolution could silently
// return this person instead of the actual owner, depending on
// traversal order.
let member_id = service
.create_node(Node::new(
"person".to_string(),
String::new(),
serde_json::json!({}),
))
.await
.unwrap();
service
.create_relationship(
&member_id,
"has_role",
DATABASE_SETTINGS_NODE_ID,
serde_json::json!({"role": "member", "status": "active"}),
)
.await
.unwrap();

let local = service
.get_local_person()
.await
.unwrap()
.expect("a local person must resolve when an owner edge exists");
assert_eq!(
local.id, seeded_owner_id,
"must resolve the person holding the owner-role edge, not a \
differently-roled has_role edge on the same node"
);
}

#[tokio::test]
async fn test_get_local_person_falls_back_to_first_person_when_owner_edge_missing() {
let (service, _temp) = create_test_service().await;

let people = service.query_nodes_by_type("person", None).await.unwrap();
let seeded_id = people[0].id.clone();

// Simulate a database that predates the ADR-037 owner edge (or one
// where it was somehow dropped): remove the has_role edge entirely.
// Simulate a database whose owner edge was dropped: remove the
// has_role edge entirely.
service
.delete_relationship(&seeded_id, "has_role", DATABASE_SETTINGS_NODE_ID)
.await
Expand Down Expand Up @@ -9115,51 +9048,69 @@ mod tests {
}

#[tokio::test]
async fn test_get_local_person_warns_when_has_role_edges_exist_without_owner_role() {
async fn test_get_local_person_warns_when_more_than_one_has_role_edge_exists() {
let (service, _temp) = create_test_service().await;

let people = service.query_nodes_by_type("person", None).await.unwrap();
let seeded_id = people[0].id.clone();

// Replace the seeded owner edge with a non-owner-role edge, so
// has_role edges exist on the singleton but none has role "owner" —
// the anomaly this warning exists to surface.
service
.delete_relationship(&seeded_id, "has_role", DATABASE_SETTINGS_NODE_ID)
// A second has_role edge onto the singleton, from another person. A
// database has one owner, so this is the anomaly the warning surfaces.
let other_id = service
.create_node(Node::new(
"person".to_string(),
String::new(),
serde_json::json!({}),
))
.await
.unwrap();
service
.create_relationship(
&seeded_id,
&other_id,
"has_role",
DATABASE_SETTINGS_NODE_ID,
serde_json::json!({"role": "member", "status": "active"}),
serde_json::json!({}),
)
.await
.unwrap();

let (local, warned) = warn_fired_during(|| service.get_local_person()).await;
let local = local
.unwrap()
.expect("fallback must still resolve a local person");
let local = local.unwrap().expect("a local person must still resolve");
assert_eq!(
local.id, seeded_id,
"behavior is unchanged: still falls back to the first person node"
"must resolve the earliest-created person holding a has_role edge"
);
assert!(
warned,
"must warn when has_role edges exist but none has role \"owner\""
"must warn when more than one has_role edge points at the singleton"
);
}

#[tokio::test]
async fn test_get_local_person_does_not_warn_when_no_has_role_edges_exist() {
async fn test_get_local_person_does_not_warn_for_the_seeded_owner_edge() {
let (service, _temp) = create_test_service().await;

let people = service.query_nodes_by_type("person", None).await.unwrap();
let seeded_id = people[0].id.clone();

// Legitimate pre-ADR-037 state: no has_role edge at all.
let (local, warned) = warn_fired_during(|| service.get_local_person()).await;
let local = local.unwrap().expect("the seeded owner resolves");
assert_eq!(local.id, seeded_id);
assert!(
!warned,
"a property-less owner edge is the expected shape and must not warn"
);
}

#[tokio::test]
async fn test_get_local_person_warns_when_no_has_role_edge_exists() {
let (service, _temp) = create_test_service().await;

let people = service.query_nodes_by_type("person", None).await.unwrap();
let seeded_id = people[0].id.clone();

// No has_role edge at all: seeding guarantees one, so its absence is
// an anomaly the fallback must surface.
service
.delete_relationship(&seeded_id, "has_role", DATABASE_SETTINGS_NODE_ID)
.await
Expand All @@ -9171,8 +9122,8 @@ mod tests {
.expect("fallback must still resolve a local person");
assert_eq!(local.id, seeded_id);
assert!(
!warned,
"must not warn when there are zero has_role edges (expected pre-seeding state)"
warned,
"must warn when the fallback runs because no has_role edge exists"
);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/proto/proto/node_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ service NodeService {

// Local identity (ADR-037): the seeded local-user PersonNode.
// GetLocalPerson resolves it via the has_role owner edge to the
// DatabaseSettingsNode singleton (falling back to the first person node on
// a database that predates that edge); the response's `node` is unset only
// DatabaseSettingsNode singleton (falling back to the first person node
// when that edge is missing); the response's `node` is unset only
// when no person node exists at all. SetLocalPersonIdentity writes into
// that SAME node — never a newly created one.
rpc GetLocalPerson(Empty) returns (OptionalNodeResponse);
Expand Down