From a059aeb14a7d381842fe8eac86539f74228fd66b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 27 Apr 2026 00:56:39 +0100 Subject: [PATCH 1/3] feat(world-model): cross-org schema CRUD + read-side tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the tenant-facing surface that consumes the agent-side cross-org plumbing landed in #374/#377. Items #1, #4 from docs/plans/world-model.md "Outstanding work"; #3, #5 collapse to doc-only. - manage_entity_schema list/get widen to (caller_org OR visibility=public) with tenant-first ORDER BY; rows now carry organization_slug. Same pattern used in entity-management.ts:249-260 resolver. - resolve_path widens both intermediate and leaf entity lookups so a tenant path can traverse into a public-catalog entity referenced via a cross-org relationship. - getEntity widens the read; comment already promised "own org or public". - Re-key entity_count helpers from slug to entity_type_id so cross-org slug collisions don't merge counts across rows. - Item #3 noted as already shipped (organization-dropdown.tsx already splits Your Organizations / Public Organizations with a separator). - Item #5 deferred — no exposed updateOrganization mutation today; the guard SQL is preserved inline for the future implementer. --- docs/plans/world-model.md | 49 +++++++----- .../src/tools/admin/manage_entity_schema.ts | 74 ++++++++++++------- .../owletto-backend/src/tools/resolve_path.ts | 16 +++- .../src/utils/entity-management.ts | 3 +- 4 files changed, 92 insertions(+), 50 deletions(-) diff --git a/docs/plans/world-model.md b/docs/plans/world-model.md index f4663af6c..8bf1933e3 100644 --- a/docs/plans/world-model.md +++ b/docs/plans/world-model.md @@ -197,16 +197,17 @@ purely visual: No new components; no new pages. The dropdown's data shape grows by one field. Same component everywhere. -### 3. Discovery: lean on the existing org dropdown +### 3. Discovery: lean on the existing org dropdown ✅ shipped A user wanting to *browse* a public catalog already navigates to `/{public-org-slug}` (e.g. `/venture-capital`). The org dropdown returns -public orgs alongside member orgs. No new UI surface needed. +public orgs alongside member orgs. -What is *missing* from the dropdown today: visual separation between "your -orgs" and "public catalogs you can read." Single-line change in the dropdown -component to render two grouped sections, gated on -`org.visibility === 'public'`. +The "Your Organizations" / "Public Organizations" split with a separator +already exists in +`packages/owletto-web/src/components/sidebar/organization-dropdown.tsx` +(grouped via `CommandGroup` headings + `CommandSeparator`, gated on +`is_member` / `visibility`). No further work. For users who want to *find* a canonical entity by name without knowing the catalog: that's an agent task (`search_knowledge`), not a frontend search. @@ -226,19 +227,29 @@ Same pattern as #377's `fetchEntityById`: widen each read site to e.deleted_at IS NULL`. Operational counts already gated by the CASE-WHEN shape. -### 5. Visibility-flip safety (~10 LOC) - -If a public catalog flips back to private, existing tenant cross-org refs -become semantically dangling. Cheapest defense: refuse the flip. In -`tools/organizations.ts` (or wherever org settings are mutated), when a -`visibility=public → private` change is requested, query -`SELECT EXISTS (SELECT 1 FROM entity_relationships - JOIN entities te ON te.id = to_entity_id - WHERE te.organization_id = $org_being_flipped)`. If true, reject with -"this catalog has incoming references; remove them or split your data into a -new private org first." - -Cleaner than retroactive revalidation, no migration cost. +### 5. Visibility-flip safety — deferred until there's a flip path + +There is no exposed mutation that lets users (or admins) change +`organization.visibility` today. `tools/organizations.ts` exposes only +`listOrganizations` and `switchOrganization`; visibility is set at org +creation. Item #7 already notes this. Adding a guard on a non-existent +mutation is dead code. + +When the admin visibility-control path lands, plug this guard in at the +mutation site: + +```sql +SELECT EXISTS ( + SELECT 1 + FROM entity_relationships r + JOIN entities te ON te.id = r.to_entity_id + WHERE te.organization_id = $org_being_flipped +) +``` + +If true on a `public → private` flip, reject with: *"this catalog has +incoming references; remove them or split your data into a new private org +first."* Cleaner than retroactive revalidation, no migration cost. ### 6. Catalog curation pass (data, not code) diff --git a/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts b/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts index 48cf302a1..51fd595d5 100644 --- a/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts +++ b/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts @@ -135,6 +135,7 @@ interface EntityTypeRow { is_system: boolean; created_by?: string | null; organization_id?: string | null; + organization_slug?: string | null; created_at: Date; updated_at: Date; entity_count?: number; @@ -157,6 +158,7 @@ interface RelationshipTypeRow { name: string; description?: string | null; organization_id?: string | null; + organization_slug?: string | null; created_by?: string | null; metadata_schema?: Record | null; is_symmetric: boolean; @@ -238,6 +240,11 @@ export async function manageEntitySchema( const ENTITY_TYPE_COLUMNS = 'id, slug, name, description, icon, color, metadata_schema, event_kinds, created_by, organization_id, created_at, updated_at, current_view_template_version_id'; +const ENTITY_TYPE_COLUMNS_WITH_ORG = `et.id, et.slug, et.name, et.description, et.icon, et.color, + et.metadata_schema, et.event_kinds, et.created_by, et.organization_id, + et.created_at, et.updated_at, et.current_view_template_version_id, + o.slug AS organization_slug`; + function mapRowToEntityType(row: Record): EntityTypeRow { return { ...(row as unknown as EntityTypeRow), @@ -281,30 +288,28 @@ function validateEntityMetadataSchemaDisplayConfig( } } -async function getEntityCountsByType(organizationId: string): Promise> { +async function getEntityCountsByType(organizationId: string): Promise> { const sql = getDb(); const rows = await sql` - SELECT et.slug AS entity_type, COUNT(*)::int as entity_count + SELECT e.entity_type_id AS entity_type_id, COUNT(*)::int as entity_count FROM entities e - JOIN entity_types et ON et.id = e.entity_type_id WHERE e.organization_id = ${organizationId} AND e.deleted_at IS NULL - GROUP BY et.slug + GROUP BY e.entity_type_id `; - const counts = new Map(); + const counts = new Map(); for (const row of rows) { - counts.set(row.entity_type as string, Number(row.entity_count)); + counts.set(Number(row.entity_type_id), Number(row.entity_count)); } return counts; } -async function getEntityCountForType(slug: string, organizationId: string): Promise { +async function getEntityCountForType(typeId: number, organizationId: string): Promise { const sql = getDb(); const rows = await sql` SELECT COUNT(*)::int as count FROM entities e - JOIN entity_types et ON et.id = e.entity_type_id - WHERE et.slug = ${slug} + WHERE e.entity_type_id = ${typeId} AND e.organization_id = ${organizationId} AND e.deleted_at IS NULL `; @@ -337,10 +342,12 @@ async function etHandleList(ctx: ToolContext): Promise const sql = getDb(); const rows = await sql.unsafe( - `SELECT ${ENTITY_TYPE_COLUMNS} FROM entity_types - WHERE deleted_at IS NULL - AND organization_id = $1 - ORDER BY name ASC`, + `SELECT ${ENTITY_TYPE_COLUMNS_WITH_ORG} + FROM entity_types et + LEFT JOIN organization o ON o.id = et.organization_id + WHERE et.deleted_at IS NULL + AND (et.organization_id = $1 OR o.visibility = 'public') + ORDER BY (et.organization_id = $1) DESC, et.name ASC`, [ctx.organizationId] ); @@ -352,11 +359,14 @@ async function etHandleList(ctx: ToolContext): Promise const entityTypes = resolved.map((row) => { const mapped = mapRowToEntityType(row); - mapped.entity_count = counts.get(mapped.slug) || 0; + mapped.entity_count = counts.get(mapped.id) || 0; return mapped; }); entityTypes.sort((a, b) => { + const aLocal = a.organization_id === ctx.organizationId ? 0 : 1; + const bLocal = b.organization_id === ctx.organizationId ? 0 : 1; + if (aLocal !== bLocal) return aLocal - bLocal; const countDiff = (b.entity_count || 0) - (a.entity_count || 0); if (countDiff !== 0) return countDiff; return a.name.localeCompare(b.name); @@ -374,17 +384,24 @@ async function etHandleGet( const sql = getDb(); const fetchRow = () => sql.unsafe( - `SELECT ${ENTITY_TYPE_COLUMNS} FROM entity_types - WHERE slug = $1 - AND deleted_at IS NULL - AND organization_id = $2 + `SELECT ${ENTITY_TYPE_COLUMNS_WITH_ORG} + FROM entity_types et + LEFT JOIN organization o ON o.id = et.organization_id + WHERE et.slug = $1 + AND et.deleted_at IS NULL + AND (et.organization_id = $2 OR o.visibility = 'public') + ORDER BY (et.organization_id = $2) DESC, et.id ASC LIMIT 1`, [slug, ctx.organizationId] ); let rows = await fetchRow(); - if (rows.length === 0 && slug === '$member') { + // $member is per-tenant: if the resolved row is cross-org (or missing), provision in the caller's org. + const needsMemberProvision = + slug === '$member' && + (rows.length === 0 || rows[0].organization_id !== ctx.organizationId); + if (needsMemberProvision) { await ensureMemberEntityType(ctx.organizationId); rows = await fetchRow(); } @@ -395,7 +412,7 @@ async function etHandleGet( const [resolved] = await resolveUsernames([rows[0] as Record], 'created_by'); const mapped = mapRowToEntityType(resolved); - mapped.entity_count = await getEntityCountForType(slug, ctx.organizationId); + mapped.entity_count = await getEntityCountForType(Number(mapped.id), ctx.organizationId); return { schema_type: 'entity_type', action: 'get', entity_type: mapped }; } @@ -536,7 +553,7 @@ async function etHandleUpdate( if (updated.length === 0) throw new Error(`Entity type '${args.slug}' not found after update`); const result = mapRowToEntityType(updated[0] as Record); - result.entity_count = await getEntityCountForType(args.slug, ctx.organizationId); + result.entity_count = await getEntityCountForType(Number(result.id), ctx.organizationId); await recordAudit( sql, @@ -569,7 +586,7 @@ async function etHandleDelete( if (existing.length === 0) throw new Error(`Entity type '${slug}' not found`); const current = existing[0]; - const entityCount = await getEntityCountForType(slug, ctx.organizationId); + const entityCount = await getEntityCountForType(Number(current.id), ctx.organizationId); if (entityCount > 0) { throw new Error( `Cannot delete entity type '${slug}': ${entityCount} entities of this type exist. Remove or reassign them first.` @@ -706,18 +723,20 @@ async function rtHandleList( rt.metadata_schema, rt.is_symmetric, rt.inverse_type_id, inv.slug as inverse_type_slug, rt.status, rt.created_at, rt.updated_at, rt.deleted_at, + o.slug AS organization_slug, COALESCE(rc.relationship_count, 0) as relationship_count FROM entity_relationship_types rt LEFT JOIN entity_relationship_types inv ON rt.inverse_type_id = inv.id + LEFT JOIN organization o ON o.id = rt.organization_id LEFT JOIN ( SELECT relationship_type_id, COUNT(*)::int as relationship_count FROM entity_relationships WHERE deleted_at IS NULL GROUP BY relationship_type_id ) rc ON rc.relationship_type_id = rt.id - WHERE rt.organization_id = $1 + WHERE (rt.organization_id = $1 OR o.visibility = 'public') ${deletedClause} - ORDER BY rt.name ASC`, + ORDER BY (rt.organization_id = $1) DESC, rt.name ASC`, [ctx.organizationId] ); @@ -748,12 +767,15 @@ async function rtHandleGet( rt.id, rt.slug, rt.name, rt.description, rt.organization_id, rt.created_by, rt.metadata_schema, rt.is_symmetric, rt.inverse_type_id, inv.slug as inverse_type_slug, - rt.status, rt.created_at, rt.updated_at, rt.deleted_at + rt.status, rt.created_at, rt.updated_at, rt.deleted_at, + o.slug AS organization_slug FROM entity_relationship_types rt LEFT JOIN entity_relationship_types inv ON rt.inverse_type_id = inv.id + LEFT JOIN organization o ON o.id = rt.organization_id WHERE rt.slug = ${args.slug} - AND rt.organization_id = ${ctx.organizationId} + AND (rt.organization_id = ${ctx.organizationId} OR o.visibility = 'public') AND rt.deleted_at IS NULL + ORDER BY (rt.organization_id = ${ctx.organizationId}) DESC, rt.id ASC LIMIT 1 `; diff --git a/packages/owletto-backend/src/tools/resolve_path.ts b/packages/owletto-backend/src/tools/resolve_path.ts index cf663b05a..9d709535a 100644 --- a/packages/owletto-backend/src/tools/resolve_path.ts +++ b/packages/owletto-backend/src/tools/resolve_path.ts @@ -353,18 +353,22 @@ async function _resolvePath( const isLeaf = i === parsedSegments.length - 1; if (!isLeaf) { - // Lightweight query for intermediate path entities – no COUNT subqueries, no template joins + // Lightweight query for intermediate path entities – no COUNT subqueries, no template joins. + // Cross-org tolerance: a tenant path can traverse into a public-catalog entity. const row = await simpleQuery(sql` SELECT e.id, et.slug AS entity_type, e.slug, e.name, e.parent_id FROM entities e JOIN entity_types et ON et.id = e.entity_type_id - WHERE e.organization_id = ${workspace.id} + LEFT JOIN organization eo ON eo.id = e.organization_id + WHERE (e.organization_id = ${workspace.id} OR eo.visibility = 'public') + AND e.deleted_at IS NULL AND et.slug = ${segment.entity_type} AND e.slug = ${segment.slug} AND ( (${parentId}::bigint IS NULL AND e.parent_id IS NULL) OR e.parent_id = ${parentId} ) + ORDER BY (e.organization_id = ${workspace.id}) DESC, e.id ASC LIMIT 1 `); @@ -386,7 +390,8 @@ async function _resolvePath( continue; } - // Leaf entity: fetch core data (without expensive COUNT subqueries) + // Leaf entity: fetch core data (without expensive COUNT subqueries). + // Cross-org tolerance: same widening as the intermediate query. const row = await simpleQuery(sql` SELECT e.id, @@ -404,13 +409,16 @@ async function _resolvePath( ON vtv_entity.id = e.current_view_template_version_id LEFT JOIN view_template_versions vtv_et ON vtv_et.id = et.current_view_template_version_id - WHERE e.organization_id = ${workspace.id} + LEFT JOIN organization eo ON eo.id = e.organization_id + WHERE (e.organization_id = ${workspace.id} OR eo.visibility = 'public') + AND e.deleted_at IS NULL AND et.slug = ${segment.entity_type} AND e.slug = ${segment.slug} AND ( (${parentId}::bigint IS NULL AND e.parent_id IS NULL) OR e.parent_id = ${parentId} ) + ORDER BY (e.organization_id = ${workspace.id}) DESC, e.id ASC LIMIT 1 `); diff --git a/packages/owletto-backend/src/utils/entity-management.ts b/packages/owletto-backend/src/utils/entity-management.ts index d474ec706..c382fb059 100644 --- a/packages/owletto-backend/src/utils/entity-management.ts +++ b/packages/owletto-backend/src/utils/entity-management.ts @@ -443,8 +443,9 @@ export async function getEntity( JOIN entity_types et ON et.id = e.entity_type_id LEFT JOIN entities pe ON e.parent_id = pe.id LEFT JOIN entity_types pet ON pet.id = pe.entity_type_id + LEFT JOIN organization eo ON eo.id = e.organization_id WHERE e.id = ${entityId} - AND e.organization_id = ${ctx.organizationId} + AND (e.organization_id = ${ctx.organizationId} OR eo.visibility = 'public') AND e.deleted_at IS NULL `; From b9eaee2b1b9683acc7ca13cf776dc16868431d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 27 Apr 2026 00:58:42 +0100 Subject: [PATCH 2/3] docs(world-model): item #6 first-pass changelog Pruned classification-test-brand (id=45) from market-intelligence. Held back the $member rows (real membership, not cruft) and the template-seed verticals (need user call before pruning whole orgs). --- docs/plans/world-model.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/plans/world-model.md b/docs/plans/world-model.md index 8bf1933e3..3f5ecaa41 100644 --- a/docs/plans/world-model.md +++ b/docs/plans/world-model.md @@ -266,6 +266,29 @@ Before recommending these as references in agent prompts, do a one-off prune. Pure SQL pass against prod (we have direct access). No PR needed — just a documented changelog of what was removed. +#### 2026-04-27 — first pass + +Inventory taken across all `visibility='public'` orgs (~200 entities, 12 +catalogs). Soft-deleted: + +- `entities.id = 45` `classification-test-brand` "Classification Test + Brand" in `market-intelligence` (no inbound rels) — clearly test data. + +Held back, pending user judgment: + +- `$member` rows in public orgs (careops 26, market-intelligence 2, + venture-capital 6). These represent real cross-org membership; auto- + provisioned when a user joins. Soft-deleting them would either be + re-created on next access or break member lookups for those users. + Treating them as catalog cruft was a misread. +- `stripe-test` brand in market-intelligence — name suggests test data + but plausibly intentional (Stripe sandbox). Skipped. +- The ~5-entity verticals (`leadership`, `sales`, `devops`, `delivery`, + `ecommerce`, `finance`, `legal-review`, `support`) — generic placeholder + rows that look like template seeds. Pruning a whole org is destructive + and may break downstream agents pointing at them. Needs explicit user + call before any further removal. + ### 7. `is_catalog` flag on `organization` (~30 LOC + migration) Pi flagged that `visibility='public'` alone trusts every public org as From c1166ca5dcf235f444f4e41e043504d4a6ea0bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Mon, 27 Apr 2026 02:03:23 +0100 Subject: [PATCH 3/3] fix(world-model): gate operational counts + $member ACL after cross-org widening Pi review of #386 flagged three real regressions introduced by the cross-org read widening. Fixes: - getEntity: scope total_content / active_connections / watchers_count / children_count by caller org. When `e` is a public-catalog row, totals now reflect the caller's references to it, never aggregate cross-tenant activity. - resolve_path leaf: same scoping for total_content (events) and watchers_count. - Exclude $member from public-catalog fallback in getEntity, resolve_path intermediate, and resolve_path leaf. Member-redaction uses ctx.memberRole (caller's workspace role), so a tenant admin/owner could otherwise read a public catalog's $member email by virtue of being admin of their own org. $member rows are per-tenant by design. - rtHandleList relationship_count: scope by caller's organization so public relationship-type rows don't expose global usage volume. Pre-existing concerns flagged in review but out of scope for this PR (documented for follow-up): resolve_path bootstrap entity-type counts (unscoped + missing deleted_at), schema get's slug ambiguity across multiple public catalogs, requireRelationshipType denying list_rules on public RTs. --- .../src/tools/admin/manage_entity_schema.ts | 1 + .../owletto-backend/src/tools/resolve_path.ts | 24 +++++++++++---- .../src/utils/entity-management.ts | 29 ++++++++++++++++--- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts b/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts index 51fd595d5..9dd9c2706 100644 --- a/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts +++ b/packages/owletto-backend/src/tools/admin/manage_entity_schema.ts @@ -732,6 +732,7 @@ async function rtHandleList( SELECT relationship_type_id, COUNT(*)::int as relationship_count FROM entity_relationships WHERE deleted_at IS NULL + AND organization_id = $1 GROUP BY relationship_type_id ) rc ON rc.relationship_type_id = rt.id WHERE (rt.organization_id = $1 OR o.visibility = 'public') diff --git a/packages/owletto-backend/src/tools/resolve_path.ts b/packages/owletto-backend/src/tools/resolve_path.ts index 9d709535a..e96b9b5cf 100644 --- a/packages/owletto-backend/src/tools/resolve_path.ts +++ b/packages/owletto-backend/src/tools/resolve_path.ts @@ -355,12 +355,17 @@ async function _resolvePath( if (!isLeaf) { // Lightweight query for intermediate path entities – no COUNT subqueries, no template joins. // Cross-org tolerance: a tenant path can traverse into a public-catalog entity. + // $member is per-tenant — never fall back to a public catalog's $member row, since + // member-redaction uses the caller's workspace role, not the resolved entity's org. const row = await simpleQuery(sql` SELECT e.id, et.slug AS entity_type, e.slug, e.name, e.parent_id FROM entities e JOIN entity_types et ON et.id = e.entity_type_id LEFT JOIN organization eo ON eo.id = e.organization_id - WHERE (e.organization_id = ${workspace.id} OR eo.visibility = 'public') + WHERE ( + e.organization_id = ${workspace.id} + OR (eo.visibility = 'public' AND et.slug <> '$member') + ) AND e.deleted_at IS NULL AND et.slug = ${segment.entity_type} AND e.slug = ${segment.slug} @@ -391,7 +396,7 @@ async function _resolvePath( } // Leaf entity: fetch core data (without expensive COUNT subqueries). - // Cross-org tolerance: same widening as the intermediate query. + // Cross-org tolerance: same widening as the intermediate query, excluding $member. const row = await simpleQuery(sql` SELECT e.id, @@ -410,7 +415,10 @@ async function _resolvePath( LEFT JOIN view_template_versions vtv_et ON vtv_et.id = et.current_view_template_version_id LEFT JOIN organization eo ON eo.id = e.organization_id - WHERE (e.organization_id = ${workspace.id} OR eo.visibility = 'public') + WHERE ( + e.organization_id = ${workspace.id} + OR (eo.visibility = 'public' AND et.slug <> '$member') + ) AND e.deleted_at IS NULL AND et.slug = ${segment.entity_type} AND e.slug = ${segment.slug} @@ -461,7 +469,10 @@ async function _resolvePath( Promise.all([ simpleQuery( sql.unsafe<{ cnt: number }>( - `SELECT COUNT(*) as cnt FROM current_event_records ev WHERE ${entityLinkMatchSql(`${Number(entityRow.id)}::bigint`, 'ev')}` + `SELECT COUNT(*) as cnt FROM current_event_records ev + WHERE ${entityLinkMatchSql(`${Number(entityRow.id)}::bigint`, 'ev')} + AND ev.organization_id = $1`, + [workspace.id] ) ), simpleQuery(sql` @@ -474,7 +485,10 @@ async function _resolvePath( AND cn.deleted_at IS NULL `), simpleQuery( - sql`SELECT COUNT(*) as cnt FROM watchers i WHERE ${Number(entityRow.id)}::int = ANY(i.entity_ids) AND i.status = 'active'` + sql`SELECT COUNT(*) as cnt FROM watchers i + WHERE ${Number(entityRow.id)}::int = ANY(i.entity_ids) + AND i.organization_id = ${workspace.id} + AND i.status = 'active'` ), fetchTabs(sql, 'entity', String(entityRow.id), workspace.id), fetchTabs(sql, 'entity_type', entityRow.entity_type, workspace.id), diff --git a/packages/owletto-backend/src/utils/entity-management.ts b/packages/owletto-backend/src/utils/entity-management.ts index c382fb059..a0b0c2588 100644 --- a/packages/owletto-backend/src/utils/entity-management.ts +++ b/packages/owletto-backend/src/utils/entity-management.ts @@ -423,29 +423,50 @@ export async function getEntity( const sql = getDb(); if (!ctx.organizationId) return null; + // Operational counts always scope to the caller's org. When `e` is a + // public-catalog entity, totals reflect the caller's events/feeds/watchers/ + // children that reference it — never cross-tenant activity around the + // public row. const result = await sql` SELECT e.id, et.slug AS entity_type, e.name, e.slug, e.parent_id, e.metadata, e.created_at, e.current_view_template_version_id, pe.name as parent_name, pe.slug as parent_slug, pet.slug as parent_entity_type, - (SELECT COUNT(*) FROM current_event_records ev WHERE ${sql.unsafe(entityLinkMatchSql('e.id::bigint', 'ev'))}) as total_content, + ( + SELECT COUNT(*) FROM current_event_records ev + WHERE ${sql.unsafe(entityLinkMatchSql('e.id::bigint', 'ev'))} + AND ev.organization_id = ${ctx.organizationId} + ) as total_content, ( SELECT COUNT(DISTINCT c.connector_key) FROM feeds f JOIN connections c ON c.id = f.connection_id WHERE e.id = ANY(f.entity_ids) + AND f.organization_id = ${ctx.organizationId} AND f.deleted_at IS NULL AND c.deleted_at IS NULL ) as active_connections, - (SELECT COUNT(*) FROM watchers i WHERE e.id = ANY(i.entity_ids)) as watchers_count, - (SELECT COUNT(*) FROM entities c WHERE c.parent_id = e.id) as children_count + ( + SELECT COUNT(*) FROM watchers i + WHERE e.id = ANY(i.entity_ids) + AND i.organization_id = ${ctx.organizationId} + ) as watchers_count, + ( + SELECT COUNT(*) FROM entities c + WHERE c.parent_id = e.id + AND c.organization_id = ${ctx.organizationId} + AND c.deleted_at IS NULL + ) as children_count FROM entities e JOIN entity_types et ON et.id = e.entity_type_id LEFT JOIN entities pe ON e.parent_id = pe.id LEFT JOIN entity_types pet ON pet.id = pe.entity_type_id LEFT JOIN organization eo ON eo.id = e.organization_id WHERE e.id = ${entityId} - AND (e.organization_id = ${ctx.organizationId} OR eo.visibility = 'public') + AND ( + e.organization_id = ${ctx.organizationId} + OR (eo.visibility = 'public' AND et.slug <> '$member') + ) AND e.deleted_at IS NULL `;