feat(world-model): cross-org schema CRUD + read-side tolerance#386
Conversation
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.
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).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a059aeb14a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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') |
There was a problem hiding this comment.
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 👍 / 👎.
| WHERE (e.organization_id = ${workspace.id} OR eo.visibility = 'public') | ||
| AND e.deleted_at IS NULL |
There was a problem hiding this comment.
Keep resolve_path follow-up reads on the resolved entity org
With the widened predicate, _resolvePath can now resolve a leaf entity from a public catalog org while the owner slug is a tenant org. The later children/siblings queries in the same function still use workspace.id, so a cross-org leaf returns incorrect navigation data (typically empty children and unrelated local siblings). This is user-visible whenever a tenant path falls through to a public entity; use the resolved entity’s organization for subsequent reads.
Useful? React with 👍 / 👎.
…rg 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.
|
Triage decision: Reasons:
Next: Human review required for P1/P2 security and data isolation concerns before merge |
Follow-up to #386 (Pi review): rtHandleListRules went through requireRelationshipType, which 403s on any RT outside the caller's org. That blocks the public-catalog UX in lobu-ai/owletto#26 from populating the rules under cross-org rel types. Add a 'read' mode to requireRelationshipType that widens the slug lookup to (caller_org OR visibility=public) with tenant-first ORDER BY. Mutating callers (add_rule, remove_rule, update, delete) keep the strict 'write' mode and continue to deny cross-org access. Only list_rules opts into read mode.
Picks up lobu-ai/owletto#26 (42b8970): cross-org public-catalog entity types and relationship types now render in the type pickers under a 'From public catalogs' heading with a read-only badge, consuming the organization_slug field landed in #386.
…ng + tests Audit follow-up to #386, #399 found three additional consumers of the widened cross-org reads that need attention: 1. utils/schema-validation.ts getEntityTypeSchema() loaded only the caller's org schema. After #374 a tenant entity can carry a public catalog type (resolved via the schema search path) — validation then ran against an empty schema and silently let bad metadata through. Widened with the same (caller_org OR visibility=public) + tenant- first ORDER BY pattern as the resolver in entity-management.ts. Now creating an entity with a public catalog type validates against the catalog's metadata_schema. 2. tools/search.ts entitySelectColumns() — connection_count and active_connection_count subqueries were CASE-WHEN-gated on caller- org-equality, but the inner FROM feeds f / JOIN connections cn didn't restate f.organization_id = e.organization_id like the children/watcher_count subqueries on the same function do. Added the predicate for consistency: defensive belt-and-suspenders against any future cross-org feed.entity_ids reference. 3. tools/get_watchers.ts entity-context query — the LEFT JOIN feeds / current_event_records on entity_id was unscoped. requireReadAccess blocks cross-org callers from reaching this site today, but the join itself should match the entity's org regardless. Added the filters so the count is always entity-org-local. Also adds packages/owletto-backend/src/__tests__/integration/entity-types/ cross-org.test.ts covering the post-#386/#399 contract: - list returns local + cross-org rows tenant-first with organization_slug - get resolves public catalog types and surfaces organization_slug - $member is per-tenant: get auto-provisions in caller, never returns the catalog's $member - tenant-first ordering wins on slug collisions - list_rules works on cross-org rel types (read mode) - add_rule still 403s on cross-org (write mode strict) - create with cross-org type validates against the catalog's schema Tests run against the project's standard integration test backend (real Postgres). PGlite mode has a pre-existing auth issue affecting all integration tests in this repo.
…ng + tests (#407) * fix(world-model): cross-org schema validation + defensive count scoping + tests Audit follow-up to #386, #399 found three additional consumers of the widened cross-org reads that need attention: 1. utils/schema-validation.ts getEntityTypeSchema() loaded only the caller's org schema. After #374 a tenant entity can carry a public catalog type (resolved via the schema search path) — validation then ran against an empty schema and silently let bad metadata through. Widened with the same (caller_org OR visibility=public) + tenant- first ORDER BY pattern as the resolver in entity-management.ts. Now creating an entity with a public catalog type validates against the catalog's metadata_schema. 2. tools/search.ts entitySelectColumns() — connection_count and active_connection_count subqueries were CASE-WHEN-gated on caller- org-equality, but the inner FROM feeds f / JOIN connections cn didn't restate f.organization_id = e.organization_id like the children/watcher_count subqueries on the same function do. Added the predicate for consistency: defensive belt-and-suspenders against any future cross-org feed.entity_ids reference. 3. tools/get_watchers.ts entity-context query — the LEFT JOIN feeds / current_event_records on entity_id was unscoped. requireReadAccess blocks cross-org callers from reaching this site today, but the join itself should match the entity's org regardless. Added the filters so the count is always entity-org-local. Also adds packages/owletto-backend/src/__tests__/integration/entity-types/ cross-org.test.ts covering the post-#386/#399 contract: - list returns local + cross-org rows tenant-first with organization_slug - get resolves public catalog types and surfaces organization_slug - $member is per-tenant: get auto-provisions in caller, never returns the catalog's $member - tenant-first ordering wins on slug collisions - list_rules works on cross-org rel types (read mode) - add_rule still 403s on cross-org (write mode strict) - create with cross-org type validates against the catalog's schema Tests run against the project's standard integration test backend (real Postgres). PGlite mode has a pre-existing auth issue affecting all integration tests in this repo. * fix(search): scope content_count subquery by entity org Pi caught one more in entitySelectColumns(): content_count subquery joined current_event_records by entityLinkMatchSql only, with no ev.organization_id filter. Cross-org events that share an entity_id or an identity-namespace match could inflate counts even though the outer CASE WHEN guards against returning anything for cross-org rows. Same pattern as the connection_count / active_connection_count fixes in the parent commit.
Summary
Closes the tenant-facing surface that consumes the agent-side cross-org plumbing landed in #374/#377. Implements items #1 and #4 from the "Outstanding work" section of
docs/plans/world-model.md(added in #385), and collapses items #3 and #5 to doc-only updates.Backend (~120 LOC)
manage_entity_schemalistandgetfor both entity types and relationship types now widen to(caller_org OR visibility = 'public')with tenant-first ORDER BY. Rows carryorganization_slugso callers can distinguish local from cross-org. Pattern matches the entity-type resolver atpackages/owletto-backend/src/utils/entity-management.ts:249-260.resolve_path(intermediate + leaf): a tenant path can now traverse into a public-catalog entity that is reached via a cross-org relationship. Without this, list_links would surface a target that a follow-up navigation 404s on.getEntityinentity-management.ts: widened the read; the existing JSDoc already promised "own org or public" but the WHERE clause didn't.getEntityCountsByType/getEntityCountForTypefromslugtoentity_type_id. Cross-org slug collisions (tenantcompany+ catalogcompany) would otherwise merge counts across the two list rows.Doc-only
organization-dropdown.tsxalready renders "Your Organizations" / "Public Organizations" with a separator. No work needed.updateOrganization/ visibility mutation today (verified viatools/organizations.ts). Guarding a non-existent path is dead code. The guard SQL is preserved inline so the future implementer can plug it into the eventual admin visibility-control mutation.Out of scope (next PR)
organization_slugfield; lands as a separateowletto-websubmodule PR plus a parent bump after this merges.Test plan
make build-packages(gateway/worker/core/sdk) cleancd packages/owletto-backend && bun run typecheckcleanbun run typecheck(repo root) cleanmake devagainst a tenant org joined to a public catalog; callmanage_entity_schema action='list'and confirm cross-org rows appear withorganization_slugpopulated, tenant-first ORDER BYresolve_pathwith a path ending at a public-catalog entity referenced by a tenant relationship — pre-fix returns 404, post-fix resolves