Organization architecture

The tree, access resolution, positions, the directory read

How the module is built. For how it is used — creating departments, placing people, reporting lines, what access follows from where somebody sits — see Organization.

Three routes — /organization, /organization/departments, /organization/directory — over api/organization.py (19 endpoints) and api/teams.py (5).

Departments are not teams#

Both exist, they are different things, and confusing them is the main way to get lost here.

DepartmentTeam
Modeldepartments, department_members, department_positionsteams, team_members
ShapeA tree — every department has an optional parentA flat set
Answers"where does this person sit, and who do they report to""who works on this together"
Grants app accessYes, via an access profileNo
Owns on-callNoYes — rotations are team-scoped

A person is normally in exactly one department and any number of teams.

Access profiles#

A department carries a DepartmentAccessProfile — a named bundle of app access, resolved from SYSTEM_APP_BUNDLES.

GET  /departments/{id}/access-profile
PUT  /departments/{id}/access-profile
GET  /access-profiles

This is why frontend/src/config/appDefinitions.ts and backend/src/aexy/models/app_definitions.py must agree: the resolver reads the backend copy, the sidebar reads the frontend one, and a disagreement means the nav offers apps the API refuses — or hides apps the user can reach by typing the URL. scripts/dump_app_catalog.py regenerates the fixture that holds the two together; test_app_catalog_fixture.py and appCatalogParity.test.ts enforce it from either side.

The tree#

GET /org-chart returns DepartmentNodes — pre-nested, rather than a flat list for the client to assemble. path and depth are maintained by OrganizationService, which is why a department must be created through it rather than inserted: a row with the wrong path looks correct in the table and breaks the chart.

POST /departments/{id}/reparent moves a subtree. It moves the children with it and re-resolves access for everyone underneath, which is the expensive part, and it walks the proposed parent's ancestry first to refuse a cycle.

Reporting lines#

Stored as workspace_members.manager_id — on the membership, not the department, because a reporting line follows the person and survives a move. set_manager requires an active member of the same workspace (the column is a foreign key to developers.id, which is wider than that) and rejects a cycle by walking the proposed manager's chain.

Positions#

department_positions describes a seat rather than a person — title, PositionStatus, the department it belongs to, and optionally who fills it. A vacant position is a real row, which is what lets Hiring open a requisition against it and what makes headcount reporting possible before anyone is hired.

Directory#

GET /people returns PersonSummary by walking WorkspaceMember — the only read that can show somebody who is in no department at all, which is what backs the roster picker, the directory's "not in a department" group and the reports-to picker. /insights/developers is the narrower list: only people with engineering activity in a period.

Common pitfalls#

  • Reparenting changes access. Moving a department under a different parent can change what its members can open. It is not a cosmetic drag.
  • Deleting a department with children is refused, not cascaded. Reparent the children first.
  • A person with no department has no profile-derived access and falls back to their role. That is a valid state, and it is why some users see a different sidebar than their colleagues.
  • Team membership does not imply department membership. On-call reads teams; app access reads departments. A rotation and a sidebar can disagree entirely and both be correct.