feat: backend CRUD + multi-user permissions (#1081, #1082)#1094
feat: backend CRUD + multi-user permissions (#1081, #1082)#1094
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (71)
WalkthroughAdds org-scoped roles and scoped-department support to auth models, middleware, and responses; introduces Suggested labels
Comment |
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Snapshot WarningsEnsure 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 FilesNone |
There was a problem hiding this comment.
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.
| except MemoryError, RecursionError: | ||
| raise |
There was a problem hiding this comment.
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| updated = user.model_copy( | ||
| update={ | ||
| "org_roles": new_roles, | ||
| "updated_at": now, | ||
| }, | ||
| ) |
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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.
| await onUpdate(department.name, { | ||
| display_name: displayName.trim() || undefined, | ||
| budget_percent: Number.isFinite(pct) ? pct : undefined, | ||
| ceremony_policy: ceremonyPolicy, | ||
| }) | ||
| onClose() |
There was a problem hiding this comment.
| @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 |
There was a problem hiding this comment.
| 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 |
There was a problem hiding this comment.
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 Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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 +
OrgMutationServicewith 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.
| <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" /> |
There was a problem hiding this comment.
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.
| 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' | ||
| } |
There was a problem hiding this comment.
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.
| try { | ||
| await onUpdate(department.name, { | ||
| display_name: displayName.trim() || undefined, | ||
| budget_percent: Number.isFinite(pct) ? pct : undefined, | ||
| ceremony_policy: ceremonyPolicy, | ||
| }) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| snapshot = { | ||
| "company_name": name, | ||
| "agents": [a.model_dump(mode="json") for a in agents], | ||
| "departments": [d.model_dump(mode="json") for d in depts], | ||
| } |
There was a problem hiding this comment.
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.
| new_etag = compute_etag( | ||
| json.dumps(updated, sort_keys=True), | ||
| "", | ||
| ) |
There was a problem hiding this comment.
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).
| 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]), | ||
| ) | ||
| ) |
There was a problem hiding this comment.
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).
| 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 |
| updated = user.model_copy( | ||
| update={ | ||
| "org_roles": new_roles, | ||
| "updated_at": now, | ||
| }, | ||
| ) |
There was a problem hiding this comment.
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).
| 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) |
| await onUpdate(agent.name, { | ||
| name: trimmedName, | ||
| role: form.role.trim() || undefined, | ||
| department: form.department as UpdateAgentOrgRequest['department'], | ||
| level: form.level, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 | 🟡 MinorMissing click handler on interactive "Add agent" chip.
The chip has
hover:bg-*andcursor-pointerstyling that suggests it's interactive, but there's noonClickhandler. 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 | 🟡 MinorSame missing click handler issue on populated department's "Add agent" chip.
This chip also has interactive styling (
hover:bg-background/50,cursor-pointer) but noonClick. 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 | 🟡 MinorStatus field is editable but changes are silently discarded on save.
The
statusfield is rendered as an editableSelectField, but thehandleSavefunction (lines 89-94) excludesstatusfrom theonUpdatepayload. Users can modify the status, but the change will never persist, creating a confusing experience.If
UpdateAgentOrgRequestno longer supportsstatus, consider making this field read-only or removing theSelectFieldentirely 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 | 🔵 TrivialConsider removing unused
dragDisabledprop.The
dragDisabledprop onDepartmentAgentsSection(and correspondingly onSortableAgentItem) 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 | 🔵 TrivialConsider extracting the event set to a module-level constant.
The
ReadonlySetis 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
📒 Files selected for processing (59)
CLAUDE.mdsrc/synthorg/api/app.pysrc/synthorg/api/auth/controller.pysrc/synthorg/api/auth/middleware.pysrc/synthorg/api/auth/models.pysrc/synthorg/api/channels.pysrc/synthorg/api/controllers/agents.pysrc/synthorg/api/controllers/company.pysrc/synthorg/api/controllers/departments.pysrc/synthorg/api/controllers/users.pysrc/synthorg/api/dto_org.pysrc/synthorg/api/guards.pysrc/synthorg/api/services/__init__.pysrc/synthorg/api/services/org_mutations.pysrc/synthorg/api/state.pysrc/synthorg/api/ws_models.pysrc/synthorg/observability/events/api.pysrc/synthorg/persistence/sqlite/migrations.pysrc/synthorg/persistence/sqlite/schema.sqlsrc/synthorg/persistence/sqlite/user_repo.pytests/unit/api/controllers/test_agent_mutations.pytests/unit/api/controllers/test_company_mutations.pytests/unit/api/controllers/test_department_mutations.pytests/unit/api/controllers/test_user_org_roles.pytests/unit/api/services/__init__.pytests/unit/api/services/test_org_mutations.pytests/unit/api/test_auto_promote.pytests/unit/api/test_channels.pytests/unit/api/test_org_role_guards.pyweb/src/__tests__/pages/org-edit/AgentCreateDialog.test.tsxweb/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsxweb/src/__tests__/pages/org-edit/DepartmentCreateDialog.test.tsxweb/src/__tests__/pages/org-edit/DepartmentEditDrawer.test.tsxweb/src/__tests__/pages/org-edit/GeneralTab.test.tsxweb/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsxweb/src/__tests__/stores/company.test.tsweb/src/api/endpoints/company.tsweb/src/api/endpoints/users.tsweb/src/api/types.tsweb/src/components/layout/Sidebar.stories.tsxweb/src/pages/OrgChartPage.tsxweb/src/pages/OrgEditPage.tsxweb/src/pages/org-edit/AgentCreateDialog.tsxweb/src/pages/org-edit/AgentEditDrawer.tsxweb/src/pages/org-edit/AgentsTab.tsxweb/src/pages/org-edit/DepartmentCreateDialog.stories.tsxweb/src/pages/org-edit/DepartmentCreateDialog.tsxweb/src/pages/org-edit/DepartmentEditDrawer.tsxweb/src/pages/org-edit/DepartmentsTab.tsxweb/src/pages/org-edit/GeneralTab.tsxweb/src/pages/org-edit/YamlEditorPanel.tsxweb/src/pages/org-edit/coming-soon.tsweb/src/pages/org/DepartmentGroupNode.tsxweb/src/pages/org/DeptAdminNode.stories.tsxweb/src/pages/org/DeptAdminNode.tsxweb/src/pages/org/OwnerNode.tsxweb/src/pages/org/build-org-tree.tsweb/src/stores/auth.tsweb/src/stores/company.ts
💤 Files with no reviewable changes (1)
- web/src/pages/org-edit/coming-soon.ts
| 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( |
There was a problem hiding this comment.
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.
| resp = test_client.patch( | ||
| "/api/v1/agents/alice", | ||
| json={"level": "senior"}, | ||
| headers={"If-Match": '"stale-etag-value000"'}, | ||
| ) | ||
| assert resp.status_code == 409 |
There was a problem hiding this comment.
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.
| 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, | ||
| }) |
There was a problem hiding this comment.
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.
| 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> | ||
| ) | ||
| } |
There was a problem hiding this comment.
🧹 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.
d0a8fbf to
029aaf0
Compare
There was a problem hiding this comment.
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 | 🟡 MinorSame issue: populated-state Add agent chip lacks click handler.
This chip also has
cursor-pointerand hover styling but noonClick. 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 | 🟡 MinorWire up onClick handler for "Add agent" chips or remove interactive styling.
Both "Add agent" chips (empty state and populated state) have
cursor-pointerandhover:bg-*styling, making them visually interactive, but neither has anonClickhandler. The component props lack anonAddAgentcallback 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 | 🔵 TrivialConsider removing unused
dragDisabledprop fromDepartmentAgentsSection.The
dragDisabledprop is no longer passed toDepartmentAgentsSection(line 235-241), but it remains in the component's interface (lines 87, 92) and is still forwarded toSortableAgentItem. If drag disabling is now fully handled by the sortable component's owndisabledbehavior, 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 | 🔵 TrivialConsider removing unused
disabledprop fromSortableDepartmentCard.Similar to
DepartmentAgentsSectioninAgentsTab.tsx, thedisabledprop is no longer passed toSortableDepartmentCardbut 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 | 🟡 MinorThese assertions don't pin the new sync contract.
updateCompanycan still pass if the store goes back to local mutation,reorderAgentsdoes not stub or assert the mandatorygetCompanyConfig()refetch, andreorderDepartmentsnever checks that the rest ofconfigsurvives 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 | 🟠 MajorSerialize 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 rollconfig/departmentHealthsback to stale data. Please funnel both paths through one sequenced refresh helper or ignore out-of-date completions before callingset(...).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 | 🔴 CriticalMove the
If-Matchcheck 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 | 🟠 MajorUse
NotBlankStrforscoped_departmentsin 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(fromcore.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 | 🟡 MinorWrong status code assertion: stale
If-Matchshould 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 | 🔴 CriticalTOCTOU race in
If-Matchvalidation — ETag check is not atomic with write.The current ETag is validated at lines 191-205 before
org_mutation_service.update_agent()acquires_org_lockat 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_matchtoupdate_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 | 🟡 MinorCase-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 == nameThis 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 | 🔴 CriticalTOCTOU race in department
If-Matchvalidation.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 | 🟠 MajorMissing
If-Match/ETag optimistic concurrency tests for department updates.
TestUpdateDepartmentonly covers the happy path and 404 cases. The department update endpoint supports optimistic concurrency viaIf-Matchheader, but there are no tests for:
- Stale ETag returning 412 Precondition Failed
- Matching ETag allowing the update
- Omitted
If-Matchheader bypassing the checkThe agent mutation tests (
test_agent_mutations.py) includeTestUpdateAgentETagwith 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 | 🟠 MajorExplicit
nullcannot 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_setalone to detect presence, then assigning the value (includingNone):- 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 | 🟡 MinorMissing log before raising
NotFoundErrorinupdate_agent().Same issue:
NotFoundErrorraised 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
📒 Files selected for processing (59)
CLAUDE.mdsrc/synthorg/api/app.pysrc/synthorg/api/auth/controller.pysrc/synthorg/api/auth/middleware.pysrc/synthorg/api/auth/models.pysrc/synthorg/api/channels.pysrc/synthorg/api/controllers/agents.pysrc/synthorg/api/controllers/company.pysrc/synthorg/api/controllers/departments.pysrc/synthorg/api/controllers/users.pysrc/synthorg/api/dto_org.pysrc/synthorg/api/guards.pysrc/synthorg/api/services/__init__.pysrc/synthorg/api/services/org_mutations.pysrc/synthorg/api/state.pysrc/synthorg/api/ws_models.pysrc/synthorg/observability/events/api.pysrc/synthorg/persistence/sqlite/migrations.pysrc/synthorg/persistence/sqlite/schema.sqlsrc/synthorg/persistence/sqlite/user_repo.pytests/unit/api/controllers/test_agent_mutations.pytests/unit/api/controllers/test_company_mutations.pytests/unit/api/controllers/test_department_mutations.pytests/unit/api/controllers/test_user_org_roles.pytests/unit/api/services/__init__.pytests/unit/api/services/test_org_mutations.pytests/unit/api/test_auto_promote.pytests/unit/api/test_channels.pytests/unit/api/test_org_role_guards.pyweb/src/__tests__/pages/org-edit/AgentCreateDialog.test.tsxweb/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsxweb/src/__tests__/pages/org-edit/DepartmentCreateDialog.test.tsxweb/src/__tests__/pages/org-edit/DepartmentEditDrawer.test.tsxweb/src/__tests__/pages/org-edit/GeneralTab.test.tsxweb/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsxweb/src/__tests__/stores/company.test.tsweb/src/api/endpoints/company.tsweb/src/api/endpoints/users.tsweb/src/api/types.tsweb/src/components/layout/Sidebar.stories.tsxweb/src/pages/OrgChartPage.tsxweb/src/pages/OrgEditPage.tsxweb/src/pages/org-edit/AgentCreateDialog.tsxweb/src/pages/org-edit/AgentEditDrawer.tsxweb/src/pages/org-edit/AgentsTab.tsxweb/src/pages/org-edit/DepartmentCreateDialog.stories.tsxweb/src/pages/org-edit/DepartmentCreateDialog.tsxweb/src/pages/org-edit/DepartmentEditDrawer.tsxweb/src/pages/org-edit/DepartmentsTab.tsxweb/src/pages/org-edit/GeneralTab.tsxweb/src/pages/org-edit/YamlEditorPanel.tsxweb/src/pages/org-edit/coming-soon.tsweb/src/pages/org/DepartmentGroupNode.tsxweb/src/pages/org/DeptAdminNode.stories.tsxweb/src/pages/org/DeptAdminNode.tsxweb/src/pages/org/OwnerNode.tsxweb/src/pages/org/build-org-tree.tsweb/src/stores/auth.tsweb/src/stores/company.ts
💤 Files with no reviewable changes (1)
- web/src/pages/org-edit/coming-soon.ts
| new_etag = compute_etag( | ||
| json.dumps(updated, sort_keys=True), | ||
| "", | ||
| ) |
There was a problem hiding this comment.
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.
029aaf0 to
eaa7deb
Compare
There was a problem hiding this comment.
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 | 🔵 TrivialConsider removing unused
dragDisabledprop fromDepartmentAgentsSection.The
dragDisabledprop is no longer passed toDepartmentAgentsSection, but the component still declares it in its props interface (line 87-92) and passes it through toSortableAgentItem. Since this prop is now alwaysundefined, drag is always enabled.If drag should always be enabled, consider removing the
dragDisabledprop fromDepartmentAgentsSection'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 | 🟠 MajorNormalize agent reordering on names before hitting the API.
The store API still takes
orderedIds, and the optimistic path keys ona.id ?? a.name, but Line 275 posts those values asagent_names. If an agent has a distinctid, 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 currentagent.namevalues before callingapiReorderAgents().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 | 🟠 MajorUse 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_namevalues depending on the URL, and_resolve_active_count()is called with that non-canonical value as well. Bind the matchedDepartmentonce and passdept.namethrough 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 | 🟠 MajorAssert
412 Precondition Failedfor stale department ETags.This test still bakes
409into the optimistic-concurrency contract. A staleIf-Matchshould fail with412, 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 | 🟠 MajorRename the stale-agent ETag test and assert
412.An
If-Matchmiss is a precondition failure, not a conflict. Keeping409here 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 | 🟠 MajorCanonicalize 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.
ENGandengcan therefore become different keys indept_ceremony_policies, leaving overrides that later GET/DELETE calls never see. Return the matcheddept.namehere (or theDepartmentitself) 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 | 🟠 MajorAllow explicit
nullto clearautonomy_level.
model_fields_setalready tells you whether the client sent the field. The extrais not Noneturns{ "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
📒 Files selected for processing (70)
CLAUDE.mdsite/src/components/islands/ComparisonTable.tsxsite/src/components/islands/DashboardPreview.tsxsite/src/components/islands/mini/AgentDetailMini.tsxsite/src/components/islands/mini/BudgetMini.tsxsite/src/components/islands/mini/OrgChartMini.tsxsite/src/components/islands/mini/TaskBoardMini.tsxsrc/synthorg/api/app.pysrc/synthorg/api/auth/controller.pysrc/synthorg/api/auth/middleware.pysrc/synthorg/api/auth/models.pysrc/synthorg/api/channels.pysrc/synthorg/api/controllers/agents.pysrc/synthorg/api/controllers/company.pysrc/synthorg/api/controllers/departments.pysrc/synthorg/api/controllers/users.pysrc/synthorg/api/dto_org.pysrc/synthorg/api/guards.pysrc/synthorg/api/services/__init__.pysrc/synthorg/api/services/org_mutations.pysrc/synthorg/api/state.pysrc/synthorg/api/ws_models.pysrc/synthorg/observability/events/api.pysrc/synthorg/persistence/sqlite/migrations.pysrc/synthorg/persistence/sqlite/schema.sqlsrc/synthorg/persistence/sqlite/user_repo.pytests/unit/api/controllers/test_agent_mutations.pytests/unit/api/controllers/test_company_mutations.pytests/unit/api/controllers/test_department_mutations.pytests/unit/api/controllers/test_user_org_roles.pytests/unit/api/services/__init__.pytests/unit/api/services/test_org_mutations.pytests/unit/api/test_auto_promote.pytests/unit/api/test_channels.pytests/unit/api/test_org_role_guards.pyweb/src/__tests__/App.test.tsxweb/src/__tests__/components/layout/Sidebar.test.tsxweb/src/__tests__/hooks/useAuth.test.tsweb/src/__tests__/pages/org-edit/AgentCreateDialog.test.tsxweb/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsxweb/src/__tests__/pages/org-edit/DepartmentCreateDialog.test.tsxweb/src/__tests__/pages/org-edit/DepartmentEditDrawer.test.tsxweb/src/__tests__/pages/org-edit/GeneralTab.test.tsxweb/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsxweb/src/__tests__/router/guards.test.tsxweb/src/__tests__/stores/auth.test.tsweb/src/__tests__/stores/company.test.tsweb/src/api/endpoints/company.tsweb/src/api/endpoints/users.tsweb/src/api/types.tsweb/src/components/layout/Sidebar.stories.tsxweb/src/pages/OrgChartPage.tsxweb/src/pages/OrgEditPage.tsxweb/src/pages/org-edit/AgentCreateDialog.tsxweb/src/pages/org-edit/AgentEditDrawer.tsxweb/src/pages/org-edit/AgentsTab.tsxweb/src/pages/org-edit/DepartmentCreateDialog.stories.tsxweb/src/pages/org-edit/DepartmentCreateDialog.tsxweb/src/pages/org-edit/DepartmentEditDrawer.tsxweb/src/pages/org-edit/DepartmentsTab.tsxweb/src/pages/org-edit/GeneralTab.tsxweb/src/pages/org-edit/YamlEditorPanel.tsxweb/src/pages/org-edit/coming-soon.tsweb/src/pages/org/DepartmentGroupNode.tsxweb/src/pages/org/DeptAdminNode.stories.tsxweb/src/pages/org/DeptAdminNode.tsxweb/src/pages/org/OwnerNode.tsxweb/src/pages/org/build-org-tree.tsweb/src/stores/auth.tsweb/src/stores/company.ts
💤 Files with no reviewable changes (1)
- web/src/pages/org-edit/coming-soon.ts
| # 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), | ||
| }, | ||
| ) |
There was a problem hiding this comment.
🧩 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 -80Repository: 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).
| 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) |
There was a problem hiding this comment.
_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.
| 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({ |
There was a problem hiding this comment.
🧩 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 -150Repository: 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.tsRepository: 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.tsRepository: 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 -50Repository: 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 3Repository: 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.tsRepository: 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 3Repository: 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 tsxRepository: 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.tsRepository: 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 tsxRepository: 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.tsRepository: 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 -iRepository: 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.tsRepository: 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.tsRepository: 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.
| 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 | ||
| } | ||
| }, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Actionable comments posted: 7
♻️ Duplicate comments (5)
src/synthorg/api/controllers/users.py (1)
532-539:⚠️ Potential issue | 🟠 MajorTOCTOU risk: Last-owner check is not atomic with the write.
The
_CEO_LOCKserializes requests within a single process, but in multi-worker or multi-instance deployments, two concurrent owner revocations could both pass theowner_countcheck (line 535-536) before either commits, leaving the system with zero owners.The
list_users()query and subsequentsave()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 | 🟡 MinorStale response handling may mask sync failures.
When
fetchCompanyDatareturns early due to a newer_refreshVersion(lines 98, 101), callers likeupdateCompanyandreorderAgentsthatawaitthis 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 | 🟡 MinorKeep 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 | 🟠 MajorMake the dept-admin node ID department-scoped.
Line 377 still builds IDs from only
admin.id.DeptAdminInfois one record per(user, department)scope, so a user who administers multiple departments will emit duplicate node IDs and break graph identity. IncludematchedDept.namein 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 | 🟠 MajorCanonicalize ceremony-policy lookups and writes too.
These routes now resolve departments case-insensitively, but the ceremony-policy helpers still key overrides by the raw
namestring and fall back with exactdept.name == department_namechecks. A request like/departments/Engineering/ceremony-policycan therefore create or miss a different override than/departments/engineering/ceremony-policy, even though the department itself resolves fine. Resolve the canonicaldept.nameonce 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
📒 Files selected for processing (23)
src/synthorg/api/auth/middleware.pysrc/synthorg/api/controllers/company.pysrc/synthorg/api/controllers/departments.pysrc/synthorg/api/controllers/users.pysrc/synthorg/api/dto_org.pysrc/synthorg/api/services/org_mutations.pysrc/synthorg/api/state.pysrc/synthorg/observability/events/api.pysrc/synthorg/persistence/sqlite/migrations.pytests/unit/api/controllers/test_company_mutations.pytests/unit/api/controllers/test_user_org_roles.pyweb/src/__tests__/pages/org-edit/AgentEditDrawer.test.tsxweb/src/__tests__/pages/org-edit/DepartmentEditDrawer.test.tsxweb/src/__tests__/pages/org-edit/YamlEditorPanel.test.tsxweb/src/__tests__/stores/company.test.tsweb/src/hooks/useOrgChartData.tsweb/src/pages/OrgChartPage.tsxweb/src/pages/org-edit/AgentsTab.tsxweb/src/pages/org-edit/DepartmentEditDrawer.tsxweb/src/pages/org/DeptAdminNode.tsxweb/src/pages/org/OwnerNode.tsxweb/src/pages/org/build-org-tree.tsweb/src/stores/company.ts
| 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. |
There was a problem hiding this comment.
🧩 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.pyRepository: Aureliolo/synthorg
Length of output: 451
🏁 Script executed:
head -80 src/synthorg/api/services/org_mutations.py | cat -nRepository: 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 -20Repository: 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.pyRepository: 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.pyRepository: 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 -nRepository: 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.
| 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") |
There was a problem hiding this comment.
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.
| 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() | ||
| }) |
There was a problem hiding this comment.
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
3b3e5b4 to
6b6627f
Compare
🤖 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).
Summary
Implements the 9 missing CRUD endpoints for company/departments/agents (#1081) and the multi-user ownership + department-admin permission system (#1082).
Backend
require_org_mutationguard factory (department-scoped for dept admins, backward-compatible fallback to HumanRole)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-promotionReview 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 passednpm --prefix web run test-- 2406 passeduv run mypy src/ tests/-- cleannpm --prefix web run type-check-- cleanuv run ruff check src/ tests/-- cleannpm --prefix web run lint-- cleanKnown remaining items
Closes #1081
Closes #1082