Skip to content

feat(registry): add team publishing and team-private visibility - #1640

Merged
Haz3-jolt merged 8 commits into
Observal:mainfrom
Haz3-jolt:feat/team-publishing
Aug 2, 2026
Merged

Haz3-jolt merged 8 commits into
Observal:mainfrom
Haz3-jolt:feat/team-publishing

Conversation

@Haz3-jolt

@Haz3-jolt Haz3-jolt commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Purpose / Description

Feature 3 of the registry publish loop: team publishing. Feature 2 gave teamspaces an identity and members; this makes them a publishing target. Authorized members can publish agents and all five component types into a team namespace, and can keep them visible only to that team. Agents gain a private concept for the first time.

Fixes

Approach

  • Migration 018_team_publishing adds is_private to agents and team_id to agents, the five component listings, and component_sources. team_id is the sole ownership and privacy axis; organization scoping is not used for registry visibility. There is no backfill: the shared helpers already resolve a legacy private row with no teamspace as creator-only, so a bulk publish would disclose data for no benefit. The downgrade refuses to run while any listing still belongs to a teamspace, because dropping team_id loses the only record of team ownership.
  • Shared authorization helpers, not per-route logic: apply_visibility_filter and apply_registry_scope for queries, resolve_visible_listing and check_listing_visibility_async for detail and install paths, resolve_publish_target for publish authorization. A hidden listing returns 404, never 403, so it is not distinguishable from one that does not exist.
  • Review scope. Team owners and team reviewers clear review for team-visibility publishing only. Team roles are self-service, since a team owner can promote any member to owner, so publishing PUBLIC from a team namespace still enters the global review queue. Turning a team-private listing public is a publication, so it returns to that queue unless the actor is a global reviewer, admin, or super_admin. Without that second rule the first is bypassed in two steps.
  • Agent composition is enforced at validate, draft save, create, version, publish, and install: a public agent cannot contain a private component, and a team-private agent cannot contain another team's private component.
  • CLI gains --team and --visibility on publish, --team and --namespace on browse, and composition validation in agent build. Bundled skills updated to match.
  • Web builder scopes component pickers to the agent's target, and team members see approved private team items in normal registry lists.

How Has This Been Tested?

Automated:

  • tests/: 4,980 passed, 20 skipped, zero failures.
  • observal_cli/tests/: 78 passed. observal-server/tests/: 407 passed, with 19 pre-existing failures in test_jwt.py and test_multi_tenancy.py from an uninitialized KeyManager in this environment, unrelated to this branch. Note make test runs only tests/, so the other two directories need running directly, and observal-server/tests/ needs PYTHONPATH=packages/observal-shared.
  • Ruff check and format clean. Pre-commit passes on every changed file. Frontend lint, type checking, and production build pass.
  • Migration verified on a clone of a populated database: upgrade, downgrade, and repeat upgrade reach head, all seven team_id columns appear and disappear together, and the downgrade guard fires while team-owned rows exist.

Manual, against a local stack with 22 users, 5 teamspaces (one user deliberately in two), 50 components across all five types, and 10 agents:

  • 7,810 of 7,810 HTTP visibility checks pass across list, detail, install, resolve, versions, and search, for every principal including anonymous, non-member, member, team reviewer, team owner, global reviewer, and admin.
  • 104 of 104 write-path and privilege-escalation checks pass, covering publish authorization, auto-approval by role, namespace forgery, the composition matrix at each write boundary, the visibility endpoint, secondary read paths, reconcile, and dashboards.
  • 3,419 of 3,419 CLI checks pass across 2,231 invocations of the commands the bundled skills document.

To reproduce the stack: make rebuild-fast, then uv tool install --editable . --force.

Learning (optional, can help others)

Adversarially probing the running stack found defects the unit suite did not, all fixed here:

  • POST /agents/preview-config resolved component ids with no visibility filter and returned the rendered config, disclosing team-private MCP commands, hook handlers, prompt templates, and SKILL.md to any authenticated user. The code comment said the builder UI had already scoped the selection, which is client-side trust.
  • PATCH /registry/{type}/{id}/visibility never changed status, so a team role could self-approve a team-private listing and flip it public, reaching the global catalog without review.
  • _require_agent_edit_access in insights.py had been weakened to a condition that can never fire, exposing every public agent's insight reports.
  • The five component listing models lacked post_update=True on latest_version, which Agent already had. Any unit of work touching a listing field and a version field raised CircularDependencyError, which made resubmitting a pending or rejected component name a 500.

Two stale-mock lessons: patching a route-local binding stops working the moment a route calls a shared helper instead, and an unset attribute on a MagicMock is truthy, so an unset is_private silently made every listing look private and kept assertions green for the wrong reason.

Checklist

Please, go through these checks before submitting the PR.

  • You have a descriptive commit message with a short title (first line, max 50 chars).
  • You have commented your code, particularly in hard-to-understand areas
  • You have performed a self-review of your own code
  • UI changes: include screenshots of all affected screens (in particular showing any new or changed strings)

This branch does change the web registry and agent builder, so the box stays unchecked. Screenshots will be added by the author rather than generated.

AI Assistance

Was generative AI tooling used to co-author this PR?

  • Yes(Please Specify the tool): Claude Code
  • Was the generated code manually reviewed and tested?

Summary by CodeRabbit

  • New Features
    • Publish agents and registry components to personal or team spaces with public or team-only visibility.
    • Filter listings by team and namespace, with visibility-aware results.
    • Manage visibility, team membership, publishing, and review workflows from the web interface.
    • Added dedicated teamspace pages with searchable content, member management, and review actions.
    • CLI commands support team and visibility options and display install commands after publishing.
  • Bug Fixes
    • Restricted listings and components are no longer exposed to unauthorized users.
    • Improved handling of inaccessible components and ambiguous names.
    • Teamspaces with published content can no longer be deleted accidentally.

Teamspaces become a publishing target. Authorized members publish agents and
all five component types into a team namespace, and can keep them visible only
to that team.

Visibility is public or team. team_id is the sole ownership and privacy axis:
organization scoping is not used for registry visibility. Migration
018_team_publishing adds is_private to agents, which had no privacy concept,
and team_id to agents, the five component listings, and component sources. It
performs no backfill, because the shared helpers already resolve a legacy
private row with no teamspace as creator-only.

Authorization is enforced by shared helpers rather than per-route logic:
apply_visibility_filter and apply_registry_scope for queries,
resolve_visible_listing and check_listing_visibility_async for detail and
install paths, and resolve_publish_target for publish authorization.

Team owners and team reviewers clear review for team-visibility publishing
only. Team roles are self-service, so publishing PUBLIC from a team namespace
still enters the global review queue, and turning a team-private listing
public returns it to that queue unless the actor is a global reviewer.

Agent composition is enforced at validate, draft save, create, version,
publish, and install: a public agent cannot contain a private component, and a
team-private agent cannot contain another team's private component.

CLI gains --team and --visibility on publish and --team and --namespace on
browse. The web builder scopes component pickers to the agent's target.


Co-Authored-By: Hari Srinivasan <harisrini21@gmail.com>
@github-actions github-actions Bot added cli CLI changes server Pull request touches server code tests Pull request adds or modifies tests web Pull request touches web frontend code labels Aug 2, 2026
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The pull request adds team-scoped publishing and public or team visibility across server APIs, CLI commands, web registry pages, review workflows, migrations, tests, and documentation. It replaces organization-based listing access with team membership and caller-aware visibility checks.

Changes

Team publishing and visibility

Layer / File(s) Summary
Visibility contracts and storage
observal-server/models/*, observal-server/schemas/*, observal-server/alembic/versions/018_team_publishing.py, observal-server/services/teamspace.py
Listings now store team ownership and public or team visibility. Schemas expose these fields. Migration logic adds indexed team references and guarded downgrade behavior.
Caller-aware visibility and server routes
observal-server/api/deps.py, observal-server/api/routes/*, observal-server/services/agent_resolver.py
Listing resolution, publishing, retrieval, installation, feedback, insights, dashboards, and previews now use caller visibility, team membership, status, and publish scope.
Scoped review and team lifecycle
observal-server/api/routes/review.py, observal-server/api/routes/teams.py, observal-server/services/teamspace.py
Review queues and actions use team review scopes. Team deletion reports and blocks deletion when linked listings remain.
CLI publishing and filtering
observal_cli/client.py, observal_cli/cmd_*.py
CLI commands support team and visibility options, namespace and team filters, scoped agent validation, and canonical install output.
Web registry and teamspaces
web/src/pages/registry/*, web/src/hooks/*, web/src/lib/*, web/src/routes/*
Registry pages support team filters and visibility updates. Teamspaces use searchable cards and detail routes with content, membership, and review views.
Validation and documentation
tests/*, tests/e2e/*, docs/cli/*, observal_cli/skills/*
Tests cover visibility, publication approval, review scope, migration safety, component access, CLI behavior, and teamspace workflows. Documentation adds team publishing and visibility examples.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • Observal/Observal#1288: This change replaces organization-scoped review queries with capability- and team-membership-based review authorization.
  • Observal/Observal#1619: This change extends the teamspace infrastructure with team-scoped publishing and visibility.
  • Observal/Observal#1638: This change updates registry scoping and listing visibility used by recommendation services and tests.

Suggested reviewers: shaannarendran, dhanpraja231

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: team publishing and team-private registry visibility.
Description check ✅ Passed The description covers purpose, approach, testing, learning, checklist status, and AI assistance, with clear details about the implementation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 14

🧹 Nitpick comments (22)
web/src/components/registry/agent-edit-form.tsx (1)

64-65: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move AgentDetail to the shared type module.

web/src/pages/registry/agents/detail.tsx defines the same API shape. Keep one exported AgentDetail type in @/lib/types and import it in both files. This prevents team and visibility fields from drifting between the detail page and edit form.

As per coding guidelines, “Centralize frontend types in src/lib/types.ts; do not define inline API response types.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/registry/agent-edit-form.tsx` around lines 64 - 65, Move
the exported AgentDetail type from the registry detail page into the shared
`@/lib/types` module, then import and reuse it in both detail.tsx and the agent
edit form. Remove the duplicate local API type definitions while preserving the
existing team_id and visibility fields.

Source: Coding guidelines

web/src/pages/registry/agents/detail.tsx (1)

168-170: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the agent API response type to the shared type module.

AgentDetail now duplicates team_id, visibility, and is_private from RegistryItem. Export an agent detail type from web/src/lib/types/registry.ts and import it here. This prevents response-contract drift between registry screens.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/registry/agents/detail.tsx` around lines 168 - 170, Move the
AgentDetail API response type from the detail page into the shared registry
types module, exporting it alongside RegistryItem with the existing team_id,
visibility, and is_private fields. Update the detail page to import and use the
shared agent detail type, removing the duplicated local definition.

Source: Coding guidelines

tests/test_dashboard_leaderboard.py (1)

156-160: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Pair each download query with its table by name, not by position.

zip(_LISTING_TABLES, _download_queries(db), strict=True) assumes component_leaderboard emits the five download queries in exactly the _LISTING_TABLES order. If the route reorders the component types, the assertion fails with a confusing message about the wrong table. Select the statement whose SQL contains the table name instead.

♻️ Proposed pairing by table name
-    for table, stmt in zip(_LISTING_TABLES, _download_queries(db), strict=True):
-        sql = _sql(stmt)
+    queries = [_sql(stmt) for stmt in _download_queries(db)]
+    assert len(queries) == len(_LISTING_TABLES)
+    for table in _LISTING_TABLES:
+        sql = next(q for q in queries if table in q)
         assert "agents.is_private = false" in sql

Also applies to: 170-174

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_dashboard_leaderboard.py` around lines 156 - 160, Update the
download-query assertions around _LISTING_TABLES and _download_queries(db) to
pair each table with the statement whose SQL contains that table name, rather
than relying on positional zip order. Apply the same table-name lookup to the
related assertions around the second download-query block, while preserving the
existing privacy checks.
tests/test_draft_workflow.py (1)

362-367: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant db rebinding.

_sequenced_db(...) builds db, then app, db, _ = _app_with(user=user, db=db) rebinds the same object. The second assignment reads as if _app_with produced a new mock. Drop the rebinding to keep one owner of the sequenced db. The same pattern repeats at lines 391, 431, 448, 466 and 487.

♻️ Proposed change
-        app, db, _ = _app_with(user=user, db=db)
+        app, _, _ = _app_with(user=user, db=db)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_draft_workflow.py` around lines 362 - 367, Remove the redundant db
rebinding in the affected tests: keep the _sequenced_db result assigned to db,
then unpack only app and the unused return value from _app_with while passing db
as its argument. Apply this consistently to the repeated cases identified in the
comment, preserving the existing mock object.
tests/test_registry_types.py (2)

751-791: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Parametrize the route cases instead of looping inside each test.

The four tests iterate over _list_endpoint_cases() in a for loop. When one route fails, pytest stops at that route and the failure message does not name it, and the remaining routes are not checked. Move the cases into @pytest.mark.parametrize so each route reports as a separate test id.

♻️ Proposed parametrization
+_LIST_CASES = [pytest.param(*case, id=case[1]) for case in _list_endpoint_cases()]
+
+
 `@pytest.mark.asyncio`
-async def test_component_lists_hide_team_private_listings_from_anonymous_callers():
-    for list_items, table, filters, _expected in _list_endpoint_cases():
-        sql = _inline_sql(await _run_list(list_items, filters, current_user=None))
-        assert f"{table}.is_private = false" in sql
-        assert f"{table}.is_private = true" not in sql
-        assert "team_memberships" not in sql
+@pytest.mark.parametrize(("list_items", "table", "filters", "_expected"), _LIST_CASES)
+async def test_component_lists_hide_team_private_listings_from_anonymous_callers(
+    list_items, table, filters, _expected
+):
+    sql = _inline_sql(await _run_list(list_items, filters, current_user=None))
+    assert f"{table}.is_private = false" in sql
+    assert f"{table}.is_private = true" not in sql
+    assert "team_memberships" not in sql

_list_endpoint_cases() imports route modules at call time, so move the imports to module scope or wrap the case list in a fixture if collection-time import order matters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_registry_types.py` around lines 751 - 791, Parametrize all four
tests with the route cases from _list_endpoint_cases() so each route executes as
an independent pytest case with a descriptive id. Remove the internal for loops
and accept the unpacked case values as test arguments. Because parametrization
is evaluated during collection, move _list_endpoint_cases() route imports to
module scope or provide the cases through a fixture if collection-time import
order requires it.

741-748: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Two helpers named _membership_predicate assert different SQL. Both build the correlated EXISTS on team_memberships as a raw SQL substring, but the bodies diverge: the copy in tests/test_registry_types.py includes the is_private = true AND team_id IS NOT NULL prefix, and the copy in tests/test_dashboard_leaderboard.py includes only the bare EXISTS. A reader who trusts the shared name assumes both assert the same guarantee, and the dashboard copy accepts SQL that gates membership without checking is_private.

  • tests/test_registry_types.py#L741-L748: move this helper into a shared test module and keep the full predicate, including the is_private and team_id prefix.
  • tests/test_dashboard_leaderboard.py#L44-L50: delete the local copy and import the shared helper, or rename it to state that it asserts only the EXISTS fragment.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_registry_types.py` around lines 741 - 748, Move the full
`_membership_predicate` helper from tests/test_registry_types.py:741-748 into a
shared test module, preserving the is_private and team_id conditions, and update
callers there to use it. Remove the duplicate helper from
tests/test_dashboard_leaderboard.py:44-50 and import the shared implementation;
do not retain a same-named helper that only asserts the EXISTS fragment.
tests/test_component_update_visibility.py (1)

73-152: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

One listing mock is copied into two test files. Both files build the same MagicMock listing with the same ~80 type-specific attributes and the same explicit is_private / team_id / visibility triple. When a model gains a field, one copy is updated and the other keeps returning a truthy MagicMock attribute, which is the exact failure mode both files added comments to prevent.

  • tests/test_component_update_visibility.py#L73-L152: replace the local _listing_mock with an import of the shared builder.
  • tests/test_listing_detail_access.py#L62-L81: move this _listing_mock into a shared test helper module or a conftest.py fixture that both files use, keeping the is_private, team_id, and visibility parameters.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_component_update_visibility.py` around lines 73 - 152, The
duplicated listing mocks must use one shared builder. In
tests/test_listing_detail_access.py lines 62-81, move the local _listing_mock
into a shared test helper or conftest.py while preserving the is_private,
team_id, and visibility parameters; in tests/test_component_update_visibility.py
lines 73-152, remove the local _listing_mock and import/use that shared builder.
Keep both tests’ existing call behavior unchanged.
tests/test_team_publishing.py (1)

73-115: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Cover non-member rejection in resolve_publish_target. The implementation already raises HTTP 403 for a non-member with a supplied team_id; add a regression test for this branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_team_publishing.py` around lines 73 - 115, Extend
TestResolvePublishTargetAutoApprove with an async regression test that supplies
a team_id for a user who has no team membership, invokes resolve_publish_target,
and asserts the call raises HTTP 403. Reuse the existing _mock_db, _user, and
team_id setup helpers and preserve the current matrix tests.
tests/test_insights_access.py (1)

396-410: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The static guard only inspects async handlers.

The loop skips ast.FunctionDef, so a synchronous route handler added later passes this check without any gate. Include both node types to close that gap.

♻️ Proposed change
     unguarded = []
     for node in ast.walk(tree):
-        if not isinstance(node, ast.AsyncFunctionDef) or node.name.startswith("_"):
+        if not isinstance(node, ast.AsyncFunctionDef | ast.FunctionDef) or node.name.startswith("_"):
             continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_insights_access.py` around lines 396 - 410, Update the AST
traversal in the authorization-coverage check around the unguarded handler
collection to inspect both ast.AsyncFunctionDef and ast.FunctionDef nodes.
Preserve the existing private-name and exempt-handler filtering, call detection,
and assertion behavior for all route handlers.
observal-server/tests/test_registry_namespace.py (1)

337-337: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Strengthen the owner_org_id pin.

The entity is constructed with owner_org_id=None at Line 337, so the assertion at Line 366 also passes if transfer_ownership never reads or writes the field. To pin the "no organization inheritance on transfer" rule, start from a non-null value and assert the transfer does not replace it with target.org_id.

♻️ Proposed test change
-        owner_org_id=None,
+        owner_org_id=original_org_id,
-    assert entity.owner_org_id is None
+    assert entity.owner_org_id == original_org_id
+    assert entity.owner_org_id != target.org_id

Define original_org_id = uuid.uuid4() before building _TransferEntity.

Also applies to: 366-366

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/tests/test_registry_namespace.py` at line 337, Strengthen the
ownership-transfer test by defining a non-null original_org_id with uuid.uuid4()
before constructing _TransferEntity, passing it as owner_org_id, and retaining
the assertion that transfer preserves this value rather than replacing it with
target.org_id.
tests/test_team_visibility_migrations.py (1)

70-74: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Relax the exact-set assertion on migration operations.

op_calls == {...} fails whenever the migration stops using one of the four allowed operations, even though that change is safe. The property under test is "no operation outside the allowlist". Assert containment instead.

♻️ Proposed change
-    assert op_calls == {f"op.{name}" for name in ALLOWED_SCHEMA_OPS}
+    assert op_calls <= {f"op.{name}" for name in ALLOWED_SCHEMA_OPS}
     assert not any(call.startswith("sa.update") for call in calls)
     assert "op.execute" not in op_calls
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_team_visibility_migrations.py` around lines 70 - 74, Update the
migration operation assertion in the test around _called_op_methods and
COMPONENT_SOURCE_MIGRATION so it verifies op_calls is contained within the
operations generated from ALLOWED_SCHEMA_OPS, rather than requiring an exact set
match. Preserve the existing assertions rejecting sa.update calls and
op.execute.
tests/test_insights_agent_lookup.py (1)

14-19: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate _result SQLAlchemy result mock in two insights test modules. Both modules define the same helper because no shared test helper exists for the insights suite.

  • tests/test_insights_agent_lookup.py#L14-L19: remove the local _result definition and import it from a shared conftest.py helper.
  • tests/test_insights_access.py#L32-L37: move this definition into the shared conftest.py helper so both modules use one implementation.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_insights_agent_lookup.py` around lines 14 - 19, Centralize the
duplicated _result SQLAlchemy mock helper in the shared tests/conftest.py,
moving the implementation from tests/test_insights_access.py lines 32-37. Remove
the local definition from tests/test_insights_agent_lookup.py lines 14-19 and
import/use the shared _result helper there; update both modules as needed so
they resolve the same implementation.
tests/test_sec009_component_source_ownership.py (1)

65-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restore the previous dependency overrides instead of clearing them.

_api installs overrides on the shared main.app and then calls app.dependency_overrides.clear(). clear() also removes overrides that other test modules or session-scoped fixtures installed on the same application object. Snapshot the mapping and restore it, so this helper only undoes its own changes.

♻️ Proposed change
-        app.dependency_overrides[get_db] = _db
-        if user is not None:
-            app.dependency_overrides[get_current_user] = lambda: user
-        try:
-            async with _make_client() as client:
-                yield client, sessions
-        finally:
-            app.dependency_overrides.clear()
+        previous = dict(app.dependency_overrides)
+        app.dependency_overrides[get_db] = _db
+        if user is not None:
+            app.dependency_overrides[get_current_user] = lambda: user
+        try:
+            async with _make_client() as client:
+                yield client, sessions
+        finally:
+            app.dependency_overrides.clear()
+            app.dependency_overrides.update(previous)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_sec009_component_source_ownership.py` around lines 65 - 72, Update
the _api helper’s dependency-override cleanup: snapshot app.dependency_overrides
before installing get_db and get_current_user overrides, then restore that
snapshot in finally instead of calling clear(). Preserve all pre-existing
overrides while removing only the changes made by this helper.
observal-server/api/routes/agent_versions.py (2)

109-114: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider forwarding prefer_user_id for consistent name resolution.

Other routes call the base loader with prefer_user_id=current_user.id. This wrapper omits it. For a name-based agent_id, the version routes can therefore resolve to a different agent than the CRUD routes resolve for the same caller and the same name. Visibility is still enforced, so this is a consistency gap rather than an access issue.

♻️ Proposed change
 async def _load_agent(db: AsyncSession, agent_id: str, current_user: User | None = None) -> Agent | None:
     """Thin wrapper that delegates to the route-level _load_agent."""
     optic.trace("agent_id={}", agent_id)
     from api.routes.agent import _load_agent as _base_load
 
-    return await _base_load(db, agent_id, current_user=current_user)
+    return await _base_load(
+        db,
+        agent_id,
+        prefer_user_id=current_user.id if current_user else None,
+        current_user=current_user,
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/agent_versions.py` around lines 109 - 114, Update
the wrapper _load_agent to pass prefer_user_id=current_user.id to the delegated
_base_load when current_user is available, while preserving None handling for
anonymous callers and the existing visibility behavior.

45-49: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Delete the unused audit coroutine and its test patches.

No production code calls api.routes.agent_versions.audit; the test patches target an unused attribute. Remove both the dead function and those patches.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/agent_versions.py` around lines 45 - 49, Remove
the unused audit coroutine from api.routes.agent_versions and delete the
corresponding test patches that target agent_versions.audit. Ensure no
references to this obsolete attribute remain.

Source: Coding guidelines

observal-server/api/routes/agent/install.py (1)

120-217: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the repeated scope-and-visibility wrapper.

The same apply_publish_scope(apply_visibility_filter(...)) wrapper is repeated for five listing models. A small local helper removes the duplication and keeps the two filters in a fixed order at every call site.

♻️ Proposed helper
def _scoped(stmt, model, current_user, target_team_id):
    return apply_publish_scope(
        apply_visibility_filter(stmt, model, current_user), model, target_team_id
    )
-        mcp_stmt = apply_publish_scope(
-            apply_visibility_filter(
-                select(McpListing).where(McpListing.id.in_(mcp_comp_ids)), McpListing, current_user
-            ),
-            McpListing,
-            component_target_team_id,
-        )
+        mcp_stmt = _scoped(
+            select(McpListing).where(McpListing.id.in_(mcp_comp_ids)),
+            McpListing,
+            current_user,
+            component_target_team_id,
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/agent/install.py` around lines 120 - 217,
Introduce a local _scoped helper near the preload logic that applies
apply_visibility_filter before apply_publish_scope with the supplied statement,
model, current_user, and target team. Replace each repeated nested wrapper for
the MCP, skill, hook, prompt, and sandbox listing queries with _scoped,
preserving their existing filters, options, and execution behavior.
observal-server/api/routes/dashboard.py (1)

334-339: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Consider a caller-scoped cache key instead of no caching.

Removing the shared cache is correct, because a single cache entry would serve team-private rows to non-members. The cost is that component_leaderboard now runs five grouped join queries plus up to five backfill queries on every request, with no caching. A cache key that includes the caller's visibility scope (for example anonymous, or the caller's team id set) restores caching without cross-tenant leakage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/dashboard.py` around lines 334 - 339, Update
component_leaderboard to use caller-scoped caching rather than disabling caching
entirely. Build the cache key from the requested window and limit plus the
caller’s visibility scope, distinguishing anonymous users and the caller’s team
ID set, so private leaderboard rows cannot be shared across tenants. Preserve
the existing query and backfill behavior on cache misses.
observal-server/api/routes/mcp.py (2)

404-422: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

_reject_visibility_edits is duplicated across five route modules. The function body and the docstring are identical in all five files. Only the item type in the error detail differs. A future change to the visibility policy must be applied five times, and any missed copy weakens the guard on that route. Extract one shared helper that takes the item type, and import it in each route module.

Suggested shared signature in observal-server/api/routes/_component_archive.py or a new _component_visibility.py:

def reject_visibility_edits(listing, req, item_type: str) -> None:
    """Refuse teamspace or visibility changes sent to a draft update route."""
    if req.team_id is not None and req.team_id != listing.team_id:
        raise HTTPException(
            status_code=400,
            detail="team_id cannot be changed here. A listing stays in the teamspace it was created under.",
        )
    if req.visibility is not None and req.visibility != listing.visibility:
        raise HTTPException(
            status_code=400,
            detail=(
                "visibility cannot be changed here. "
                f"Use PATCH /api/v1/registry/{item_type}/{listing.id}/visibility."
            ),
        )
  • observal-server/api/routes/mcp.py#L404-L422: delete the local helper and call reject_visibility_edits(listing, req, "mcp") at line 440.
  • observal-server/api/routes/prompt.py#L269-L287: delete the local helper and call reject_visibility_edits(listing, req, "prompt") at line 305.
  • observal-server/api/routes/hook.py#L310-L328: delete the local helper and call reject_visibility_edits(listing, req, "hook") at line 346.
  • observal-server/api/routes/sandbox.py#L249-L267: delete the local helper and call reject_visibility_edits(listing, req, "sandbox") at line 285.
  • observal-server/api/routes/skill.py#L412-L430: delete the local helper and call reject_visibility_edits(listing, req, "skill") at line 448.

Delete each local copy in the same change. As per coding guidelines, "Use a hard rewrite policy: do not add deprecation wrappers; update callers in the same change and delete dead code immediately."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/mcp.py` around lines 404 - 422, Extract the
duplicated _reject_visibility_edits logic into one shared
reject_visibility_edits(listing, req, item_type: str) helper, preserving both
validation behaviors and using item_type in the visibility endpoint detail. In
observal-server/api/routes/mcp.py:404-422, prompt.py:269-287, hook.py:310-328,
sandbox.py:249-267, and skill.py:412-430, delete each local helper and update
their draft routes to call the shared helper with "mcp", "prompt", "hook",
"sandbox", and "skill" respectively; do not add wrappers or retain dead copies.

Source: Coding guidelines


308-317: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify the fallback guard.

Line 311 already proves listing is falsy before the reassignment. Line 313 then re-tests not listing inside a combined condition. observal-server/api/routes/hook.py (lines 215-223) and observal-server/api/routes/skill.py (lines 300-308) use a clearer two-step form for the same logic. Align this route with that form.

♻️ Proposed refactor
     if not listing:
         listing = await resolve_visible_listing(McpListing, listing_id, db, current_user)
-        if not listing or (
+        if not listing:
+            raise HTTPException(status_code=404, detail="Listing not found or not approved")
+        if (
             listing.status != ListingStatus.archived
             and get_effective_component_permission(listing, current_user) != "owner"
         ):
             raise HTTPException(status_code=404, detail="Listing not found or not approved")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/mcp.py` around lines 308 - 317, In the fallback
logic around resolve_visible_listing, remove the redundant not listing check
after reassignment, since the outer if already establishes the first lookup was
falsy. Align the guard with the two-step pattern used in the hook and skill
routes: re-resolve the listing, then separately raise 404 when the fallback
result is absent or fails the archived/owner permission condition.
observal-server/api/routes/skill.py (1)

227-233: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Reconsider including skill_md_content in the keyword search.

skill_md_content holds the full SKILL.md document. Line 240 also runs a COUNT(*) over the same predicate. Every search request therefore scans full document text twice, and no index covers this column. Consider a dedicated full-text index or a materialized search column for the large fields.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/skill.py` around lines 227 - 233, Update the skill
search query to avoid applying the keyword predicate directly to
SkillVersion.skill_md_content, while preserving searches across the smaller
indexed fields. If full-document search is required, route it through an
appropriate dedicated full-text index or materialized search column and ensure
the matching COUNT(*) predicate uses the same optimized search path.
observal-server/api/deps.py (1)

468-477: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the duplicated local or_ imports.

The module already imports or_ at module scope; Line 379 uses it. The local from sqlalchemy import or_ statements inside apply_publish_scope and apply_visibility_filter add no value.

♻️ Proposed cleanup
 def apply_publish_scope(stmt, model, target_team_id):
     """Limit components to what an agent published for one target can contain."""
-    from sqlalchemy import or_
-
     if not hasattr(model, "is_private") or not hasattr(model, "team_id"):
     Public listings are visible to everyone. Team-private listings require a
     membership row for the requesting user, while legacy private user listings
     remain visible only to their creator.
     """
-    from sqlalchemy import or_
-
     from models.team import TeamMembership

Also applies to: 508-508

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/deps.py` around lines 468 - 477, Remove the redundant
local sqlalchemy.or_ imports from apply_publish_scope and
apply_visibility_filter, and rely on the existing module-level or_ import while
preserving both functions’ query behavior.
observal-server/services/agent_resolver.py (1)

15-15: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider moving the scope helpers out of the api layer.

services/agent_resolver.py now imports from api.deps at module scope. That inverts the usual dependency direction and creates a cycle risk, because api/routes/agent/helpers.py imports both api.deps and this service. apply_visibility_filter and apply_publish_scope are pure query builders over models. A neutral module such as services/visibility.py would let both layers import them safely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/services/agent_resolver.py` at line 15, Move
apply_publish_scope and apply_visibility_filter from api.deps into a neutral
services visibility module, then update agent_resolver and
api/routes/agent/helpers.py to import them from that module. Remove the api.deps
dependency from services/agent_resolver.py while preserving both helpers’
query-building behavior and signatures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/cli/registry.md`:
- Around line 35-49: Update the Teamspace visibility documentation to cover the
published skill visibility change endpoint, including PATCH
/api/v1/registry/skill/{listing_id}/visibility and its visibility values public
or team; otherwise remove the claim that team owners and reviewers can change
visibility after publication.

In `@observal_cli/cmd_agent.py`:
- Around line 1021-1023: Update the agent publish flow around add_publish_target
and the update operation near the visibility-only PATCH so --team is not
silently ignored: either pass the normalized team_id through a dedicated update
operation, or explicitly reject --team with --update. Normalize and validate
visibility before issuing the PATCH request, preserving the existing non-update
publish behavior.

In `@observal-server/alembic/versions/018_team_publishing.py`:
- Around line 80-103: Extend _assert_no_team_owned_rows to also detect agents
rows where is_private is true and team_id is null, alongside the existing
team-owned row checks. Include those personal-private agent counts in the owned
details and prevent downgrade before agents.is_private is dropped, while
preserving the current handling and error message for all guarded rows.

In `@observal-server/api/deps.py`:
- Around line 526-531: Update the creator_column selection in the authenticated
user filtering logic to avoid boolean evaluation of SQLAlchemy attributes:
choose model.created_by only when getattr(model, "submitted_by", None) returns
None, otherwise retain submitted_by. Preserve the existing own condition and
current_user.id comparison behavior.

In `@observal-server/api/routes/feedback.py`:
- Around line 47-54: Reduce query fan-out in `_visible_listing_by_id` by
resolving the listing type from the stored `Feedback.listing_type` for the given
`listing_id`, then invoke `_visible_listing` only for that model instead of
iterating through every `LISTING_MODELS` entry. Preserve the existing visibility
checks and 404 behavior when no matching feedback or listing exists.

In `@observal-server/api/routes/insights.py`:
- Around line 421-424: In export_report_html, call _authorize_report_agent
immediately after confirming the report exists and before checking
report.status. Keep the existing 400 response for incomplete reports, ensuring
unauthorized callers cannot learn the report’s completion state.

In `@observal-server/api/routes/preview.py`:
- Around line 122-151: Update the component-resolution flow around
`_visible_map` and the four
`McpListing`/`SkillListing`/`HookListing`/`PromptListing` lookups to enforce
approval authorization in addition to privacy visibility. Public components with
pending or rejected status must resolve only for their submitter, co-authors,
admins, or reviewers; otherwise require approved status. Preserve the existing
404 behavior using the `requested` and `resolved` sets so unauthorized
components are not exposed.

In `@observal-server/api/routes/registry.py`:
- Around line 198-217: Update the agent visibility validation branch in the
registry route to validate component references from every approved
AgentVersion, not only listing.components from the latest version. Iterate
through the approved versions, apply validate_component_ids with the existing
visibility and target-team parameters, and raise the same 409 conflict when any
version has errors.

In `@tests/test_draft_workflow.py`:
- Around line 757-771: Add an explicit assertion in
test_global_reviewer_auto_approves_without_membership that the team membership
lookup mock is not called during submission, while preserving the existing
approval and reviewed_by assertions.

In `@web/src/components/registry/submit-component-dialog.tsx`:
- Around line 177-180: Update the dialog’s reset() function to call
setTeamId("") and setVisibility("public") so closing after a team-only
submission restores the default publication target. Also synchronize these state
values from the current editItem when the dialog opens, ensuring reused
edit-dialog instances reflect the active item.

In `@web/src/pages/registry/agents/builder.tsx`:
- Around line 100-101: Update the local draft save payload and
restoreLocalDraft() to persist and restore the team publication state using the
existing teamId and visibility symbols, storing them as team_id and visibility
alongside the other draft fields. Ensure restored values repopulate both state
variables so team-only drafts retain their intended visibility after refresh.
- Around line 258-262: Update the dependency list of the effect that validates
the publication payload containing allComponents, teamId, and visibility to
include teamId and visibility alongside selectedComponents, ensuring validation
reruns whenever the publication target changes.

In `@web/src/pages/registry/components/index.tsx`:
- Around line 220-224: Preserve team filters for discoverable teams by using a
stable route/API-supported team value instead of relying only on
membership-aware useTeams(). Update web/src/pages/registry/components/index.tsx
lines 220-224 and web/src/pages/registry/agents/index.tsx lines 426-460 to
resolve and apply that value, and update web/src/pages/registry/teamspaces.tsx
lines 100-102 to pass the same stable filter value through the navigation path.

In `@web/src/pages/registry/teamspaces.tsx`:
- Around line 100-102: Update the “Browse registry” control in the teamspaces
page to render the Link through Button using the existing asChild pattern,
rather than nesting a Button inside the Link; preserve the current destination,
search parameter, variant, and size.

---

Nitpick comments:
In `@observal-server/api/deps.py`:
- Around line 468-477: Remove the redundant local sqlalchemy.or_ imports from
apply_publish_scope and apply_visibility_filter, and rely on the existing
module-level or_ import while preserving both functions’ query behavior.

In `@observal-server/api/routes/agent_versions.py`:
- Around line 109-114: Update the wrapper _load_agent to pass
prefer_user_id=current_user.id to the delegated _base_load when current_user is
available, while preserving None handling for anonymous callers and the existing
visibility behavior.
- Around line 45-49: Remove the unused audit coroutine from
api.routes.agent_versions and delete the corresponding test patches that target
agent_versions.audit. Ensure no references to this obsolete attribute remain.

In `@observal-server/api/routes/agent/install.py`:
- Around line 120-217: Introduce a local _scoped helper near the preload logic
that applies apply_visibility_filter before apply_publish_scope with the
supplied statement, model, current_user, and target team. Replace each repeated
nested wrapper for the MCP, skill, hook, prompt, and sandbox listing queries
with _scoped, preserving their existing filters, options, and execution
behavior.

In `@observal-server/api/routes/dashboard.py`:
- Around line 334-339: Update component_leaderboard to use caller-scoped caching
rather than disabling caching entirely. Build the cache key from the requested
window and limit plus the caller’s visibility scope, distinguishing anonymous
users and the caller’s team ID set, so private leaderboard rows cannot be shared
across tenants. Preserve the existing query and backfill behavior on cache
misses.

In `@observal-server/api/routes/mcp.py`:
- Around line 404-422: Extract the duplicated _reject_visibility_edits logic
into one shared reject_visibility_edits(listing, req, item_type: str) helper,
preserving both validation behaviors and using item_type in the visibility
endpoint detail. In observal-server/api/routes/mcp.py:404-422,
prompt.py:269-287, hook.py:310-328, sandbox.py:249-267, and skill.py:412-430,
delete each local helper and update their draft routes to call the shared helper
with "mcp", "prompt", "hook", "sandbox", and "skill" respectively; do not add
wrappers or retain dead copies.
- Around line 308-317: In the fallback logic around resolve_visible_listing,
remove the redundant not listing check after reassignment, since the outer if
already establishes the first lookup was falsy. Align the guard with the
two-step pattern used in the hook and skill routes: re-resolve the listing, then
separately raise 404 when the fallback result is absent or fails the
archived/owner permission condition.

In `@observal-server/api/routes/skill.py`:
- Around line 227-233: Update the skill search query to avoid applying the
keyword predicate directly to SkillVersion.skill_md_content, while preserving
searches across the smaller indexed fields. If full-document search is required,
route it through an appropriate dedicated full-text index or materialized search
column and ensure the matching COUNT(*) predicate uses the same optimized search
path.

In `@observal-server/services/agent_resolver.py`:
- Line 15: Move apply_publish_scope and apply_visibility_filter from api.deps
into a neutral services visibility module, then update agent_resolver and
api/routes/agent/helpers.py to import them from that module. Remove the api.deps
dependency from services/agent_resolver.py while preserving both helpers’
query-building behavior and signatures.

In `@observal-server/tests/test_registry_namespace.py`:
- Line 337: Strengthen the ownership-transfer test by defining a non-null
original_org_id with uuid.uuid4() before constructing _TransferEntity, passing
it as owner_org_id, and retaining the assertion that transfer preserves this
value rather than replacing it with target.org_id.

In `@tests/test_component_update_visibility.py`:
- Around line 73-152: The duplicated listing mocks must use one shared builder.
In tests/test_listing_detail_access.py lines 62-81, move the local _listing_mock
into a shared test helper or conftest.py while preserving the is_private,
team_id, and visibility parameters; in tests/test_component_update_visibility.py
lines 73-152, remove the local _listing_mock and import/use that shared builder.
Keep both tests’ existing call behavior unchanged.

In `@tests/test_dashboard_leaderboard.py`:
- Around line 156-160: Update the download-query assertions around
_LISTING_TABLES and _download_queries(db) to pair each table with the statement
whose SQL contains that table name, rather than relying on positional zip order.
Apply the same table-name lookup to the related assertions around the second
download-query block, while preserving the existing privacy checks.

In `@tests/test_draft_workflow.py`:
- Around line 362-367: Remove the redundant db rebinding in the affected tests:
keep the _sequenced_db result assigned to db, then unpack only app and the
unused return value from _app_with while passing db as its argument. Apply this
consistently to the repeated cases identified in the comment, preserving the
existing mock object.

In `@tests/test_insights_access.py`:
- Around line 396-410: Update the AST traversal in the authorization-coverage
check around the unguarded handler collection to inspect both
ast.AsyncFunctionDef and ast.FunctionDef nodes. Preserve the existing
private-name and exempt-handler filtering, call detection, and assertion
behavior for all route handlers.

In `@tests/test_insights_agent_lookup.py`:
- Around line 14-19: Centralize the duplicated _result SQLAlchemy mock helper in
the shared tests/conftest.py, moving the implementation from
tests/test_insights_access.py lines 32-37. Remove the local definition from
tests/test_insights_agent_lookup.py lines 14-19 and import/use the shared
_result helper there; update both modules as needed so they resolve the same
implementation.

In `@tests/test_registry_types.py`:
- Around line 751-791: Parametrize all four tests with the route cases from
_list_endpoint_cases() so each route executes as an independent pytest case with
a descriptive id. Remove the internal for loops and accept the unpacked case
values as test arguments. Because parametrization is evaluated during
collection, move _list_endpoint_cases() route imports to module scope or provide
the cases through a fixture if collection-time import order requires it.
- Around line 741-748: Move the full `_membership_predicate` helper from
tests/test_registry_types.py:741-748 into a shared test module, preserving the
is_private and team_id conditions, and update callers there to use it. Remove
the duplicate helper from tests/test_dashboard_leaderboard.py:44-50 and import
the shared implementation; do not retain a same-named helper that only asserts
the EXISTS fragment.

In `@tests/test_sec009_component_source_ownership.py`:
- Around line 65-72: Update the _api helper’s dependency-override cleanup:
snapshot app.dependency_overrides before installing get_db and get_current_user
overrides, then restore that snapshot in finally instead of calling clear().
Preserve all pre-existing overrides while removing only the changes made by this
helper.

In `@tests/test_team_publishing.py`:
- Around line 73-115: Extend TestResolvePublishTargetAutoApprove with an async
regression test that supplies a team_id for a user who has no team membership,
invokes resolve_publish_target, and asserts the call raises HTTP 403. Reuse the
existing _mock_db, _user, and team_id setup helpers and preserve the current
matrix tests.

In `@tests/test_team_visibility_migrations.py`:
- Around line 70-74: Update the migration operation assertion in the test around
_called_op_methods and COMPONENT_SOURCE_MIGRATION so it verifies op_calls is
contained within the operations generated from ALLOWED_SCHEMA_OPS, rather than
requiring an exact set match. Preserve the existing assertions rejecting
sa.update calls and op.execute.

In `@web/src/components/registry/agent-edit-form.tsx`:
- Around line 64-65: Move the exported AgentDetail type from the registry detail
page into the shared `@/lib/types` module, then import and reuse it in both
detail.tsx and the agent edit form. Remove the duplicate local API type
definitions while preserving the existing team_id and visibility fields.

In `@web/src/pages/registry/agents/detail.tsx`:
- Around line 168-170: Move the AgentDetail API response type from the detail
page into the shared registry types module, exporting it alongside RegistryItem
with the existing team_id, visibility, and is_private fields. Update the detail
page to import and use the shared agent detail type, removing the duplicated
local definition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 38e08426-8335-4ed0-9130-80d867270864

📥 Commits

Reviewing files that changed from the base of the PR and between 5f5324a and 89d7234.

⛔ Files ignored due to path filters (2)
  • observal-server/uv.lock is excluded by !**/*.lock
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (97)
  • docs/cli/agent.md
  • docs/cli/registry.md
  • observal-server/alembic/versions/018_team_publishing.py
  • observal-server/api/deps.py
  • observal-server/api/routes/_component_archive.py
  • observal-server/api/routes/admin/org.py
  • observal-server/api/routes/agent/crud.py
  • observal-server/api/routes/agent/draft.py
  • observal-server/api/routes/agent/helpers.py
  • observal-server/api/routes/agent/install.py
  • observal-server/api/routes/agent_versions.py
  • observal-server/api/routes/bulk.py
  • observal-server/api/routes/co_authors.py
  • observal-server/api/routes/component_source.py
  • observal-server/api/routes/component_versions.py
  • observal-server/api/routes/dashboard.py
  • observal-server/api/routes/exec_dashboard.py
  • observal-server/api/routes/feedback.py
  • observal-server/api/routes/hook.py
  • observal-server/api/routes/insights.py
  • observal-server/api/routes/mcp.py
  • observal-server/api/routes/preview.py
  • observal-server/api/routes/prompt.py
  • observal-server/api/routes/registry.py
  • observal-server/api/routes/sandbox.py
  • observal-server/api/routes/skill.py
  • observal-server/models/agent.py
  • observal-server/models/component_source.py
  • observal-server/models/hook.py
  • observal-server/models/mcp.py
  • observal-server/models/prompt.py
  • observal-server/models/sandbox.py
  • observal-server/models/skill.py
  • observal-server/pyproject.toml
  • observal-server/schemas/agent.py
  • observal-server/schemas/component_source.py
  • observal-server/schemas/constants.py
  • observal-server/schemas/hook.py
  • observal-server/schemas/mcp.py
  • observal-server/schemas/prompt.py
  • observal-server/schemas/sandbox.py
  • observal-server/schemas/skill.py
  • observal-server/services/agent_builder.py
  • observal-server/services/agent_resolver.py
  • observal-server/services/insights/self_learn.py
  • observal-server/services/ownership.py
  • observal-server/services/teamspace.py
  • observal-server/tests/test_agent_pull.py
  • observal-server/tests/test_component_versions_api.py
  • observal-server/tests/test_registry_namespace.py
  • observal-server/tests/test_teamspace.py
  • observal_cli/client.py
  • observal_cli/cmd_agent.py
  • observal_cli/cmd_hook.py
  • observal_cli/cmd_mcp.py
  • observal_cli/cmd_migrate.py
  • observal_cli/cmd_prompt.py
  • observal_cli/cmd_sandbox.py
  • observal_cli/cmd_skill.py
  • observal_cli/skills/observal-agents/SKILL.md
  • observal_cli/skills/observal-registry/SKILL.md
  • observal_cli/skills/observal/SKILL.md
  • packages/pi-extension/package.json
  • pyproject.toml
  • tests/test_agent_composition.py
  • tests/test_co_authors.py
  • tests/test_component_update_visibility.py
  • tests/test_dashboard_leaderboard.py
  • tests/test_draft_workflow.py
  • tests/test_insights_access.py
  • tests/test_insights_agent_lookup.py
  • tests/test_listing_detail_access.py
  • tests/test_listing_version_flush.py
  • tests/test_preview_config.py
  • tests/test_pull_and_agent_cli.py
  • tests/test_registry_types.py
  • tests/test_registry_visibility.py
  • tests/test_sec009_component_source_ownership.py
  • tests/test_sec009_org_scoping.py
  • tests/test_team_publishing.py
  • tests/test_team_visibility_migrations.py
  • web/package.json
  • web/src/components/registry/agent-edit-form.tsx
  • web/src/components/registry/component-picker.tsx
  • web/src/components/registry/submit-component-dialog.tsx
  • web/src/hooks/use-api.ts
  • web/src/hooks/use-registry-api.ts
  • web/src/lib/api.ts
  • web/src/lib/types/registry.ts
  • web/src/pages/registry/agents/builder.tsx
  • web/src/pages/registry/agents/detail.tsx
  • web/src/pages/registry/agents/index.tsx
  • web/src/pages/registry/components/detail.tsx
  • web/src/pages/registry/components/index.tsx
  • web/src/pages/registry/teamspaces.tsx
  • web/src/routes/_authed/agents/index.tsx
  • web/src/routes/_authed/components/index.tsx
💤 Files with no reviewable changes (5)
  • observal-server/api/routes/bulk.py
  • observal-server/api/routes/admin/org.py
  • observal-server/services/insights/self_learn.py
  • observal-server/services/ownership.py
  • observal-server/api/routes/exec_dashboard.py

Comment thread docs/cli/registry.md
Comment thread observal_cli/cmd_agent.py Outdated
Comment thread observal-server/alembic/versions/018_team_publishing.py
Comment on lines +526 to +531
creator_column = getattr(model, "submitted_by", None) or getattr(model, "created_by", None)
if hasattr(model, "team_id"):
own = (model.is_private == True) & model.team_id.is_(None) # noqa: E712
else:
own = model.is_private == True # noqa: E712
own = own & (creator_column == current_user.id) if creator_column is not None else False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check truthiness of a SQLAlchemy InstrumentedAttribute and list creator columns per registry model.
set -euo pipefail

python - <<'PY'
import sqlalchemy
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
print("sqlalchemy", sqlalchemy.__version__)

class Base(DeclarativeBase):
    pass

class T(Base):
    __tablename__ = "t"
    id: Mapped[int] = mapped_column(primary_key=True)
    submitted_by: Mapped[int]

try:
    print("bool(T.submitted_by) =", bool(T.submitted_by))
except Exception as exc:
    print("raised:", type(exc).__name__, exc)
PY

rg -nP --type=py -g '**/models/*.py' '\b(submitted_by|created_by)\s*:\s*Mapped'

Repository: Observal/Observal

Length of output: 278


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target function ---'
sed -n '480,590p' observal-server/api/deps.py

printf '%s\n' '--- creator-field declarations ---'
rg -n --type=py '\b(submitted_by|created_by)\b' observal-server | head -200

printf '%s\n' '--- dependency declarations ---'
rg -n --glob 'requirements*.txt' --glob 'pyproject.toml' --glob 'uv.lock' --glob 'poetry.lock' 'SQLAlchemy|sqlalchemy' .

printf '%s\n' '--- usages of the containing dependency ---'
rg -n --type=py 'get_current_user|current_user|creator_column|submitted_by' observal-server/api observal-server | head -250

Repository: Observal/Observal

Length of output: 50373


🌐 Web query:

SQLAlchemy 2.0 InstrumentedAttribute bool() TypeError Boolean value of this clause is not defined

💡 Result:

The TypeError: Boolean value of this clause is not defined error in SQLAlchemy occurs when a SQL expression object (such as an InstrumentedAttribute or a binary expression) is evaluated in a Python boolean context (e.g., in an if statement, or with bool) [1][2][3]. Because SQLAlchemy objects are designed to construct SQL strings rather than represent truthy or falsy Python values, the library explicitly raises this error to prevent ambiguous behavior [1][3]. Common Scenarios and Solutions: 1. Testing for Existence of a Filter or Column: If you are trying to check if a filter or attribute exists in an if statement, do not check the object itself [1]. Instead, check if the variable is not None [1]. Incorrect: if my_filter: Correct: if my_filter is not None: [1] 2. Hybrid Properties: When using @hybrid_property, you must define both a Python-side implementation and a SQL-side expression [2][3]. The Python-side implementation runs in standard Python and should return a regular Python value (like True, False, or None), while the expression-side handles the SQL translation [2][3]. The error often happens when the expression-side logic is unintentionally triggered in a Python context where it cannot be evaluated [2][3]. 3. Mismatched Usage in Mapped Annotations: In some versions of SQLAlchemy 2.0 (specifically versions prior to 2.0.51), inappropriate use of objects inside Mapped annotations could trigger this error [4][5]. Ensure you are using the correct type annotations for your columns [4][5]. If you encounter this, verify you are on a recent version of SQLAlchemy, as these errors have been improved for clarity [4][5]. In summary, the error is a safeguard against misuse [1]. Whenever you see it, look for where a SQL-construct (like a Column, QueryableAttribute, or BinaryExpression) is being evaluated as if it were a simple Python boolean [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- apply_visibility_filter callers ---'
rg -n -B3 -A8 --type=py 'apply_visibility_filter\(' observal-server

printf '%s\n' '--- model class declarations and registry models ---'
rg -n --type=py '^(class |from models\.)|__tablename__' observal-server/models/{agent,hook,mcp,prompt,sandbox,skill,component_bundle,submission}.py

printf '%s\n' '--- exact SQLAlchemy pins ---'
awk '
  /name = "sqlalchemy"/ {show=1; n=0}
  show {print; n++}
  show && n >= 8 {show=0}
' ob ser val-server/uv.lock 2>/dev/null || true
awk '
  /name = "sqlalchemy"/ {show=1; n=0}
  show {print; n++}
  show && n >= 8 {show=0}
' uv.lock

Repository: Observal/Observal

Length of output: 27478


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

root = Path("observal-server/models")
fields = {"submitted_by", "created_by", "is_private", "team_id"}
for path in sorted(root.glob("*.py")):
    tree = ast.parse(path.read_text(), filename=str(path))
    for node in tree.body:
        if not isinstance(node, ast.ClassDef):
            continue
        names = set()
        for item in node.body:
            if isinstance(item, (ast.AnnAssign, ast.Assign)):
                targets = item.targets if isinstance(item, ast.Assign) else [item.target]
                for target in targets:
                    if isinstance(target, ast.Name):
                        names.add(target.id)
        present = sorted(fields & names)
        if {"is_private"} <= names:
            creator = "submitted_by" if "submitted_by" in names else "created_by" if "created_by" in names else "<none>"
            print(f"{path}:{node.lineno} {node.name}: creator={creator}, fields={','.join(present)}")
PY

Repository: Observal/Observal

Length of output: 804


Select creator_column without truthiness evaluation

For authenticated non-privileged users, registry models with submitted_by raise TypeError: Boolean value of this clause is not defined because or evaluates the SQLAlchemy InstrumentedAttribute. Select the fallback only when the first attribute is None.

🛠️ Proposed fix
-    creator_column = getattr(model, "submitted_by", None) or getattr(model, "created_by", None)
+    creator_column = getattr(model, "submitted_by", None)
+    if creator_column is None:
+        creator_column = getattr(model, "created_by", None)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
creator_column = getattr(model, "submitted_by", None) or getattr(model, "created_by", None)
if hasattr(model, "team_id"):
own = (model.is_private == True) & model.team_id.is_(None) # noqa: E712
else:
own = model.is_private == True # noqa: E712
own = own & (creator_column == current_user.id) if creator_column is not None else False
creator_column = getattr(model, "submitted_by", None)
if creator_column is None:
creator_column = getattr(model, "created_by", None)
if hasattr(model, "team_id"):
own = (model.is_private == True) & model.team_id.is_(None) # noqa: E712
else:
own = model.is_private == True # noqa: E712
own = own & (creator_column == current_user.id) if creator_column is not None else False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/deps.py` around lines 526 - 531, Update the
creator_column selection in the authenticated user filtering logic to avoid
boolean evaluation of SQLAlchemy attributes: choose model.created_by only when
getattr(model, "submitted_by", None) returns None, otherwise retain
submitted_by. Preserve the existing own condition and current_user.id comparison
behavior.

Comment thread observal-server/api/routes/feedback.py Outdated
Comment thread web/src/components/registry/submit-component-dialog.tsx
Comment thread web/src/pages/registry/agents/builder.tsx
Comment thread web/src/pages/registry/agents/builder.tsx
Comment thread web/src/pages/registry/components/index.tsx
Comment thread web/src/pages/registry/teamspaces.tsx Outdated
…m global reviewers

Team-private items were reviewable only by a global reviewer who was not in the
team, so private content was routed to outsiders and the team was blocked until
one acted. The reviewer team role granted no review capability at all.

Global reviewers no longer see team-private listings. Admins and super_admins
still do. can_see_private_listings in api/deps.py names that rule, and is kept
separate from may_view_unapproved, which answers a status question and keeps its
reviewer arm so the public queue still works.

The review queue is now capability scoped. Team owners and team reviewers review
their own teamspace's private items. Global reviewers keep the public catalog,
including team-to-public flips. A team owner cannot approve a public item in
their own namespace, which is the escalation team_role_self_publishes closes at
publish time. review_scope and can_review in services/teamspace.py answer once,
so the queue and the actions cannot drift apart.

Publishing into a teamspace you do not belong to is now an admin capability. A
global reviewer cannot read another team's private listings, so allowing one to
publish there would create a listing its own author can no longer see.

Web gains a teamspace detail route with Agents, Components, Members, and a
Review tab shown only to team owners and reviewers, since the admin review page
stays gated to global roles. Flipping a listing to public now asks for
confirmation and says it leaves the catalog until a reviewer approves it.


Co-Authored-By: Hari Srinivasan <harisrini21@gmail.com>
@Haz3-jolt
Haz3-jolt force-pushed the feat/team-publishing branch from 58db12e to 93630aa Compare August 2, 2026 09:08

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (6)
web/src/pages/registry/teamspace-detail.tsx (2)

627-636: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Leaving a teamspace runs without confirmation.

The Leave button calls leaveTeam.mutate directly and then navigates away. A team owner can also lose access this way. Confirm the action first, as the Delete button does.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/registry/teamspace-detail.tsx` around lines 627 - 636, Update
the Leave button handler in the teamspace detail component to show the same
confirmation flow used by the Delete button before invoking leaveTeam.mutate.
Only proceed with leaving and navigating to /teamspaces after confirmation,
including for team owners.

344-356: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Member removal runs without confirmation.

A single click on the trash button removes the member. The action is destructive and has no undo. The same page already confirms teamspace deletion with AlertDialog at lines 698-716. Add the same confirmation step before removeMember.mutate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/registry/teamspace-detail.tsx` around lines 344 - 356, Update
the member removal button’s onClick flow in the canManageMembers block to show
an AlertDialog confirmation before invoking removeMember.mutate(member.id).
Reuse the page’s existing teamspace deletion AlertDialog pattern and only
perform the mutation after the user confirms.
web/src/pages/registry/agents/detail.tsx (1)

721-746: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared visibility-change flow. Both detail pages carry byte-identical copies of readReturnedToReview and applyVisibility, plus the same confirmation dialog copy. The shared root cause is a copied flow rather than one extracted module. A future change to the review-queue messaging or the response contract must be applied twice, and the copies will diverge.

  • web/src/pages/registry/agents/detail.tsx#L721-L746: replace the local applyVisibility and the readReturnedToReview helper at lines 122-129 with the shared implementation, passing the display name and type: "agents".
  • web/src/pages/registry/components/detail.tsx#L170-L195: replace the local applyVisibility and the readReturnedToReview helper at lines 63-70 with the same shared implementation, passing the display name and the current type.

A shared hook, for example useVisibilityChange({ type, id, displayName }) returning { applyVisibility, isPending }, keeps the dialog markup in each page while centralizing the response parsing and the toast text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/registry/agents/detail.tsx` around lines 721 - 746, Extract the
duplicated readReturnedToReview and applyVisibility flow into a shared
useVisibilityChange implementation returning applyVisibility and isPending;
preserve the existing response parsing and toast behavior. Update
web/src/pages/registry/agents/detail.tsx lines 721-746 to use the shared
implementation with type "agents" and the display name, and update
web/src/pages/registry/components/detail.tsx lines 170-195 with the shared
implementation using the current type and display name; keep the confirmation
dialog markup in each page.
web/src/routes/_authed/teamspaces.$handle.tsx (1)

15-22: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

COMPONENT_TYPES is duplicated and can drift.

web/src/pages/registry/teamspace-detail.tsx declares its own COMPONENT_TYPES list at lines 70-76 for the component tabs. If a new registry type is added to that list only, validateSearch drops the type search parameter and the selected tab is not preserved in the URL. The failure is silent. Export one list and derive the validation set from it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/routes/_authed/teamspaces`.$handle.tsx around lines 15 - 22, Remove
the duplicated COMPONENT_TYPES declaration from the route and reuse the exported
component-type list from teamspace-detail.tsx in validateSearch. Ensure the
validation set is derived from that shared list so newly supported registry
types preserve the type search parameter consistently.
web/src/pages/registry/components/detail.tsx (1)

137-138: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant visibility cast.

RegistryItem declares visibility and is_private in web/src/lib/types/registry.ts. The fallback already produces a string accepted by PickerSelect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/registry/components/detail.tsx` around lines 137 - 138, Remove
the unnecessary string cast from the currentVisibility assignment in the detail
component. Use item?.visibility directly with the existing nullish fallback
based on item?.is_private, preserving the string value passed to PickerSelect.

Source: Coding guidelines

web/src/routes/_authed/teamspaces.tsx (1)

14-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a file-based index route for /teamspaces.

Make teamspaces.tsx render only <Outlet />, and move TeamspacesPage to teamspaces.index.tsx. This uses TanStack Router’s route matching instead of manual location.pathname matching. A configured basepath is stripped from useLocation().pathname.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/routes/_authed/teamspaces.tsx` around lines 14 - 23, Update
TeamspacesRoute in teamspaces.tsx to render only Outlet, remove the manual
useLocation/pathname matching, and move the TeamspacesPage rendering into a new
teamspaces.index.tsx file-based index route so /teamspaces is handled by
TanStack Router.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@observal-server/api/routes/review.py`:
- Around line 93-105: Apply a consistent non-disclosure policy across the review
write routes: in _authorize_item, avoid revealing visibility details by
returning 404 for team-private items or using a visibility-neutral message; at
observal-server/api/routes/review.py:882-894, remove listing.name and report
only that the bundle contains an out-of-scope member; at
observal-server/api/routes/review.py:1054-1056, replace skill.name with the
caller-supplied sid in the refusal message.
- Around line 93-105: Update _authorize_item to avoid disclosing an unauthorized
entity’s visibility: use the same non-revealing 404 behavior as get_review for
out-of-scope private items, or replace both visibility-specific 403 details with
one message that does not identify whether the item is private or public. Keep
authorization success and in-scope denial behavior unchanged.

In `@web/src/hooks/use-teams-api.ts`:
- Around line 39-46: Update the return object in useTeamsApi to treat an active
background refetch without a team as loading by setting isLoading from
query.isLoading or query.isFetching && !team, and require !query.isFetching
alongside the existing notFound conditions.

---

Nitpick comments:
In `@web/src/pages/registry/agents/detail.tsx`:
- Around line 721-746: Extract the duplicated readReturnedToReview and
applyVisibility flow into a shared useVisibilityChange implementation returning
applyVisibility and isPending; preserve the existing response parsing and toast
behavior. Update web/src/pages/registry/agents/detail.tsx lines 721-746 to use
the shared implementation with type "agents" and the display name, and update
web/src/pages/registry/components/detail.tsx lines 170-195 with the shared
implementation using the current type and display name; keep the confirmation
dialog markup in each page.

In `@web/src/pages/registry/components/detail.tsx`:
- Around line 137-138: Remove the unnecessary string cast from the
currentVisibility assignment in the detail component. Use item?.visibility
directly with the existing nullish fallback based on item?.is_private,
preserving the string value passed to PickerSelect.

In `@web/src/pages/registry/teamspace-detail.tsx`:
- Around line 627-636: Update the Leave button handler in the teamspace detail
component to show the same confirmation flow used by the Delete button before
invoking leaveTeam.mutate. Only proceed with leaving and navigating to
/teamspaces after confirmation, including for team owners.
- Around line 344-356: Update the member removal button’s onClick flow in the
canManageMembers block to show an AlertDialog confirmation before invoking
removeMember.mutate(member.id). Reuse the page’s existing teamspace deletion
AlertDialog pattern and only perform the mutation after the user confirms.

In `@web/src/routes/_authed/teamspaces`.$handle.tsx:
- Around line 15-22: Remove the duplicated COMPONENT_TYPES declaration from the
route and reuse the exported component-type list from teamspace-detail.tsx in
validateSearch. Ensure the validation set is derived from that shared list so
newly supported registry types preserve the type search parameter consistently.

In `@web/src/routes/_authed/teamspaces.tsx`:
- Around line 14-23: Update TeamspacesRoute in teamspaces.tsx to render only
Outlet, remove the manual useLocation/pathname matching, and move the
TeamspacesPage rendering into a new teamspaces.index.tsx file-based index route
so /teamspaces is handled by TanStack Router.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8b03225d-177e-40d6-ad4e-89dc85c643a8

📥 Commits

Reviewing files that changed from the base of the PR and between 89d7234 and 58db12e.

📒 Files selected for processing (23)
  • observal-server/api/deps.py
  • observal-server/api/routes/registry.py
  • observal-server/api/routes/review.py
  • observal-server/services/teamspace.py
  • tests/test_dashboard_leaderboard.py
  • tests/test_insights_access.py
  • tests/test_listing_detail_access.py
  • tests/test_registry_types.py
  • tests/test_registry_visibility.py
  • tests/test_sec009_org_scoping.py
  • tests/test_team_publishing.py
  • tests/test_team_review.py
  • tests/test_team_visibility_migrations.py
  • web/src/hooks/use-review-api.ts
  • web/src/hooks/use-teams-api.ts
  • web/src/lib/api.ts
  • web/src/pages/registry/agents/detail.tsx
  • web/src/pages/registry/components/detail.tsx
  • web/src/pages/registry/teamspace-detail.tsx
  • web/src/pages/registry/teamspaces.tsx
  • web/src/routeTree.gen.ts
  • web/src/routes/_authed/teamspaces.$handle.tsx
  • web/src/routes/_authed/teamspaces.tsx
🚧 Files skipped from review as they are similar to previous changes (7)
  • tests/test_team_visibility_migrations.py
  • web/src/lib/api.ts
  • tests/test_dashboard_leaderboard.py
  • tests/test_team_publishing.py
  • tests/test_insights_access.py
  • tests/test_registry_visibility.py
  • tests/test_sec009_org_scoping.py

Comment on lines +93 to +105
def _authorize_item(entity, scope: ReviewScope) -> None:
"""Authorize one item from its own visibility and teamspace."""
if can_review(entity, scope):
return
if getattr(entity, "is_private", False):
raise HTTPException(
status_code=403,
detail="Team-private items are reviewed by their teamspace's owners and reviewers",
)
raise HTTPException(
status_code=403,
detail="Public items are reviewed by global reviewers, not by teamspace roles",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Review write routes describe out-of-scope items in the 403 detail. The read routes hide an out-of-scope item: get_review returns 404 at line 545 and get_related_skills returns 404 at line 969, both to avoid confirming that a team-private item exists. The three write paths do the opposite and return a 403 that describes the item, so a caller who supplies an id learns the item's visibility class or its name. Apply one disclosure rule across all review routes.

  • observal-server/api/routes/review.py#L93-L105: return 404 for the team-private branch, or use one message that does not reveal the item's visibility.
  • observal-server/api/routes/review.py#L882-L894: remove listing.name from the bundle refusal message and report only that the bundle contains an out-of-scope member.
  • observal-server/api/routes/review.py#L1054-L1056: replace skill.name with the caller-supplied sid in the refusal message.
📍 Affects 1 file
  • observal-server/api/routes/review.py#L93-L105 (this comment)
  • observal-server/api/routes/review.py#L882-L894
  • observal-server/api/routes/review.py#L1054-L1056
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/review.py` around lines 93 - 105, Apply a
consistent non-disclosure policy across the review write routes: in
_authorize_item, avoid revealing visibility details by returning 404 for
team-private items or using a visibility-neutral message; at
observal-server/api/routes/review.py:882-894, remove listing.name and report
only that the bundle contains an out-of-scope member; at
observal-server/api/routes/review.py:1054-1056, replace skill.name with the
caller-supplied sid in the refusal message.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

_authorize_item discloses the visibility of items the caller cannot review.

get_review returns 404 for an out-of-scope item at line 545, with a comment stating that a 403 would confirm a team-private item exists. _authorize_item returns 403 and, through two distinct messages, also tells the caller whether the item is team-private or public. The approve and reject routes therefore disclose what the detail route hides.

Align the two surfaces. Either return 404 here for the private case, or use a single message that does not reveal the item's visibility.

🛡️ Proposed direction
 def _authorize_item(entity, scope: ReviewScope) -> None:
     """Authorize one item from its own visibility and teamspace."""
     if can_review(entity, scope):
         return
     if getattr(entity, "is_private", False):
-        raise HTTPException(
-            status_code=403,
-            detail="Team-private items are reviewed by their teamspace's owners and reviewers",
-        )
+        # Mirror get_review: never confirm that a team-private item exists.
+        raise HTTPException(status_code=404, detail="Listing not found")
     raise HTTPException(
         status_code=403,
         detail="Public items are reviewed by global reviewers, not by teamspace roles",
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _authorize_item(entity, scope: ReviewScope) -> None:
"""Authorize one item from its own visibility and teamspace."""
if can_review(entity, scope):
return
if getattr(entity, "is_private", False):
raise HTTPException(
status_code=403,
detail="Team-private items are reviewed by their teamspace's owners and reviewers",
)
raise HTTPException(
status_code=403,
detail="Public items are reviewed by global reviewers, not by teamspace roles",
)
def _authorize_item(entity, scope: ReviewScope) -> None:
"""Authorize one item from its own visibility and teamspace."""
if can_review(entity, scope):
return
if getattr(entity, "is_private", False):
# Mirror get_review: never confirm that a team-private item exists.
raise HTTPException(status_code=404, detail="Listing not found")
raise HTTPException(
status_code=403,
detail="Public items are reviewed by global reviewers, not by teamspace roles",
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/review.py` around lines 93 - 105, Update
_authorize_item to avoid disclosing an unauthorized entity’s visibility: use the
same non-revealing 404 behavior as get_review for out-of-scope private items, or
replace both visibility-specific 403 details with one message that does not
identify whether the item is private or public. Keep authorization success and
in-scope denial behavior unchanged.

Comment on lines +39 to +46
return {
team,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
refetch: query.refetch,
notFound: !query.isLoading && !query.isError && !team,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

TanStack Query v5 isLoading vs isPending vs isFetching difference

💡 Result:

In TanStack Query v5, isPending, isLoading, and isFetching are distinct flags that provide information about the current state of your query [1][2][3]. Understanding their differences is crucial for correctly handling data loading and background synchronization [1][4]. 1. isPending This boolean indicates that the query has no data and is currently in the 'pending' status [1][2]. It is the recommended flag for determining if you need to show an initial loading state [1][4]. When a query is in this state, it means the query attempt has not finished yet and no cached data is available [2][3]. 2. isLoading In v5, isLoading is a derived boolean defined as isPending && isFetching [2][3][5]. It is true only when the query is in the initial 'pending' state and is actively executing the query function (fetching) [2][3]. This replaces the deprecated isInitialLoading flag [2][5]. Because it relies on the fetch status, it can be false even if the query has no data (for example, if the query is 'paused' due to a lack of network connection) [6][4]. 3. isFetching This is a broader flag that indicates whether the query function is currently executing [1][2]. It is true whenever a fetch is in-flight, which includes both the initial 'pending' fetch and any background refetches (e.g., when a component re-mounts or when data is being synchronized in the background while data already exists) [2][3]. Summary Comparison - status vs. fetchStatus: The status (pending, error, success) tells you whether data is available [1][4]. The fetchStatus (fetching, paused, idle) tells you whether the query function is running [1][4]. - Recommendation: For most use cases, checking isPending is sufficient to handle the initial loading state [1][4]. isFetching is primarily used when you want to show a background indicator (e.g., a "Refreshing..." spinner) while the user is already viewing the existing data [7][4]. Migration Note: In v4, the flag previously called isLoading was renamed to isPending in v5, and the new v5 isLoading is equivalent to the old v4 isInitialLoading [5][8].

Citations:


🏁 Script executed:

# Inspect the hook, its consumers, and the installed TanStack Query version without running repository code.
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(use-teams-api\.ts|package\.json|.*team.*\.(ts|tsx))$' | head -80
printf '%s\n' '--- hook ---'
cat -n web/src/hooks/use-teams-api.ts | sed -n '1,120p'
printf '%s\n' '--- query API usage ---'
rg -n -C 3 'useTeam|notFound|useAllTeams|invalidateQueries|isFetching' web/src --glob '*.ts' --glob '*.tsx' | head -240
printf '%s\n' '--- TanStack Query declarations ---'
rg -n -C 2 '"`@tanstack/react-query`"|"`@tanstack/query`"' --glob 'package.json' --glob '*lock*' --glob 'pnpm-workspace.yaml' --glob 'yarn.lock'

Repository: Observal/Observal

Length of output: 21824


🏁 Script executed:

printf '%s\n' '--- package versions ---'
cat -n web/package.json | sed -n '1,180p'
printf '%s\n' '--- detail route and page references ---'
cat -n web/src/routes/_authed/teamspaces.\$handle.tsx | sed -n '1,220p'
rg -n -C 8 'useTeamByHandle|No teamspace|notFound|isLoading' web/src/pages web/src/routes --glob '*.ts' --glob '*.tsx'
printf '%s\n' '--- all teams query and creation call sites ---'
rg -n -C 6 'useAllTeams|useCreateTeam|queryKey:\s*\["teams"|listAll' web/src/hooks web/src/pages web/src/routes --glob '*.ts' --glob '*.tsx' | head -240

Repository: Observal/Observal

Length of output: 50374


🏁 Script executed:

printf '%s\n' '--- teamspace detail state handling ---'
cat -n web/src/pages/registry/teamspace-detail.tsx | sed -n '505,575p'
printf '%s\n' '--- creation success/navigation flow ---'
rg -n -C 10 'mutate|onSuccess|useCreateTeam|Teamspace created|teamspaces/\$|teamspaces/' web/src/pages/registry/teamspaces.tsx web/src/pages/registry/teamspace-detail.tsx web/src --glob '*.ts' --glob '*.tsx' | head -220
printf '%s\n' '--- exact relevant dependency lock entries ---'
rg -n -A 8 -B 3 '`@tanstack/react-query`@|/@tanstack/react-query@|version: 5\.' web/pnpm-lock.yaml pnpm-lock.yaml 2>/dev/null | head -100

Repository: Observal/Observal

Length of output: 23199


🏁 Script executed:

python3 - <<'PY'
from itertools import product

def current(query_is_loading, query_is_fetching, query_is_error, team):
    is_loading = query_is_loading
    not_found = not query_is_loading and not query_is_error and not team
    page_not_found = not_found or not team
    return is_loading, not_found, page_not_found

def proposed(query_is_loading, query_is_fetching, query_is_error, team):
    is_loading = query_is_loading or (query_is_fetching and not team)
    not_found = (
        not query_is_loading
        and not query_is_fetching
        and not query_is_error
        and not team
    )
    page_not_found = not_found or not team
    return is_loading, not_found, page_not_found

case = (False, True, False, False)  # cached list, background refetch, handle absent
print("background-refetch case:")
print("current :", current(*case))
print("proposed:", proposed(*case))

print("\nrelevant state table (isLoading, notFound, page_not_found):")
for is_loading, is_fetching, is_error, team in product([False, True], repeat=4):
    if (is_fetching, team) in ((True, False), (False, False)) and not is_error:
        print(
            {
                "query_isLoading": is_loading,
                "query_isFetching": is_fetching,
                "query_isError": is_error,
                "team": team,
                "current": current(is_loading, is_fetching, is_error, team),
                "proposed": proposed(is_loading, is_fetching, is_error, team),
            }
        )
PY

Repository: Observal/Observal

Length of output: 929


🏁 Script executed:

python3 - <<'PY'
def render_state(query_is_loading, query_is_fetching, query_is_error, team):
    is_loading = query_is_loading or (query_is_fetching and not team)
    not_found = (
        not query_is_loading
        and not query_is_fetching
        and not query_is_error
        and not team
    )
    if is_loading:
        return "skeleton"
    if query_is_error:
        return "error"
    if not_found or not team:
        return "not-found"
    return "detail"

cases = {
    "initial fetch without cached team": (True, True, False, False),
    "background refetch; handle absent from cached list": (False, True, False, False),
    "idle successful query; handle absent": (False, False, False, False),
    "background refetch; cached handle present": (False, True, False, True),
}
for label, state in cases.items():
    print(f"{label}: {render_state(*state)}")
PY

Repository: Observal/Observal

Length of output: 358


Gate both loading and not-found states on background refetches.

React Query v5 sets isLoading to false when cached data exists during a background refetch. If the new handle is absent from cached data, the detail page can render “No teamspace named X” before the refetch completes. Set isLoading to query.isLoading || (query.isFetching && !team) and require !query.isFetching in notFound.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/hooks/use-teams-api.ts` around lines 39 - 46, Update the return
object in useTeamsApi to treat an active background refetch without a team as
loading by setting isLoading from query.isLoading or query.isFetching && !team,
and require !query.isFetching alongside the existing notFound conditions.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
tests/test_component_update_visibility.py (1)

43-50: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Wire db.execute explicitly in _mock_db.

db is a bare AsyncMock, so await db.execute(...) returns a MagicMock, and result.scalar_one_or_none() returns a truthy sentinel. If the update route runs any query, the success-path tests in TestUnchangedUpdatesSucceed pass on that sentinel instead of on real behavior. The sibling helpers document this hazard and wire the result explicitly: _db_with_membership in tests/test_draft_workflow.py and _mock_db in tests/test_listing_detail_access.py.

Set db.execute to an AsyncMock with an explicit result, or to a side_effect sequence that raises on an unexpected query.

♻️ Proposed helper change
 def _mock_db():
     db = AsyncMock()
     db.add = MagicMock()
     db.commit = AsyncMock()
     db.flush = AsyncMock()
     db.refresh = AsyncMock()
     db.delete = AsyncMock()
+    # A bare AsyncMock hands back a truthy MagicMock for every query result.
+    # Fail loudly instead: these routes are expected to run no query here.
+    db.execute = AsyncMock(side_effect=AssertionError("unexpected query in the update route"))
     return db
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_component_update_visibility.py` around lines 43 - 50, Update the
_mock_db helper to explicitly configure db.execute with a realistic result or a
side-effect sequence that raises on unexpected queries, ensuring update-route
tests validate actual query behavior instead of truthy AsyncMock sentinels.
tests/test_registry_visibility.py (1)

561-571: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Assert the review metadata on the never-approved path too.

_seed_team_private_component sets reviewed_by and reviewed_at on every seeded version, including the draft, pending, and rejected cases. test_team_owner_making_a_listing_public_sends_it_back_to_review asserts that both fields are cleared, but this test does not. A route that clears review metadata only on the approved branch, or that leaves stale team-level approval metadata on a never-approved listing, still passes here.

💚 Proposed assertion
         assert result["returned_to_review"] is False
         assert result["status"] == status.value
+
+        async with sessions() as session:
+            listing = await session.get(McpListing, seed.listing_id)
+            version = await session.get(McpVersion, listing.latest_version_id)
+            assert version.status == status
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_registry_visibility.py` around lines 561 - 571, Extend
test_never_approved_listing_is_not_moved_when_it_becomes_public to assert that
the listing’s review metadata is cleared after _patch_visibility: verify
reviewed_by and reviewed_at are both absent or null, while preserving the
existing returned_to_review and status assertions for draft, pending, and
rejected cases.
tests/test_team_publishing.py (1)

34-40: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Set org_id explicitly on the mock user.

_user builds a MagicMock(spec=User) without org_id. The spec allows the attribute, so any read returns a truthy MagicMock. If resolve_publish_target or a submit route branches on org scoping, the tests take the org branch by accident. The sibling helpers in this PR set the field explicitly: tests/test_component_update_visibility.py line 39, tests/test_listing_detail_access.py line 37, and tests/test_preview_config.py line 30.

♻️ Proposed helper change
 def _user(role=UserRole.user, **kw):
     u = MagicMock(spec=User)
     u.id = kw.get("id", uuid.uuid4())
     u.role = role
     u.username = kw.get("username", "testuser")
     u.email = kw.get("email", "test@example.com")
+    # A bare spec'd attribute is truthy, which would put every caller in an org.
+    u.org_id = kw.get("org_id")
     return u
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_team_publishing.py` around lines 34 - 40, Update the _user test
helper to explicitly assign org_id on the User mock, using a kwarg override with
an appropriate default consistent with the sibling helpers, so org-scoping logic
does not observe an implicit truthy MagicMock.
tests/test_agent_composition.py (1)

1218-1232: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Document or align visibility-change status codes.

Agent routes return 422; component draft routes return 400 for this rejection. Record the intended API distinction or use one status code across both route families.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_agent_composition.py` around lines 1218 - 1232, Align the
visibility-change rejection status code between the agent route tested by
test_visibility_change_is_rejected and the corresponding component draft routes,
choosing either the existing 422/400 convention consistently or documenting the
intentional distinction in the relevant API contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@observal-server/models/sandbox.py`:
- Around line 36-38: Update the team deletion flow in delete_team to handle
listings belonging to the deleted team instead of leaving private listings with
team_id=NULL and visibility "team": block deletion or archive/reassign those
listings, preserving valid access semantics. Add regression coverage for the
team-deletion transition and ensure visibility and membership checks remain
consistent.

---

Nitpick comments:
In `@tests/test_agent_composition.py`:
- Around line 1218-1232: Align the visibility-change rejection status code
between the agent route tested by test_visibility_change_is_rejected and the
corresponding component draft routes, choosing either the existing 422/400
convention consistently or documenting the intentional distinction in the
relevant API contract.

In `@tests/test_component_update_visibility.py`:
- Around line 43-50: Update the _mock_db helper to explicitly configure
db.execute with a realistic result or a side-effect sequence that raises on
unexpected queries, ensuring update-route tests validate actual query behavior
instead of truthy AsyncMock sentinels.

In `@tests/test_registry_visibility.py`:
- Around line 561-571: Extend
test_never_approved_listing_is_not_moved_when_it_becomes_public to assert that
the listing’s review metadata is cleared after _patch_visibility: verify
reviewed_by and reviewed_at are both absent or null, while preserving the
existing returned_to_review and status assertions for draft, pending, and
rejected cases.

In `@tests/test_team_publishing.py`:
- Around line 34-40: Update the _user test helper to explicitly assign org_id on
the User mock, using a kwarg override with an appropriate default consistent
with the sibling helpers, so org-scoping logic does not observe an implicit
truthy MagicMock.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6812b322-a3c6-4c1d-9ff5-5a0bdc0600a0

📥 Commits

Reviewing files that changed from the base of the PR and between 58db12e and 93630aa.

📒 Files selected for processing (101)
  • docs/cli/agent.md
  • docs/cli/registry.md
  • observal-server/alembic/versions/018_team_publishing.py
  • observal-server/api/deps.py
  • observal-server/api/routes/_component_archive.py
  • observal-server/api/routes/admin/org.py
  • observal-server/api/routes/agent/crud.py
  • observal-server/api/routes/agent/draft.py
  • observal-server/api/routes/agent/helpers.py
  • observal-server/api/routes/agent/install.py
  • observal-server/api/routes/agent_versions.py
  • observal-server/api/routes/bulk.py
  • observal-server/api/routes/co_authors.py
  • observal-server/api/routes/component_source.py
  • observal-server/api/routes/component_versions.py
  • observal-server/api/routes/dashboard.py
  • observal-server/api/routes/exec_dashboard.py
  • observal-server/api/routes/feedback.py
  • observal-server/api/routes/hook.py
  • observal-server/api/routes/insights.py
  • observal-server/api/routes/mcp.py
  • observal-server/api/routes/preview.py
  • observal-server/api/routes/prompt.py
  • observal-server/api/routes/registry.py
  • observal-server/api/routes/review.py
  • observal-server/api/routes/sandbox.py
  • observal-server/api/routes/skill.py
  • observal-server/models/agent.py
  • observal-server/models/component_source.py
  • observal-server/models/hook.py
  • observal-server/models/mcp.py
  • observal-server/models/prompt.py
  • observal-server/models/sandbox.py
  • observal-server/models/skill.py
  • observal-server/schemas/agent.py
  • observal-server/schemas/component_source.py
  • observal-server/schemas/constants.py
  • observal-server/schemas/hook.py
  • observal-server/schemas/mcp.py
  • observal-server/schemas/prompt.py
  • observal-server/schemas/sandbox.py
  • observal-server/schemas/skill.py
  • observal-server/services/agent_builder.py
  • observal-server/services/agent_resolver.py
  • observal-server/services/insights/self_learn.py
  • observal-server/services/ownership.py
  • observal-server/services/teamspace.py
  • observal-server/tests/test_agent_pull.py
  • observal-server/tests/test_component_versions_api.py
  • observal-server/tests/test_registry_namespace.py
  • observal-server/tests/test_teamspace.py
  • observal_cli/client.py
  • observal_cli/cmd_agent.py
  • observal_cli/cmd_hook.py
  • observal_cli/cmd_mcp.py
  • observal_cli/cmd_migrate.py
  • observal_cli/cmd_prompt.py
  • observal_cli/cmd_sandbox.py
  • observal_cli/cmd_skill.py
  • observal_cli/skills/observal-agents/SKILL.md
  • observal_cli/skills/observal-registry/SKILL.md
  • observal_cli/skills/observal/SKILL.md
  • tests/test_agent_composition.py
  • tests/test_co_authors.py
  • tests/test_component_update_visibility.py
  • tests/test_dashboard_leaderboard.py
  • tests/test_draft_workflow.py
  • tests/test_insights_access.py
  • tests/test_insights_agent_lookup.py
  • tests/test_listing_detail_access.py
  • tests/test_listing_version_flush.py
  • tests/test_preview_config.py
  • tests/test_pull_and_agent_cli.py
  • tests/test_registry_types.py
  • tests/test_registry_visibility.py
  • tests/test_sec009_component_source_ownership.py
  • tests/test_sec009_org_scoping.py
  • tests/test_team_publishing.py
  • tests/test_team_review.py
  • tests/test_team_visibility_migrations.py
  • web/src/components/registry/agent-edit-form.tsx
  • web/src/components/registry/component-picker.tsx
  • web/src/components/registry/submit-component-dialog.tsx
  • web/src/hooks/use-api.ts
  • web/src/hooks/use-registry-api.ts
  • web/src/hooks/use-review-api.ts
  • web/src/hooks/use-teams-api.ts
  • web/src/lib/api.ts
  • web/src/lib/types/registry.ts
  • web/src/pages/registry/agents/builder.tsx
  • web/src/pages/registry/agents/detail.tsx
  • web/src/pages/registry/agents/index.tsx
  • web/src/pages/registry/components/detail.tsx
  • web/src/pages/registry/components/index.tsx
  • web/src/pages/registry/teamspace-detail.tsx
  • web/src/pages/registry/teamspaces.tsx
  • web/src/routeTree.gen.ts
  • web/src/routes/_authed/agents/index.tsx
  • web/src/routes/_authed/components/index.tsx
  • web/src/routes/_authed/teamspaces.$handle.tsx
  • web/src/routes/_authed/teamspaces.tsx
💤 Files with no reviewable changes (5)
  • observal-server/services/insights/self_learn.py
  • observal-server/api/routes/bulk.py
  • observal-server/api/routes/admin/org.py
  • observal-server/services/ownership.py
  • observal-server/api/routes/exec_dashboard.py
🚧 Files skipped from review as they are similar to previous changes (86)
  • observal-server/tests/test_registry_namespace.py
  • observal-server/schemas/constants.py
  • web/src/routes/_authed/components/index.tsx
  • web/src/routes/_authed/teamspaces.tsx
  • observal-server/services/agent_builder.py
  • web/src/routes/_authed/teamspaces.$handle.tsx
  • observal_cli/skills/observal-registry/SKILL.md
  • tests/test_co_authors.py
  • docs/cli/agent.md
  • observal_cli/cmd_skill.py
  • docs/cli/registry.md
  • observal-server/api/routes/co_authors.py
  • web/src/components/registry/component-picker.tsx
  • web/src/pages/registry/teamspace-detail.tsx
  • tests/test_listing_version_flush.py
  • observal-server/models/hook.py
  • observal_cli/skills/observal/SKILL.md
  • observal-server/api/routes/insights.py
  • observal-server/api/routes/component_source.py
  • observal-server/schemas/agent.py
  • observal_cli/client.py
  • web/src/pages/registry/agents/index.tsx
  • web/src/pages/registry/components/detail.tsx
  • observal-server/models/skill.py
  • web/src/lib/types/registry.ts
  • observal_cli/cmd_migrate.py
  • web/src/lib/api.ts
  • observal-server/api/routes/agent/helpers.py
  • observal-server/schemas/prompt.py
  • observal-server/api/routes/component_versions.py
  • observal-server/schemas/skill.py
  • tests/test_insights_agent_lookup.py
  • web/src/pages/registry/components/index.tsx
  • observal-server/tests/test_agent_pull.py
  • observal_cli/skills/observal-agents/SKILL.md
  • tests/test_dashboard_leaderboard.py
  • web/src/hooks/use-teams-api.ts
  • observal-server/models/component_source.py
  • web/src/hooks/use-registry-api.ts
  • web/src/routes/_authed/agents/index.tsx
  • observal_cli/cmd_agent.py
  • observal-server/api/routes/feedback.py
  • web/src/hooks/use-review-api.ts
  • observal-server/services/agent_resolver.py
  • observal-server/api/routes/registry.py
  • observal-server/alembic/versions/018_team_publishing.py
  • tests/test_pull_and_agent_cli.py
  • observal-server/models/agent.py
  • tests/test_sec009_org_scoping.py
  • tests/test_insights_access.py
  • observal-server/api/routes/agent_versions.py
  • observal_cli/cmd_sandbox.py
  • observal-server/models/mcp.py
  • observal-server/tests/test_teamspace.py
  • observal_cli/cmd_prompt.py
  • observal-server/schemas/mcp.py
  • observal-server/models/prompt.py
  • observal-server/api/routes/dashboard.py
  • web/src/components/registry/agent-edit-form.tsx
  • observal-server/api/routes/prompt.py
  • observal-server/api/routes/sandbox.py
  • observal-server/api/routes/preview.py
  • observal-server/api/routes/agent/install.py
  • observal-server/schemas/hook.py
  • observal-server/api/routes/hook.py
  • observal_cli/cmd_hook.py
  • observal_cli/cmd_mcp.py
  • web/src/hooks/use-api.ts
  • web/src/pages/registry/agents/builder.tsx
  • web/src/pages/registry/agents/detail.tsx
  • observal-server/api/routes/mcp.py
  • observal-server/schemas/sandbox.py
  • web/src/components/registry/submit-component-dialog.tsx
  • observal-server/api/routes/_component_archive.py
  • observal-server/api/routes/skill.py
  • web/src/routeTree.gen.ts
  • observal-server/api/routes/agent/draft.py
  • observal-server/tests/test_component_versions_api.py
  • tests/test_team_visibility_migrations.py
  • web/src/pages/registry/teamspaces.tsx
  • observal-server/schemas/component_source.py
  • tests/test_sec009_component_source_ownership.py
  • observal-server/api/routes/agent/crud.py
  • observal-server/services/teamspace.py
  • observal-server/api/routes/review.py
  • observal-server/api/deps.py

Comment thread observal-server/models/sandbox.py
The team_id foreign keys are ON DELETE SET NULL, so deleting a teamspace did
not delete its listings, it stripped their teamspace and left them behind with
is_private=True and team_id=NULL.

Reproduced against a live stack: the delete returned 204, the submitter kept
access through the personal-private clause, and every other member went from
200 to 404 with no warning. The listing still reported visibility "team" while
belonging to no team, and nothing could reattach it because setting team
visibility requires an existing teamspace.

Deleting a team also frees its handle, so a later registrant could claim the
namespace those listings still carry. Public listings therefore block the
delete too: changing visibility does not clear team_id, so only transferring or
deleting a listing actually detaches it, and the error says exactly that.

Co-Authored-By: Hari Srinivasan <harisrini21@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
tests/test_team_review.py (1)

596-664: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Deletion-guard tests match the current implementation but don't cover the soft-delete gap.

TestTeamDeletionGuard correctly validates the six-model count loop and the 409/204 branching against the current _team_owned_listing_counts and delete_team implementation. These tests mock db.scalar generically, so they cannot surface the missing deleted_at filter on the Agent count flagged in observal-server/api/routes/teams.py (Lines 205-232): a soft-deleted-only agent would still count as "owned" and block deletion in production, but this mock-based test can't distinguish that from a real filtered query. Once that fix lands, add a test that seeds Agent.deleted_at as set and asserts the query still returns 0 (or asserts the actual WHERE clause via a real query against the in-memory DB used earlier in this file, rather than a plain AsyncMock).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_team_review.py` around lines 596 - 664, Add coverage in
TestTeamDeletionGuard for a team containing only a soft-deleted Agent: seed
Agent.deleted_at as set and verify _team_owned_listing_counts or the delete_team
flow treats the count as zero, preferably using the file’s in-memory database or
by inspecting the generated query’s WHERE clause rather than generic db.scalar
mocking. Keep the existing deletion-guard tests unchanged.
tests/e2e/teamspace-review.spec.ts (2)

140-160: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The confirmation dialog's "confirm" path is untested.

This test only exercises the Cancel branch: it opens the dialog, verifies its copy, then cancels and asserts is_private stays true. There is no assertion for the accept path, where confirming should flip is_private to false and route the listing into public review. Since the dialog exists specifically to warn about that state transition, add a companion test (or extend this one) that clicks the confirm button and asserts the resulting is_private/review state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/teamspace-review.spec.ts` around lines 140 - 160, Extend the
teamspace visibility test around the alertdialog to exercise the confirm path,
using the dialog’s confirm/accept button rather than the existing cancel
control. After confirmation, fetch the seeded listing and assert is_private
becomes false and the listing enters the expected public-review state, while
preserving the existing dialog-copy and cancellation coverage.

113-160: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Seeded skill listing is never cleaned up.

The "visibility confirmation" test creates a new skill via POST /api/v1/skills/submit on every run (e2e-vis-${Date.now()}) but never deletes it afterward. Repeated CI runs against the same environment will accumulate orphaned team-private skill listings tied to TEAM_HANDLE. Add an afterEach/finally cleanup call (or a delete API call) once the assertions complete.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/teamspace-review.spec.ts` around lines 113 - 160, The visibility
confirmation test leaves the skill created by the POST request in the
environment. Track seeded.id and add cleanup after the test, using the
authenticated owner token to delete the seeded listing via the appropriate
skills delete API, ensuring cleanup runs even when assertions fail.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@observal-server/api/routes/teams.py`:
- Around line 205-232: Update _team_owned_listing_counts so Agent counts include
only active rows by adding a deleted_at IS NULL condition to the Agent query,
while preserving the existing team_id filtering and counting behavior for all
other listing models.
- Around line 241-266: Update delete_team’s team-loading path to acquire a
row-level SELECT FOR UPDATE lock on the team via _load_team, keeping that lock
held through _team_owned_listing_counts, db.delete, and transaction commit.
Ensure concurrent team-scoped listing creation cannot insert between the
ownership check and deletion, while preserving the existing conflict response.

---

Nitpick comments:
In `@tests/e2e/teamspace-review.spec.ts`:
- Around line 140-160: Extend the teamspace visibility test around the
alertdialog to exercise the confirm path, using the dialog’s confirm/accept
button rather than the existing cancel control. After confirmation, fetch the
seeded listing and assert is_private becomes false and the listing enters the
expected public-review state, while preserving the existing dialog-copy and
cancellation coverage.
- Around line 113-160: The visibility confirmation test leaves the skill created
by the POST request in the environment. Track seeded.id and add cleanup after
the test, using the authenticated owner token to delete the seeded listing via
the appropriate skills delete API, ensuring cleanup runs even when assertions
fail.

In `@tests/test_team_review.py`:
- Around line 596-664: Add coverage in TestTeamDeletionGuard for a team
containing only a soft-deleted Agent: seed Agent.deleted_at as set and verify
_team_owned_listing_counts or the delete_team flow treats the count as zero,
preferably using the file’s in-memory database or by inspecting the generated
query’s WHERE clause rather than generic db.scalar mocking. Keep the existing
deletion-guard tests unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7f9d5bde-186a-42cc-809f-71c28dcf523d

📥 Commits

Reviewing files that changed from the base of the PR and between 93630aa and 36fe84a.

📒 Files selected for processing (3)
  • observal-server/api/routes/teams.py
  • tests/e2e/teamspace-review.spec.ts
  • tests/test_team_review.py

Comment thread observal-server/api/routes/teams.py
Comment on lines 241 to +266
team = await _require_owner_or_admin(db, team_id, current_user)

# Refuse while the teamspace still owns listings. ON DELETE SET NULL would
# otherwise leave every one of them with is_private=True and team_id=NULL:
# the membership check can no longer match anybody, so each listing silently
# collapses to creator-only access while still reporting visibility "team",
# and no member other than its original submitter can reach it again. The
# handle also becomes free to claim, which would hand those listings'
# namespace to whoever registers it next. Make the owner deal with the
# listings first rather than destroying access as a side effect.
owned = await _team_owned_listing_counts(db, team.id)
if owned:
# Public listings block too. Changing visibility does not clear team_id, and
# deleting the team frees the handle for anyone to claim, which would hand a
# new owner the namespace those listings still carry. Only transferring or
# deleting them actually detaches them, so the message says exactly that.
detail = ", ".join(f"{count} {label}" for label, count in sorted(owned.items()))
raise HTTPException(
status_code=409,
detail=(
f"Teamspace '{team.handle}' still owns {detail}. Transfer or delete those listings before "
"deleting the teamspace, otherwise their members lose access and the namespace is "
"left claimable by someone else."
),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- candidate files ---'
fd -i 'teams.py|.*(team|listing).*\.py$' . | head -80
printf '%s\n' '--- relevant symbols and foreign keys ---'
rg -n -C 3 '_team_owned_listing_counts|_load_team|delete_team|team_id|ondelete' observal-server --glob '*.py' | head -300

Repository: Observal/Observal

Length of output: 20180


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- teams route ---'
sed -n '1,330p' observal-server/api/routes/teams.py
printf '%s\n' '--- team model and listing model declarations ---'
cat -n observal-server/models/team.py
rg -n -C 5 'ForeignKey\("teams\.id"|class .*Listing|team_id' observal-server/models --glob '*.py' | head -500
printf '%s\n' '--- listing creation/update paths ---'
rg -n -C 5 'team_id|publish|create.*listing|Listing\(' observal-server/api/routes --glob '*.py' | head -500

Repository: Observal/Observal

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- team publish resolution ---'
ast-grep outline observal-server/services/teamspace.py
sed -n '1,300p' observal-server/services/teamspace.py
printf '%s\n' '--- all direct team-scoped listing writes ---'
rg -n -C 8 'team_id\s*=|team_id:' observal-server/api/routes observal-server/services --glob '*.py' \
  | rg -n 'team_id|listing|Listing|resolve_publish_target|db\.(add|flush|commit)' | head -400
printf '%s\n' '--- migration foreign keys and delete constraints ---'
rg -n -C 4 'team_id|teams|SET NULL|ForeignKeyConstraint' observal-server/alembic/versions/017_teams.py observal-server/alembic/versions/018_team_publishing.py
printf '%s\n' '--- relevant tests ---'
rg -n -C 8 'delete.*team|team.*delete|owned_listing|team_id.*team|SET NULL|orphan' observal-server/tests tests --glob '*.py' | head -400

Repository: Observal/Observal

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import ast
import re

route = Path("observal-server/api/routes/teams.py").read_text()
service = Path("observal-server/services/teamspace.py").read_text()

tree = ast.parse(route)
functions = {n.name: n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}

load = ast.get_source_segment(route, functions["_load_team"])
delete = ast.get_source_segment(route, functions["delete_team"])
counts = ast.get_source_segment(route, functions["_team_owned_listing_counts"])

print("load_team_uses_for_update:", "with_for_update" in load or "for_update" in load)
print("delete_order:", [
    "require_owner_or_admin" if "_require_owner_or_admin" in delete else None,
    "owned_counts" if "_team_owned_listing_counts" in delete else None,
    "db.delete" if "db.delete" in delete else None,
    "db.commit" if "db.commit" in delete else None,
])
print("counted_models:", re.findall(r"from models\.(\w+) import (\w+)", counts))
print("delete_fk_policy_mentions:", "ON DELETE SET NULL" in counts)

service_tree = ast.parse(service)
resolve = next(
    n for n in service_tree.body
    if isinstance(n, ast.AsyncFunctionDef) and n.name == "resolve_publish_target"
)
resolve_source = ast.get_source_segment(service, resolve)
print("publish_target_loads_team_without_lock:", "db.get(Team, team_id)" in resolve_source)
print("publish_target_writes_team_id:", "team_id=target.team_id" in route or "team_id=target.team_id" in service)

print("\nteam_id foreign-key declarations:")
for path in sorted(Path("observal-server/models").glob("*.py")):
    text = path.read_text()
    if 'ForeignKey("teams.id' in text:
        for line_no, line in enumerate(text.splitlines(), 1):
            if 'ForeignKey("teams.id' in line:
                print(f"{path}:{line_no}:{line.strip()}")
PY
printf '%s\n' '--- exact migration constraints for team_id ---'
rg -n -B 2 -A 3 'ForeignKeyConstraint\(\["team_id"\].*teams\.id|teams\.id.*ondelete="SET NULL"|team_id.*teams\.id' \
  observal-server/alembic/versions --glob '*.py'

Repository: Observal/Observal

Length of output: 2847


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- team publishing migration ---'
cat -n observal-server/alembic/versions/018_team_publishing.py
printf '%s\n' '--- publish transaction tails ---'
for f in observal-server/api/routes/{mcp,skill,hook,prompt,sandbox,component_source}.py observal-server/api/routes/agent/{draft,crud}.py; do
  if [ -f "$f" ]; then
    printf '\n--- %s ---\n' "$f"
    rg -n -A 18 -B 6 'resolve_publish_target|team_id=target\.team_id|db\.commit\(\)' "$f" | head -120
  fi
done
printf '%s\n' '--- component source schema and access behavior ---'
sed -n '1,150p' observal-server/api/routes/component_source.py

Repository: Observal/Observal

Length of output: 44292


🌐 Web query:

PostgreSQL official documentation SELECT FOR UPDATE conflicts with FOR KEY SHARE foreign key insert

💡 Result:

In PostgreSQL, the SELECT FOR UPDATE command conflicts with the FOR KEY SHARE lock implicitly acquired by foreign key operations, which leads to blocking [1][2]. When you perform an INSERT into a table with a foreign key constraint, PostgreSQL automatically acquires a FOR KEY SHARE lock on the referenced row in the parent table to ensure the referenced data remains valid [3][2]. The FOR UPDATE lock is an exclusive lock that prevents other transactions from modifying, deleting, or locking the same row [1][2]. Because FOR UPDATE is incompatible with FOR KEY SHARE, a transaction holding a FOR UPDATE lock on a parent row will block any concurrent transaction from inserting child rows that reference that parent row [1][2]. To avoid this contention, you can use FOR NO KEY UPDATE if you do not need to modify the columns that are part of a unique index or primary key [3][4]. FOR NO KEY UPDATE is a weaker lock that is compatible with FOR KEY SHARE, allowing child row inserts to proceed concurrently [1][2]. Summary of Locking Compatibility: - FOR UPDATE: Conflicts with FOR KEY SHARE (blocks inserts) [1][5]. - FOR NO KEY UPDATE: Compatible with FOR KEY SHARE (does not block inserts) [1][2].

Citations:


Serialize team deletion with team-scoped listing creation.

delete_team counts listings before db.delete(team), but _load_team does not lock the team row. A concurrent publish can insert a row after the count and before the delete commit. ON DELETE SET NULL then detaches that listing and recreates the orphaned-access scenario. Load the team with SELECT ... FOR UPDATE in the deletion transaction and hold the lock through the counts and commit. This blocks concurrent foreign-key inserts or makes them fail cleanly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/teams.py` around lines 241 - 266, Update
delete_team’s team-loading path to acquire a row-level SELECT FOR UPDATE lock on
the team via _load_team, keeping that lock held through
_team_owned_listing_counts, db.delete, and transaction commit. Ensure concurrent
team-scoped listing creation cannot insert between the ownership check and
deletion, while preserving the existing conflict response.

ShaanNarendran and others added 2 commits August 2, 2026 09:47
…owns

?team_id= reused the publish-scope predicate, so it returned every public
listing in the registry plus the team's private ones. Teamspace pages and the
CLI --team flag therefore showed other namespaces' listings as if the team
owned them: GET /skills?team_id=<acme> returned 80 rows, 5 of them belonging to
f3hooli, f3umbrella, f3initech, f3globex and ccs.

The two questions now have separate parameters:

  team_id                 ownership. What this teamspace published, nothing
                          else. Used by the teamspace page, the registry list
                          filter, and the CLI --team flag.
  composable_for_team_id  publish scope. Public listings plus this teamspace's
                          private ones, the set an agent published for that
                          target may contain. Used only by the agent builder's
                          component picker.

Caller visibility is still applied first either way, so a non-member passing a
teamspace id sees only that team's public rows.

CLI help and the bundled skill docs described the old widening behaviour and
now describe ownership.

Co-Authored-By: Hari Srinivasan <harisrini21@gmail.com>
…visibility

Six issues from review, all on paths the read-path probes never exercised.

resolve_listing now enforces visibility on every lookup, not just on ambiguous
bare names. Archive, unarchive, draft edit and the editing lock all resolve
through it and authorize with get_effective_component_permission, which answers
"owner" for the original submitter forever, so a user removed from a teamspace
could still mutate a listing they could no longer read.

Version endpoints no longer serve unapproved versions to callers who cannot
review or own them. A version carries the real payload, and a listing goes
public the moment its visibility changes while its versions return to the
queue, so without this an ordinary caller read content no reviewer had
accepted. review_publication_to_public now returns EVERY approved version to
review, not only the latest, because installs can pin any approved version.

The visibility endpoint's agent branch validated only the latest version's
components. It now covers every installable version, matching the rule the
component branch already applied.

Deleting a teamspace was a dead end: emptying it required a transfer, and
transfer refused every team-owned listing. Transfer now detaches, for a team
owner or admin only, clearing team_id, dropping team visibility, and returning
the listing to review because leaving a teamspace makes it public. The delete
guard also counts component sources and ignores soft-deleted agents.

The migration downgrade guard now also catches personal-private agents, which
lose is_private on downgrade and return public.

Preview gates on approval status as well as privacy, so a public but unapproved
component is no longer previewable by anyone.

The builder's local draft keeps its teamspace and visibility, so restoring one
no longer silently turns a team-private agent into a personal public one, and
validation reruns when the target changes.

The teamspace review tab now opens the same detail sheet the global queue uses,
so a reviewer inspects the prompt, command or script before approving instead
of deciding from summary text. Visibility controls render only for callers the
server accepts. The feedback summary resolves a listing's type from its
feedback rows instead of probing all six listing tables on an anonymous
endpoint hit on every registry detail render.

observal agent publish --update now refuses --team instead of ignoring it, and
applies visibility before the update so a refused publish cannot leave a
half-applied edit.

Co-Authored-By: Hari Srinivasan <harisrini21@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
observal-server/alembic/versions/018_team_publishing.py (1)

103-110: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Distinguish personal-private agents in the downgrade error.

When private_agents is non-zero, Line 104 adds a non-team-owned entry to owned. The error still says “team-owned listings” and instructs the operator to reassign rows because teamspace ownership is lost. A personal-private agent has no teamspace to reassign. Use remediation text that distinguishes team-owned rows from personal-private agents.

Proposed wording fix
-            "Cannot downgrade 018_team_publishing: team-owned listings still exist "
+            "Cannot downgrade 018_team_publishing: protected registry rows still exist "
             f"({detail}). Reassign or delete them before rolling back, otherwise "
-            "their teamspace ownership is lost and a later re-upgrade cannot restore it."
+            "their ownership or privacy state is lost and a later re-upgrade cannot restore it."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/alembic/versions/018_team_publishing.py` around lines 103 -
110, Update the downgrade error construction around the `owned` summary and
`private_agents` handling to distinguish team-owned listings from
personal-private agents. Keep the existing teamspace reassignment guidance for
team-owned rows, but provide separate remediation for personal-private agents
that does not suggest reassigning them to a teamspace.
observal-server/api/routes/co_authors.py (1)

196-207: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reorder the identity conflict check before the team-detach and review side effects.

The code mutates entity.team_id and entity.is_private, and calls review_publication_to_public, before checking identity_exists. If identity_exists returns True, the function raises HTTPException(409) after these side effects already ran.

AsyncSession autoflushes pending changes before a query by default, so the in-memory team_id/is_private changes are likely sent to the database as part of the identity_exists SELECT, before the conflict is known. review_publication_to_public may also produce side effects, such as a review-queue entry, that a later exception cannot undo. Move the identity_exists check before the team-detach and review-publication block, so a name conflict does not leave the entity partially detached from its team.

🐛 Proposed fix to reorder the conflict check
-    was_private = bool(getattr(entity, "is_private", False))
-    if team_id is not None:
-        entity.team_id = None
-        entity.is_private = False
-        await review_publication_to_public(entity, current_user, db, was_private=was_private)
-
-    model = ENTITY_MODELS[entity_type]
-    if await identity_exists(db, model, target_user.username, entity.slug, exclude_id=entity.id):
-        raise HTTPException(
-            status_code=409,
-            detail=f"{target_user.username}/{entity.slug} already exists",
-        )
+    model = ENTITY_MODELS[entity_type]
+    if await identity_exists(db, model, target_user.username, entity.slug, exclude_id=entity.id):
+        raise HTTPException(
+            status_code=409,
+            detail=f"{target_user.username}/{entity.slug} already exists",
+        )
+
+    was_private = bool(getattr(entity, "is_private", False))
+    if team_id is not None:
+        entity.team_id = None
+        entity.is_private = False
+        await review_publication_to_public(entity, current_user, db, was_private=was_private)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/co_authors.py` around lines 196 - 207, Move the
`identity_exists` conflict check using `ENTITY_MODELS` before the
`team_id`/`is_private` mutations and the `review_publication_to_public` call.
Preserve the existing 409 `HTTPException` response, and only execute the
team-detach and review-publication block after the identity is confirmed
available.
🧹 Nitpick comments (1)
observal-server/api/routes/component_versions.py (1)

98-100: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated version-visibility filter into a helper.

_list_versions, _get_version, and _review_version each build version_filters with the same two lines: append the listing filter, then append version_model.status == ListingStatus.approved unless may_view_unapproved is true. Extract this into a shared helper to avoid drift if the unapproved-visibility rule changes.

♻️ Proposed helper extraction
def _version_visibility_filters(listing, version_model, current_user, *extra):
    filters = list(extra)
    if not may_view_unapproved(get_effective_component_permission(listing, current_user), current_user):
        filters.append(version_model.status == ListingStatus.approved)
    return filters

Then in each function:

-    version_filters = [version_model.listing_id == listing.id]
-    if not may_view_unapproved(get_effective_component_permission(listing, current_user), current_user):
-        version_filters.append(version_model.status == ListingStatus.approved)
+    version_filters = _version_visibility_filters(listing, version_model, current_user, version_model.listing_id == listing.id)

Also applies to: 138-140, 282-284

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@observal-server/api/routes/component_versions.py` around lines 98 - 100,
Extract the shared version-visibility construction into a helper such as
_version_visibility_filters, accepting the listing, version model, current user,
and optional extra filters; ensure it includes listing.id matching and
conditionally appends approved status based on may_view_unapproved. Update
_list_versions, _get_version, and _review_version to use this helper while
preserving any function-specific filters.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@observal-server/alembic/versions/018_team_publishing.py`:
- Around line 103-110: Update the downgrade error construction around the
`owned` summary and `private_agents` handling to distinguish team-owned listings
from personal-private agents. Keep the existing teamspace reassignment guidance
for team-owned rows, but provide separate remediation for personal-private
agents that does not suggest reassigning them to a teamspace.

In `@observal-server/api/routes/co_authors.py`:
- Around line 196-207: Move the `identity_exists` conflict check using
`ENTITY_MODELS` before the `team_id`/`is_private` mutations and the
`review_publication_to_public` call. Preserve the existing 409 `HTTPException`
response, and only execute the team-detach and review-publication block after
the identity is confirmed available.

---

Nitpick comments:
In `@observal-server/api/routes/component_versions.py`:
- Around line 98-100: Extract the shared version-visibility construction into a
helper such as _version_visibility_filters, accepting the listing, version
model, current user, and optional extra filters; ensure it includes listing.id
matching and conditionally appends approved status based on may_view_unapproved.
Update _list_versions, _get_version, and _review_version to use this helper
while preserving any function-specific filters.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9db7ae1d-aca1-4dc9-842e-88a5e8b7608e

📥 Commits

Reviewing files that changed from the base of the PR and between 36fe84a and 06813fc.

📒 Files selected for processing (33)
  • observal-server/alembic/versions/018_team_publishing.py
  • observal-server/api/deps.py
  • observal-server/api/routes/agent/crud.py
  • observal-server/api/routes/agent/draft.py
  • observal-server/api/routes/co_authors.py
  • observal-server/api/routes/component_versions.py
  • observal-server/api/routes/feedback.py
  • observal-server/api/routes/hook.py
  • observal-server/api/routes/mcp.py
  • observal-server/api/routes/preview.py
  • observal-server/api/routes/prompt.py
  • observal-server/api/routes/registry.py
  • observal-server/api/routes/sandbox.py
  • observal-server/api/routes/skill.py
  • observal-server/api/routes/teams.py
  • observal-server/services/teamspace.py
  • observal_cli/cmd_agent.py
  • observal_cli/cmd_hook.py
  • observal_cli/cmd_mcp.py
  • observal_cli/cmd_prompt.py
  • observal_cli/cmd_sandbox.py
  • observal_cli/cmd_skill.py
  • observal_cli/skills/observal-agents/SKILL.md
  • observal_cli/skills/observal-registry/SKILL.md
  • tests/test_co_authors.py
  • tests/test_registry_types.py
  • tests/test_sec009_org_scoping.py
  • tests/test_team_review.py
  • web/src/components/registry/component-picker.tsx
  • web/src/pages/registry/agents/builder.tsx
  • web/src/pages/registry/agents/detail.tsx
  • web/src/pages/registry/components/detail.tsx
  • web/src/pages/registry/teamspace-detail.tsx
🚧 Files skipped from review as they are similar to previous changes (27)
  • observal-server/api/routes/teams.py
  • web/src/components/registry/component-picker.tsx
  • observal_cli/cmd_skill.py
  • observal_cli/cmd_prompt.py
  • observal_cli/cmd_hook.py
  • web/src/pages/registry/teamspace-detail.tsx
  • observal_cli/skills/observal-agents/SKILL.md
  • observal_cli/skills/observal-registry/SKILL.md
  • web/src/pages/registry/components/detail.tsx
  • observal-server/api/routes/preview.py
  • web/src/pages/registry/agents/detail.tsx
  • observal_cli/cmd_sandbox.py
  • observal-server/api/routes/registry.py
  • observal-server/api/routes/feedback.py
  • tests/test_team_review.py
  • tests/test_registry_types.py
  • observal_cli/cmd_mcp.py
  • observal_cli/cmd_agent.py
  • observal-server/api/routes/mcp.py
  • observal-server/api/routes/agent/draft.py
  • observal-server/api/routes/skill.py
  • observal-server/services/teamspace.py
  • web/src/pages/registry/agents/builder.tsx
  • observal-server/api/deps.py
  • tests/test_sec009_org_scoping.py
  • observal-server/api/routes/prompt.py
  • observal-server/api/routes/hook.py

Every observal team command called server_supports("teamspaces") first, which
fetched /api/v1/config/version and compared min(cli_version, server_version)
against a hardcoded minimum in features.py.

That minimum could only ever be wrong. Both versions come from this repository,
so the negotiated version is just this package's version and the gate reduces to
comparing a number against itself. It shipped naming 1.11.0 while teamspaces were
already in the 1.10.7 tree, so the check failed on every server that fully
supported the feature:

    $ observal team list
    Teamspaces are not supported by this server.

Nobody downgrades a server behind a CLI to a version predating teamspaces, and a
server that genuinely lacks the endpoints answers for itself. The gate, its eight
call sites, and the now-unreferenced feature entry are removed, and
web/src/lib/features.ts is regenerated through scripts/sync_features.py.

Co-Authored-By: Hari Srinivasan <harisrini21@gmail.com>
@Haz3-jolt
Haz3-jolt force-pushed the feat/team-publishing branch from 5e0c22e to 6705441 Compare August 2, 2026 10:42
ShaanNarendran and others added 2 commits August 2, 2026 11:05
…eable

Agent content bypassed publication review. _load_agent applied the approved-status
condition only on the name path, so a UUID or prefix returned an agent whose
version was pending, which is the state a team-private agent enters the moment it
is made public. The version list, diff and generated-config routes returned every
version regardless of status. Both now gate on status unless the caller owns the
agent or can review it, matching what component versions already did.

Teamspace deletion is now enforced by the database. The team_id foreign keys move
from ON DELETE SET NULL to RESTRICT in migration 019, so the check and the delete
are one statement and a publish landing mid-delete can no longer be orphaned. The
application count is demoted to a pre-check that exists only to name what is in
the way, and it counts soft-deleted agents again: they are restorable and still
carry the namespace, so freeing the handle while one exists lets a new team
inherit it.

Transfer was authorized in the wrong order. _get_entity_for_transfer refused
anyone who was not the original submitter before the teamspace rule was
consulted, so a team owner could not move a member's listing and a teamspace
could never be emptied. Authority now follows the listing: a team-owned listing
answers to its teamspace owners and admins, a personal one to its owner.
Transferring a team listing to yourself is allowed, since that is how a teamspace
is emptied.

Co-author management resolved entities with a raw db.get, bypassing visibility
entirely, so a removed member kept write access to an item they could no longer
read. It resolves through resolve_listing now. The /my routes selected purely on
the author column for the same reason and are visibility-scoped too.

Migration 018's downgrade guard excluded component_sources on the claim that they
follow their listing. They have no listing relationship, only their own team_id
and is_public, so dropping team_id strands them the same way. They are guarded.

Review write routes answer 404 rather than 403 for a team-private item outside
the caller's scope, so a refusal no longer confirms the item exists and is
private. Public refusals still say why, since a public item is discoverable
anyway.

observal agent publish now refuses --visibility with --update. They are two
server calls with no atomic form, so ordering them only chooses which half is
left applied when one fails.

The component submit dialog resets its teamspace and visibility on close, so the
next submission cannot inherit the previous target.

Co-Authored-By: Hari Srinivasan <harisrini21@gmail.com>
@Haz3-jolt
Haz3-jolt force-pushed the feat/team-publishing branch from 2abd105 to bba894a Compare August 2, 2026 11:39
@Haz3-jolt
Haz3-jolt merged commit 18c0e4b into Observal:main Aug 2, 2026
24 checks passed
@Haz3-jolt
Haz3-jolt deleted the feat/team-publishing branch August 2, 2026 11:47
@Haz3-jolt Haz3-jolt mentioned this pull request Aug 2, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli CLI changes server Pull request touches server code tests Pull request adds or modifies tests web Pull request touches web frontend code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants