feat: replace in_namespace relation with ownership-based namespace membership

Remove the in_namespace edge relation. A node now belongs to a namespace if that
namespace has has_ownership on it. This simplifies the model: namespace membership
is determined by the ownership chain rather than a separate relation type.

Changes:
- Remove RelInNamespace constant
- Add Namespace fields to AddInput, UpdateInput, and ListFilter
- Update Add() to resolve namespace from input and assign it as owner
- Update List() to filter by namespace ownership instead of in_namespace edges
- Update() can now transfer nodes between namespaces via ownership transfer
- Remove in_namespace self-references from ensureNamespace/ensureGlobalNamespace

The ownership chain now fully describes both permissions and namespace membership,
reducing redundancy. All tests pass with the new model.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 13:20:03 +02:00
parent 63044a697d
commit 89432e608b
12 changed files with 90 additions and 104 deletions
+65 -63
View File
@@ -75,7 +75,7 @@ const (
// namespaces are globally readable and any node can reference them.
func isReferenceRel(t models.RelType) bool {
switch t {
case models.RelAssignee, models.RelCreated, models.RelMentions, models.RelInNamespace:
case models.RelAssignee, models.RelCreated, models.RelMentions:
return true
}
return false
@@ -208,6 +208,28 @@ func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) {
}
func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
// Resolve namespace filter to owned node IDs.
var nsOwnedIDs map[string]bool
if filter.Namespace != "" {
nsID, _ := s.resolveIDByNameAndType(s.store, filter.Namespace, "namespace")
if nsID == "" {
if exists, _ := s.store.NodeExists(filter.Namespace); exists {
nsID = filter.Namespace
}
}
if nsID == "" {
return nil, nil // namespace not found
}
nsNode, err := s.store.GetNode(nsID)
if err != nil {
return nil, nil
}
nsOwnedIDs = make(map[string]bool)
for _, ownedID := range nsNode.Relations[string(models.RelHasOwnership)] {
nsOwnedIDs[ownedID] = true
}
}
var storeFilters []*models.Rel
for _, ri := range filter.Rels {
if ri.Target == "" {
@@ -230,9 +252,13 @@ func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
}
var result []*models.Node
for _, n := range nodes {
if pc.canRead(n.ID) {
result = append(result, n)
if !pc.canRead(n.ID) {
continue
}
if nsOwnedIDs != nil && !nsOwnedIDs[n.ID] {
continue
}
result = append(result, n)
}
if filter.HasDueDate || filter.DueWithin != nil {
@@ -323,13 +349,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
}
}
hasNamespace := false
for _, ri := range input.Rels {
if ri.Type == models.RelInNamespace && ri.Target != "" {
hasNamespace = true
}
}
dueDate, err := parseDueDate(input.DueDate)
if err != nil {
return nil, err
@@ -364,9 +383,8 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
}
}
// Edge rels. Track the namespace the node is placed in for ownership.
// Edge rels.
hasCreated := false
var actualNsID string
for _, ri := range input.Rels {
if ri.Target == "" {
continue // already stored as tag
@@ -378,9 +396,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
if err != nil {
return err
}
if ri.Type == models.RelInNamespace {
actualNsID = resolved
}
if ri.Type == models.RelHasOwnership {
// Ownership transfer: remove existing owner of the target.
existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}})
@@ -393,18 +408,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
}
}
// Default namespace.
if !hasNamespace {
nsID, err := s.resolveNamespaceRef(st, s.userID)
if err != nil {
return err
}
if err := st.AddRel(id, string(models.RelInNamespace), nsID); err != nil {
return err
}
actualNsID = nsID
}
// Default created.
if !hasCreated {
userID, err := s.resolveUserRef(st, s.userID)
@@ -417,39 +420,30 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
}
// Grant ownership of the new node.
// Namespace nodes are owned by their creator. All other nodes are owned
// by the namespace they belong to — the user retains transitive ownership
// through the namespace's own ownership chain (e.g. user→owns→default-ns→owns→node).
// Namespace nodes are owned by their creator (user node).
// All other nodes are owned by the namespace they belong to — the user
// retains transitive ownership through the namespace's own ownership chain
// (e.g. user→has_ownership→default-ns→has_ownership→node).
creatorID, err := s.resolveUserRef(st, s.userID)
if err != nil {
return err
}
ownerID := creatorID
if tmp.GetProperty("type") != "namespace" && actualNsID != "" {
ownerID = actualNsID
if tmp.GetProperty("type") != "namespace" {
nsRef := input.Namespace
if nsRef == "" {
nsRef = s.userID
}
nsID, err := s.resolveNamespaceRef(st, nsRef)
if err != nil {
return err
}
ownerID = nsID
}
if err := st.AddRel(ownerID, string(models.RelHasOwnership), id); err != nil {
return err
}
// Namespace bootstrap: when creating a namespace node directly, apply the
// same setup as ensureNamespace — self in_namespace and creator ownership.
if tmp.GetProperty("type") == "namespace" {
if !hasNamespace {
// Replace the default namespace rel (user's ns) with self-reference.
userNsID, _ := s.resolveIDByNameAndType(st, s.userID, "namespace")
if userNsID != "" {
if err := st.RemoveRel(id, string(models.RelInNamespace), userNsID); err != nil {
return err
}
}
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return err
}
}
// Creator already gets ownership via the block above; nothing more to do.
}
return nil
})
if err != nil {
@@ -470,8 +464,8 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
return nil, err
}
// Field/tag changes and rel removals require can_write on the node.
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil
// Field/tag changes, rel removals, and namespace change require can_write on the node.
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil || input.Namespace != nil
for _, ri := range input.AddRels {
if ri.Target == "" {
needsWrite = true
@@ -619,7 +613,7 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
return err
}
// Single-value relations replace the previous target.
if ri.Type == models.RelAssignee || ri.Type == models.RelInNamespace {
if ri.Type == models.RelAssignee {
for _, oldTgt := range currentRels[string(ri.Type)] {
if err := st.RemoveRel(id, string(ri.Type), oldTgt); err != nil {
return err
@@ -651,6 +645,24 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
}
}
// Namespace change: transfer ownership from the current namespace to the new one.
if input.Namespace != nil {
newNsID, err := s.resolveNamespaceRef(st, *input.Namespace)
if err != nil {
return err
}
// Remove ownership from any current namespace owner.
currentOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: id}})
for _, owner := range currentOwners {
if owner.GetProperty("type") == "namespace" {
st.RemoveRel(owner.ID, string(models.RelHasOwnership), id) //nolint:errcheck
}
}
if err := st.AddRel(newNsID, string(models.RelHasOwnership), id); err != nil {
return err
}
}
return nil
})
if err != nil {
@@ -770,8 +782,6 @@ func (s *nodeServiceImpl) resolveRelTarget(st store.GraphStore, ri RelInput) (st
switch ri.Type {
case models.RelAssignee, models.RelCreated, models.RelMentions:
return s.resolveUserRef(st, ri.Target)
case models.RelInNamespace:
return s.resolveNamespaceRef(st, ri.Target)
default:
// Permission rels and all other edge rels expect raw node IDs.
return ri.Target, nil
@@ -788,8 +798,6 @@ func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string)
switch relType {
case models.RelAssignee, models.RelCreated, models.RelMentions:
nodeType = "user"
case models.RelInNamespace:
nodeType = "namespace"
default:
// Permission rels and other edge rels use raw node IDs.
return "", false
@@ -843,9 +851,6 @@ func (s *nodeServiceImpl) ensureGlobalNamespace(st store.GraphStore) (string, er
if err := st.AddRel(id, "_type::namespace", ""); err != nil {
return "", err
}
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return "", err
}
// Self-owned so no single user controls it.
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
return "", err
@@ -916,9 +921,6 @@ func (s *nodeServiceImpl) ensureNamespace(st store.GraphStore, name string) (str
if err := st.AddRel(id, "_type::namespace", ""); err != nil {
return "", err
}
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return "", err
}
userID, err := s.resolveUserRef(st, s.userID)
if err != nil {
return "", err