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
68 changes: 51 additions & 17 deletions docs/plans/world-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
### 5. Visibility-flip safety — deferred until there's a flip path

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."
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.

Cleaner than retroactive revalidation, no migration cost.
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)

Expand All @@ -255,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
Expand Down
75 changes: 49 additions & 26 deletions packages/owletto-backend/src/tools/admin/manage_entity_schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, unknown> | null;
is_symmetric: boolean;
Expand Down Expand Up @@ -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<string, unknown>): EntityTypeRow {
return {
...(row as unknown as EntityTypeRow),
Expand Down Expand Up @@ -281,30 +288,28 @@ function validateEntityMetadataSchemaDisplayConfig(
}
}

async function getEntityCountsByType(organizationId: string): Promise<Map<string, number>> {
async function getEntityCountsByType(organizationId: string): Promise<Map<number, number>> {
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<string, number>();
const counts = new Map<number, number>();
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<number> {
async function getEntityCountForType(typeId: number, organizationId: string): Promise<number> {
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
`;
Expand Down Expand Up @@ -337,10 +342,12 @@ async function etHandleList(ctx: ToolContext): Promise<ManageEntitySchemaResult>
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]
);

Expand All @@ -352,11 +359,14 @@ async function etHandleList(ctx: ToolContext): Promise<ManageEntitySchemaResult>

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);
Expand All @@ -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();
}
Expand All @@ -395,7 +412,7 @@ async function etHandleGet(

const [resolved] = await resolveUsernames([rows[0] as Record<string, unknown>], '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 };
}
Expand Down Expand Up @@ -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<string, unknown>);
result.entity_count = await getEntityCountForType(args.slug, ctx.organizationId);
result.entity_count = await getEntityCountForType(Number(result.id), ctx.organizationId);

await recordAudit(
sql,
Expand Down Expand Up @@ -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.`
Expand Down Expand Up @@ -706,18 +723,21 @@ 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
AND organization_id = $1
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')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope relationship-type counts to the caller org

This list query now includes public relationship types, but relationship_count is still sourced from rc which aggregates entity_relationships across all organizations by relationship_type_id. When a public type is reused by multiple tenants, callers will see cross-tenant counts instead of their own usage, which leaks other orgs’ activity and misranks types. Please constrain the count subquery to r.organization_id = $1 (or zero out counts for non-local rows).

Useful? React with 👍 / 👎.

${deletedClause}
ORDER BY rt.name ASC`,
ORDER BY (rt.organization_id = $1) DESC, rt.name ASC`,
[ctx.organizationId]
);

Expand Down Expand Up @@ -748,12 +768,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
`;

Expand Down
34 changes: 28 additions & 6 deletions packages/owletto-backend/src/tools/resolve_path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,18 +353,27 @@ 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.
// $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
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 et.slug <> '$member')
)
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
`);

Expand All @@ -386,7 +395,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, excluding $member.
const row = await simpleQuery(sql`
SELECT
e.id,
Expand All @@ -404,13 +414,19 @@ 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 et.slug <> '$member')
)
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
`);

Expand Down Expand Up @@ -453,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`
Expand All @@ -466,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),
Expand Down
Loading
Loading