Skip to content

feat: backend CRUD + multi-user permissions (#1081, #1082)#1094

Merged
Aureliolo merged 7 commits intomainfrom
feat/backend-crud-multi-user-permissions
Apr 6, 2026
Merged

feat: backend CRUD + multi-user permissions (#1081, #1082)#1094
Aureliolo merged 7 commits intomainfrom
feat/backend-crud-multi-user-permissions

Conversation

@Aureliolo
Copy link
Copy Markdown
Owner

Summary

Implements the 9 missing CRUD endpoints for company/departments/agents (#1081) and the multi-user ownership + department-admin permission system (#1082).

Backend

  • 9 CRUD endpoints: PATCH /company, POST/PATCH/DELETE /departments, POST /company/reorder-departments, POST/PATCH/DELETE /agents, POST /departments/{name}/reorder-agents
  • OrgRole permission system: owner, department_admin, editor, viewer roles with require_org_mutation guard factory (department-scoped for dept admins, backward-compatible fallback to HumanRole)
  • OrgMutationService: read-modify-write service layer with asyncio.Lock serialization
  • ETag/If-Match optimistic concurrency on all PATCH endpoints
  • Referential integrity: 409 on delete dept with agents, 409 on delete c-suite agent
  • Auto-promote: first user auto-promoted to owner on startup
  • Role management: grant/revoke org roles on users (CEO-only, with TOCTOU locks)
  • WebSocket broadcast: all mutations emit domain events + WS events on company/departments/agents channels
  • SQLite migration: org_roles + scoped_departments columns with SQL injection hardening

Frontend

Tests (37 new)

  • test_org_role_guards.py: 11 tests for require_org_mutation guard (owner/editor/dept_admin/viewer/fallback)
  • test_user_org_roles.py: 13 tests for grant/revoke endpoints (happy path, 409, 422, 404)
  • test_auto_promote.py: 4 tests for first-owner auto-promotion
  • ETag 412 tests, CEO deletion 409 test, test quality fixes

Review coverage

Pre-reviewed by 18 specialized agents (code, security, persistence, async, types, API contract, docs, frontend, tests). 49 findings addressed before first push.

Test plan

  • uv run python -m pytest tests/ -n 8 -- 14545 passed
  • npm --prefix web run test -- 2406 passed
  • uv run mypy src/ tests/ -- clean
  • npm --prefix web run type-check -- clean
  • uv run ruff check src/ tests/ -- clean
  • npm --prefix web run lint -- clean

Known remaining items

  • Users page UI for role editing (backend + API client ready, page component needed)
  • Design spec updates for OrgRole documentation (docs/design/operations.md)

Closes #1081
Closes #1082

Copilot AI review requested due to automatic review settings April 6, 2026 12:18
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 6, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 762a3764-bfdf-4a6c-8773-5965e9beac7a

📥 Commits

Reviewing files that changed from the base of the PR and between 3b3e5b4 and 6b6627f.

📒 Files selected for processing (71)
  • CLAUDE.md
  • site/src/components/islands/ComparisonTable.tsx
  • site/src/components/islands/DashboardPreview.tsx
  • site/src/components/islands/mini/AgentDetailMini.tsx
  • site/src/components/islands/mini/BudgetMini.tsx
  • site/src/components/islands/mini/OrgChartMini.tsx
  • site/src/components/islands/mini/TaskBoardMini.tsx
  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/models.py
  • src/synthorg/api/channels.py
  • src/synthorg/api/controllers/agents.py
  • src/synthorg/api/controllers/company.py
  • src/synthorg/api/controllers/departments.py
  • src/synthorg/api/controllers/users.py
  • src/synthorg/api/dto_org.py
  • src/synthorg/api/guards.py
  • src/synthorg/api/services/__init__.py
  • src/synthorg/api/services/org_mutations.py
  • src/synthorg/api/state.py
  • src/synthorg/api/ws_models.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/persistence/sqlite/migrations.py
  • src/synthorg/persistence/sqlite/schema.sql
  • src/synthorg/persistence/sqlite/user_repo.py
  • tests/unit/api/controllers/test_agent_mutations.py
  • tests/unit/api/controllers/test_company_mutations.py
  • tests/unit/api/controllers/test_department_mutations.py
  • tests/unit/api/controllers/test_user_org_roles.py
  • tests/unit/api/services/__init__.py
  • tests/unit/api/services/test_org_mutations.py
  • tests/unit/api/test_auto_promote.py
  • tests/unit/api/test_channels.py
  • tests/unit/api/test_org_role_guards.py
  • web/src/__tests__/App.test.tsx
  • web/src/__tests__/components/layout/Sidebar.test.tsx
  • web/src/__tests__/hooks/useAuth.test.ts
  • web/src/__tests__/pages/org-edit/AgentCreateDialog.test.tsx
  • web/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsx
  • web/src/__tests__/pages/org-edit/DepartmentCreateDialog.test.tsx
  • web/src/__tests__/pages/org-edit/DepartmentEditDrawer.test.tsx
  • web/src/__tests__/pages/org-edit/GeneralTab.test.tsx
  • web/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsx
  • web/src/__tests__/router/guards.test.tsx
  • web/src/__tests__/stores/auth.test.ts
  • web/src/__tests__/stores/company.test.ts
  • web/src/api/endpoints/company.ts
  • web/src/api/endpoints/users.ts
  • web/src/api/types.ts
  • web/src/components/layout/Sidebar.stories.tsx
  • web/src/hooks/useOrgChartData.ts
  • web/src/pages/OrgChartPage.tsx
  • web/src/pages/OrgEditPage.tsx
  • web/src/pages/org-edit/AgentCreateDialog.tsx
  • web/src/pages/org-edit/AgentEditDrawer.tsx
  • web/src/pages/org-edit/AgentsTab.tsx
  • web/src/pages/org-edit/DepartmentCreateDialog.stories.tsx
  • web/src/pages/org-edit/DepartmentCreateDialog.tsx
  • web/src/pages/org-edit/DepartmentEditDrawer.tsx
  • web/src/pages/org-edit/DepartmentsTab.tsx
  • web/src/pages/org-edit/GeneralTab.tsx
  • web/src/pages/org-edit/YamlEditorPanel.tsx
  • web/src/pages/org-edit/coming-soon.ts
  • web/src/pages/org/DepartmentGroupNode.tsx
  • web/src/pages/org/DeptAdminNode.stories.tsx
  • web/src/pages/org/DeptAdminNode.tsx
  • web/src/pages/org/OwnerNode.tsx
  • web/src/pages/org/build-org-tree.ts
  • web/src/stores/auth.ts
  • web/src/stores/company.ts

Walkthrough

Adds org-scoped roles and scoped-department support to auth models, middleware, and responses; introduces OrgRole (OWNER, DEPARTMENT_ADMIN, EDITOR, VIEWER) and scoped_departments. Implements a require_org_mutation guard and a startup helper to auto-promote the first owner. Introduces immutable org mutation DTOs, an OrgMutationService (serialized mutations, ETag semantics, validations), new controller mutation endpoints for company/departments/agents, new WebSocket channels/events and observability constants, SQLite migration and repo support for user role fields, frontend API/type/UI updates, and extensive unit tests.

Suggested labels

autorelease: tagged


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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 6, 2026

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA 6b6627f.
Ensure that dependencies are being submitted on PR branches. Re-running this action after a short time may resolve the issue. See the documentation for more information and troubleshooting advice.

Scanned Files

None

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements comprehensive CRUD mutation endpoints for company, department, and agent configurations, along with a new role-based access control system for organization configuration. Key changes include the introduction of an OrgMutationService to handle atomic updates, new API DTOs, and updated persistence logic to support org_roles and scoped_departments. I have identified several critical issues, including invalid Python 2 syntax for exception handling, missing cleanup logic for scoped departments when revoking roles, and stale UI code referencing removed API fields. Please address these issues before merging.

Comment on lines +340 to +341
except MemoryError, RecursionError:
raise
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

The exception handling syntax except MemoryError, RecursionError: is from Python 2. In Python 3, multiple exceptions must be caught as a tuple: except (MemoryError, RecursionError):. This will cause a SyntaxError at runtime. The same issue exists on lines 368-369.

    except (MemoryError, RecursionError):
        raise

Comment on lines +523 to +528
updated = user.model_copy(
update={
"org_roles": new_roles,
"updated_at": now,
},
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

When revoking the DEPARTMENT_ADMIN role, the associated scoped_departments are not cleared. This will cause a ValueError from the _validate_scoped_departments model validator in the User model if the user has any scoped departments. You should clear scoped_departments when this specific role is revoked.

            updates = {
                "org_roles": new_roles,
                "updated_at": now,
            }
            if org_role == OrgRole.DEPARTMENT_ADMIN:
                updates["scoped_departments"] = ()
            updated = user.model_copy(
                update=updates,
            )

const handleSubmit = useCallback(async () => {
const next: Partial<Record<keyof FormState, string>> = {}
if (!form.name.trim()) next.name = 'Name is required'
if (!form.display_name.trim()) next.display_name = 'Display name is required'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The validation checks for display_name, but this field has been removed from the CreateDepartmentRequest API. This will cause a runtime error because form.display_name is not a valid property on the form state. The validation for display_name and its corresponding InputField in the JSX should be removed.

Comment on lines 63 to 67
await onUpdate(department.name, {
display_name: displayName.trim() || undefined,
budget_percent: Number.isFinite(pct) ? pct : undefined,
ceremony_policy: ceremonyPolicy,
})
onClose()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The onUpdate call no longer includes display_name, which is correct as it has been removed from the API. However, the displayName state and its corresponding InputField are still present in this component. They should be removed to avoid confusion and dead code.

Comment on lines +120 to +126
@model_validator(mode="after")
def _validate_scoped_departments(self) -> AuthenticatedUser:
"""Reject non-empty scoped_departments without DEPARTMENT_ADMIN."""
if self.scoped_departments and OrgRole.DEPARTMENT_ADMIN not in self.org_roles:
msg = "scoped_departments requires DEPARTMENT_ADMIN in org_roles"
raise ValueError(msg)
return self
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

This _validate_scoped_departments validator is identical to the one in the User model (lines 60-66). To avoid code duplication and improve maintainability, consider extracting this logic into a standalone function and reusing it in both models with model_validator(mode="after").

Comment on lines +190 to +205
if if_match:
agents = await app_state.config_resolver.get_agents()
name_lower = agent_name.lower()
for agent in agents:
if agent.name.lower() == name_lower:
cur = json.dumps(
agent.model_dump(mode="json"),
sort_keys=True,
)
cur_etag = compute_etag(cur, "")
check_if_match(
if_match,
cur_etag,
f"agent:{agent_name}",
)
break
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The logic for checking the If-Match ETag is duplicated across update_agent (here), update_company (src/synthorg/api/controllers/company.py), and update_department (src/synthorg/api/controllers/departments.py). Consider refactoring this into a reusable dependency or a helper function to reduce code duplication and centralize the ETag validation logic. This would make the controllers cleaner and easier to maintain.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 6, 2026

Codecov Report

❌ Patch coverage is 82.67420% with 92 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.29%. Comparing base (35977c0) to head (6b6627f).
⚠️ Report is 3 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/synthorg/api/services/org_mutations.py 88.02% 11 Missing and 9 partials ⚠️
src/synthorg/persistence/sqlite/migrations.py 41.66% 11 Missing and 3 partials ⚠️
src/synthorg/api/auth/models.py 50.00% 8 Missing and 4 partials ⚠️
src/synthorg/api/controllers/departments.py 76.74% 9 Missing and 1 partial ⚠️
src/synthorg/api/controllers/users.py 85.07% 9 Missing and 1 partial ⚠️
src/synthorg/persistence/sqlite/user_repo.py 44.44% 8 Missing and 2 partials ⚠️
src/synthorg/api/dto_org.py 85.24% 7 Missing and 2 partials ⚠️
src/synthorg/api/app.py 77.77% 6 Missing ⚠️
src/synthorg/api/state.py 90.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1094      +/-   ##
==========================================
- Coverage   89.35%   89.29%   -0.06%     
==========================================
  Files         768      770       +2     
  Lines       45213    45719     +506     
  Branches     4547     4597      +50     
==========================================
+ Hits        40399    40826     +427     
- Misses       3989     4048      +59     
- Partials      825      845      +20     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements the missing org-configuration write APIs (company/departments/agents) and introduces org-level multi-user permissions (OrgRole) with WebSocket events, persistence/migrations, and corresponding frontend/store updates to enable previously gated UI actions.

Changes:

  • Added backend mutation endpoints + OrgMutationService with optimistic concurrency (ETag/If-Match) and WS broadcasts.
  • Introduced OrgRole/scoped department permissions with grant/revoke endpoints, auto-promote-first-owner startup logic, and SQLite persistence.
  • Updated frontend API types/endpoints, stores, and UI to remove “coming soon” gates and handle new WS/mutation flows.

Reviewed changes

Copilot reviewed 57 out of 59 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
web/src/stores/company.ts Refetch-on-WS and updated mutation handling for org config
web/src/stores/auth.ts DEV auth user includes org_roles/scoped_departments
web/src/pages/OrgEditPage.tsx Removed read-only banner/gates
web/src/pages/OrgChartPage.tsx Re-enabled drag for hierarchy view
web/src/pages/org/OwnerNode.tsx Owner node doc updated for OrgRole owners
web/src/pages/org/DeptAdminNode.tsx New dept-admin node component
web/src/pages/org/DeptAdminNode.stories.tsx Storybook coverage for DeptAdminNode
web/src/pages/org/DepartmentGroupNode.tsx “Add agent” affordance ungated/styling changed
web/src/pages/org/build-org-tree.ts Adds dept admin node emission + new input shape
web/src/pages/org-edit/YamlEditorPanel.tsx YAML editor editable + save enabled based on dirty
web/src/pages/org-edit/GeneralTab.tsx Save enabled when dirty/saving
web/src/pages/org-edit/DepartmentsTab.tsx Department create/reorder ungated + wiring to create dialog
web/src/pages/org-edit/DepartmentEditDrawer.tsx Save/Delete ungated; payload adjusted
web/src/pages/org-edit/DepartmentCreateDialog.tsx Create dialog now submits to backend
web/src/pages/org-edit/DepartmentCreateDialog.stories.tsx Storybook args updated for onCreate
web/src/pages/org-edit/coming-soon.ts Removed “coming soon” constants module
web/src/pages/org-edit/AgentsTab.tsx Add Agent ungated; drag prop removed
web/src/pages/org-edit/AgentEditDrawer.tsx Save/Delete ungated; update payload adjusted
web/src/pages/org-edit/AgentCreateDialog.tsx Create Agent ungated
web/src/components/layout/Sidebar.stories.tsx Story user includes org_roles/scoped_departments
web/src/api/types.ts Adds OrgRole + updates mutation request/WS channel/type shapes
web/src/api/endpoints/users.ts New users/org-roles API client
web/src/api/endpoints/company.ts Mutation endpoints now real + updated response types
web/src/tests/stores/company.test.ts Updated store tests for new mutation return shapes
web/src/tests/pages/org-edit/YamlEditorPanel.test.tsx Updated for editable YAML + dirty-based save disablement
web/src/tests/pages/org-edit/GeneralTab.test.tsx Updated for dirty-based save enablement
web/src/tests/pages/org-edit/DepartmentEditDrawer.test.tsx Updated expectations for enabled actions
web/src/tests/pages/org-edit/DepartmentCreateDialog.test.tsx Updated expectations for enabled create
web/src/tests/pages/org-edit/AgentEditDrawer.test.tsx Updated expectations for enabled actions
web/src/tests/pages/org-edit/AgentCreateDialog.test.tsx Updated expectations for enabled create
tests/unit/api/test_org_role_guards.py New unit tests for require_org_mutation guard
tests/unit/api/test_channels.py Adds company/departments channels + count update
tests/unit/api/test_auto_promote.py New unit tests for first-owner auto-promotion
tests/unit/api/services/test_org_mutations.py New unit tests for OrgMutationService CRUD/reorder/update
tests/unit/api/controllers/test_user_org_roles.py New controller tests for org-role grant/revoke
tests/unit/api/controllers/test_department_mutations.py New controller tests for department mutations
tests/unit/api/controllers/test_company_mutations.py New controller tests for company mutations
tests/unit/api/controllers/test_agent_mutations.py New controller tests for agent mutations + ETag checks
src/synthorg/persistence/sqlite/user_repo.py Persist/deserialize org_roles + scoped_departments JSON columns
src/synthorg/persistence/sqlite/schema.sql Adds org_roles/scoped_departments columns to users
src/synthorg/persistence/sqlite/migrations.py Adds idempotent column-add migration helper + new columns
src/synthorg/observability/events/api.py Adds mutation event constants
src/synthorg/api/ws_models.py Adds WS event types for org mutations
src/synthorg/api/state.py Wires OrgMutationService into AppState
src/synthorg/api/services/org_mutations.py New service for serialized org config mutations
src/synthorg/api/guards.py Adds require_org_mutation OrgRole guard factory
src/synthorg/api/dto_org.py New request DTOs for org mutation endpoints
src/synthorg/api/controllers/users.py Adds org-role grant/revoke endpoints + response fields
src/synthorg/api/controllers/departments.py Adds department CRUD + reorder-agents + updated guards/ETag
src/synthorg/api/controllers/company.py Adds PATCH company + reorder-departments + WS/ETag logic
src/synthorg/api/controllers/agents.py Adds agent CRUD + WS/ETag logic
src/synthorg/api/channels.py Adds company/departments WS channels
src/synthorg/api/auth/models.py Adds OrgRole + scoped dept validation to user models
src/synthorg/api/auth/middleware.py Plumbs org_roles/scoped_departments into AuthenticatedUser
src/synthorg/api/auth/controller.py Exposes org_roles/scoped_departments in /auth/me responses
src/synthorg/api/app.py Startup auto-promote-first-owner hook
CLAUDE.md Repo docs updated for OrgRole/mutation services

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 210 to 214
<span
className="inline-flex cursor-not-allowed items-center gap-1 rounded-md border border-border bg-background/50 px-2 py-1 text-micro text-text-secondary opacity-70"
title="Add agent -- coming soon (#1081)"
className="inline-flex items-center gap-1 rounded-md border border-border bg-background/50 px-2 py-1 text-micro text-text-secondary hover:bg-background/80 cursor-pointer"
title="Add agent"
>
<Plus className="size-3" aria-hidden="true" />
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

The “Add agent” chip is styled as interactive (cursor-pointer/hover) but is a plain <span> with no click handler and no button semantics, so it’s a dead affordance and not keyboard-accessible. Either wire it to the add-agent action (use a <button> with onClick) or revert to non-interactive styling until it actually does something.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +55
if (!form.name.trim()) next.name = 'Name is required'
if (!form.display_name.trim()) next.display_name = 'Display name is required'
const pct = Number(form.budget_percent)
if (!Number.isFinite(pct) || pct < 0 || pct > 100) {
next.budget_percent = 'Budget percent must be between 0 and 100'
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

The form requires display_name (validation) but CreateDepartmentRequest no longer includes it and the submit payload never sends it, so users must enter a value that will be ignored. Either remove the Display Name field/validation, or add display_name back to the request type and send/persist it consistently.

Copilot uses AI. Check for mistakes.
Comment on lines 62 to 66
try {
await onUpdate(department.name, {
display_name: displayName.trim() || undefined,
budget_percent: Number.isFinite(pct) ? pct : undefined,
ceremony_policy: ceremonyPolicy,
})
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

Display Name is editable in the UI, but the save handler does not include it in the onUpdate payload, so changes will never persist. Either remove/disable the Display Name input or include the corresponding field in UpdateDepartmentRequest and send it here.

Copilot uses AI. Check for mistakes.
Comment on lines 248 to +252
reorderAgents: async (deptName, orderedIds) => {
set((s) => ({ savingCount: s.savingCount + 1, saveError: null }))
try {
const updatedDept = await apiReorderAgents(deptName, { agent_ids: orderedIds })
const prev = get().config
await apiReorderAgents(deptName, { agent_names: orderedIds })
// Refetch to pick up the reordered agents consistently
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

reorderAgents now sends agent_names, but it forwards orderedIds (and call sites build this list via a.id ?? a.name, e.g. AgentsTab.tsx:179). If agents have IDs, this will send IDs to an endpoint that expects names, causing 422s or incorrect ordering. Align the contract by sending names everywhere (and rename orderedIds), or change the API/backend to accept IDs.

Copilot uses AI. Check for mistakes.
Comment on lines +107 to +111
snapshot = {
"company_name": name,
"agents": [a.model_dump(mode="json") for a in agents],
"departments": [d.model_dump(mode="json") for d in depts],
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

If-Match validation computes the current ETag from a full company snapshot (includes agents + departments). But the endpoint later returns an ETag computed from the partial updated dict, so a client that reuses the returned ETag for a subsequent If-Match will not match the snapshot-based ETag.

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +128
new_etag = compute_etag(
json.dumps(updated, sort_keys=True),
"",
)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

The response ETag is computed from updated (the partial fields dict), which does not match the snapshot used for If-Match validation earlier in the handler. Compute/return the ETag from the same representation you validate against (ideally the full current company snapshot after mutation).

Copilot uses AI. Check for mistakes.
Comment on lines +446 to +457
if data.role == OrgRole.DEPARTMENT_ADMIN and not data.scoped_departments:
msg = "department_admin role requires scoped_departments"
logger.warning(API_VALIDATION_FAILED, reason=msg)
raise ApiValidationError(msg)

new_roles = (*user.org_roles, data.role)
# Merge scoped departments (additive, sorted for stability)
new_scoped = tuple(
sorted(
dict.fromkeys([*user.scoped_departments, *data.scoped_departments]),
)
)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

grant_org_role merges scoped_departments for every granted role. If a caller sends scoped_departments when granting a non-department_admin role, this can persist an invalid user state (scoped_departments set without DEPARTMENT_ADMIN). Consider only applying scoped_departments when granting department_admin (and otherwise validate/reject).

Suggested change
if data.role == OrgRole.DEPARTMENT_ADMIN and not data.scoped_departments:
msg = "department_admin role requires scoped_departments"
logger.warning(API_VALIDATION_FAILED, reason=msg)
raise ApiValidationError(msg)
new_roles = (*user.org_roles, data.role)
# Merge scoped departments (additive, sorted for stability)
new_scoped = tuple(
sorted(
dict.fromkeys([*user.scoped_departments, *data.scoped_departments]),
)
)
if data.role != OrgRole.DEPARTMENT_ADMIN and data.scoped_departments:
msg = "scoped_departments can only be provided for department_admin role"
logger.warning(API_VALIDATION_FAILED, reason=msg)
raise ApiValidationError(msg)
if data.role == OrgRole.DEPARTMENT_ADMIN and not data.scoped_departments:
msg = "department_admin role requires scoped_departments"
logger.warning(API_VALIDATION_FAILED, reason=msg)
raise ApiValidationError(msg)
new_roles = (*user.org_roles, data.role)
if data.role == OrgRole.DEPARTMENT_ADMIN:
# Merge scoped departments (additive, sorted for stability)
new_scoped = tuple(
sorted(
dict.fromkeys([*user.scoped_departments, *data.scoped_departments]),
)
)
else:
new_scoped = user.scoped_departments

Copilot uses AI. Check for mistakes.
Comment on lines +523 to +528
updated = user.model_copy(
update={
"org_roles": new_roles,
"updated_at": now,
},
)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

When revoking department_admin, this only updates org_roles and leaves scoped_departments unchanged. That can persist an invalid state (scoped_departments set without DEPARTMENT_ADMIN) and may break auth user validation. Clear scoped_departments when removing department_admin (or when the user no longer has that role).

Suggested change
updated = user.model_copy(
update={
"org_roles": new_roles,
"updated_at": now,
},
)
user_updates: dict[str, Any] = {
"org_roles": new_roles,
"updated_at": now,
}
if OrgRole.DEPARTMENT_ADMIN not in new_roles:
user_updates["scoped_departments"] = ()
updated = user.model_copy(update=user_updates)

Copilot uses AI. Check for mistakes.
Comment on lines 89 to 93
await onUpdate(agent.name, {
name: trimmedName,
role: form.role.trim() || undefined,
department: form.department as UpdateAgentOrgRequest['department'],
level: form.level,
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

The drawer lets users change Status (form state + Status SelectField), but handleSave does not include status in the onUpdate payload, so status changes will never persist. Either remove/disable the Status field or add status back to UpdateAgentOrgRequest and send it here.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 16

Caution

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

⚠️ Outside diff range comments (5)
web/src/pages/org/DepartmentGroupNode.tsx (2)

209-217: ⚠️ Potential issue | 🟡 Minor

Missing click handler on interactive "Add agent" chip.

The chip has hover:bg-* and cursor-pointer styling that suggests it's interactive, but there's no onClick handler. Users will expect clicking to trigger an action. Either wire up the handler to open an agent creation flow, or use a <button> element with proper semantics if this is intended to be actionable.

🔧 Proposed fix to add button semantics and placeholder handler
          {showAddAgentButton && (
-            <span
+            <button
+              type="button"
+              onClick={() => { /* TODO: open agent creation dialog */ }}
               className="inline-flex items-center gap-1 rounded-md border border-border bg-background/50 px-2 py-1 text-micro text-text-secondary hover:bg-background/80 cursor-pointer"
               title="Add agent"
             >
               <Plus className="size-3" aria-hidden="true" />
               Add agent
-            </span>
+            </button>
           )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org/DepartmentGroupNode.tsx` around lines 209 - 217, The "Add
agent" chip rendered when showAddAgentButton is true is styled as interactive
but lacks a click handler and proper semantics; replace the span with a
focusable element (preferably a <button>) inside DepartmentGroupNode and wire it
to the handler that opens the agent-creation flow (e.g., handleAddAgent or
openAddAgentModal) and ensure it preserves existing className, title/aria-label
and icon (Plus). Add an onClick on that button and, if needed, keyboard
accessibility props (type="button") so the UI is actionable and accessible.

228-237: ⚠️ Potential issue | 🟡 Minor

Same missing click handler issue on populated department's "Add agent" chip.

This chip also has interactive styling (hover:bg-background/50, cursor-pointer) but no onClick. For consistency with the empty-state chip, both should be wired to the same agent creation flow.

🔧 Proposed fix
       {!isEmpty && showAddAgentButton && (
         <div className="mt-auto flex items-center justify-center pt-5">
-          <span
+          <button
+            type="button"
+            onClick={() => { /* TODO: open agent creation dialog */ }}
             className="inline-flex items-center gap-1 rounded-md border border-dashed border-border bg-background/30 px-2 py-0.5 text-micro text-text-muted hover:bg-background/50 cursor-pointer"
             title="Add agent"
           >
             <Plus className="size-3" aria-hidden="true" />
             Add agent
-          </span>
+          </button>
         </div>
       )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org/DepartmentGroupNode.tsx` around lines 228 - 237, The "Add
agent" chip rendered when !isEmpty && showAddAgentButton in DepartmentGroupNode
is missing an onClick handler despite having interactive styling; wire this span
to the same agent-creation flow used by the empty-state chip so both trigger the
same behavior (e.g., call the existing handler used elsewhere such as the
component's create/add agent function or openCreateAgent modal). Update the span
in DepartmentGroupNode (the block conditioned on !isEmpty && showAddAgentButton)
to attach that same onClick prop and ensure accessibility by adding a
role/button or tabindex if needed.
web/src/pages/org-edit/AgentEditDrawer.tsx (1)

151-156: ⚠️ Potential issue | 🟡 Minor

Status field is editable but changes are silently discarded on save.

The status field is rendered as an editable SelectField, but the handleSave function (lines 89-94) excludes status from the onUpdate payload. Users can modify the status, but the change will never persist, creating a confusing experience.

If UpdateAgentOrgRequest no longer supports status, consider making this field read-only or removing the SelectField entirely and displaying status as static text (similar to the "Model" section below).

💡 Suggested fix: Make status read-only
-            <SelectField
-              label="Status"
-              options={STATUS_OPTIONS}
-              value={form.status ?? 'active'}
-              onChange={(v) => setForm((prev) => ({ ...prev, status: v as AgentConfig['status'] }))}
-            />
+            {/* Status is read-only; managed by system */}
+            <div className="space-y-1">
+              <p className="text-xs font-semibold uppercase tracking-wider text-text-muted">Status</p>
+              <StatusBadge status={toRuntimeStatus(agent.status ?? 'active')} label />
+            </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org-edit/AgentEditDrawer.tsx` around lines 151 - 156, The
Status SelectField is editable but handleSave excludes status from the onUpdate
payload, so persistency is broken; either include status in the update payload
or make it read-only. Update handleSave (the function calling
onUpdate/UpdateAgentOrgRequest) to include form.status (typed as
AgentConfig['status']) in the payload sent to onUpdate, or replace the editable
SelectField with a non-editable display (remove SelectField usage and render
static text) and stop updating form via setForm for status. Ensure references:
SelectField, form.status, setForm, handleSave, onUpdate, and
UpdateAgentOrgRequest are adjusted consistently.
web/src/pages/org-edit/AgentsTab.tsx (1)

83-92: 🧹 Nitpick | 🔵 Trivial

Consider removing unused dragDisabled prop.

The dragDisabled prop on DepartmentAgentsSection (and correspondingly on SortableAgentItem) is no longer passed from the parent component (lines 235-241). If drag-disable functionality is no longer needed, consider removing these unused props to reduce code surface.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org-edit/AgentsTab.tsx` around lines 83 - 92,
DepartmentAgentsSection and the SortableAgentItem still declare and accept a
dragDisabled prop that is no longer used or passed from parents; remove the
dragDisabled property from the DepartmentAgentsSection props interface, its
parameter list, and any references inside DepartmentAgentsSection, then remove
the dragDisabled prop from the SortableAgentItem component signature/props and
any conditional logic that checks it; finally clean up callers (the parent
component that previously forwarded dragDisabled) so they no longer pass the
prop and update related type annotations/imports to keep TS types consistent
(look for symbols DepartmentAgentsSection and SortableAgentItem to locate the
code).
web/src/stores/company.ts (1)

106-122: 🧹 Nitpick | 🔵 Trivial

Consider extracting the event set to a module-level constant.

The ReadonlySet is recreated on every WebSocket event. While the performance impact is minimal, extracting it to a module-level constant improves clarity and avoids repeated allocations.

♻️ Suggested refactor
+const ORG_MUTATION_EVENTS: ReadonlySet<string> = new Set([
+  'agent.hired', 'agent.fired',
+  'company.updated',
+  'department.created', 'department.updated', 'department.deleted', 'departments.reordered',
+  'agent.created', 'agent.updated', 'agent.deleted', 'agents.reordered',
+])
+
 export const useCompanyStore = create<CompanyState>()((set, get) => ({
   // ...
   updateFromWsEvent: (event) => {
-    const orgMutationEvents: ReadonlySet<string> = new Set([
-      'agent.hired', 'agent.fired',
-      'company.updated',
-      'department.created', 'department.updated', 'department.deleted', 'departments.reordered',
-      'agent.created', 'agent.updated', 'agent.deleted', 'agents.reordered',
-    ])
-    if (orgMutationEvents.has(event.event_type)) {
+    if (ORG_MUTATION_EVENTS.has(event.event_type)) {
       // ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/company.ts` around lines 106 - 122, The ReadonlySet
orgMutationEvents is being recreated for every WebSocket event; extract it to a
module-level constant (e.g., ORG_MUTATION_EVENTS) and replace the inline
declaration with a reference to that constant to avoid repeated allocations and
improve clarity; update the event check to use
ORG_MUTATION_EVENTS.has(event.event_type) and keep existing calls to
useCompanyStore.getState(), fetchCompanyData(), and fetchDepartmentHealths()
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/auth/models.py`:
- Around line 60-66: The validator _validate_scoped_departments currently only
rejects scoped_departments when org_roles lacks OrgRole.DEPARTMENT_ADMIN; add
the symmetric check so that if OrgRole.DEPARTMENT_ADMIN is present in org_roles
then scoped_departments must be non-empty (raise ValueError with a clear message
otherwise), and update the same pattern in the analogous validator that
validates scoped_vaults (ensure DEPARTMENT_ADMIN implies a non-empty
scoped_vaults). Ensure both validators are model_validator(mode="after") methods
on the User model and reference OrgRole.DEPARTMENT_ADMIN,
scoped_departments/scoped_vaults and org_roles when enforcing the invariant.

In `@src/synthorg/api/controllers/agents.py`:
- Around line 188-207: The ETag pre-check in the controller is racy because it
runs before OrgMutationService acquires _org_lock; move the compare-and-set into
the locked mutation path by removing the external If-Match validation in
agents.py and instead pass the provided If-Match (or computed expected ETag
string) into AppState.org_mutation_service.update_agent(), then implement the
ETag verification inside update_agent while holding _org_lock using compute_etag
and check_if_match (or equivalent logic) so the check-and-write is atomic;
reference the controller's use of compute_etag/check_if_match and the service
method update_agent to locate where to change behavior.

In `@src/synthorg/api/controllers/company.py`:
- Around line 100-118: The If-Match precondition is checked before acquiring the
mutation lock, allowing a race; move the ETag verification into the mutation
critical section so it runs while the service holds its serialization lock.
Remove the current pre-lock block that reads request.headers.get("if-match") and
computing snapshot/cur_json/current_etag in the controller; instead either (A)
compute the snapshot and call check_if_match(if_match, current_etag, "company")
from inside org_mutation_service.update_company(...) while holding its internal
lock, or (B) change update_company to accept the incoming if_match (e.g., a
parameter named if_match) and have update_company call compute_etag(...) and
check_if_match(...) under the service's lock before applying changes. Ensure you
keep references to compute_etag, check_if_match, app_state.config_resolver, and
org_mutation_service.update_company when relocating the logic so the check
executes atomically with the mutation.

In `@src/synthorg/api/controllers/departments.py`:
- Around line 722-724: get_department() now lowercases the lookup but sibling
endpoints still do exact-name compares, causing inconsistent routing; update
get_department_health(), _require_department_exists(), and the ceremony-policy
resolution logic to perform case-insensitive matching the same way (prefer using
str.casefold() or .lower()) by normalizing the incoming department name and
comparing against dept.name.casefold()/lower() (or reuse the name_lower
variable) so all checks use the same normalized comparison.
- Around line 781-803: The If-Match ETag validation must be performed atomically
with the write to avoid a TOCTOU race: stop computing and checking the ETag
outside the mutation lock and instead move the validation into the mutation
path. Modify the call to app_state.org_mutation_service.update_department (or
add a new method like update_department_with_expected_etag) to accept the
expected ETag (computed with compute_etag) or the raw If-Match header, and
perform check_if_match while holding the service's internal _org_lock before
applying the update so the ETag validation and the write occur under the same
lock.

In `@src/synthorg/api/controllers/users.py`:
- Around line 446-457: The code is incorrectly allowing scoped_departments to be
changed when granting non-department-admin roles and leaving stale scopes when
department_admin is revoked; update the grant and revoke logic so
scoped_departments are only merged/updated when data.role ==
OrgRole.DEPARTMENT_ADMIN being added (use data.scoped_departments to merge into
user.scoped_departments to compute new_scoped) and prevent merging
scoped_departments for other roles, and when removing OrgRole.DEPARTMENT_ADMIN
ensure scoped_departments are cleared/removed from the user (i.e., when
computing new_roles/new_scoped on revocation remove department scopes), applying
the same fix in both the grant block (where new_roles/new_scoped are computed)
and the revoke block (lines referenced around where removal logic computes
roles/scopes).
- Around line 66-72: The GrantOrgRoleRequest model currently types
scoped_departments as tuple[str, ...], which allows blank/whitespace
identifiers; change its type to tuple[NotBlankStr, ...] and import NotBlankStr
from core.types so API input enforces the same non-blank invariant as the
persisted model, keeping the default value as () and leaving model_config
intact; update any tests or call sites that construct GrantOrgRoleRequest to
pass NotBlankStr-compatible strings if needed.

In `@src/synthorg/api/services/org_mutations.py`:
- Around line 487-491: The current check blocks deletion of any c-suite agent;
change it to only block the CEO. Replace the condition that uses existing.level
== SeniorityLevel.C_SUITE with a combined check that also verifies the job title
equals "CEO" (e.g., if existing.level == SeniorityLevel.C_SUITE and
existing.title == "CEO":) and only then raise the ConflictError with the same
message using name; if your model uses a different field for job title (e.g.,
existing.role or existing.position) use that field instead.
- Around line 121-134: The _validate_permutation method raises
ApiValidationError without logging; update it to log a warning/error with
context (entity, current_names, requested_names, and any relevant ids)
immediately before raising (use self.logger.warning or error consistently).
Apply the same pattern to all other places in this module that raise
ConflictError, NotFoundError, or ApiValidationError (the failure sites
referenced in the review: around the blocks covering lines 213-217, 256-258,
307-311, 375-381, 431-450, 519-520) so each raise is preceded by a log entry
including the mutation context to aid tracing.
- Around line 261-270: The current checks (e.g., in update_department() /
update_agent()) use "is not None" so a client-sent explicit null is ignored;
change the guards to detect whether the field was provided (not its value) by
using the model's presence tracking (e.g., for Pydantic use data.__fields_set__
or data.dict(exclude_unset=True), or fallback to checking field in
data.__dict__) and then assign updates["head"] = data.head (allowing None),
updates["ceremony_policy"] = data.ceremony_policy, updates["autonomy_level"] =
data.autonomy_level, and for teams convert to tuple(data.teams) only when the
field was provided; apply the same change pattern to the other occurrence (lines
~444-455).

In `@src/synthorg/persistence/sqlite/migrations.py`:
- Around line 76-77: Replace the dynamic import call with a normal module
import: add "import re" alongside the other imports in migrations.py and change
the _ALLOWED_COLUMN_RE definition to use re.compile(r"^[a-z_][a-z0-9_]*$"); this
updates the symbol _ALLOWED_COLUMN_RE to be created via the standard re module
rather than __import__("re").

In `@tests/unit/api/controllers/test_agent_mutations.py`:
- Around line 205-210: The test currently asserts a 409 when sending a stale
ETag via test_client.patch with headers {"If-Match": '"stale-etag-value000"'},
but the API contract mandates a 412 Precondition Failed on ETag mismatch; update
the assertion to assert resp.status_code == 412 and run the test suite to ensure
the controller handling (agent update path that checks If-Match/ETag) returns
412 for stale/mismatched ETags consistently.

In `@tests/unit/api/controllers/test_department_mutations.py`:
- Around line 50-76: Extend TestUpdateDepartment to cover the
optimistic-concurrency branch by adding tests that exercise the If-Match
precondition: create or fetch a department to obtain its current ETag header,
then perform a PATCH to "/api/v1/departments/{name}" with If-Match set to that
ETag and assert 200 and the updated value (matching case), and add a separate
test that sends a stale/incorrect If-Match value (e.g., "W/\"stale\"" or a
modified ETag) and asserts a 412 Precondition Failed response; reference the
existing test class TestUpdateDepartment and the same endpoints used in
test_update_department_happy_path to locate where to add these checks.

In `@web/src/pages/org-edit/DepartmentCreateDialog.tsx`:
- Around line 48-65: The form validation marks display_name required but the
handleSubmit payload omits it; update the handleSubmit function to include
display_name (e.g., pass display_name: form.display_name.trim()) in the object
sent to onCreate so the mutation receives the display name, keeping the existing
validation using setErrors and pct handling intact; ensure you trim display_name
like name and include it in the await onCreate({...}) call.

In `@web/src/pages/org-edit/DepartmentEditDrawer.tsx`:
- Around line 63-66: The Display Name edits are not being sent on save—update
the handleSave logic in DepartmentEditDrawer so the onUpdate call includes
display_name using the component's displayName state (e.g. await
onUpdate(department.name, { display_name: displayName.trim() || undefined,
budget_percent: Number.isFinite(pct) ? pct : undefined, ceremony_policy:
ceremonyPolicy })). Alternatively, if editing should be disabled, make the
Display Name input readOnly/disabled where displayName state is defined (lines
around its initialization and the input JSX) and remove editing affordance; pick
one approach and implement it consistently.

In `@web/src/pages/org/DeptAdminNode.stories.tsx`:
- Around line 8-32: The Wrapper function currently uses inline styles on the
container div (style={{ width: 400, height: 200 }}); replace that inline style
with Tailwind utility classes by changing the div to use className="w-[400px]
h-[200px]" so the visual fixture matches the codebase patterns while leaving
Wrapper, ReactFlowProvider, and the ReactFlow props (nodes, nodeTypes, etc.)
unchanged.

---

Outside diff comments:
In `@web/src/pages/org-edit/AgentEditDrawer.tsx`:
- Around line 151-156: The Status SelectField is editable but handleSave
excludes status from the onUpdate payload, so persistency is broken; either
include status in the update payload or make it read-only. Update handleSave
(the function calling onUpdate/UpdateAgentOrgRequest) to include form.status
(typed as AgentConfig['status']) in the payload sent to onUpdate, or replace the
editable SelectField with a non-editable display (remove SelectField usage and
render static text) and stop updating form via setForm for status. Ensure
references: SelectField, form.status, setForm, handleSave, onUpdate, and
UpdateAgentOrgRequest are adjusted consistently.

In `@web/src/pages/org-edit/AgentsTab.tsx`:
- Around line 83-92: DepartmentAgentsSection and the SortableAgentItem still
declare and accept a dragDisabled prop that is no longer used or passed from
parents; remove the dragDisabled property from the DepartmentAgentsSection props
interface, its parameter list, and any references inside
DepartmentAgentsSection, then remove the dragDisabled prop from the
SortableAgentItem component signature/props and any conditional logic that
checks it; finally clean up callers (the parent component that previously
forwarded dragDisabled) so they no longer pass the prop and update related type
annotations/imports to keep TS types consistent (look for symbols
DepartmentAgentsSection and SortableAgentItem to locate the code).

In `@web/src/pages/org/DepartmentGroupNode.tsx`:
- Around line 209-217: The "Add agent" chip rendered when showAddAgentButton is
true is styled as interactive but lacks a click handler and proper semantics;
replace the span with a focusable element (preferably a <button>) inside
DepartmentGroupNode and wire it to the handler that opens the agent-creation
flow (e.g., handleAddAgent or openAddAgentModal) and ensure it preserves
existing className, title/aria-label and icon (Plus). Add an onClick on that
button and, if needed, keyboard accessibility props (type="button") so the UI is
actionable and accessible.
- Around line 228-237: The "Add agent" chip rendered when !isEmpty &&
showAddAgentButton in DepartmentGroupNode is missing an onClick handler despite
having interactive styling; wire this span to the same agent-creation flow used
by the empty-state chip so both trigger the same behavior (e.g., call the
existing handler used elsewhere such as the component's create/add agent
function or openCreateAgent modal). Update the span in DepartmentGroupNode (the
block conditioned on !isEmpty && showAddAgentButton) to attach that same onClick
prop and ensure accessibility by adding a role/button or tabindex if needed.

In `@web/src/stores/company.ts`:
- Around line 106-122: The ReadonlySet orgMutationEvents is being recreated for
every WebSocket event; extract it to a module-level constant (e.g.,
ORG_MUTATION_EVENTS) and replace the inline declaration with a reference to that
constant to avoid repeated allocations and improve clarity; update the event
check to use ORG_MUTATION_EVENTS.has(event.event_type) and keep existing calls
to useCompanyStore.getState(), fetchCompanyData(), and fetchDepartmentHealths()
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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: badfc4af-aaf7-4746-a04d-5bdfd2c7c537

📥 Commits

Reviewing files that changed from the base of the PR and between 1e86ca6 and d0a8fbf.

📒 Files selected for processing (59)
  • CLAUDE.md
  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/models.py
  • src/synthorg/api/channels.py
  • src/synthorg/api/controllers/agents.py
  • src/synthorg/api/controllers/company.py
  • src/synthorg/api/controllers/departments.py
  • src/synthorg/api/controllers/users.py
  • src/synthorg/api/dto_org.py
  • src/synthorg/api/guards.py
  • src/synthorg/api/services/__init__.py
  • src/synthorg/api/services/org_mutations.py
  • src/synthorg/api/state.py
  • src/synthorg/api/ws_models.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/persistence/sqlite/migrations.py
  • src/synthorg/persistence/sqlite/schema.sql
  • src/synthorg/persistence/sqlite/user_repo.py
  • tests/unit/api/controllers/test_agent_mutations.py
  • tests/unit/api/controllers/test_company_mutations.py
  • tests/unit/api/controllers/test_department_mutations.py
  • tests/unit/api/controllers/test_user_org_roles.py
  • tests/unit/api/services/__init__.py
  • tests/unit/api/services/test_org_mutations.py
  • tests/unit/api/test_auto_promote.py
  • tests/unit/api/test_channels.py
  • tests/unit/api/test_org_role_guards.py
  • web/src/__tests__/pages/org-edit/AgentCreateDialog.test.tsx
  • web/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsx
  • web/src/__tests__/pages/org-edit/DepartmentCreateDialog.test.tsx
  • web/src/__tests__/pages/org-edit/DepartmentEditDrawer.test.tsx
  • web/src/__tests__/pages/org-edit/GeneralTab.test.tsx
  • web/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsx
  • web/src/__tests__/stores/company.test.ts
  • web/src/api/endpoints/company.ts
  • web/src/api/endpoints/users.ts
  • web/src/api/types.ts
  • web/src/components/layout/Sidebar.stories.tsx
  • web/src/pages/OrgChartPage.tsx
  • web/src/pages/OrgEditPage.tsx
  • web/src/pages/org-edit/AgentCreateDialog.tsx
  • web/src/pages/org-edit/AgentEditDrawer.tsx
  • web/src/pages/org-edit/AgentsTab.tsx
  • web/src/pages/org-edit/DepartmentCreateDialog.stories.tsx
  • web/src/pages/org-edit/DepartmentCreateDialog.tsx
  • web/src/pages/org-edit/DepartmentEditDrawer.tsx
  • web/src/pages/org-edit/DepartmentsTab.tsx
  • web/src/pages/org-edit/GeneralTab.tsx
  • web/src/pages/org-edit/YamlEditorPanel.tsx
  • web/src/pages/org-edit/coming-soon.ts
  • web/src/pages/org/DepartmentGroupNode.tsx
  • web/src/pages/org/DeptAdminNode.stories.tsx
  • web/src/pages/org/DeptAdminNode.tsx
  • web/src/pages/org/OwnerNode.tsx
  • web/src/pages/org/build-org-tree.ts
  • web/src/stores/auth.ts
  • web/src/stores/company.ts
💤 Files with no reviewable changes (1)
  • web/src/pages/org-edit/coming-soon.ts

Comment on lines +188 to +207
app_state: AppState = state.app_state
if_match = request.headers.get("if-match")
if if_match:
agents = await app_state.config_resolver.get_agents()
name_lower = agent_name.lower()
for agent in agents:
if agent.name.lower() == name_lower:
cur = json.dumps(
agent.model_dump(mode="json"),
sort_keys=True,
)
cur_etag = compute_etag(cur, "")
check_if_match(
if_match,
cur_etag,
f"agent:{agent_name}",
)
break

updated = await app_state.org_mutation_service.update_agent(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Make the If-Match check atomic with the write.

The controller validates the current ETag before OrgMutationService.update_agent() acquires _org_lock. Another mutation can land in that gap, so a stale precondition still passes and the later PATCH overwrites newer data. The compare-and-set needs to happen inside the locked mutation path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/agents.py` around lines 188 - 207, The ETag
pre-check in the controller is racy because it runs before OrgMutationService
acquires _org_lock; move the compare-and-set into the locked mutation path by
removing the external If-Match validation in agents.py and instead pass the
provided If-Match (or computed expected ETag string) into
AppState.org_mutation_service.update_agent(), then implement the ETag
verification inside update_agent while holding _org_lock using compute_etag and
check_if_match (or equivalent logic) so the check-and-write is atomic; reference
the controller's use of compute_etag/check_if_match and the service method
update_agent to locate where to change behavior.

Comment on lines +205 to +210
resp = test_client.patch(
"/api/v1/agents/alice",
json={"level": "senior"},
headers={"If-Match": '"stale-etag-value000"'},
)
assert resp.status_code == 409
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Assert 412 for stale If-Match.

The PR contract is 412 Precondition Failed for an ETag mismatch. Expecting 409 here will bake the wrong HTTP behavior into the suite and hide a spec regression.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/controllers/test_agent_mutations.py` around lines 205 - 210,
The test currently asserts a 409 when sending a stale ETag via test_client.patch
with headers {"If-Match": '"stale-etag-value000"'}, but the API contract
mandates a 412 Precondition Failed on ETag mismatch; update the assertion to
assert resp.status_code == 412 and run the test suite to ensure the controller
handling (agent update path that checks If-Match/ETag) returns 412 for
stale/mismatched ETags consistently.

Comment on lines +48 to +65
const handleSubmit = useCallback(async () => {
const next: Partial<Record<keyof FormState, string>> = {}
if (!form.name.trim()) next.name = 'Name is required'
if (!form.display_name.trim()) next.display_name = 'Display name is required'
const pct = Number(form.budget_percent)
if (!Number.isFinite(pct) || pct < 0 || pct > 100) {
next.budget_percent = 'Budget percent must be between 0 and 100'
}
setErrors(next)
if (Object.keys(next).length > 0) return

setSubmitting(true)
setSubmitError(null)
try {
await onCreate({
name: form.name.trim(),
budget_percent: pct,
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

display_name is required but never used.

handleSubmit() blocks until display_name is filled, then sends only { name, budget_percent } to onCreate(). Right now the dialog asks for data the mutation never receives, so users cannot actually control the department display name.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org-edit/DepartmentCreateDialog.tsx` around lines 48 - 65, The
form validation marks display_name required but the handleSubmit payload omits
it; update the handleSubmit function to include display_name (e.g., pass
display_name: form.display_name.trim()) in the object sent to onCreate so the
mutation receives the display name, keeping the existing validation using
setErrors and pct handling intact; ensure you trim display_name like name and
include it in the await onCreate({...}) call.

Comment on lines +8 to +32
function Wrapper({ data }: { data: DeptAdminNodeData }) {
return (
<ReactFlowProvider>
<div style={{ width: 400, height: 200 }}>
<ReactFlow
nodes={[
{
id: '1',
type: 'deptAdmin',
position: { x: 80, y: 40 },
data,
},
]}
edges={[]}
nodeTypes={nodeTypes}
fitView
nodesDraggable={false}
nodesConnectable={false}
zoomOnScroll={false}
panOnDrag={false}
/>
</div>
</ReactFlowProvider>
)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider using Tailwind classes instead of inline styles for consistency.

The wrapper uses inline style={{ width: 400, height: 200 }}. While acceptable for story fixtures, using Tailwind classes (className="w-[400px] h-[200px]") would be more consistent with the codebase patterns.

♻️ Suggested change
 function Wrapper({ data }: { data: DeptAdminNodeData }) {
   return (
     <ReactFlowProvider>
-      <div style={{ width: 400, height: 200 }}>
+      <div className="h-[200px] w-[400px]">
         <ReactFlow
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org/DeptAdminNode.stories.tsx` around lines 8 - 32, The Wrapper
function currently uses inline styles on the container div (style={{ width: 400,
height: 200 }}); replace that inline style with Tailwind utility classes by
changing the div to use className="w-[400px] h-[200px]" so the visual fixture
matches the codebase patterns while leaving Wrapper, ReactFlowProvider, and the
ReactFlow props (nodes, nodeTypes, etc.) unchanged.

@Aureliolo Aureliolo force-pushed the feat/backend-crud-multi-user-permissions branch from d0a8fbf to 029aaf0 Compare April 6, 2026 18:57
@Aureliolo Aureliolo temporarily deployed to cloudflare-preview April 6, 2026 18:58 — with GitHub Actions Inactive
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Caution

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

⚠️ Outside diff range comments (6)
web/src/pages/org/DepartmentGroupNode.tsx (2)

228-237: ⚠️ Potential issue | 🟡 Minor

Same issue: populated-state Add agent chip lacks click handler.

This chip also has cursor-pointer and hover styling but no onClick. Both chips should have consistent behavior—either both functional or both styled as non-interactive placeholders.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org/DepartmentGroupNode.tsx` around lines 228 - 237, The "Add
agent" chip in DepartmentGroupNode (the span with title "Add agent" and
className including cursor-pointer) is styled as interactive but has no click
handler; either wire it to the same click handler used for the other add button
or make it non-interactive. Fix by adding the appropriate onClick prop (e.g.,
call the existing addAgent/openAddAgentModal function used elsewhere in
DepartmentGroupNode) and keyboard accessibility (onKeyPress/role="button" or use
a button element) so behavior matches the other add-agent control, or remove
cursor-pointer/hover styles and the title if you intend it to be a placeholder.

209-217: ⚠️ Potential issue | 🟡 Minor

Wire up onClick handler for "Add agent" chips or remove interactive styling.

Both "Add agent" chips (empty state and populated state) have cursor-pointer and hover:bg-* styling, making them visually interactive, but neither has an onClick handler. The component props lack an onAddAgent callback to trigger the action. Either add the missing event handler and callback prop to enable agent creation, or remove the interactive styling if this functionality isn't ready.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org/DepartmentGroupNode.tsx` around lines 209 - 217, The "Add
agent" chip in DepartmentGroupNode (the span rendered when showAddAgentButton is
true) is styled as interactive but has no onClick handler or prop; either add an
onAddAgent callback prop to the component props and types, thread it from
parent, and attach it to the span's onClick (and optionally keyboard
handlers/role) to perform agent creation, or remove the interactive classes
(cursor-pointer and hover:bg-*) and title to make it non-interactive—update the
span rendering and the DepartmentGroupNode props (e.g., onAddAgent) accordingly
so the UI and behavior match.
web/src/pages/org-edit/AgentsTab.tsx (1)

235-241: 🧹 Nitpick | 🔵 Trivial

Consider removing unused dragDisabled prop from DepartmentAgentsSection.

The dragDisabled prop is no longer passed to DepartmentAgentsSection (line 235-241), but it remains in the component's interface (lines 87, 92) and is still forwarded to SortableAgentItem. If drag disabling is now fully handled by the sortable component's own disabled behavior, consider removing the unused prop to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org-edit/AgentsTab.tsx` around lines 235 - 241, The
DepartmentAgentsSection component still defines and forwards a dragDisabled prop
to SortableAgentItem but no callers pass it; remove the entire dragDisabled prop
from DepartmentAgentsSection's props/interface and stop forwarding it to
SortableAgentItem (update the props definition and any occurrences inside
DepartmentAgentsSection where dragDisabled is referenced), and ensure
SortableAgentItem relies solely on its own disabled/drag behavior; search for
the symbol DepartmentAgentsSection and the prop name dragDisabled to remove all
references and adjust any TypeScript types accordingly.
web/src/pages/org-edit/DepartmentsTab.tsx (1)

309-313: 🧹 Nitpick | 🔵 Trivial

Consider removing unused disabled prop from SortableDepartmentCard.

Similar to DepartmentAgentsSection in AgentsTab.tsx, the disabled prop is no longer passed to SortableDepartmentCard but remains in the component's interface (lines 48, 53, 58). If this prop is no longer needed, consider removing it from the interface to reduce confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org-edit/DepartmentsTab.tsx` around lines 309 - 313,
SortableDepartmentCard still declares a disabled prop in its props interface
although callers (e.g., the map usage shown and DepartmentAgentsSection in
AgentsTab) no longer pass it; remove the unused prop to avoid confusion by
deleting disabled from the SortableDepartmentCard props/type definition and any
default value or destructured parameter in the SortableDepartmentCard component,
and remove any internal branches that only run when disabled is truthy (or
replace them with the intended behavior). Ensure you also remove any leftover
disabled references in related helper types or exported interfaces so the
component signature matches actual usage (keep SortableDepartmentCard,
getAgentCount, and setEditDept unchanged).
web/src/__tests__/stores/company.test.ts (1)

208-217: ⚠️ Potential issue | 🟡 Minor

These assertions don't pin the new sync contract.

updateCompany can still pass if the store goes back to local mutation, reorderAgents does not stub or assert the mandatory getCompanyConfig() refetch, and reorderDepartments never checks that the rest of config survives the departments-only update. Please assert the refresh call(s) and preserved fields so regressions fail fast.

Also applies to: 279-285, 340-346

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/__tests__/stores/company.test.ts` around lines 208 - 217, The test
doesn't assert that updateCompany triggers the mandatory refetch nor that the
rest of config survives the update; update the test for
useCompanyStore.updateCompany to assert mockGetCompanyConfig was called (and/or
calledOnce) after mockUpdateCompany resolves, and verify preserved fields from
mockConfig remain (e.g., check another key besides company_name). Apply the same
pattern to the tests exercising reorderAgents and reorderDepartments:
stub/mockGetCompanyConfig before calling
useCompanyStore.reorderAgents/reorderDepartments, assert the refetch was
invoked, and assert non-target parts of useCompanyStore.getState().config remain
unchanged so the full sync contract is enforced.
web/src/stores/company.ts (1)

112-123: ⚠️ Potential issue | 🟠 Major

Serialize or discard overlapping refreshes.

These paths can now issue concurrent getCompanyConfig() calls for the same session: a direct post-mutation refetch and a WebSocket-driven refetch for the emitted org event. Because each completion writes straight into the store, an older response can land last and roll config / departmentHealths back to stale data. Please funnel both paths through one sequenced refresh helper or ignore out-of-date completions before calling set(...).

Also applies to: 127-133, 249-255

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/company.ts` around lines 112 - 123, Concurrent refreshes from
updateFromWsEvent and direct post-mutation refetch can race and let an older
response overwrite newer store state; modify the store to serialize or ignore
stale completions by adding a refresh key/version and checking it before
applying fetched data. Specifically, introduce a refreshToken/refreshVersion on
the company store that you bump when starting a fetch (used by
fetchCompanyData/getCompanyConfig and fetchDepartmentHealths) and pass or
capture that token in each async result; before calling set(...) or updating
state in those methods, compare the captured token to the current store token
and discard the result if they differ, or alternatively funnel both callers
through a single sequencer helper (e.g., a refreshCompanyData helper invoked by
updateFromWsEvent and post-mutation paths) to ensure only one in-flight refresh
can complete and update the store.
♻️ Duplicate comments (9)
src/synthorg/api/controllers/company.py (1)

100-118: ⚠️ Potential issue | 🔴 Critical

Move the If-Match check into the mutation critical section.

This precondition still runs before org_mutation_service.update_company() acquires its serialization lock, so two requests carrying the same stale ETag can both pass and then commit sequentially.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/company.py` around lines 100 - 118, The If-Match
precondition is checked before acquiring the mutation lock, allowing race
conditions; move the entire ETag computation and check (using
request.headers.get("if-match"),
app_state.config_resolver.get_str/get_agents/get_departments, json.dumps,
compute_etag, and check_if_match) so it executes inside the critical section
immediately after update_company acquires its serialization/lock (i.e., within
the same code path where app_state.org_mutation_service.update_company runs
under the mutation lock) so the computed current_etag reflects the state guarded
by the lock and the check_if_match prevents stale updates.
src/synthorg/api/controllers/users.py (1)

66-72: ⚠️ Potential issue | 🟠 Major

Use NotBlankStr for scoped_departments in both DTOs.

tuple[str, ...] here accepts blank/whitespace-only scope names at the API boundary and weakens the same identifier field again in the response DTO.

♻️ Suggested change
 class GrantOrgRoleRequest(BaseModel):
     """Request body for granting an org-level role."""
 
     model_config = ConfigDict(frozen=True, allow_inf_nan=False)
 
     role: OrgRole
-    scoped_departments: tuple[str, ...] = ()
+    scoped_departments: tuple[NotBlankStr, ...] = ()
@@
 class UserResponse(BaseModel):
@@
-    scoped_departments: tuple[str, ...] = ()
+    scoped_departments: tuple[NotBlankStr, ...] = ()

As per coding guidelines, "Use NotBlankStr (from core.types) for all identifier/name fields in Pydantic models -- including optional (NotBlankStr | None) and tuple (tuple[NotBlankStr, ...]) variants -- instead of manual whitespace validators."

Also applies to: 84-85

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/users.py` around lines 66 - 72, Change the
scoped_departments field to use NotBlankStr so blank/whitespace-only names are
rejected at the API boundary: import NotBlankStr from core.types and update
GrantOrgRoleRequest.scoped_departments to type tuple[NotBlankStr, ...] = () (and
do the same update for the corresponding response DTO's scoped_departments field
declared below), keeping the existing default and model_config.
tests/unit/api/controllers/test_agent_mutations.py (1)

204-210: ⚠️ Potential issue | 🟡 Minor

Wrong status code assertion: stale If-Match should return 412, not 409.

Line 210 asserts resp.status_code == 409, but the HTTP specification and PR contract require 412 Precondition Failed for ETag mismatches. This test will either fail when the controller is fixed, or it's baking incorrect behavior into the suite.

-        assert resp.status_code == 409
+        assert resp.status_code == 412
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/controllers/test_agent_mutations.py` around lines 204 - 210,
Update the test to expect the correct HTTP status for a stale If-Match ETag:
change the assertion on resp.status_code in the test block that sends a stale
ETag (the test_client.patch call producing resp) from 409 to 412 so the
assertion checks for 412 Precondition Failed instead of 409.
src/synthorg/api/controllers/agents.py (1)

188-210: ⚠️ Potential issue | 🔴 Critical

TOCTOU race in If-Match validation — ETag check is not atomic with write.

The current ETag is validated at lines 191-205 before org_mutation_service.update_agent() acquires _org_lock at line 207. A concurrent mutation can slip between validation and the actual write, allowing a stale precondition to overwrite newer data.

The compare-and-set must happen inside the locked mutation path. Consider passing if_match to update_agent() and performing the ETag validation while holding _org_lock.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/agents.py` around lines 188 - 210, The If-Match
ETag validation is done before acquiring the organization lock, creating a
TOCTOU race; move the compare-and-set into the mutation path by passing the raw
if_match (from request.headers.get("if-match")) into
app_state.org_mutation_service.update_agent and perform the compute_etag +
check_if_match logic inside update_agent while holding the _org_lock so the ETag
comparison and the write are atomic; update update_agent's signature and callers
accordingly and remove the pre-check in agents.py (references: compute_etag,
check_if_match, update_agent, _org_lock).
src/synthorg/api/controllers/departments.py (2)

722-728: ⚠️ Potential issue | 🟡 Minor

Case-insensitive lookup inconsistency across department routes.

get_department() now uses lowercase comparison (line 724), but sibling endpoints still use exact matching:

  • get_department_health() at line 918: if name not in dept_by_name
  • _require_department_exists() at line 434: if dept.name == name

This can cause the same mixed-case path to succeed on one endpoint and 404 on another.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/departments.py` around lines 722 - 728, The
department name matching is inconsistent: get_department() uses case-insensitive
comparison but get_department_health() and _require_department_exists() use
exact matches, causing mixed-case paths to sometimes 404; make all lookups
consistent by normalizing names to a single case (e.g., lower()) wherever
departments are indexed or compared — update the construction/usage of
dept_by_name and the comparisons in get_department_health() and
_require_department_exists() to use name.lower() (or always store keys as
lowercased names) and ensure incoming name is normalized before lookup so all
endpoints use the same case-insensitive logic.

781-803: ⚠️ Potential issue | 🔴 Critical

TOCTOU race in department If-Match validation.

Same issue as the agent controller: the ETag is computed and validated at lines 784-798 before org_mutation_service.update_department() acquires _org_lock. A concurrent mutation can slip in between, causing a stale precondition to overwrite newer state.

Move the ETag validation inside the locked mutation path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/departments.py` around lines 781 - 803, The
If-Match ETag check is done before acquiring the org mutation lock, causing a
TOCTOU race; move the validation into the locked mutation path so the state used
to compute the ETag cannot change before update. Specifically, stop computing
cur_etag and calling check_if_match in the controller and instead perform the
ETag validation inside the org mutation code (e.g., inside
org_mutation_service.update_department or the method that holds _org_lock):
compute_etag from the current department JSON and call check_if_match while
holding the same _org_lock that protects mutations, or change update_department
to accept the incoming If-Match header and perform the check under that lock.
Ensure you reference compute_etag and check_if_match when relocating the logic
so the controller no longer does the pre-lock validation.
tests/unit/api/controllers/test_department_mutations.py (1)

50-76: 🛠️ Refactor suggestion | 🟠 Major

Missing If-Match/ETag optimistic concurrency tests for department updates.

TestUpdateDepartment only covers the happy path and 404 cases. The department update endpoint supports optimistic concurrency via If-Match header, but there are no tests for:

  • Stale ETag returning 412 Precondition Failed
  • Matching ETag allowing the update
  • Omitted If-Match header bypassing the check

The agent mutation tests (test_agent_mutations.py) include TestUpdateAgentETag with these scenarios—similar coverage should exist here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/controllers/test_department_mutations.py` around lines 50 -
76, Add optimistic-concurrency tests to the TestUpdateDepartment test class:
implement three new test methods that use the test_client to (1) fetch the
department to capture its ETag (GET /api/v1/departments/{name}), then perform a
PATCH with an outdated If-Match header and assert a 412 Precondition Failed, (2)
fetch the current ETag and PATCH with If-Match set to that value and assert 200
and the budget_percent was updated, and (3) perform a PATCH without any If-Match
header and assert the update succeeds (200) and budget_percent changes. Use the
existing TestUpdateDepartment class and test_client, target the same endpoint
"/api/v1/departments/{name}", and assert on response.status_code and
resp.json()["data"]["budget_percent"] to validate each scenario.
src/synthorg/api/services/org_mutations.py (2)

270-286: ⚠️ Potential issue | 🟠 Major

Explicit null cannot clear nullable fields.

The checks use is not None (e.g., lines 271-272, 275-278, 282-285), so a client-sent { "head": null } is silently ignored. This prevents clearing fields once set.

Consider using model_fields_set alone to detect presence, then assigning the value (including None):

-            if "head" in data.model_fields_set and data.head is not None:
-                updates["head"] = data.head
+            if "head" in data.model_fields_set:
+                updates["head"] = data.head  # allows None to clear
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/services/org_mutations.py` around lines 270 - 286, The
update-building logic ignores explicit nulls because it conditions assignments
on both membership in data.model_fields_set and value checks like "is not None";
change each conditional to only check membership in data.model_fields_set (e.g.,
for "head", "autonomy_level", "ceremony_policy", "teams", and "budget_percent")
and assign the corresponding value from data (allowing None) into the updates
dict so a client-sent null clears the field; keep the special handling for teams
to convert to tuple when present (but still assign tuple(data.teams) even if
data.teams is None/empty as appropriate).

500-502: ⚠️ Potential issue | 🟡 Minor

Missing log before raising NotFoundError in update_agent().

Same issue: NotFoundError raised without logging context.

🛡️ Proposed fix
             if existing is None:
                 msg = f"Agent {name!r} not found"
+                logger.warning(API_RESOURCE_NOT_FOUND, reason=msg, agent=name)
                 raise NotFoundError(msg)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/services/org_mutations.py` around lines 500 - 502, The
update_agent() code raises NotFoundError when existing is None without logging
context; before raising, log a clear message including the agent name and any
relevant identifiers (e.g., name, maybe org_id if available) using the module
logger or processLogger so failures are auditable; specifically, add a log call
just before the raise in the block that checks existing (refer to update_agent,
the existing variable and name) that records the attempted update and that the
agent was not found, then raise NotFoundError as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/controllers/company.py`:
- Around line 125-128: The response ETag is being computed from the partial
"updated" dict but the If-Match check validated against the full company
snapshot; change the compute_etag call in the PATCH response to hash the
canonical full company snapshot used for validation (either the merged/original
company object or the full company returned by update_company()), e.g.,
serialize that full snapshot with json.dumps(..., sort_keys=True) and pass it to
compute_etag (keeping the same salt/params), so the returned ETag matches the
If-Match source and won't cause spurious 412s.

In `@src/synthorg/api/controllers/users.py`:
- Around line 467-475: The persistence save calls for user mutations (the two
app_state.persistence.users.save(...) calls) are unguarded; wrap each in a
try/except that catches QueryError, log the failure at WARNING or ERROR with
context (include user.id, user.email, the mutation intent such as updated
org_roles/scoped_departments or which fields were changed, and the exception)
using the controller logger (e.g., logger.error(..., exc_info=True)), then
re-raise the exception; keep the existing constructs that create updated via
user.model_copy(update={...}) (variables like updated, now, new_roles,
new_scoped) and apply the same try/except logging pattern to the other save
block mentioned.

In `@src/synthorg/api/services/org_mutations.py`:
- Around line 266-268: In update_department(), before raising NotFoundError when
existing is None, add a warning log that includes the department name and any
relevant context (e.g., current user or request id if available) so operators
can trace the failure; call the module logger (or process logger used elsewhere)
with logger.warning(...) including f"Department {name!r} not found" and any
contextual fields, then raise NotFoundError(msg) as before.
- Around line 532-543: In delete_agent(), add warning logs before raising
errors: log a warning including the agent name (name) when existing is None
(before raising NotFoundError) and log a warning including the agent name and
existing.level/role when rejecting deletion of a C-suite CEO (existing,
SeniorityLevel.C_SUITE and existing.role) before raising ConflictError; use the
module/class logger (e.g., logger.warning) to record these details so the error
conditions are visible in logs.

In `@src/synthorg/persistence/sqlite/migrations.py`:
- Around line 106-109: The ALTER statement uses the variable definition
unchecked; add a defensive validation for the definition parameter in the same
routine (where table, column, definition are used) to reject dangerous tokens
(e.g., semicolons or SQL comment markers). Implement a simple regex test that
fails if definition contains ";" or "--" or "/*" or "*/" (or any other
disallowed characters/patterns you choose), and raise a ValueError with a clear
message when validation fails before calling db.execute; keep the existing
validations for table and column intact.

In `@src/synthorg/persistence/sqlite/user_repo.py`:
- Around line 64-78: The current deserialisation uses `data.get(...) or "[]"`
which treats empty strings as missing; change the logic in the deserialisation
block so you only substitute the default "[]" when the column is actually
missing or None (i.e., `data.get("org_roles") is None`), otherwise pass the raw
value through to json.loads to let empty strings/malformed payloads raise; apply
the same change for `"scoped_departments"`, preserving the subsequent validation
and assignments to `data["org_roles"] = tuple(OrgRole(r) for r in parsed_org)`
and `data["scoped_departments"] = tuple(parsed_dept)`.

In `@web/src/__tests__/pages/org-edit/AgentCreateDialog.test.tsx`:
- Around line 31-35: The current test only asserts the Create Agent button is
enabled which can miss broken submit wiring; update the test that uses
renderDialog() to simulate user behavior: fill the required form fields with
valid values (including extra whitespace), click the Create Agent button via
screen.getByRole('button', { name: /create agent/i }), and assert the mock
onCreate handler was called once with the trimmed payload (verify expected
properties and trimmed strings), ensuring the submit flow and trimming logic
work correctly.

In `@web/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsx`:
- Around line 46-51: The test currently only checks that Save/Delete buttons are
enabled but not that their handlers run; update the test that calls
renderDrawer() to pass jest mock functions for onUpdate and onDelete (or access
the component's props/mocks used by renderDrawer), then simulate user clicks on
the buttons using userEvent.click (or fireEvent.click) targeting the elements
found via screen.getByRole('button', { name: /save/i }) and
screen.getByRole('button', { name: /delete/i }) and assert the onUpdate and
onDelete mocks were called (e.g., toHaveBeenCalled or toHaveBeenCalledTimes).
Ensure you reference renderDrawer, onUpdate, and onDelete when modifying the
test so the click interactions verify the actual handlers execute.

In `@web/src/__tests__/pages/org-edit/DepartmentCreateDialog.test.tsx`:
- Around line 27-30: The test currently only asserts the Create Department
button is enabled; extend it to exercise the submit path by using renderDialog()
to render the form, fill in required inputs with valid values (e.g., department
name and any other required fields), click the Create Department button
(getByRole('button', { name: /create department/i })), and assert that the
mockOnCreate handler (mockOnCreate or the prop passed into renderDialog) was
called with the expected payload; ensure you reference and call the same mock
used by renderDialog so the test fails if submit wiring is broken.

In `@web/src/api/endpoints/company.ts`:
- Around line 43-45: The updateCompany function currently returns a broad
Record<string, unknown>; change its return typing to preserve the company
contract (e.g., Promise<Partial<CompanyConfig>> or
Promise<UpdateCompanyResponse>) and update the apiClient.patch generic and
unwrap call accordingly so the response is typed (replace
ApiResponse<Record<string, unknown>> with ApiResponse<Partial<CompanyConfig>> or
ApiResponse<UpdateCompanyResponse> and change the function signature from
updateCompany(data: UpdateCompanyRequest): Promise<Record<string, unknown>> to
the chosen concrete type) to keep field-level types for callers.

In `@web/src/api/endpoints/users.ts`:
- Around line 15-18: The current GrantOrgRoleRequest allows invalid combinations
(e.g., role: "department_admin" without scoped_departments or other roles with
scoped_departments); replace it with a discriminated union keyed on role (using
the existing OrgRole values) so that the branch where role ===
"department_admin" requires scoped_departments: readonly string[] and every
other role variant either omits scoped_departments or has it typed as
never/undefined, ensuring the invariant is enforced at compile time under
TypeScript 6+ strict settings; update any callers to match the new union shape
and keep the original symbol name GrantOrgRoleRequest for compatibility.

---

Outside diff comments:
In `@web/src/__tests__/stores/company.test.ts`:
- Around line 208-217: The test doesn't assert that updateCompany triggers the
mandatory refetch nor that the rest of config survives the update; update the
test for useCompanyStore.updateCompany to assert mockGetCompanyConfig was called
(and/or calledOnce) after mockUpdateCompany resolves, and verify preserved
fields from mockConfig remain (e.g., check another key besides company_name).
Apply the same pattern to the tests exercising reorderAgents and
reorderDepartments: stub/mockGetCompanyConfig before calling
useCompanyStore.reorderAgents/reorderDepartments, assert the refetch was
invoked, and assert non-target parts of useCompanyStore.getState().config remain
unchanged so the full sync contract is enforced.

In `@web/src/pages/org-edit/AgentsTab.tsx`:
- Around line 235-241: The DepartmentAgentsSection component still defines and
forwards a dragDisabled prop to SortableAgentItem but no callers pass it; remove
the entire dragDisabled prop from DepartmentAgentsSection's props/interface and
stop forwarding it to SortableAgentItem (update the props definition and any
occurrences inside DepartmentAgentsSection where dragDisabled is referenced),
and ensure SortableAgentItem relies solely on its own disabled/drag behavior;
search for the symbol DepartmentAgentsSection and the prop name dragDisabled to
remove all references and adjust any TypeScript types accordingly.

In `@web/src/pages/org-edit/DepartmentsTab.tsx`:
- Around line 309-313: SortableDepartmentCard still declares a disabled prop in
its props interface although callers (e.g., the map usage shown and
DepartmentAgentsSection in AgentsTab) no longer pass it; remove the unused prop
to avoid confusion by deleting disabled from the SortableDepartmentCard
props/type definition and any default value or destructured parameter in the
SortableDepartmentCard component, and remove any internal branches that only run
when disabled is truthy (or replace them with the intended behavior). Ensure you
also remove any leftover disabled references in related helper types or exported
interfaces so the component signature matches actual usage (keep
SortableDepartmentCard, getAgentCount, and setEditDept unchanged).

In `@web/src/pages/org/DepartmentGroupNode.tsx`:
- Around line 228-237: The "Add agent" chip in DepartmentGroupNode (the span
with title "Add agent" and className including cursor-pointer) is styled as
interactive but has no click handler; either wire it to the same click handler
used for the other add button or make it non-interactive. Fix by adding the
appropriate onClick prop (e.g., call the existing addAgent/openAddAgentModal
function used elsewhere in DepartmentGroupNode) and keyboard accessibility
(onKeyPress/role="button" or use a button element) so behavior matches the other
add-agent control, or remove cursor-pointer/hover styles and the title if you
intend it to be a placeholder.
- Around line 209-217: The "Add agent" chip in DepartmentGroupNode (the span
rendered when showAddAgentButton is true) is styled as interactive but has no
onClick handler or prop; either add an onAddAgent callback prop to the component
props and types, thread it from parent, and attach it to the span's onClick (and
optionally keyboard handlers/role) to perform agent creation, or remove the
interactive classes (cursor-pointer and hover:bg-*) and title to make it
non-interactive—update the span rendering and the DepartmentGroupNode props
(e.g., onAddAgent) accordingly so the UI and behavior match.

In `@web/src/stores/company.ts`:
- Around line 112-123: Concurrent refreshes from updateFromWsEvent and direct
post-mutation refetch can race and let an older response overwrite newer store
state; modify the store to serialize or ignore stale completions by adding a
refresh key/version and checking it before applying fetched data. Specifically,
introduce a refreshToken/refreshVersion on the company store that you bump when
starting a fetch (used by fetchCompanyData/getCompanyConfig and
fetchDepartmentHealths) and pass or capture that token in each async result;
before calling set(...) or updating state in those methods, compare the captured
token to the current store token and discard the result if they differ, or
alternatively funnel both callers through a single sequencer helper (e.g., a
refreshCompanyData helper invoked by updateFromWsEvent and post-mutation paths)
to ensure only one in-flight refresh can complete and update the store.

---

Duplicate comments:
In `@src/synthorg/api/controllers/agents.py`:
- Around line 188-210: The If-Match ETag validation is done before acquiring the
organization lock, creating a TOCTOU race; move the compare-and-set into the
mutation path by passing the raw if_match (from request.headers.get("if-match"))
into app_state.org_mutation_service.update_agent and perform the compute_etag +
check_if_match logic inside update_agent while holding the _org_lock so the ETag
comparison and the write are atomic; update update_agent's signature and callers
accordingly and remove the pre-check in agents.py (references: compute_etag,
check_if_match, update_agent, _org_lock).

In `@src/synthorg/api/controllers/company.py`:
- Around line 100-118: The If-Match precondition is checked before acquiring the
mutation lock, allowing race conditions; move the entire ETag computation and
check (using request.headers.get("if-match"),
app_state.config_resolver.get_str/get_agents/get_departments, json.dumps,
compute_etag, and check_if_match) so it executes inside the critical section
immediately after update_company acquires its serialization/lock (i.e., within
the same code path where app_state.org_mutation_service.update_company runs
under the mutation lock) so the computed current_etag reflects the state guarded
by the lock and the check_if_match prevents stale updates.

In `@src/synthorg/api/controllers/departments.py`:
- Around line 722-728: The department name matching is inconsistent:
get_department() uses case-insensitive comparison but get_department_health()
and _require_department_exists() use exact matches, causing mixed-case paths to
sometimes 404; make all lookups consistent by normalizing names to a single case
(e.g., lower()) wherever departments are indexed or compared — update the
construction/usage of dept_by_name and the comparisons in
get_department_health() and _require_department_exists() to use name.lower() (or
always store keys as lowercased names) and ensure incoming name is normalized
before lookup so all endpoints use the same case-insensitive logic.
- Around line 781-803: The If-Match ETag check is done before acquiring the org
mutation lock, causing a TOCTOU race; move the validation into the locked
mutation path so the state used to compute the ETag cannot change before update.
Specifically, stop computing cur_etag and calling check_if_match in the
controller and instead perform the ETag validation inside the org mutation code
(e.g., inside org_mutation_service.update_department or the method that holds
_org_lock): compute_etag from the current department JSON and call
check_if_match while holding the same _org_lock that protects mutations, or
change update_department to accept the incoming If-Match header and perform the
check under that lock. Ensure you reference compute_etag and check_if_match when
relocating the logic so the controller no longer does the pre-lock validation.

In `@src/synthorg/api/controllers/users.py`:
- Around line 66-72: Change the scoped_departments field to use NotBlankStr so
blank/whitespace-only names are rejected at the API boundary: import NotBlankStr
from core.types and update GrantOrgRoleRequest.scoped_departments to type
tuple[NotBlankStr, ...] = () (and do the same update for the corresponding
response DTO's scoped_departments field declared below), keeping the existing
default and model_config.

In `@src/synthorg/api/services/org_mutations.py`:
- Around line 270-286: The update-building logic ignores explicit nulls because
it conditions assignments on both membership in data.model_fields_set and value
checks like "is not None"; change each conditional to only check membership in
data.model_fields_set (e.g., for "head", "autonomy_level", "ceremony_policy",
"teams", and "budget_percent") and assign the corresponding value from data
(allowing None) into the updates dict so a client-sent null clears the field;
keep the special handling for teams to convert to tuple when present (but still
assign tuple(data.teams) even if data.teams is None/empty as appropriate).
- Around line 500-502: The update_agent() code raises NotFoundError when
existing is None without logging context; before raising, log a clear message
including the agent name and any relevant identifiers (e.g., name, maybe org_id
if available) using the module logger or processLogger so failures are
auditable; specifically, add a log call just before the raise in the block that
checks existing (refer to update_agent, the existing variable and name) that
records the attempted update and that the agent was not found, then raise
NotFoundError as before.

In `@tests/unit/api/controllers/test_agent_mutations.py`:
- Around line 204-210: Update the test to expect the correct HTTP status for a
stale If-Match ETag: change the assertion on resp.status_code in the test block
that sends a stale ETag (the test_client.patch call producing resp) from 409 to
412 so the assertion checks for 412 Precondition Failed instead of 409.

In `@tests/unit/api/controllers/test_department_mutations.py`:
- Around line 50-76: Add optimistic-concurrency tests to the
TestUpdateDepartment test class: implement three new test methods that use the
test_client to (1) fetch the department to capture its ETag (GET
/api/v1/departments/{name}), then perform a PATCH with an outdated If-Match
header and assert a 412 Precondition Failed, (2) fetch the current ETag and
PATCH with If-Match set to that value and assert 200 and the budget_percent was
updated, and (3) perform a PATCH without any If-Match header and assert the
update succeeds (200) and budget_percent changes. Use the existing
TestUpdateDepartment class and test_client, target the same endpoint
"/api/v1/departments/{name}", and assert on response.status_code and
resp.json()["data"]["budget_percent"] to validate each scenario.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4188225a-8be8-4f39-b1ba-d4435bcc4b25

📥 Commits

Reviewing files that changed from the base of the PR and between d0a8fbf and 029aaf0.

📒 Files selected for processing (59)
  • CLAUDE.md
  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/models.py
  • src/synthorg/api/channels.py
  • src/synthorg/api/controllers/agents.py
  • src/synthorg/api/controllers/company.py
  • src/synthorg/api/controllers/departments.py
  • src/synthorg/api/controllers/users.py
  • src/synthorg/api/dto_org.py
  • src/synthorg/api/guards.py
  • src/synthorg/api/services/__init__.py
  • src/synthorg/api/services/org_mutations.py
  • src/synthorg/api/state.py
  • src/synthorg/api/ws_models.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/persistence/sqlite/migrations.py
  • src/synthorg/persistence/sqlite/schema.sql
  • src/synthorg/persistence/sqlite/user_repo.py
  • tests/unit/api/controllers/test_agent_mutations.py
  • tests/unit/api/controllers/test_company_mutations.py
  • tests/unit/api/controllers/test_department_mutations.py
  • tests/unit/api/controllers/test_user_org_roles.py
  • tests/unit/api/services/__init__.py
  • tests/unit/api/services/test_org_mutations.py
  • tests/unit/api/test_auto_promote.py
  • tests/unit/api/test_channels.py
  • tests/unit/api/test_org_role_guards.py
  • web/src/__tests__/pages/org-edit/AgentCreateDialog.test.tsx
  • web/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsx
  • web/src/__tests__/pages/org-edit/DepartmentCreateDialog.test.tsx
  • web/src/__tests__/pages/org-edit/DepartmentEditDrawer.test.tsx
  • web/src/__tests__/pages/org-edit/GeneralTab.test.tsx
  • web/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsx
  • web/src/__tests__/stores/company.test.ts
  • web/src/api/endpoints/company.ts
  • web/src/api/endpoints/users.ts
  • web/src/api/types.ts
  • web/src/components/layout/Sidebar.stories.tsx
  • web/src/pages/OrgChartPage.tsx
  • web/src/pages/OrgEditPage.tsx
  • web/src/pages/org-edit/AgentCreateDialog.tsx
  • web/src/pages/org-edit/AgentEditDrawer.tsx
  • web/src/pages/org-edit/AgentsTab.tsx
  • web/src/pages/org-edit/DepartmentCreateDialog.stories.tsx
  • web/src/pages/org-edit/DepartmentCreateDialog.tsx
  • web/src/pages/org-edit/DepartmentEditDrawer.tsx
  • web/src/pages/org-edit/DepartmentsTab.tsx
  • web/src/pages/org-edit/GeneralTab.tsx
  • web/src/pages/org-edit/YamlEditorPanel.tsx
  • web/src/pages/org-edit/coming-soon.ts
  • web/src/pages/org/DepartmentGroupNode.tsx
  • web/src/pages/org/DeptAdminNode.stories.tsx
  • web/src/pages/org/DeptAdminNode.tsx
  • web/src/pages/org/OwnerNode.tsx
  • web/src/pages/org/build-org-tree.ts
  • web/src/stores/auth.ts
  • web/src/stores/company.ts
💤 Files with no reviewable changes (1)
  • web/src/pages/org-edit/coming-soon.ts

Comment on lines +125 to +128
new_etag = compute_etag(
json.dumps(updated, sort_keys=True),
"",
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Hash the canonical company snapshot for the response ETag.

Line 113 validates If-Match against the full company snapshot, but Lines 125-128 hash only updated. If update_company() returns just the patched fields, the ETag returned here cannot be reused on the next PATCH without spuriously triggering 412.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/company.py` around lines 125 - 128, The response
ETag is being computed from the partial "updated" dict but the If-Match check
validated against the full company snapshot; change the compute_etag call in the
PATCH response to hash the canonical full company snapshot used for validation
(either the merged/original company object or the full company returned by
update_company()), e.g., serialize that full snapshot with json.dumps(...,
sort_keys=True) and pass it to compute_etag (keeping the same salt/params), so
the returned ETag matches the If-Match source and won't cause spurious 412s.

@Aureliolo Aureliolo force-pushed the feat/backend-crud-multi-user-permissions branch from 029aaf0 to eaa7deb Compare April 6, 2026 19:39
@Aureliolo Aureliolo temporarily deployed to cloudflare-preview April 6, 2026 19:41 — with GitHub Actions Inactive
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 19

Caution

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

⚠️ Outside diff range comments (3)
web/src/pages/org-edit/AgentsTab.tsx (1)

235-241: 🧹 Nitpick | 🔵 Trivial

Consider removing unused dragDisabled prop from DepartmentAgentsSection.

The dragDisabled prop is no longer passed to DepartmentAgentsSection, but the component still declares it in its props interface (line 87-92) and passes it through to SortableAgentItem. Since this prop is now always undefined, drag is always enabled.

If drag should always be enabled, consider removing the dragDisabled prop from DepartmentAgentsSection's interface to avoid confusion about whether it's still a controllable behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org-edit/AgentsTab.tsx` around lines 235 - 241,
DepartmentAgentsSection still declares a dragDisabled prop and forwards it to
SortableAgentItem although no parent passes it; remove dragDisabled from
DepartmentAgentsSection's props interface and delete any forwarding/usage (the
prop passed into SortableAgentItem), and update SortableAgentItem
usage/signature if it expects that prop so types remain consistent; ensure no
other components reference DepartmentAgentsSection.dragDisabled and run TS
checks to confirm the prop is fully removed.
web/src/stores/company.ts (1)

63-63: ⚠️ Potential issue | 🟠 Major

Normalize agent reordering on names before hitting the API.

The store API still takes orderedIds, and the optimistic path keys on a.id ?? a.name, but Line 275 posts those values as agent_names. If an agent has a distinct id, the request body is no longer a permutation of names and the local optimistic reorder can diverge from the server. Either change this surface to ordered names end-to-end or translate IDs back to current agent.name values before calling apiReorderAgents().

Also applies to: 71-71, 272-276, 414-458

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/company.ts` at line 63, The reorderAgents flow is sending
orderedIds to apiReorderAgents while the optimistic update uses a.id ?? a.name
and the request body uses agent_names, causing divergence when agents have ids;
update reorderAgents (and any callers) to normalize to ordered agent names
before calling apiReorderAgents: either change the public surface to accept
orderedNames (string[]) or, inside reorderAgents, map each id in orderedIds to
the current agent.name via the store lookup (using the agents collection
referenced where optimistic keys are computed) and pass that array as
agent_names to apiReorderAgents; ensure the optimistic update and the POST
payload both use the same name-based permutation (adjust references around
reorderAgents, apiReorderAgents, and the a.id ?? a.name logic).
src/synthorg/api/controllers/departments.py (1)

903-929: ⚠️ Potential issue | 🟠 Major

Use the matched department name for health aggregation.

The lookup is case-insensitive now, but the rest of the method still uses the caller's casing. The same department can therefore emit different department_name values depending on the URL, and _resolve_active_count() is called with that non-canonical value as well. Bind the matched Department once and pass dept.name through filtering, aggregation, and logging.

Suggested fix
-        dept_by_name = {dept.name.lower(): dept for dept in departments}
-        if name.lower() not in dept_by_name:
+        dept_by_name = {dept.name.lower(): dept for dept in departments}
+        dept = dept_by_name.get(name.lower())
+        if dept is None:
             msg = f"Department {name!r} not found"
             logger.warning(
                 API_RESOURCE_NOT_FOUND,
                 resource="department",
                 name=name,
             )
             raise NotFoundError(msg)

         agents = await app_state.config_resolver.get_agents()
-        dept_agents = _filter_agents_by_department(agents, name)
+        dept_agents = _filter_agents_by_department(agents, dept.name)
         budget_cfg = await app_state.config_resolver.get_budget_config()
         health = await _assemble_department_health(
             app_state,
-            name,
+            dept.name,
             dept_agents,
             currency=budget_cfg.currency,
         )

         logger.debug(
             API_DEPARTMENT_HEALTH_QUERIED,
-            department=name,
+            department=dept.name,
             agent_count=health.agent_count,
             active_count=health.active_agent_count,
             cost_7d=health.department_cost_7d,
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/departments.py` around lines 903 - 929, The
lookup is case-insensitive but later calls still use the caller's casing; change
the logic to bind the matched Department object from dept_by_name (e.g., dept =
dept_by_name[name.lower()]) after the existence check and then use dept.name for
filtering, aggregation and logging: call _filter_agents_by_department(agents,
dept.name), pass dept.name into _assemble_department_health, and use dept.name
in the logger.debug/API_DEPARTMENT_HEALTH_QUERIED call; keep the NotFoundError
behavior when no match is found.
♻️ Duplicate comments (4)
tests/unit/api/controllers/test_department_mutations.py (1)

77-90: ⚠️ Potential issue | 🟠 Major

Assert 412 Precondition Failed for stale department ETags.

This test still bakes 409 into the optimistic-concurrency contract. A stale If-Match should fail with 412, otherwise the wrong HTTP behavior can regress unnoticed.

Suggested test update
-        assert resp.status_code == 409
+        assert resp.status_code == 412
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/controllers/test_department_mutations.py` around lines 77 -
90, The test_update_department_stale_etag test is asserting 409 for a stale
If-Match value but should assert 412 Precondition Failed; update the assertion
to expect 412 (keep the request to "/api/v1/departments/eng" with headers
{"If-Match": '"stale-etag-value000"'} and the same payload) so the
optimistic-concurrency contract is validated correctly.
tests/unit/api/controllers/test_agent_mutations.py (1)

187-210: ⚠️ Potential issue | 🟠 Major

Rename the stale-agent ETag test and assert 412.

An If-Match miss is a precondition failure, not a conflict. Keeping 409 here will mask the wrong contract and leave the test name misleading.

Suggested test update
-    def test_stale_etag_returns_409(
+    def test_stale_etag_returns_412(
         self,
         test_client: TestClient[Any],
     ) -> None:
@@
-        assert resp.status_code == 409
+        assert resp.status_code == 412
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/controllers/test_agent_mutations.py` around lines 187 - 210,
The test named test_stale_etag_returns_409 is asserting the wrong status and has
a misleading name; rename the test (e.g., test_stale_etag_returns_412 or
test_if_match_miss_returns_412) and change the assertion to expect HTTP 412
(Precondition Failed) instead of 409, updating the assertion in the
test_stale_etag_returns_409 function and any references to that test name
accordingly so the behavior and name match the If-Match contract.
src/synthorg/api/controllers/departments.py (1)

433-437: ⚠️ Potential issue | 🟠 Major

Canonicalize department names before touching ceremony-policy storage.

After Line 434 made department existence checks case-insensitive, the ceremony-policy endpoints still read and write the raw path value. ENG and eng can therefore become different keys in dept_ceremony_policies, leaving overrides that later GET/DELETE calls never see. Return the matched dept.name here (or the Department itself) and use that canonical value throughout _get/_set/_clear_dept_ceremony_override().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/departments.py` around lines 433 - 437, The
department existence check currently compares case-insensitively but does not
return the canonical department identifier, causing duplicate keys like "ENG" vs
"eng" in ceremony-policy storage; modify the loop in the function that calls
app_state.config_resolver.get_departments() so that when a matching department
is found it returns the matched dept (or at least dept.name) instead of
returning None, and then propagate and use that canonical dept.name in the
helper functions _get_dept_ceremony_override, _set_dept_ceremony_override and
_clear_dept_ceremony_override (update callers to accept the returned
Department/name) so all reads/writes use the same canonical key.
src/synthorg/api/services/org_mutations.py (1)

505-506: ⚠️ Potential issue | 🟠 Major

Allow explicit null to clear autonomy_level.

model_fields_set already tells you whether the client sent the field. The extra is not None turns { "autonomy_level": null } into a no-op, so an agent cannot return to inherited autonomy once this field has been overridden.

Suggested fix
-        if "autonomy_level" in fields_set and data.autonomy_level is not None:
-            updates["autonomy_level"] = data.autonomy_level
+        if "autonomy_level" in fields_set:
+            updates["autonomy_level"] = data.autonomy_level
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/services/org_mutations.py` around lines 505 - 506, The
current check `if "autonomy_level" in fields_set and data.autonomy_level is not
None:` prevents clients from explicitly clearing autonomy by sending null;
change the logic so presence in `fields_set` alone controls updating: when
`"autonomy_level"` is in `fields_set`, assign `updates["autonomy_level"] =
data.autonomy_level` (allowing None) so an explicit null clears the field;
update the code around `fields_set`, `data.autonomy_level`, and the `updates`
dict in org_mutations.py accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/app.py`:
- Around line 367-374: The code assumes list_users() returns users ordered by
created_at but the UserRepository protocol doesn't guarantee that; to fix, make
_maybe_promote_first_owner defensive by sorting the returned users by their
created_at ascending before selecting the first to promote. Update the logic in
_maybe_promote_first_owner to sort the users list by the User model's created_at
attribute (and keep the current promotion using model_copy and OrgRole.OWNER),
and also add or update the docstring for UserRepository.list_users to state that
implementations must return users ordered oldest-first by created_at (or at
minimum document the ordering expectation).

In `@src/synthorg/api/auth/middleware.py`:
- Around line 357-358: The code is inheriting db_user.org_roles and
scoped_departments into the AuthenticatedUser created by _resolve_api_key_user
which allows an API key (api_key.role) to escalate privileges; change
_resolve_api_key_user so that when constructing AuthenticatedUser it does NOT
copy db_user.org_roles or scoped_departments but instead derives
org_roles/scoped_departments from the api key (e.g., empty or explicitly stored
api_key.org_roles) or otherwise restrict them to the API key's allowed scope,
and/or update require_org_mutation to also validate api_key.role against the
minimum role required for the mutation; update tests to include cases where
api_key.role and db_user.org_roles differ to ensure no escalation.

In `@src/synthorg/api/controllers/users.py`:
- Around line 475-485: The error logging in the exception handler for
app_state.persistence.users.save is emitting the success event constant
API_USER_UPDATED; change this to a failure-specific event constant (e.g.,
API_USER_UPDATE_FAILED or another appropriate constant from
synthorg.observability.events.users) and update the logger.error call to use
that failure constant while preserving the structured fields (user_id, intent,
role, exc_info=True); do the same replacement for the similar logger.error block
referenced around the other occurrence (the block mentioned at lines 551-560) so
error-path logs are distinct from success events.
- Around line 432-476: The in-memory _CEO_LOCK around _get_user_or_404 and
app_state.persistence.users.save is insufficient for multi-instance concurrency;
replace the read-modify-write with a persistence-level atomic operation
(transaction or compare-and-swap) to update user.org_roles and
user.scoped_departments and enforce the "at least one owner" invariant.
Concretely: stop relying only on _CEO_LOCK and instead use an atomic update API
on app_state.persistence.users (e.g., a DB transaction or an
update_if_match/update_where that checks current org_roles or a
version/updated_at field) when modifying the User returned by _get_user_or_404
and when performing owner-revocation logic, ensure the owner-count check and the
write occur in the same transaction/conditional update so two concurrent
requests cannot both succeed and leave zero owners. Ensure the code paths that
add roles (org_roles/scoped_departments) and the paths mentioned around
owner-revocation use this atomic persistence method.

In `@src/synthorg/api/services/org_mutations.py`:
- Around line 317-325: The PATCH path is copying an unchecked ceremony_policy
into the persisted model; add validation to UpdateDepartmentRequest so
ceremony_policy, when present, is validated with
CeremonyPolicyConfig.model_validate() (or removed from the DTO). Implement a
field validator on UpdateDepartmentRequest that runs
CeremonyPolicyConfig.model_validate(data.ceremony_policy) and either returns the
validated dict or raises a validation error, then ensure the controller uses the
validated value when building updates (the code that calls model_copy/update and
the symbols to locate are UpdateDepartmentRequest, CeremonyPolicyConfig, and
model_copy in org_mutations.py).

In `@src/synthorg/api/state.py`:
- Around line 437-443: Add a has_org_mutation_service boolean property mirroring
the pattern used for other services so callers can check availability before
accessing org_mutation_service; implement has_org_mutation_service to return
True when self._org_mutation_service is present (or call the existing
_has_service helper if one exists) and False otherwise, keeping the name
has_org_mutation_service consistent with the org_mutation_service property and
referencing the backing attribute _org_mutation_service.

In `@src/synthorg/persistence/sqlite/migrations.py`:
- Around line 100-105: The allowlist validation currently raises ValueError for
bad table or column without logging; before each raise, emit a structured log at
WARNING or ERROR including the offending value and reason (e.g., "invalid table"
or "invalid column") using the same module-level logger used elsewhere in this
module, then raise the ValueError as before; apply this to the checks that
reference _ALLOWED_TABLES (log the table variable) and _ALLOWED_COLUMN_RE (log
the column variable) so migration failures produce a migration-event style log
with context before the exception is raised.

In `@tests/unit/api/controllers/test_company_mutations.py`:
- Around line 25-34: test_patch_company_observer_denied mutates the shared
test_client.headers with make_auth_headers("observer"), which can leak auth
state to other tests; instead, avoid mutating the shared fixture by creating a
fresh client or using a temp headers dict for the request (e.g., pass
headers=make_auth_headers("observer") into test_client.patch) or by copying
test_client.headers before updating and restoring it after the test; update
test_patch_company_observer_denied to use a request-scoped headers argument or a
new TestClient instance so test_client.headers is not permanently modified.
- Around line 52-58: The test expects a 201 but the reorder endpoint modifies
existing resources; update the controller that handles
"/api/v1/company/reorder-departments" (the reorder_departments handler) to
explicitly return status 200 OK (add status_code=200 on its route decorator or
return Response with status_code=200) and then change this test's assertion to
assert resp.status_code == 200 instead of 201 so REST semantics and tests are
aligned.

In `@tests/unit/api/controllers/test_user_org_roles.py`:
- Around line 70-99: Add a negative test that asserts the controller rejects
payloads containing "scoped_departments" when the role is not
"department_admin": create a new test (e.g.,
test_grant_non_department_admin_with_scopes_422) analogous to
test_grant_department_admin_without_scoped_departments_422 that seeds a user
with _seed_target_user, posts to f"/api/v1/users/{user.id}/org-roles" with json
{"role": "owner", "scoped_departments": ["eng"]}, and asserts resp.status_code
== 422 to cover the invariant that only department_admin may include
scoped_departments.

In `@web/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsx`:
- Around line 80-87: The test currently performs fireEvent.click inside waitFor
which can cause multiple clicks; refactor the block so waitFor only performs
assertions or element queries and move the click outside it — use findAllByRole
or await screen.findAllByRole to get the buttons (or use waitFor to assert
existence), then locate the destructive button by checking
getAttribute('data-variant') === 'destructive' and call
fireEvent.click(destructiveButton) after the await; update references to
waitFor, screen.getAllByRole/findAllByRole, and fireEvent.click accordingly.

In `@web/src/__tests__/pages/org-edit/DepartmentEditDrawer.test.tsx`:
- Around line 61-69: The test currently only checks enabled state for
save/delete but should verify the click handlers run and avoid fragile indexing;
update the test that uses renderDrawer to pass or access mock handler functions
(e.g., onSave, onDelete) and replace deleteButtons[deleteButtons.length - 1]
with a robust query (like getByRole with an accessible name that identifies the
department delete or a data-testid on the department delete button). Then
simulate user clicks on the Save button (saveButton) and the department delete
button (deptDelete) and assert the corresponding mock handlers were called
(e.g., expect(onSave).toHaveBeenCalled(), expect(onDelete).toHaveBeenCalled()).

In `@web/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsx`:
- Around line 30-34: The test currently checks that the Save YAML button is
disabled initially; extend the YamlEditorPanel test by simulating a user change
to the editor textarea (targeting the textarea element rendered by
YamlEditorPanel, e.g., via screen.getByRole('textbox') or querying the textarea)
using fireEvent.change or userEvent.type, then re-query the Save YAML button and
assert it is enabled; reference the test case name in this file and the
YamlEditorPanel component so you add the edit-and-assert step within the same
it('disables Save YAML button when form is not dirty', ...) test.

In `@web/src/__tests__/stores/company.test.ts`:
- Around line 261-265: The test currently only checks the department name which
doesn't prove the update path; change the assertions to verify a field that the
update actually mutates (e.g., budget_percent). After calling
useCompanyStore.getState().updateDepartment('engineering', { budget_percent: 50
}), assert that result.budget_percent === 50 and that
useCompanyStore.getState().config!.departments[0]!.budget_percent === 50 so both
the returned object (result) and the store state reflect the mutation; keep the
call to updateDepartment and replace the name assertions with these
budget_percent checks.

In `@web/src/pages/org/build-org-tree.ts`:
- Around line 364-372: The dept-admin graph node IDs are currently built only
from admin.id inside the loop over deptAdmins (DeptAdminInfo), which can produce
duplicate node IDs when the same user is admin of multiple departments; update
the adminNodeId construction (where adminNodeId is defined) to include the
department scope (matchedDept.name) so each emitted node ID is unique per
department (e.g., combine admin.id and matchedDept.name) and ensure any
references to adminNodeId in the nodes/push use the new department-scoped ID.

In `@web/src/pages/org/DeptAdminNode.tsx`:
- Around line 27-32: DeptAdminNode.tsx currently hardcodes the node width via
the class "w-[200px]" while build-org-tree.ts separately hardcodes width=200 and
height=70, which will drift; extract a single shared size definition (e.g.,
NODE_WIDTH and NODE_HEIGHT or a NODE_SIZE object) into a common module or a
shared export (create or use a shared constants file) and update DeptAdminNode
(replace "w-[200px]" and any implicit height assumptions) and build-org-tree.ts
(replace width=200 and height=70) to import and use those constants so both
components use the same source of truth.

In `@web/src/pages/org/OwnerNode.tsx`:
- Around line 12-13: The subtitle "You" in the OwnerNode component is hardcoded
and mislabels other owner nodes in multi-owner charts; update the OwnerNode
component to render "You" only when the node represents the current user (e.g.,
use an existing prop or context such as currentUserId or isCurrentUser) and for
other owner nodes render the appropriate identity label (e.g., owner.name,
owner.role, or nothing). Locate the OwnerNode React component and replace the
static "You" subtitle with a conditional that checks the node's identity (for
example: if props.isCurrentUser || owner.id === currentUserId then "You" else
owner.displayName/role).

In `@web/src/pages/OrgChartPage.tsx`:
- Line 749: Currently nodesDraggable is a global boolean
(nodesDraggable={viewMode === 'hierarchy'}) which enables drag for all node
types while your drag handlers (handleNodeDragStart and handleNodeDragStop) only
support agent nodes; change nodesDraggable to be conditional per-node so
dragging is enabled only when viewMode === 'hierarchy' AND the node's type is
'agent' (i.e., use a function or per-node predicate that checks node.type ===
'agent' together with viewMode) so the drag affordance matches the mutation
logic in handleNodeDragStart/handleNodeDragStop.

In `@web/src/stores/company.ts`:
- Around line 93-105: fetchCompanyData currently returns early on stale
responses (when get()._refreshVersion !== version) which lets callers like
updateCompany and reorderAgents treat a skipped refresh as success; change
fetchCompanyData to indicate whether it actually applied its result by returning
a boolean (or rejecting) instead of returning void: on the early "stale
response" paths in fetchCompanyData return false, on successful apply return
true, and on real errors keep throwing after setting error state; then update
callers (updateCompany, reorderAgents and other flows) to await fetchCompanyData
and verify it returned true (or retry/await the latest in-flight refresh) before
resolving their own promises so they don't consider a superseded refresh as a
successful sync. Ensure references to _refreshVersion are used exactly as in the
diff to detect staleness.

---

Outside diff comments:
In `@src/synthorg/api/controllers/departments.py`:
- Around line 903-929: The lookup is case-insensitive but later calls still use
the caller's casing; change the logic to bind the matched Department object from
dept_by_name (e.g., dept = dept_by_name[name.lower()]) after the existence check
and then use dept.name for filtering, aggregation and logging: call
_filter_agents_by_department(agents, dept.name), pass dept.name into
_assemble_department_health, and use dept.name in the
logger.debug/API_DEPARTMENT_HEALTH_QUERIED call; keep the NotFoundError behavior
when no match is found.

In `@web/src/pages/org-edit/AgentsTab.tsx`:
- Around line 235-241: DepartmentAgentsSection still declares a dragDisabled
prop and forwards it to SortableAgentItem although no parent passes it; remove
dragDisabled from DepartmentAgentsSection's props interface and delete any
forwarding/usage (the prop passed into SortableAgentItem), and update
SortableAgentItem usage/signature if it expects that prop so types remain
consistent; ensure no other components reference
DepartmentAgentsSection.dragDisabled and run TS checks to confirm the prop is
fully removed.

In `@web/src/stores/company.ts`:
- Line 63: The reorderAgents flow is sending orderedIds to apiReorderAgents
while the optimistic update uses a.id ?? a.name and the request body uses
agent_names, causing divergence when agents have ids; update reorderAgents (and
any callers) to normalize to ordered agent names before calling
apiReorderAgents: either change the public surface to accept orderedNames
(string[]) or, inside reorderAgents, map each id in orderedIds to the current
agent.name via the store lookup (using the agents collection referenced where
optimistic keys are computed) and pass that array as agent_names to
apiReorderAgents; ensure the optimistic update and the POST payload both use the
same name-based permutation (adjust references around reorderAgents,
apiReorderAgents, and the a.id ?? a.name logic).

---

Duplicate comments:
In `@src/synthorg/api/controllers/departments.py`:
- Around line 433-437: The department existence check currently compares
case-insensitively but does not return the canonical department identifier,
causing duplicate keys like "ENG" vs "eng" in ceremony-policy storage; modify
the loop in the function that calls app_state.config_resolver.get_departments()
so that when a matching department is found it returns the matched dept (or at
least dept.name) instead of returning None, and then propagate and use that
canonical dept.name in the helper functions _get_dept_ceremony_override,
_set_dept_ceremony_override and _clear_dept_ceremony_override (update callers to
accept the returned Department/name) so all reads/writes use the same canonical
key.

In `@src/synthorg/api/services/org_mutations.py`:
- Around line 505-506: The current check `if "autonomy_level" in fields_set and
data.autonomy_level is not None:` prevents clients from explicitly clearing
autonomy by sending null; change the logic so presence in `fields_set` alone
controls updating: when `"autonomy_level"` is in `fields_set`, assign
`updates["autonomy_level"] = data.autonomy_level` (allowing None) so an explicit
null clears the field; update the code around `fields_set`,
`data.autonomy_level`, and the `updates` dict in org_mutations.py accordingly.

In `@tests/unit/api/controllers/test_agent_mutations.py`:
- Around line 187-210: The test named test_stale_etag_returns_409 is asserting
the wrong status and has a misleading name; rename the test (e.g.,
test_stale_etag_returns_412 or test_if_match_miss_returns_412) and change the
assertion to expect HTTP 412 (Precondition Failed) instead of 409, updating the
assertion in the test_stale_etag_returns_409 function and any references to that
test name accordingly so the behavior and name match the If-Match contract.

In `@tests/unit/api/controllers/test_department_mutations.py`:
- Around line 77-90: The test_update_department_stale_etag test is asserting 409
for a stale If-Match value but should assert 412 Precondition Failed; update the
assertion to expect 412 (keep the request to "/api/v1/departments/eng" with
headers {"If-Match": '"stale-etag-value000"'} and the same payload) so the
optimistic-concurrency contract is validated correctly.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 74e926f8-667f-4664-9a19-5a435595c080

📥 Commits

Reviewing files that changed from the base of the PR and between 029aaf0 and eaa7deb.

📒 Files selected for processing (70)
  • CLAUDE.md
  • site/src/components/islands/ComparisonTable.tsx
  • site/src/components/islands/DashboardPreview.tsx
  • site/src/components/islands/mini/AgentDetailMini.tsx
  • site/src/components/islands/mini/BudgetMini.tsx
  • site/src/components/islands/mini/OrgChartMini.tsx
  • site/src/components/islands/mini/TaskBoardMini.tsx
  • src/synthorg/api/app.py
  • src/synthorg/api/auth/controller.py
  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/auth/models.py
  • src/synthorg/api/channels.py
  • src/synthorg/api/controllers/agents.py
  • src/synthorg/api/controllers/company.py
  • src/synthorg/api/controllers/departments.py
  • src/synthorg/api/controllers/users.py
  • src/synthorg/api/dto_org.py
  • src/synthorg/api/guards.py
  • src/synthorg/api/services/__init__.py
  • src/synthorg/api/services/org_mutations.py
  • src/synthorg/api/state.py
  • src/synthorg/api/ws_models.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/persistence/sqlite/migrations.py
  • src/synthorg/persistence/sqlite/schema.sql
  • src/synthorg/persistence/sqlite/user_repo.py
  • tests/unit/api/controllers/test_agent_mutations.py
  • tests/unit/api/controllers/test_company_mutations.py
  • tests/unit/api/controllers/test_department_mutations.py
  • tests/unit/api/controllers/test_user_org_roles.py
  • tests/unit/api/services/__init__.py
  • tests/unit/api/services/test_org_mutations.py
  • tests/unit/api/test_auto_promote.py
  • tests/unit/api/test_channels.py
  • tests/unit/api/test_org_role_guards.py
  • web/src/__tests__/App.test.tsx
  • web/src/__tests__/components/layout/Sidebar.test.tsx
  • web/src/__tests__/hooks/useAuth.test.ts
  • web/src/__tests__/pages/org-edit/AgentCreateDialog.test.tsx
  • web/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsx
  • web/src/__tests__/pages/org-edit/DepartmentCreateDialog.test.tsx
  • web/src/__tests__/pages/org-edit/DepartmentEditDrawer.test.tsx
  • web/src/__tests__/pages/org-edit/GeneralTab.test.tsx
  • web/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsx
  • web/src/__tests__/router/guards.test.tsx
  • web/src/__tests__/stores/auth.test.ts
  • web/src/__tests__/stores/company.test.ts
  • web/src/api/endpoints/company.ts
  • web/src/api/endpoints/users.ts
  • web/src/api/types.ts
  • web/src/components/layout/Sidebar.stories.tsx
  • web/src/pages/OrgChartPage.tsx
  • web/src/pages/OrgEditPage.tsx
  • web/src/pages/org-edit/AgentCreateDialog.tsx
  • web/src/pages/org-edit/AgentEditDrawer.tsx
  • web/src/pages/org-edit/AgentsTab.tsx
  • web/src/pages/org-edit/DepartmentCreateDialog.stories.tsx
  • web/src/pages/org-edit/DepartmentCreateDialog.tsx
  • web/src/pages/org-edit/DepartmentEditDrawer.tsx
  • web/src/pages/org-edit/DepartmentsTab.tsx
  • web/src/pages/org-edit/GeneralTab.tsx
  • web/src/pages/org-edit/YamlEditorPanel.tsx
  • web/src/pages/org-edit/coming-soon.ts
  • web/src/pages/org/DepartmentGroupNode.tsx
  • web/src/pages/org/DeptAdminNode.stories.tsx
  • web/src/pages/org/DeptAdminNode.tsx
  • web/src/pages/org/OwnerNode.tsx
  • web/src/pages/org/build-org-tree.ts
  • web/src/stores/auth.ts
  • web/src/stores/company.ts
💤 Files with no reviewable changes (1)
  • web/src/pages/org-edit/coming-soon.ts

Comment on lines +367 to +374
# Promote the first user (by created_at, oldest first from list_users)
first = users[0]
promoted = first.model_copy(
update={
"org_roles": (*first.org_roles, OrgRole.OWNER),
"updated_at": datetime.now(UTC),
},
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for list_users implementation to verify ordering guarantees
ast-grep --pattern 'async def list_users($$$) -> $$$:
    $$$
'

# Also check if there's ORDER BY in the SQL query
rg -n "list_users" --type py -A 10 | head -80

Repository: Aureliolo/synthorg

Length of output: 11342


Document ordering requirement in the UserRepository protocol.

The production SQLiteUserRepository does guarantee ordering by created_at (oldest first) via explicit SQL ORDER BY, as shown in its docstring. However, the UserRepository protocol (src/synthorg/persistence/repositories.py:384-393) doesn't mandate this ordering as part of its contract. This creates a subtle contract mismatch: if a custom repository implementation is added without maintaining order, _maybe_promote_first_owner would promote an arbitrary user instead of the first (oldest) one.

Add an ordering guarantee to the list_users() docstring in the protocol, or add a defensive sort in _maybe_promote_first_owner if heterogeneous repository implementations are expected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/app.py` around lines 367 - 374, The code assumes
list_users() returns users ordered by created_at but the UserRepository protocol
doesn't guarantee that; to fix, make _maybe_promote_first_owner defensive by
sorting the returned users by their created_at ascending before selecting the
first to promote. Update the logic in _maybe_promote_first_owner to sort the
users list by the User model's created_at attribute (and keep the current
promotion using model_copy and OrgRole.OWNER), and also add or update the
docstring for UserRepository.list_users to state that implementations must
return users ordered oldest-first by created_at (or at minimum document the
ordering expectation).

Comment on lines +432 to +476
async with _CEO_LOCK:
user = await _get_user_or_404(app_state, user_id)

if user.role == HumanRole.SYSTEM:
msg = "Cannot assign org roles to the system user"
logger.warning(API_VALIDATION_FAILED, reason=msg)
raise ApiValidationError(msg)

existing_roles = set(user.org_roles)
if data.role in existing_roles:
msg = f"User already has role: {data.role.value}"
logger.warning(API_RESOURCE_CONFLICT, reason=msg)
raise ConflictError(msg)

if data.role == OrgRole.DEPARTMENT_ADMIN and not data.scoped_departments:
msg = "department_admin role requires scoped_departments"
logger.warning(API_VALIDATION_FAILED, reason=msg)
raise ApiValidationError(msg)
if data.role != OrgRole.DEPARTMENT_ADMIN and data.scoped_departments:
msg = "scoped_departments can only be set for department_admin"
logger.warning(API_VALIDATION_FAILED, reason=msg)
raise ApiValidationError(msg)

new_roles = (*user.org_roles, data.role)
new_scoped = (
tuple(
sorted(
dict.fromkeys(
[*user.scoped_departments, *data.scoped_departments]
),
)
)
if data.role == OrgRole.DEPARTMENT_ADMIN
else user.scoped_departments
)
now = datetime.now(UTC)
updated = user.model_copy(
update={
"org_roles": new_roles,
"scoped_departments": new_scoped,
"updated_at": now,
},
)
try:
await app_state.persistence.users.save(updated)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

_CEO_LOCK is not enough to protect org-role writes.

These handlers still do read-modify-write on user.org_roles / scoped_departments and the last-owner count under a process-local asyncio.Lock. In a multi-worker or multi-instance deployment, concurrent requests can overwrite each other's role changes, and two owner revocations can both pass the owner_count check and leave the system with zero owners. This invariant needs a persistence-level transaction or compare-and-swap, not just an in-memory lock.

Also applies to: 523-552

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/users.py` around lines 432 - 476, The in-memory
_CEO_LOCK around _get_user_or_404 and app_state.persistence.users.save is
insufficient for multi-instance concurrency; replace the read-modify-write with
a persistence-level atomic operation (transaction or compare-and-swap) to update
user.org_roles and user.scoped_departments and enforce the "at least one owner"
invariant. Concretely: stop relying only on _CEO_LOCK and instead use an atomic
update API on app_state.persistence.users (e.g., a DB transaction or an
update_if_match/update_where that checks current org_roles or a
version/updated_at field) when modifying the User returned by _get_user_or_404
and when performing owner-revocation logic, ensure the owner-count check and the
write occur in the same transaction/conditional update so two concurrent
requests cannot both succeed and leave zero owners. Ensure the code paths that
add roles (org_roles/scoped_departments) and the paths mentioned around
owner-revocation use this atomic persistence method.

Comment on lines +364 to +372
for (const admin of deptAdmins) {
const deptLower = admin.department.toLowerCase()
const matchedDept = allDepartments.find(
(d) => d.name.toLowerCase() === deptLower,
)
if (!matchedDept) continue
const adminNodeId = `dept-admin-${admin.id}`
const groupId = `dept-${matchedDept.name}`
nodes.push({
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the file to understand the context
cat -n web/src/pages/org/build-org-tree.ts | head -400 | tail -150

Repository: Aureliolo/synthorg

Length of output: 6117


🏁 Script executed:

# Let's see the full context of the function and the loop in question
sed -n '340,390p' web/src/pages/org/build-org-tree.ts

Repository: Aureliolo/synthorg

Length of output: 1608


🏁 Script executed:

# Check the DeptAdminInfo interface definition
rg -A 10 "interface DeptAdminInfo" web/src/pages/org/

Repository: Aureliolo/synthorg

Length of output: 673


🏁 Script executed:

# Look at how the function is called to understand the structure of deptAdmins
rg -B 5 -A 5 "buildOrgTree.*deptAdmins" web/src/pages/org/

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Check other node ID constructions to understand the pattern
sed -n '1,363p' web/src/pages/org/build-org-tree.ts | grep -n "NodeId\|nodes.push\|const.*NodeId\|const.*Id ="

Repository: Aureliolo/synthorg

Length of output: 929


🏁 Script executed:

# Let's check if there are any duplicate ID checks or graph integrity validation
rg -n "duplicate\|DuplicateError\|unique.*id" web/src/pages/org/build-org-tree.ts

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Check how deptAdmins is populated - look for the function signature and callers
rg -B 5 "deptAdmins\s*[:=]" web/src/pages/org/ | head -50

Repository: Aureliolo/synthorg

Length of output: 519


🏁 Script executed:

# Look for any data fetch or API call that creates DeptAdminInfo records
rg -n "DeptAdminInfo" web/src/pages/org/ -A 3 -B 3

Repository: Aureliolo/synthorg

Length of output: 1102


🏁 Script executed:

# Check the broader function signature to understand deptAdmins parameter
sed -n '70,160p' web/src/pages/org/build-org-tree.ts

Repository: Aureliolo/synthorg

Length of output: 2819


🏁 Script executed:

# Search for usages of buildOrgTree to see how deptAdmins is passed
rg "buildOrgTree" web/src/ -A 3 -B 3

Repository: Aureliolo/synthorg

Length of output: 19636


🏁 Script executed:

# Check if there's any duplicate ID validation in the codebase or test files
rg -n "duplicate.*id\|unique.*id\|nodeId.*nodeId" web/src/__tests__/pages/org/

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Look at how deptAdmins might be populated in future (search for any admin-related data fetching)
rg -n "DeptAdminInfo\|deptAdmins" web/src/ --type ts --type tsx

Repository: Aureliolo/synthorg

Length of output: 89


🏁 Script executed:

# Check the test file to see if there are tests for deptAdmins with multi-scope scenarios
sed -n '1,50p' web/src/__tests__/pages/org/build-org-tree.test.ts

Repository: Aureliolo/synthorg

Length of output: 1711


🏁 Script executed:

# Search for any existing multi-department support patterns in the codebase
rg -n "\.department\s*=.*\[\]|departments.*array" web/src/ --type ts --type tsx

Repository: Aureliolo/synthorg

Length of output: 89


🏁 Script executed:

# Search for DeptAdminInfo without strict file type checking
rg -n "DeptAdminInfo|deptAdmins" web/src/

Repository: Aureliolo/synthorg

Length of output: 295


🏁 Script executed:

# Check if there are any existing tests for deptAdmins
rg -n "deptAdmin" web/src/__tests__/pages/org/build-org-tree.test.ts

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Look for documentation or comments about multi-department admin support
rg -n "multiple.*department|scoped.*department|dept.*admin" web/src/pages/org/build-org-tree.ts -i

Repository: Aureliolo/synthorg

Length of output: 575


🏁 Script executed:

# Check the full test file to understand test patterns and see if deptAdmins tests exist
wc -l web/src/__tests__/pages/org/build-org-tree.test.ts

Repository: Aureliolo/synthorg

Length of output: 116


🏁 Script executed:

# Check how useOrgChartData.ts calls buildOrgTree to see if/when deptAdmins is populated
sed -n '1,150p' web/src/hooks/useOrgChartData.ts | grep -A 10 -B 5 "buildOrgTree"

Repository: Aureliolo/synthorg

Length of output: 1298


🏁 Script executed:

# Search for any data sources that might populate deptAdmins
rg -n "DeptAdminInfo\|deptAdmin" web/src/hooks/ web/src/pages/

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Check if there's a data model comment or API type for admin data
rg -n "admin.*department\|AdminInfo" web/src/api/types.ts web/src/api/

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Final verification: check if React Flow requires unique node IDs
rg -n "node.*id\|unique\|duplicate" web/src/pages/org/ | grep -i "flow\|id.*unique"

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Double-check the loop logic to ensure admin records are per-department
sed -n '364,387p' web/src/pages/org/build-org-tree.ts

Repository: Aureliolo/synthorg

Length of output: 769


Make the dept-admin node ID department-scoped.

The DeptAdminInfo interface defines one department per record. If a single user is admin of multiple departments, they appear as separate entries in the deptAdmins array—one per scoped department. Line 370 uses only admin.id to construct the node ID, which means the loop can emit multiple nodes with identical graph IDs (one for each department scope). Include matchedDept.name in the node ID to ensure uniqueness across all emitted nodes.

♻️ Proposed fix
-    const adminNodeId = `dept-admin-${admin.id}`
+    const adminNodeId = `dept-admin-${matchedDept.name}-${admin.id}`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org/build-org-tree.ts` around lines 364 - 372, The dept-admin
graph node IDs are currently built only from admin.id inside the loop over
deptAdmins (DeptAdminInfo), which can produce duplicate node IDs when the same
user is admin of multiple departments; update the adminNodeId construction
(where adminNodeId is defined) to include the department scope
(matchedDept.name) so each emitted node ID is unique per department (e.g.,
combine admin.id and matchedDept.name) and ensure any references to adminNodeId
in the nodes/push use the new department-scoped ID.

Comment on lines 93 to 105
fetchCompanyData: async () => {
set({ loading: true, error: null })
const version = get()._refreshVersion + 1
set({ _refreshVersion: version, loading: true, error: null })
try {
const config = await getCompanyConfig()
if (get()._refreshVersion !== version) return // stale response
set({ config, loading: false, error: null })
} catch (err) {
if (get()._refreshVersion !== version) return // stale error
set({ loading: false, error: getErrorMessage(err) })
throw err
}
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't treat a superseded refresh as a successful sync.

On Line 98 and Line 101, fetchCompanyData() returns normally when a newer refresh bumps _refreshVersion. updateCompany() and reorderAgents() await that method to guarantee fresh state after a write, so a later WS/manual refresh that then fails can leave config stale even though the mutation promise resolved. Make fetchCompanyData() report whether it actually applied its result, or have mutation flows await the newest in-flight refresh instead of silently accepting a skipped one.

Also applies to: 150-156, 272-277

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/company.ts` around lines 93 - 105, fetchCompanyData currently
returns early on stale responses (when get()._refreshVersion !== version) which
lets callers like updateCompany and reorderAgents treat a skipped refresh as
success; change fetchCompanyData to indicate whether it actually applied its
result by returning a boolean (or rejecting) instead of returning void: on the
early "stale response" paths in fetchCompanyData return false, on successful
apply return true, and on real errors keep throwing after setting error state;
then update callers (updateCompany, reorderAgents and other flows) to await
fetchCompanyData and verify it returned true (or retry/await the latest
in-flight refresh) before resolving their own promises so they don't consider a
superseded refresh as a successful sync. Ensure references to _refreshVersion
are used exactly as in the diff to detect staleness.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

♻️ Duplicate comments (5)
src/synthorg/api/controllers/users.py (1)

532-539: ⚠️ Potential issue | 🟠 Major

TOCTOU risk: Last-owner check is not atomic with the write.

The _CEO_LOCK serializes requests within a single process, but in multi-worker or multi-instance deployments, two concurrent owner revocations could both pass the owner_count check (line 535-536) before either commits, leaving the system with zero owners.

The list_users() query and subsequent save() are not in the same database transaction. Consider using a persistence-level compare-and-swap or a conditional update that validates the owner count atomically.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/users.py` around lines 532 - 539, The current
last-owner check (OrgRole.OWNER) reads all users via
app_state.persistence.users.list_users() and counts owners before revoking,
which is vulnerable to TOCTOU across processes; change the persistence call to
perform the check-and-update atomically in the database (e.g. add a new
persistence method like users.revoke_org_role_if_owner_count_gt(org_id, user_id,
role, min_owner_count) or run the logic inside a DB transaction/conditional
update using SELECT ... FOR UPDATE or a single conditional UPDATE/DELETE that
ensures owner_count > 1), then call that new atomic persistence method instead
of list_users() so the revoke and owner-count validation cannot race.
web/src/stores/company.ts (1)

93-105: ⚠️ Potential issue | 🟡 Minor

Stale response handling may mask sync failures.

When fetchCompanyData returns early due to a newer _refreshVersion (lines 98, 101), callers like updateCompany and reorderAgents that await this method treat the early return as success. If a concurrent WS-triggered refresh starts and fails, the mutation caller won't know the sync didn't complete.

Consider returning a boolean indicating whether the fetch actually applied its result, or having mutation flows verify the final state matches their expectation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/company.ts` around lines 93 - 105, fetchCompanyData currently
returns early on stale _refreshVersion which makes callers (e.g., updateCompany,
reorderAgents) think the sync succeeded; change fetchCompanyData to return a
boolean (true when the fetched config was applied, false when an early "stale
response" return happens) and preserve throwing on real errors (rethrow err in
catch after setting error). Update callers that await fetchCompanyData (like
updateCompany and reorderAgents) to check the boolean result and treat false as
a failed/no-op sync (e.g., throw or retry) so mutations don't assume success
when a concurrent refresh superseded them; reference fetchCompanyData,
updateCompany, reorderAgents in your changes.
tests/unit/api/controllers/test_company_mutations.py (1)

60-69: ⚠️ Potential issue | 🟡 Minor

Keep the auth-denied reorder test request-scoped and valid.

Line 64 still mutates test_client.headers, which can leak observer auth into later tests, and the empty reorder payload can fail for validation/business rules before this assertion ever exercises the guard. Pass observer headers on the request and reuse a valid permutation here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/controllers/test_company_mutations.py` around lines 60 - 69,
The test_reorder_observer_denied mutates test_client.headers and sends an empty
payload which can leak auth into other tests and fail validation; change it to
pass observer headers on the single request (use make_auth_headers("observer")
as the headers argument to test_client.post rather than updating
test_client.headers) and supply a valid department permutation (e.g., a
non-empty list of existing department names) in the json body so the request
reaches the authorization guard for the "/api/v1/company/reorder-departments"
endpoint.
web/src/pages/org/build-org-tree.ts (1)

371-378: ⚠️ Potential issue | 🟠 Major

Make the dept-admin node ID department-scoped.

Line 377 still builds IDs from only admin.id. DeptAdminInfo is one record per (user, department) scope, so a user who administers multiple departments will emit duplicate node IDs and break graph identity. Include matchedDept.name in the ID.

♻️ Suggested fix
-    const adminNodeId = `dept-admin-${admin.id}`
+    const adminNodeId = `dept-admin-${matchedDept.name}-${admin.id}`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/org/build-org-tree.ts` around lines 371 - 378, The dept-admin
node IDs are currently built only from admin.id in the for loop over deptAdmins
(DeptAdminInfo records), causing duplicate IDs when a user administers multiple
departments; update the adminNodeId construction in that loop (where adminNodeId
is defined) to include the department scope (use matchedDept.name or deptLower)
so the ID is unique per (user, department) pair and also ensure groupId remains
`dept-${matchedDept.name}` to match the scope.
src/synthorg/api/controllers/departments.py (1)

724-726: ⚠️ Potential issue | 🟠 Major

Canonicalize ceremony-policy lookups and writes too.

These routes now resolve departments case-insensitively, but the ceremony-policy helpers still key overrides by the raw name string and fall back with exact dept.name == department_name checks. A request like /departments/Engineering/ceremony-policy can therefore create or miss a different override than /departments/engineering/ceremony-policy, even though the department itself resolves fine. Resolve the canonical dept.name once and use that for _get_dept_ceremony_override(), _set_dept_ceremony_override(), and _clear_dept_ceremony_override().

Also applies to: 960-963, 1019-1021

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/departments.py` around lines 724 - 726, Resolve
the department once to its canonical name and use that canonical string when
calling the ceremony-policy helpers instead of the raw request `name`; i.e.,
after you find `dept` via case-insensitive match (the `dept` variable found by
comparing `dept.name.lower()` to `name.lower()`), set a local `canonical_name =
dept.name` (or reuse `dept.name`) and pass that to
`_get_dept_ceremony_override()`, `_set_dept_ceremony_override()`, and
`_clear_dept_ceremony_override()` so all lookups/writes use the same canonical
department identifier; apply the same change in the other similar blocks that
call these helpers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/dto_org.py`:
- Around line 91-100: Add model_provider and model_id fields to
UpdateAgentOrgRequest with the same types/constraints used in
CreateAgentOrgRequest (match field names and types exactly) and add the same
pairwise validation logic (the same validator/root_validator function used in
CreateAgentOrgRequest) to enforce that either both model_provider and model_id
are supplied or both are omitted; update the validator to reference
UpdateAgentOrgRequest’s fields so PATCH requests can change or clear model
assignment consistently with creation.

In `@src/synthorg/api/services/org_mutations.py`:
- Around line 161-173: _company_snapshot_etag currently builds the snapshot only
from company_name, agents, and departments so changes to autonomy_level,
budget_monthly, or communication_pattern aren't included in the ETag; update
_company_snapshot_etag to include all writable company fields (at least
autonomy_level, budget_monthly, communication_pattern) in the snapshot dict
before calling compute_etag, and ensure the JSON serialization (json.dumps(...,
sort_keys=True)) includes these keys so update_company's concurrency check using
compute_etag/If-Match will detect changes; update any other snapshot code paths
referenced around update_company to match this same full-field inclusion.
- Around line 192-197: The current check_if_match helper raises
VersionConflictError (mapped to HTTP 409), causing stale If-Match checks to
return 409 instead of the expected 412; update the behavior so check_if_match
signals a Precondition Failed condition (either by raising a dedicated
PreconditionFailedError or by adjusting errors.py to map the check_if_match
failure to HTTP 412) and ensure all callers (e.g., the If-Match branches in
org_mutations.py that call check_if_match and the other locations mentioned)
continue to catch/propagate that error type; specifically, modify
check_if_match's raised exception or the error-to-status mapping in
src/synthorg/api/errors.py so that the stale ETag path returns 412 rather than
409.
- Around line 3-6: The module-level asyncio.Lock named _org_lock is only
per-process and therefore does not provide cross-process synchronization when
the server is run with multiple workers (see configured workers up to 32);
update the module docstring (or add a clear comment) to state that mutations are
only serialized for single-process/single-event-loop deployments or implement a
cross-process sync mechanism if multi-worker support is required; also modify
the _company_snapshot_etag function so the ETag includes autonomy_level,
budget_monthly, and communication_pattern (i.e., ensure the ETag covers every
field that UpdateCompanyRequest can change) so concurrent updates to those
fields are detected by If-Match checks.

In `@web/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsx`:
- Around line 78-83: The test is flaky because findAllByRole returns as soon as
any Delete button appears (the drawer's), so destructuveButton can be undefined;
change the test to explicitly wait for the dialog's destructive confirm button
instead of scanning all delete buttons: after opening the dialog, await the
specific destructive confirmation button using a findByRole/findByText targeting
the confirm/delete button text (or scope to the dialog element then use
within(dialog).findByRole) and then call fireEvent.click on that returned
element (look for the element with data-variant="destructive" or the
confirm/delete label to identify it).

In `@web/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsx`:
- Around line 36-42: The test in YamlEditorPanel.test.tsx uses a single example
input instead of a property-based test; replace the current "enables Save YAML
button after editor content changes" test with a fast-check property using
fc.assert and fc.property: generate arbitrary non-empty YAML-ish strings (or
arbitrary strings), render the <YamlEditorPanel config={makeCompanyConfig()}
onSave={mockOnSave} saving={false}>, perform fireEvent.change on the textarea
(screen.getByLabelText(/yaml editor/i)) with each generated value, then assert
the Save YAML button (screen.getByText('Save YAML').closest('button')!) is not
disabled for all cases; keep references to YamlEditorPanel, makeCompanyConfig,
mockOnSave, and the textarea label to locate the code.

In `@web/src/pages/org/build-org-tree.ts`:
- Around line 379-381: buildOrgTree creates nodes with type 'deptAdmin' but
OrgChartPage.tsx's nodeTypes map only registers agent, ceo, department, team,
and owner, so deptAdmin nodes won't render; update the nodeTypes map in
OrgChartPage.tsx to include the 'deptAdmin' key mapping to the DeptAdminNode
component (or the correct component name used for department admins) so that
nodes created by buildOrgTree with type 'deptAdmin' are rendered properly.

---

Duplicate comments:
In `@src/synthorg/api/controllers/departments.py`:
- Around line 724-726: Resolve the department once to its canonical name and use
that canonical string when calling the ceremony-policy helpers instead of the
raw request `name`; i.e., after you find `dept` via case-insensitive match (the
`dept` variable found by comparing `dept.name.lower()` to `name.lower()`), set a
local `canonical_name = dept.name` (or reuse `dept.name`) and pass that to
`_get_dept_ceremony_override()`, `_set_dept_ceremony_override()`, and
`_clear_dept_ceremony_override()` so all lookups/writes use the same canonical
department identifier; apply the same change in the other similar blocks that
call these helpers.

In `@src/synthorg/api/controllers/users.py`:
- Around line 532-539: The current last-owner check (OrgRole.OWNER) reads all
users via app_state.persistence.users.list_users() and counts owners before
revoking, which is vulnerable to TOCTOU across processes; change the persistence
call to perform the check-and-update atomically in the database (e.g. add a new
persistence method like users.revoke_org_role_if_owner_count_gt(org_id, user_id,
role, min_owner_count) or run the logic inside a DB transaction/conditional
update using SELECT ... FOR UPDATE or a single conditional UPDATE/DELETE that
ensures owner_count > 1), then call that new atomic persistence method instead
of list_users() so the revoke and owner-count validation cannot race.

In `@tests/unit/api/controllers/test_company_mutations.py`:
- Around line 60-69: The test_reorder_observer_denied mutates
test_client.headers and sends an empty payload which can leak auth into other
tests and fail validation; change it to pass observer headers on the single
request (use make_auth_headers("observer") as the headers argument to
test_client.post rather than updating test_client.headers) and supply a valid
department permutation (e.g., a non-empty list of existing department names) in
the json body so the request reaches the authorization guard for the
"/api/v1/company/reorder-departments" endpoint.

In `@web/src/pages/org/build-org-tree.ts`:
- Around line 371-378: The dept-admin node IDs are currently built only from
admin.id in the for loop over deptAdmins (DeptAdminInfo records), causing
duplicate IDs when a user administers multiple departments; update the
adminNodeId construction in that loop (where adminNodeId is defined) to include
the department scope (use matchedDept.name or deptLower) so the ID is unique per
(user, department) pair and also ensure groupId remains
`dept-${matchedDept.name}` to match the scope.

In `@web/src/stores/company.ts`:
- Around line 93-105: fetchCompanyData currently returns early on stale
_refreshVersion which makes callers (e.g., updateCompany, reorderAgents) think
the sync succeeded; change fetchCompanyData to return a boolean (true when the
fetched config was applied, false when an early "stale response" return happens)
and preserve throwing on real errors (rethrow err in catch after setting error).
Update callers that await fetchCompanyData (like updateCompany and
reorderAgents) to check the boolean result and treat false as a failed/no-op
sync (e.g., throw or retry) so mutations don't assume success when a concurrent
refresh superseded them; reference fetchCompanyData, updateCompany,
reorderAgents in your changes.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ca97ee00-4200-4e79-b250-efa74592ed0c

📥 Commits

Reviewing files that changed from the base of the PR and between eaa7deb and 3b3e5b4.

📒 Files selected for processing (23)
  • src/synthorg/api/auth/middleware.py
  • src/synthorg/api/controllers/company.py
  • src/synthorg/api/controllers/departments.py
  • src/synthorg/api/controllers/users.py
  • src/synthorg/api/dto_org.py
  • src/synthorg/api/services/org_mutations.py
  • src/synthorg/api/state.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/persistence/sqlite/migrations.py
  • tests/unit/api/controllers/test_company_mutations.py
  • tests/unit/api/controllers/test_user_org_roles.py
  • web/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsx
  • web/src/__tests__/pages/org-edit/DepartmentEditDrawer.test.tsx
  • web/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsx
  • web/src/__tests__/stores/company.test.ts
  • web/src/hooks/useOrgChartData.ts
  • web/src/pages/OrgChartPage.tsx
  • web/src/pages/org-edit/AgentsTab.tsx
  • web/src/pages/org-edit/DepartmentEditDrawer.tsx
  • web/src/pages/org/DeptAdminNode.tsx
  • web/src/pages/org/OwnerNode.tsx
  • web/src/pages/org/build-org-tree.ts
  • web/src/stores/company.ts

Comment on lines +3 to +6
Encapsulates read-modify-write operations on the company, department,
and agent configuration stored in the settings system. All mutations
are serialised through a module-level ``asyncio.Lock`` which is safe
for the single-process, single-event-loop Litestar deployment model.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "_org_lock = asyncio.Lock\(|workers:\s*int|workers=server\.workers|OrgMutationService\(" \
  src/synthorg/api/services/org_mutations.py \
  src/synthorg/api/config.py \
  src/synthorg/api/server.py \
  src/synthorg/api/state.py

Repository: Aureliolo/synthorg

Length of output: 451


🏁 Script executed:

head -80 src/synthorg/api/services/org_mutations.py | cat -n

Repository: Aureliolo/synthorg

Length of output: 3126


🏁 Script executed:

# Check for database locks or external sync
rg -A 5 -B 5 "async with _org_lock" src/synthorg/api/services/org_mutations.py | head -50

# Check departments.py pattern
rg -B 3 -A 3 "_dept_policy_lock" src/synthorg/api/services/departments.py | head -30

# Check if there's any cross-process locking
rg -n "redis|distributed|lock|mutex" src/synthorg/api/ | head -20

Repository: Aureliolo/synthorg

Length of output: 3861


🏁 Script executed:

# Check server.py workers configuration
sed -n '1,100p' src/synthorg/api/server.py | cat -n

# Check config.py workers field
sed -n '165,180p' src/synthorg/api/config.py | cat -n

# Check concurrency check_if_match
rg -B 3 -A 10 "def check_if_match" src/synthorg/api/concurrency.py

Repository: Aureliolo/synthorg

Length of output: 3734


🏁 Script executed:

# Check check_if_match and VersionConflictError mapping
rg -A 20 "def check_if_match" src/synthorg/api/concurrency.py

# Check VersionConflictError definition and HTTP mapping
rg -B 5 -A 5 "class VersionConflictError|VersionConflictError" src/synthorg/api/errors.py

# Check _company_snapshot_etag implementation
rg -B 2 -A 10 "_company_snapshot_etag" src/synthorg/api/services/org_mutations.py

Repository: Aureliolo/synthorg

Length of output: 2659


🏁 Script executed:

# Check UpdateCompanyRequest fields
rg -B 2 -A 20 "class UpdateCompanyRequest" src/synthorg/api/dto_org.py

# Check what _company_snapshot_etag includes vs what update_company modifies
sed -n '161,240p' src/synthorg/api/services/org_mutations.py | cat -n

Repository: Aureliolo/synthorg

Length of output: 4490


_org_lock does not protect multi-worker deployments; docstring contradicts server configuration.

The module docstring claims mutations are safe "for the single-process, single-event-loop Litestar deployment model," but the server config allows up to 32 workers (src/synthorg/api/config.py:170-171, workers: int = Field(default=1, ge=1, le=32, ...)). When workers > 1, each process has its own asyncio.Lock() instance, so concurrent requests to different workers can both pass stale If-Match checks. Update the docstring to document this constraint or implement cross-process synchronization.

Additionally, _company_snapshot_etag() includes only company_name, agents, and departments, but UpdateCompanyRequest also allows updating autonomy_level, budget_monthly, and communication_pattern. These three fields are not in the ETag, so concurrent mutations to these fields bypass conflict detection.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/services/org_mutations.py` around lines 3 - 6, The
module-level asyncio.Lock named _org_lock is only per-process and therefore does
not provide cross-process synchronization when the server is run with multiple
workers (see configured workers up to 32); update the module docstring (or add a
clear comment) to state that mutations are only serialized for
single-process/single-event-loop deployments or implement a cross-process sync
mechanism if multi-worker support is required; also modify the
_company_snapshot_etag function so the ETag includes autonomy_level,
budget_monthly, and communication_pattern (i.e., ensure the ETag covers every
field that UpdateCompanyRequest can change) so concurrent updates to those
fields are detected by If-Match checks.

Comment on lines +192 to +197
if if_match:
cur_name = await self._resolver.get_str("company", "company_name")
cur_agents = await self._read_agents()
cur_depts = await self._read_departments()
cur_etag = self._company_snapshot_etag(cur_name, cur_agents, cur_depts)
check_if_match(if_match, cur_etag, "company")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Stale If-Match requests still return 409 instead of 412.

All three PATCH flows delegate to check_if_match(), but that helper raises VersionConflictError, which src/synthorg/api/errors.py maps to HTTP 409. As written, stale ETags on company, department, and agent updates will not satisfy the 412 Precondition Failed contract called out for this feature.

Also applies to: 304-306, 540-542

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/services/org_mutations.py` around lines 192 - 197, The
current check_if_match helper raises VersionConflictError (mapped to HTTP 409),
causing stale If-Match checks to return 409 instead of the expected 412; update
the behavior so check_if_match signals a Precondition Failed condition (either
by raising a dedicated PreconditionFailedError or by adjusting errors.py to map
the check_if_match failure to HTTP 412) and ensure all callers (e.g., the
If-Match branches in org_mutations.py that call check_if_match and the other
locations mentioned) continue to catch/propagate that error type; specifically,
modify check_if_match's raised exception or the error-to-status mapping in
src/synthorg/api/errors.py so that the stale ETag path returns 412 rather than
409.

Comment on lines +36 to 42
it('enables Save YAML button after editor content changes', () => {
render(<YamlEditorPanel config={makeCompanyConfig()} onSave={mockOnSave} saving={false} />)
const textarea = screen.getByLabelText(/yaml editor/i)
fireEvent.change(textarea, { target: { value: 'company_name: Updated' } })
const saveButton = screen.getByText('Save YAML').closest('button')!
expect(saveButton).not.toBeDisabled()
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use fast-check for this dirty-state enablement test

This example test should be expressed as a property test (fc.assert + fc.property) per repo testing standards for React tests.

♻️ Suggested rewrite with fast-check
+import fc from 'fast-check'
 import { fireEvent, render, screen } from '@testing-library/react'

@@
-  it('enables Save YAML button after editor content changes', () => {
-    render(<YamlEditorPanel config={makeCompanyConfig()} onSave={mockOnSave} saving={false} />)
-    const textarea = screen.getByLabelText(/yaml editor/i)
-    fireEvent.change(textarea, { target: { value: 'company_name: Updated' } })
-    const saveButton = screen.getByText('Save YAML').closest('button')!
-    expect(saveButton).not.toBeDisabled()
-  })
+  it('enables Save YAML button after editor content changes', () => {
+    fc.assert(
+      fc.property(fc.string(), (nextValue) => {
+        render(<YamlEditorPanel config={makeCompanyConfig()} onSave={mockOnSave} saving={false} />)
+        const textarea = screen.getByLabelText(/yaml editor/i)
+        fireEvent.change(textarea, { target: { value: nextValue } })
+        const saveButton = screen.getByText('Save YAML').closest('button')!
+        expect(saveButton).not.toBeDisabled()
+      }),
+    )
+  })

As per coding guidelines, web/src/**/*.test.{ts,tsx}: “Use fast-check for property-based testing in React with fc.assert and fc.property instead of Hypothesis.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsx` around lines 36 -
42, The test in YamlEditorPanel.test.tsx uses a single example input instead of
a property-based test; replace the current "enables Save YAML button after
editor content changes" test with a fast-check property using fc.assert and
fc.property: generate arbitrary non-empty YAML-ish strings (or arbitrary
strings), render the <YamlEditorPanel config={makeCompanyConfig()}
onSave={mockOnSave} saving={false}>, perform fireEvent.change on the textarea
(screen.getByLabelText(/yaml editor/i)) with each generated value, then assert
the Save YAML button (screen.getByText('Save YAML').closest('button')!) is not
disabled for all cases; keep references to YamlEditorPanel, makeCompanyConfig,
mockOnSave, and the textarea label to locate the code.

…#1081)

Add OrgMutationService with read-modify-write pattern for org config
mutations (asyncio.Lock serialization, referential integrity checks,
budget sum warnings).

Backend:
- PATCH /company (update name, autonomy, budget, communication)
- POST/PATCH/DELETE /departments/{name} (create, update, delete)
- POST /company/reorder-departments (exact permutation validation)
- POST/PATCH/DELETE /agents/{name} (create, update, delete)
- POST /departments/{name}/reorder-agents (exact permutation)
- 9 new WsEventType values + CHANNEL_COMPANY, CHANNEL_DEPARTMENTS
- 9 new observability event constants
- Request DTOs in dto_org.py mirroring frontend types
- OrgMutationService wired into AppState

Frontend:
- Remove mutation stubs comment (endpoints now real)
- Add WS channels and event types to types.ts
- Extend updateFromWsEvent reducer for all org mutation events
- Rename agent_ids to agent_names in ReorderAgentsRequest

Tests:
- 24 service-layer tests (all CRUD + error cases)
- 21 controller-level tests (HTTP status, guards, referential integrity)
- Updated channel count test (9 -> 11)

Closes #1081
Add OrgRole enum (owner/department_admin/editor/viewer) orthogonal
to HumanRole. OrgRole controls org config access; HumanRole controls
operational persona within the simulation.

Backend:
- OrgRole enum + User/AuthenticatedUser model extensions
  (org_roles, scoped_departments fields with empty defaults)
- SQLite schema migration (ADD COLUMN with defaults, idempotent)
- User repo updated to persist/load JSON org_roles + scoped_departments
- Auth middleware propagates org_roles to AuthenticatedUser
- /auth/me response includes org_roles + scoped_departments
- require_org_mutation() guard factory: owner/editor always allowed,
  department_admin checked against scoped_departments, viewer denied,
  falls back to HumanRole write check when no org_roles set
- POST /users/{id}/org-roles (grant) +
  DELETE /users/{id}/org-roles/{role} (revoke)
- First-user auto-promotion to owner on startup (idempotent)

Frontend:
- OrgRole type added to types.ts
- UserInfoResponse extended with optional org_roles/scoped_departments
- DeptAdminNode component (info-blue accent, shield icon)
- DeptAdminInfo interface + parameter on buildOrgTree
- OwnerNode docstring updated (no longer future reference)
- users.ts endpoint module (listUsers, grantOrgRole, revokeOrgRole)
- Auth store dev user includes org_roles

Closes #1082
Pre-reviewed by 18 agents, 49 findings addressed:

- Wire require_org_mutation guard to all CRUD endpoints (was dead code)
- Fix OrgMutationService creation in deferred init path (503 fix)
- Fix migration except syntax (PEP 758) + catch-all OperationalError
- Add SQL injection hardening (allowlist + quoted identifiers)
- Add JSON type validation in user_repo deserialization
- Fix change_password response missing org_roles
- Add TOCTOU locks to grant/revoke org role endpoints
- Implement ETag/If-Match on PATCH endpoints (company/dept/agent)
- Add CEO agent deletion guard (c_suite 409)
- Remove dead DTO fields (display_name, status, personality_preset)
- Add model validators (scoped_departments, model_provider/model_id)
- Fix NotBlankStr for scoped_departments fields
- Fix API response types (frontend type mismatches)
- Remove coming-soon UI gates (#1081 endpoints now live)
- Wire dept admin nodes in org chart build-org-tree
- Create DeptAdminNode.stories.tsx
- Add 37 new tests (guards, grant/revoke, auto-promote, ETag, CEO)
- Fix scoped_departments non-deterministic ordering
- Add logging on grant/revoke error paths
- Update CLAUDE.md package structure
- Fix controller docstrings (read-only to CRUD)
- Fix stale line reference in org_mutations.py
…nd fixes

CRITICAL fixes:
- Add symmetric invariant: DEPARTMENT_ADMIN requires non-empty scoped_departments
- Clear scoped_departments when revoking DEPARTMENT_ADMIN role
- Only merge scoped_departments when granting DEPARTMENT_ADMIN (not other roles)
- Fix CEO deletion check: only block CEO, not all C-suite
- Add logging before all raises in org_mutations.py
- Use model_fields_set for explicit null field updates (department, agent)
- Use standard import re instead of __import__('re')
- Fix test: assert 412 (not 409) for stale ETag precondition

MAJOR fixes:
- Make UserInfoResponse org_roles/scoped_departments required (not optional)
- Remove display_name from DepartmentCreateDialog/DepartmentEditDrawer (not in API)
- Status field in AgentEditDrawer now read-only display (not sent to backend)
- Extract ORG_MUTATION_EVENTS to module-level constant
- Refactor update_agent to reduce complexity below threshold (extract _validate_agent_update)

All changes validated against CLAUDE.md conventions and Python 3.14 PEP 758 except syntax.
Backend:
- Move ETag If-Match checks inside OrgMutationService lock (TOCTOU fix)
- Fix company response ETag to use full snapshot, not partial dict
- Add missing warning logs before raises in org_mutations
- Wrap users.py persistence saves in try/except QueryError
- Fix department name lookups to be consistently case-insensitive
- Allow explicit null-clearing in update_department fields
- Use NotBlankStr for scoped_departments in GrantOrgRoleRequest
- Fix user_repo deserialization: use 'is None' not falsy 'or'
- Fix CEO deletion error message (was 'c-suite', now 'CEO')

Frontend:
- Type updateCompany return as Partial<CompanyConfig>
- Add GrantOrgRoleRequest discriminated union for type safety
- Remove interactive styling from unhandled 'Add agent' chips
- Add refresh version guard to prevent store race conditions

Tests:
- Add submit-flow tests for AgentCreate/AgentEdit/DepartmentCreate
- Add refetch assertions to company store tests
- Add 3 ETag tests for department PATCH endpoint
- Fix UserInfoResponse test fixtures (org_roles, scoped_departments)

Site:
- Add explicit React imports to all Astro island components (SSR fix)
Security:
- Prevent API key privilege escalation: org_roles/scoped_departments
  no longer copied from db_user into AuthenticatedUser (empty tuples)

Backend:
- Use API_USER_SAVE_FAILED error event in persistence error handlers
- Validate ceremony_policy via CeremonyPolicyConfig in DTO validator
- Add has_org_mutation_service property to AppState
- Log warnings before ValueError raises in migrations.py
- Return 200 (not 201) for reorder-departments endpoint
- Use canonical dept.name in get_department_health after lookup
- Allow autonomy_level null-clearing in _validate_agent_update

Frontend:
- OwnerNode: show 'You' only for current user, 'Owner' for others
- OrgChartPage: restrict draggable to agent nodes in hierarchy view
- Remove unused dragDisabled prop from AgentsTab components
- Fix reorderAgents id/name divergence (resolve ids to names)
- Add data-testid to dept delete button, remove fragile indexing
- Extract DEPT_ADMIN_WIDTH/HEIGHT as shared constants

Tests:
- Fix shared headers leak in test_company_mutations
- Add negative test for scoped_departments with non-dept-admin role
- Add dirty-state enablement test for YamlEditorPanel
- Assert budget_percent in updateDepartment store test
- Move fireEvent.click out of waitFor in AgentEditDrawer test
- Add model_provider/model_id to UpdateAgentOrgRequest with pairwise validator
- Include all company scalars in _company_snapshot_etag (autonomy, budget, comm)
- Register deptAdmin in OrgChartPage nodeTypes map
- Use canonical dept.name for ceremony-policy helper calls
- Fix reorder observer test shared headers leak
@Aureliolo Aureliolo force-pushed the feat/backend-crud-multi-user-permissions branch from 3b3e5b4 to 6b6627f Compare April 6, 2026 20:40
@Aureliolo Aureliolo merged commit 93e469b into main Apr 6, 2026
12 of 14 checks passed
@Aureliolo Aureliolo deleted the feat/backend-crud-multi-user-permissions branch April 6, 2026 20:40
@Aureliolo Aureliolo temporarily deployed to cloudflare-preview April 6, 2026 20:40 — with GitHub Actions Inactive
Aureliolo added a commit that referenced this pull request Apr 6, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.6.3](v0.6.2...v0.6.3)
(2026-04-06)


### Features

* backend CRUD + multi-user permissions
([#1081](#1081),
[#1082](#1082))
([#1094](#1094))
([93e469b](93e469b))
* in-dashboard team editing + budget rebalance on pack apply
([#1093](#1093))
([35977c0](35977c0)),
closes [#1079](#1079)
[#1080](#1080)
* tiered rate limiting, NotificationSink protocol, in-dashboard
notifications
([#1092](#1092))
([df2142c](df2142c)),
closes [#1077](#1077)
[#1078](#1078)
[#849](#849)
* two-stage safety classifier and cross-provider uncertainty check for
approval gates
([#1090](#1090))
([0b2edee](0b2edee)),
closes [#847](#847)
[#701](#701)


### Refactoring

* memory pipeline improvements
([#1075](#1075),
[#997](#997))
([#1091](#1091))
([a048a4c](a048a4c))


### Documentation

* add OpenCode parity setup and hookify rule documentation
([#1095](#1095))
([52e877a](52e877a))


### Maintenance

* bump vite from 8.0.3 to 8.0.4 in /web in the all group across 1
directory ([#1088](#1088))
([1e86ca6](1e86ca6))
* tune ZAP DAST scan -- auth, timeouts, rules, report artifacts
([#1097](#1097))
([82bf0e1](82bf0e1)),
closes [#1096](#1096)

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants