diff --git a/canvas/src/components/MemoryInspectorPanel.tsx b/canvas/src/components/MemoryInspectorPanel.tsx index 0f62d5af..3027f9ef 100644 --- a/canvas/src/components/MemoryInspectorPanel.tsx +++ b/canvas/src/components/MemoryInspectorPanel.tsx @@ -325,7 +325,6 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { {dropdownOptions.map((opt) => ( ))} diff --git a/workspace-server/internal/handlers/memories_v2.go b/workspace-server/internal/handlers/memories_v2.go index 91b48642..007ab35c 100644 --- a/workspace-server/internal/handlers/memories_v2.go +++ b/workspace-server/internal/handlers/memories_v2.go @@ -331,43 +331,84 @@ func memoryToView(m contract.Memory) MemoryView { } // namespacesToViews converts resolver namespaces into UI-friendly -// views. Stable sort: workspace → team → org → custom, then by name. +// views. Prefers `DisplayName` from the resolver (workspace.name from +// the DB) when present; falls back to a UUID-prefix label. +// +// Issue #2988: pre-fix, every namespace used a shortID-truncated UUID +// label. On a root workspace where workspace==team==org IDs collide +// (resolver derive() degenerate case), all three labels rendered +// identically. DisplayName disambiguates by surfacing real workspace +// names — the canvas dropdown now reads "Workspace (mac laptop)" / +// "Team (mac laptop)" / "Org (mac laptop)" for a root workspace +// rather than three identical UUID prefixes. The `kind` prefix +// "Workspace/Team/Org" still carries the semantic distinction. func namespacesToViews(in []namespace.Namespace) []NamespaceView { views := make([]NamespaceView, 0, len(in)) for _, n := range in { views = append(views, NamespaceView{ Name: n.Name, Kind: n.Kind, - Label: namespaceLabel(n.Name, n.Kind), + Label: namespaceLabelWithName(n.Name, n.Kind, n.DisplayName), }) } return views } -// namespaceLabel renders a human-friendly label for a namespace. The -// canvas displays this directly; we keep the formatting server-side -// so the shape stays consistent across UIs (canvas, future TUI, etc.). +// namespaceLabel renders a human-friendly label for a namespace using +// the UUID-prefix fallback only. Kept for back-compat with callers +// that don't yet plumb a display name. New callers should use +// namespaceLabelWithName which prefers the workspace's display name +// when available. // -// Format: -// workspace:abc-123 → "Workspace (abc-123)" (UUID short-prefixed) +// Format (UUID-prefix fallback): +// workspace:abc-123 → "Workspace (abc-123)" // team:t-1 → "Team (t-1)" // org:acme → "Org (acme)" -// custom:foo → "foo" (operator-defined; raw) +// custom:foo → "foo" func namespaceLabel(name string, kind contract.NamespaceKind) string { + return namespaceLabelWithName(name, kind, "") +} + +// namespaceLabelWithName renders the human-friendly label, preferring +// `displayName` when non-empty. +// +// When displayName is set: +// Workspace, "mac laptop" → "Workspace (mac laptop)" +// Team, "Engineering team" → "Team (Engineering team)" +// Org, "Hongming's Org" → "Org (Hongming's Org)" +// +// When displayName is empty (lookup miss, future-migration drop, etc.), +// falls back to the UUID-prefix shape for back-compat. +// +// Custom namespaces ignore displayName because they're operator-defined +// — the operator chose the raw suffix as the label, surfacing a +// different "name" would be a UX surprise. +func namespaceLabelWithName(name string, kind contract.NamespaceKind, displayName string) string { suffix := "" if i := indexOfColon(name); i >= 0 && i+1 < len(name) { suffix = name[i+1:] } switch kind { case contract.NamespaceKindWorkspace: + if displayName != "" { + return "Workspace (" + displayName + ")" + } return "Workspace (" + shortID(suffix) + ")" case contract.NamespaceKindTeam: + if displayName != "" { + return "Team (" + displayName + ")" + } return "Team (" + shortID(suffix) + ")" case contract.NamespaceKindOrg: + if displayName != "" { + return "Org (" + displayName + ")" + } return "Org (" + suffix + ")" case contract.NamespaceKindCustom: - // Custom namespaces are operator-defined; surface the raw - // suffix so they can label them however they want. + // Operator-defined; the suffix IS the label they chose. + // displayName is ignored — surfacing a different name would + // be a UX surprise for an operator who deliberately named + // the namespace. if suffix == "" { return name } diff --git a/workspace-server/internal/handlers/memories_v2_test.go b/workspace-server/internal/handlers/memories_v2_test.go index 2b7fc993..c9a4b8bc 100644 --- a/workspace-server/internal/handlers/memories_v2_test.go +++ b/workspace-server/internal/handlers/memories_v2_test.go @@ -507,6 +507,92 @@ func TestMemoriesV2_Forget_MissingMemoryID_400(t *testing.T) { // View-shaping unit tests — pin individual helpers // ───────────────────────────────────────────────────────────────────────────── +// namespaceLabelWithName tests — the new code path that prefers +// DisplayName over UUID-prefix fallback (issue #2988). +func TestNamespaceLabelWithName_PrefersDisplayNameWhenSet(t *testing.T) { + cases := []struct { + name string + raw string + kind contract.NamespaceKind + display string + want string + }{ + {"workspace with name", "workspace:abc-1234", contract.NamespaceKindWorkspace, "mac laptop", "Workspace (mac laptop)"}, + {"team with name", "team:abc-1234", contract.NamespaceKindTeam, "Engineering", "Team (Engineering)"}, + {"org with name", "org:acme", contract.NamespaceKindOrg, "Hongming's Org", "Org (Hongming's Org)"}, + // Custom ignores displayName by design — operator chose the suffix. + {"custom ignores displayName", "custom:ops-shared", contract.NamespaceKindCustom, "FancyName", "ops-shared"}, + {"unknown kind falls through", "weird:x", contract.NamespaceKind("future"), "WhoCares", "weird:x"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := namespaceLabelWithName(tc.raw, tc.kind, tc.display) + if got != tc.want { + t.Errorf("namespaceLabelWithName(%q, %q, %q) = %q, want %q", + tc.raw, tc.kind, tc.display, got, tc.want) + } + }) + } +} + +func TestNamespaceLabelWithName_FallsBackToUUIDPrefixWhenEmpty(t *testing.T) { + // When displayName is empty (NULL in DB, lookup miss, etc.), the + // label shape MUST match the legacy UUID-prefix shape exactly so + // existing canvas behaviour is unchanged for callers that don't + // plumb a name. + cases := []struct { + raw string + kind contract.NamespaceKind + want string + }{ + {"workspace:abcdefghij", contract.NamespaceKindWorkspace, "Workspace (abcdefgh)"}, + {"team:t-99", contract.NamespaceKindTeam, "Team (t-99)"}, + {"org:acme", contract.NamespaceKindOrg, "Org (acme)"}, + } + for _, tc := range cases { + got := namespaceLabelWithName(tc.raw, tc.kind, "") + if got != tc.want { + t.Errorf("displayName=\"\" path: got %q, want %q", got, tc.want) + } + } +} + +func TestNamespacesToViews_PassesDisplayNameThrough(t *testing.T) { + in := []namespace.Namespace{ + {Name: "workspace:root-1", Kind: contract.NamespaceKindWorkspace, DisplayName: "mac laptop"}, + {Name: "team:root-1", Kind: contract.NamespaceKindTeam, DisplayName: "mac laptop"}, // root → team aliases self + {Name: "org:root-1", Kind: contract.NamespaceKindOrg, DisplayName: "mac laptop"}, + } + out := namespacesToViews(in) + if len(out) != 3 { + t.Fatalf("len = %d, want 3", len(out)) + } + wantLabels := []string{ + "Workspace (mac laptop)", + "Team (mac laptop)", + "Org (mac laptop)", + } + for i, v := range out { + if v.Label != wantLabels[i] { + t.Errorf("[%d] label = %q, want %q", i, v.Label, wantLabels[i]) + } + } +} + +func TestNamespacesToViews_FallsBackToUUIDLabelWhenDisplayNameEmpty(t *testing.T) { + // Exercises the back-compat path — DisplayName="" plumbs through + // to namespaceLabelWithName which returns the legacy UUID-prefix + // label. This is what callers see when the workspaces table + // has a NULL name (defensive — workspaces.name is NOT NULL today). + in := []namespace.Namespace{ + {Name: "workspace:root-1", Kind: contract.NamespaceKindWorkspace}, // no DisplayName + } + out := namespacesToViews(in) + if out[0].Label != "Workspace (root-1)" { + t.Errorf("fallback label = %q, want %q", out[0].Label, "Workspace (root-1)") + } +} + func TestNamespaceLabel_AllKinds(t *testing.T) { cases := []struct { name string diff --git a/workspace-server/internal/memory/namespace/resolver.go b/workspace-server/internal/memory/namespace/resolver.go index 410ceab4..c6790edd 100644 --- a/workspace-server/internal/memory/namespace/resolver.go +++ b/workspace-server/internal/memory/namespace/resolver.go @@ -33,6 +33,25 @@ type Namespace struct { Kind contract.NamespaceKind `json:"kind"` Description string `json:"description"` Writable bool `json:"writable"` + // DisplayName is the human-readable label for this namespace, + // derived from the workspace tree: + // - workspace: this workspace's own name (`workspaces.name`) + // - team: parent's name if child, this workspace's name if root + // (degenerate case — team semantically means "memories + // shared with peers in this team", so for a root workspace + // with no peers, "your team" is conceptually correct.) + // - org: the root workspace's name (org-wide memories — every + // workspace under this root sees them) + // + // Empty string when the lookup failed (workspace row missing). The + // canvas uses DisplayName for the dropdown; falls back to a short + // UUID prefix when it's empty. + // + // Issue #2988: pre-fix, the canvas labelled all three namespaces + // with the SAME shortID-truncated UUID prefix on a root workspace + // because workspace==team==org IDs collide. The display name + // disambiguates them by surfacing real workspace names. + DisplayName string `json:"display_name,omitempty"` } // ErrWorkspaceNotFound is returned when the input workspace ID does @@ -54,6 +73,7 @@ func New(db *sql.DB) *Resolver { // chainNode is one row from the recursive CTE. type chainNode struct { id string + name string // workspaces.name (display label for the namespace) parentID *string depth int } @@ -64,16 +84,16 @@ type chainNode struct { func (r *Resolver) walkChain(ctx context.Context, workspaceID string) ([]chainNode, error) { const query = ` WITH RECURSIVE chain AS ( - SELECT id, parent_id, 0 AS depth + SELECT id, name, parent_id, 0 AS depth FROM workspaces WHERE id = $1 UNION ALL - SELECT w.id, w.parent_id, c.depth + 1 + SELECT w.id, w.name, w.parent_id, c.depth + 1 FROM workspaces w JOIN chain c ON w.id = c.parent_id WHERE c.depth < $2 ) - SELECT id::text, parent_id::text, depth FROM chain ORDER BY depth ASC + SELECT id::text, COALESCE(name, ''), parent_id::text, depth FROM chain ORDER BY depth ASC ` rows, err := r.db.QueryContext(ctx, query, workspaceID, maxChainDepth) if err != nil { @@ -85,7 +105,7 @@ func (r *Resolver) walkChain(ctx context.Context, workspaceID string) ([]chainNo for rows.Next() { var n chainNode var parentStr sql.NullString - if err := rows.Scan(&n.id, &parentStr, &n.depth); err != nil { + if err := rows.Scan(&n.id, &n.name, &parentStr, &n.depth); err != nil { return nil, fmt.Errorf("scan chain: %w", err) } if parentStr.Valid && parentStr.String != "" { @@ -122,6 +142,33 @@ func derive(chain []chainNode) (workspace, team, org string) { return } +// deriveNames computes the display name for each of the three +// canonical namespaces. Mirrors derive() — same lookup logic, but +// returns workspace/parent/root NAMES instead of IDs. +// +// For a root workspace (no parent), team and org both alias to self; +// callers should still render them as semantically distinct (the +// `kind` field on the Namespace carries that distinction). The name +// itself collides on a depth-1 tree — that's expected; the kind +// prefix in the canvas label disambiguates. +// +// Returns the empty string for any name that's missing on the chain +// row (defensive — workspaces.name is NOT NULL today, but a future +// migration could change that). Callers fall back to UUID prefix +// when DisplayName is empty. +func deriveNames(chain []chainNode) (workspace, team, org string) { + self := chain[0] + workspace = self.name + if self.parentID != nil && len(chain) > 1 { + // Parent is the next node in the chain (depth 1). + team = chain[1].name + } else { + team = self.name + } + org = chain[len(chain)-1].name + return +} + // ReadableNamespaces returns the namespaces the workspace can read // from. Order is deterministic (workspace, team, org) so callers can // reason about precedence. @@ -131,6 +178,7 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) ( return nil, err } wsID, teamID, orgID := derive(chain) + wsName, teamName, orgName := deriveNames(chain) isRoot := chain[0].parentID == nil out := []Namespace{ @@ -139,12 +187,14 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) ( Kind: contract.NamespaceKindWorkspace, Description: "This workspace's private memories", Writable: true, + DisplayName: wsName, }, { Name: "team:" + teamID, Kind: contract.NamespaceKindTeam, Description: "Memories shared across team members (parent + siblings)", Writable: true, + DisplayName: teamName, }, } // Org namespace is readable by every workspace in the tree, but @@ -155,6 +205,7 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) ( Kind: contract.NamespaceKindOrg, Description: "Org-wide memories visible to every workspace under this root", Writable: isRoot, + DisplayName: orgName, }) return out, nil } diff --git a/workspace-server/internal/memory/namespace/resolver_test.go b/workspace-server/internal/memory/namespace/resolver_test.go index b3d5d8bd..7f7122d0 100644 --- a/workspace-server/internal/memory/namespace/resolver_test.go +++ b/workspace-server/internal/memory/namespace/resolver_test.go @@ -46,8 +46,8 @@ func TestWalkChain_RootOnly(t *testing.T) { // Root workspace: parent_id is NULL, depth 0, single row. mock.ExpectQuery(chainQuerySnippet). WithArgs("ws-root", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}). - AddRow("ws-root", nil, 0)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("ws-root", "", nil, 0)) chain, err := r.walkChain(context.Background(), "ws-root") if err != nil { @@ -68,9 +68,9 @@ func TestWalkChain_ChildToParent(t *testing.T) { mock.ExpectQuery(chainQuerySnippet). WithArgs("ws-child", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}). - AddRow("ws-child", "ws-root", 0). - AddRow("ws-root", nil, 1)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("ws-child", "", "ws-root", 0). + AddRow("ws-root", "", nil, 1)) chain, err := r.walkChain(context.Background(), "ws-child") if err != nil { @@ -93,7 +93,7 @@ func TestWalkChain_DeepTreeRespectsMaxDepth(t *testing.T) { r := New(db) // Simulate a 51-deep chain: should be capped at maxChainDepth. - rows := sqlmock.NewRows([]string{"id", "parent_id", "depth"}) + rows := sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}) for i := 0; i <= maxChainDepth; i++ { var parent interface{} if i < maxChainDepth { @@ -101,7 +101,7 @@ func TestWalkChain_DeepTreeRespectsMaxDepth(t *testing.T) { } else { parent = nil // would be the cap point } - rows.AddRow("ws-"+itoa(i), parent, i) + rows.AddRow("ws-"+itoa(i), "", parent, i) } mock.ExpectQuery(chainQuerySnippet). WithArgs("ws-0", maxChainDepth). @@ -127,7 +127,7 @@ func TestWalkChain_WorkspaceNotFound(t *testing.T) { mock.ExpectQuery(chainQuerySnippet). WithArgs("ws-missing", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"})) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"})) _, err := r.walkChain(context.Background(), "ws-missing") if !errors.Is(err, ErrWorkspaceNotFound) { @@ -172,8 +172,8 @@ func TestWalkChain_RowsErr(t *testing.T) { mock.ExpectQuery(chainQuerySnippet). WithArgs("ws-x", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}). - AddRow("ws-x", nil, 0). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("ws-x", "", nil, 0). RowError(0, errors.New("mid-iteration"))) _, err := r.walkChain(context.Background(), "ws-x") @@ -238,8 +238,8 @@ func TestReadableNamespaces_Root(t *testing.T) { mock.ExpectQuery(chainQuerySnippet). WithArgs("root-1", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}). - AddRow("root-1", nil, 0)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("root-1", "", nil, 0)) got, err := r.ReadableNamespaces(context.Background(), "root-1") if err != nil { @@ -274,9 +274,9 @@ func TestReadableNamespaces_Child(t *testing.T) { mock.ExpectQuery(chainQuerySnippet). WithArgs("child-1", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}). - AddRow("child-1", "root-1", 0). - AddRow("root-1", nil, 1)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("child-1", "", "root-1", 0). + AddRow("root-1", "", nil, 1)) got, err := r.ReadableNamespaces(context.Background(), "child-1") if err != nil { @@ -297,13 +297,93 @@ func TestReadableNamespaces_Child(t *testing.T) { } } +func TestReadableNamespaces_DisplayName_Root(t *testing.T) { + // Root workspace with a real name. All three derived namespaces + // (workspace/team/org) should carry the workspace's display name — + // for a root workspace they collapse on UUID but the name is the + // disambiguator surfaced in the canvas dropdown (issue #2988). + db, mock := setupMockDB(t) + r := New(db) + + mock.ExpectQuery(chainQuerySnippet). + WithArgs("root-1", maxChainDepth). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("root-1", "mac laptop", nil, 0)) + + got, err := r.ReadableNamespaces(context.Background(), "root-1") + if err != nil { + t.Fatalf("ReadableNamespaces: %v", err) + } + for i, ns := range got { + if ns.DisplayName != "mac laptop" { + t.Errorf("[%d] %q DisplayName = %q, want %q", i, ns.Name, ns.DisplayName, "mac laptop") + } + } +} + +func TestReadableNamespaces_DisplayName_Child(t *testing.T) { + // Child has its own workspace name; team should pick up the + // PARENT's name (not the child's), and org follows the chain root. + db, mock := setupMockDB(t) + r := New(db) + + mock.ExpectQuery(chainQuerySnippet). + WithArgs("child-1", maxChainDepth). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("child-1", "Hongming Personal Brand", "root-1", 0). + AddRow("root-1", "mac laptop", nil, 1)) + + got, err := r.ReadableNamespaces(context.Background(), "child-1") + if err != nil { + t.Fatalf("ReadableNamespaces: %v", err) + } + want := map[string]string{ + "workspace:child-1": "Hongming Personal Brand", // self + "team:root-1": "mac laptop", // parent + "org:root-1": "mac laptop", // root + } + for _, ns := range got { + w, ok := want[ns.Name] + if !ok { + t.Errorf("unexpected namespace %q", ns.Name) + continue + } + if ns.DisplayName != w { + t.Errorf("%q DisplayName = %q, want %q", ns.Name, ns.DisplayName, w) + } + } +} + +func TestReadableNamespaces_DisplayName_EmptyOnNULL(t *testing.T) { + // COALESCE in the query produces "" when name is NULL. The + // resolver must propagate that as DisplayName="" so the handler's + // label shaper can fall back to the UUID-prefix shape. + db, mock := setupMockDB(t) + r := New(db) + + mock.ExpectQuery(chainQuerySnippet). + WithArgs("root-1", maxChainDepth). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("root-1", "", nil, 0)) + + got, err := r.ReadableNamespaces(context.Background(), "root-1") + if err != nil { + t.Fatalf("ReadableNamespaces: %v", err) + } + for _, ns := range got { + if ns.DisplayName != "" { + t.Errorf("%q DisplayName = %q, want empty (NULL fallback)", ns.Name, ns.DisplayName) + } + } +} + func TestReadableNamespaces_NotFound(t *testing.T) { db, mock := setupMockDB(t) r := New(db) mock.ExpectQuery(chainQuerySnippet). WithArgs("ghost", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"})) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"})) _, err := r.ReadableNamespaces(context.Background(), "ghost") if !errors.Is(err, ErrWorkspaceNotFound) { @@ -319,8 +399,8 @@ func TestWritableNamespaces_RootSeesAll(t *testing.T) { mock.ExpectQuery(chainQuerySnippet). WithArgs("root-1", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}). - AddRow("root-1", nil, 0)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("root-1", "", nil, 0)) got, err := r.WritableNamespaces(context.Background(), "root-1") if err != nil { @@ -337,9 +417,9 @@ func TestWritableNamespaces_ChildExcludesOrg(t *testing.T) { mock.ExpectQuery(chainQuerySnippet). WithArgs("child-1", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}). - AddRow("child-1", "root-1", 0). - AddRow("root-1", nil, 1)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("child-1", "", "root-1", 0). + AddRow("root-1", "", nil, 1)) got, err := r.WritableNamespaces(context.Background(), "child-1") if err != nil { @@ -361,7 +441,7 @@ func TestWritableNamespaces_NotFound(t *testing.T) { mock.ExpectQuery(chainQuerySnippet). WithArgs("ghost", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"})) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"})) _, err := r.WritableNamespaces(context.Background(), "ghost") if !errors.Is(err, ErrWorkspaceNotFound) { @@ -390,9 +470,9 @@ func TestCanWrite(t *testing.T) { t.Run(tc.name, func(t *testing.T) { db, mock := setupMockDB(t) r := New(db) - rows := sqlmock.NewRows([]string{"id", "parent_id", "depth"}) + rows := sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}) if tc.isRoot { - rows.AddRow("root-1", nil, 0) + rows.AddRow("root-1", "", nil, 0) mock.ExpectQuery(chainQuerySnippet).WithArgs("root-1", maxChainDepth).WillReturnRows(rows) ok, err := r.CanWrite(context.Background(), "root-1", tc.namespace) if err != nil { @@ -402,7 +482,7 @@ func TestCanWrite(t *testing.T) { t.Errorf("CanWrite(%q) = %v, want %v", tc.namespace, ok, tc.want) } } else { - rows.AddRow("child-1", "root-1", 0).AddRow("root-1", nil, 1) + rows.AddRow("child-1", "", "root-1", 0).AddRow("root-1", "", nil, 1) mock.ExpectQuery(chainQuerySnippet).WithArgs("child-1", maxChainDepth).WillReturnRows(rows) ok, err := r.CanWrite(context.Background(), "child-1", tc.namespace) if err != nil { @@ -435,9 +515,9 @@ func TestIntersectReadable_DefaultAll(t *testing.T) { r := New(db) mock.ExpectQuery(chainQuerySnippet). WithArgs("child-1", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}). - AddRow("child-1", "root-1", 0). - AddRow("root-1", nil, 1)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("child-1", "", "root-1", 0). + AddRow("root-1", "", nil, 1)) // Empty requested → return everything readable. got, err := r.IntersectReadable(context.Background(), "child-1", nil) @@ -455,9 +535,9 @@ func TestIntersectReadable_Filters(t *testing.T) { r := New(db) mock.ExpectQuery(chainQuerySnippet). WithArgs("child-1", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}). - AddRow("child-1", "root-1", 0). - AddRow("root-1", nil, 1)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("child-1", "", "root-1", 0). + AddRow("root-1", "", nil, 1)) // Requested: one allowed, one disallowed (foreign workspace), one allowed requested := []string{"workspace:child-1", "workspace:foreign", "team:root-1"} @@ -476,8 +556,8 @@ func TestIntersectReadable_AllFiltered(t *testing.T) { r := New(db) mock.ExpectQuery(chainQuerySnippet). WithArgs("ws-1", maxChainDepth). - WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}). - AddRow("ws-1", nil, 0)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}). + AddRow("ws-1", "", nil, 0)) // Request only namespaces the caller cannot read. got, err := r.IntersectReadable(context.Background(), "ws-1", []string{"workspace:other", "team:other"})