Workforce Planning — Semantic Model
1. Overview
A scenario-based headcount planning system. The system tracks the current org (departments, locations, cost centers, jobs, employees, positions) as the source-of-truth baseline, and lets planners draft alternative future-state plans through scenarios containing staged headcount actions (add, eliminate, transfer). Approved scenarios materialize into real position records, which can then be handed off to recruiting via lightweight hiring requisitions.
2. Entity summary
| # | Table name | Singular label | Purpose |
|---|---|---|---|
| 1 | departments | Department | Org units the workforce is grouped into; supports hierarchy via parent_department_id |
| 2 | locations | Location | Offices, regions, or remote pools where positions sit |
| 3 | cost_centers | Cost Center | Financial buckets used to budget headcount cost |
| 4 | jobs | Job | Catalog of role definitions — title, level, family — used to template positions |
| 5 | employees | Employee | Current workforce members; can occupy a position and report to a manager |
| 6 | positions | Position | A discrete seat (filled, open, or approved-future), tied to a job, department, location, cost center |
| 7 | headcount_plans | Headcount Plan | A named plan covering a timeframe (e.g. “FY2026”) with status (draft → in-review → approved → active → archived) |
| 8 | scenarios | Scenario | A what-if version of a plan (base, optimistic, conservative, custom). Many per plan; one is marked active |
| 9 | headcount_actions | Headcount Action | A staged change within a scenario — add, eliminate, or transfer — with effective date and cost impact |
| 10 | hiring_requisitions | Hiring Requisition | A lightweight handoff record marking that a position has been cleared to start recruiting |
Entity-relationship diagram
flowchart LR
departments -->|parent of| departments
employees -->|heads| departments
departments -->|primary dept for| cost_centers
employees -->|owns| cost_centers
employees -->|manages| employees
locations -->|home for| employees
jobs -->|templates| positions
departments -->|contains| positions
locations -->|hosts| positions
cost_centers -->|funds| positions
employees ---|fills| positions
positions -->|backfilled by| positions
headcount_actions -->|materializes as| positions
employees -->|owns| headcount_plans
employees -->|approves| headcount_plans
headcount_plans -->|contains| scenarios
employees -->|creates| scenarios
scenarios -->|contains| headcount_actions
positions -->|targeted by| headcount_actions
jobs -->|specified in| headcount_actions
departments -->|target of| headcount_actions
locations -->|target of| headcount_actions
cost_centers -->|target of| headcount_actions
positions -->|opens| hiring_requisitions
employees -->|recruits| hiring_requisitions
employees -->|hiring manager for| hiring_requisitions
3. Entities
3.1 departments — Department
Plural label: Departments
Label column: department_name
Audit log: no
Description: An org unit the workforce is grouped into (e.g. Engineering, Sales). Supports hierarchy via parent_department_id so sub-departments can roll up under a parent. Created by HR or planning admins as the org’s structural skeleton.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
department_name | string | yes | Name | unique |
department_code | string | no | Code | unique; short code, e.g. ENG, SLS |
parent_department_id | reference | no | Parent Department | → departments (N:1, self) |
head_employee_id | reference | no | Department Head | → employees (N:1) |
description | text | no | Description | |
is_active | boolean | yes | Active |
Relationships
- A
departmentmay have a parentdepartment(N:1, optional, self-referential, clear on delete). - A
departmentmay have one heademployee(N:1, optional, clear on delete). - A
departmentmay have manycost_centersfor which it is the primary department (1:N, viacost_centers.primary_department_id). - A
departmentmay host manypositions(1:N, viapositions.department_id). - A
departmentmay be the target of manyheadcount_actions(1:N, viaheadcount_actions.department_id).
3.2 locations — Location
Plural label: Locations
Label column: location_name
Audit log: no
Description: An office, regional hub, remote pool, or field location where positions can sit. Used to plan headcount geography and assign employees a home base.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
location_name | string | yes | Name | |
location_type | enum | yes | Type | values: office, remote_pool, hybrid_hub, field |
city | string | no | City | |
region | string | no | Region / State | |
country | string | no | Country | ISO-2 or full name |
timezone | string | no | Time Zone | IANA, e.g. Europe/Berlin |
is_active | boolean | yes | Active |
Relationships
- A
locationmay be the home location for manyemployees(1:N, viaemployees.home_location_id). - A
locationmay host manypositions(1:N, viapositions.location_id). - A
locationmay be the target of manyheadcount_actions(1:N, viaheadcount_actions.location_id).
3.3 cost_centers — Cost Center
Plural label: Cost Centers
Label column: cost_center_code
Audit log: no
Description: A financial bucket against which headcount cost is budgeted and reported. Often (but not always) maps 1:1 with a department. Carries a currency for multi-currency budgeting support.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
cost_center_code | string | yes | Code | unique |
cost_center_name | string | yes | Name | |
primary_department_id | reference | no | Primary Department | → departments (N:1) |
owner_employee_id | reference | no | Owner | → employees (N:1) |
currency_code | string | yes | Currency | ISO-4217, e.g. USD, EUR |
is_active | boolean | yes | Active |
Relationships
- A
cost_centermay have a primarydepartment(N:1, optional, clear on delete). - A
cost_centermay have one owneremployee(N:1, optional, clear on delete). - A
cost_centermay fund manypositions(1:N, viapositions.cost_center_id). - A
cost_centermay be the target of manyheadcount_actions(1:N, viaheadcount_actions.cost_center_id).
3.4 jobs — Job
Plural label: Jobs
Label column: job_name
Audit log: no
Description: A reusable role definition (title + level + family) that templates positions. A position is “an instance of a job” placed in a department/location/cost center. Carries an optional comp band for budgeting reference.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
job_name | string | yes | Name | e.g. “Senior Software Engineer” |
job_code | string | no | Code | unique |
job_family | string | no | Job Family | e.g. Engineering, Sales |
job_level | string | no | Level | e.g. L4, Manager, Director |
job_type | enum | yes | Type | values: individual_contributor, people_manager, executive |
description | text | no | Description | |
min_annual_compensation | float | no | Min Annual Compensation | comp band low |
max_annual_compensation | float | no | Max Annual Compensation | comp band high |
compensation_currency | string | no | Compensation Currency | ISO-4217 |
is_active | boolean | yes | Active |
Relationships
- A
jobmay template manypositions(1:N, viapositions.job_id). - A
jobmay be specified in manyheadcount_actions(1:N, viaheadcount_actions.job_id).
3.5 employees — Employee
Plural label: Employees
Label column: employee_full_name
Audit log: yes
Description: A current workforce member (full-time, part-time, contractor, or intern). Each employee may occupy a position and reports to a manager. Lifecycle covers pending_start → active → on_leave → terminated.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
employee_full_name | string | yes | Full Name | |
employee_number | string | no | Employee Number | unique |
work_email | email | no | Work Email | unique |
employment_type | enum | yes | Employment Type | values: full_time, part_time, contractor, intern |
employment_status | enum | yes | Employment Status | values: pending_start, active, on_leave, terminated |
hire_date | date | no | Hire Date | |
termination_date | date | no | Termination Date | |
manager_employee_id | reference | no | Manager | → employees (N:1, self) |
home_location_id | reference | no | Home Location | → locations (N:1) |
Relationships
- An
employeemay have a manageremployee(N:1, optional, self-referential, clear on delete). - An
employeemay have a homelocation(N:1, optional, clear on delete). - An
employeemay fill exactly oneposition(1:1, viapositions.current_employee_idwith uniqueness). - An
employeemay head manydepartments(1:N, viadepartments.head_employee_id). - An
employeemay own manycost_centers(1:N, viacost_centers.owner_employee_id). - An
employeemay own and approve manyheadcount_plans(1:N each, viaheadcount_plans.owner_employee_idand.approved_by_employee_id). - An
employeemay create manyscenarios(1:N, viascenarios.created_by_employee_id). - An
employeemay serve as recruiter or hiring manager on manyhiring_requisitions(1:N each).
3.6 positions — Position
Plural label: Positions
Label column: position_code
Audit log: yes
Description: A discrete seat in the org. Captures both reality (filled or open today) and approved-future seats (committed from an approved scenario, with a target start date). Uncommitted what-if seats live as headcount_actions, not as positions. Status drives lifecycle: filled ↔ open, approved_future → open once the start date arrives, eliminated for closed seats.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
position_code | string | yes | Position Code | unique, e.g. POS-00123 |
position_status | enum | yes | Status | values: filled, open, approved_future, on_hold, eliminated |
job_id | reference | yes | Job | → jobs (N:1) |
department_id | reference | yes | Department | → departments (N:1) |
location_id | reference | yes | Location | → locations (N:1) |
cost_center_id | reference | yes | Cost Center | → cost_centers (N:1) |
current_employee_id | reference | no | Current Employee | → employees (N:1); unique — at most one position per employee |
fte | float | yes | FTE | 1.0 = full-time, 0.5 = half-time |
target_start_date | date | no | Target Start Date | for approved_future and open |
actual_start_date | date | no | Actual Start Date | when the seat became filled |
end_date | date | no | End Date | when the seat was eliminated |
budgeted_annual_cost | float | no | Budgeted Annual Cost | |
budget_currency | string | no | Budget Currency | ISO-4217 |
is_backfill | boolean | no | Is Backfill | |
backfill_for_position_id | reference | no | Backfill For | → positions (N:1, self) |
originated_from_action_id | reference | no | Originated From Action | → headcount_actions (N:1); set when committed from a scenario |
notes | text | no | Notes |
Relationships
- A
positionbelongs to onejob, onedepartment, onelocation, onecost_center(each N:1, required, restrict on delete). - A
positionmay be filled by exactly oneemployee(1:1, viacurrent_employee_idwith uniqueness, clear on delete). - A
positionmay be a backfill of anotherposition(N:1, optional, self-referential, clear on delete). - A
positionmay have originated from oneheadcount_action(N:1, optional, clear on delete). - A
positionmay be targeted by manyheadcount_actions(1:N, viaheadcount_actions.target_position_id). - A
positionmay have manyhiring_requisitionsover time (1:N, viahiring_requisitions.position_id, restrict on delete).
3.7 headcount_plans — Headcount Plan
Plural label: Headcount Plans
Label column: plan_name
Audit log: yes
Description: A named plan covering a fiscal timeframe. Acts as a container for one or more scenarios. Lifecycle: draft → in_review → approved → active → archived.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
plan_name | string | yes | Name | e.g. “FY2026 Headcount Plan” |
plan_status | enum | yes | Status | values: draft, in_review, approved, active, archived |
fiscal_year_label | string | no | Fiscal Year | e.g. FY2026 |
start_date | date | yes | Start Date | |
end_date | date | yes | End Date | |
owner_employee_id | reference | no | Plan Owner | → employees (N:1) |
description | text | no | Description | |
approved_at | date-time | no | Approved At | |
approved_by_employee_id | reference | no | Approved By | → employees (N:1) |
Relationships
- A
headcount_planmay have one owneremployeeand one approveremployee(each N:1, optional, clear on delete). - A
headcount_planhas manyscenarios(1:N, parent, cascade on delete — scenarios live and die with their plan).
3.8 scenarios — Scenario
Plural label: Scenarios
Label column: scenario_name
Audit log: yes
Description: An alternative version of a plan (base case, aggressive growth, conservative). Each plan has many scenarios; exactly one per plan is marked is_active_for_plan = true. The active scenario’s actions are what gets committed when the plan is approved.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
scenario_name | string | yes | Name | e.g. “Base Case”, “Aggressive Growth” |
headcount_plan_id | parent | yes | Plan | ↳ headcount_plans (N:1, cascade) |
scenario_type | enum | yes | Type | values: base, optimistic, conservative, custom |
scenario_status | enum | yes | Status | values: draft, in_review, approved, archived |
is_active_for_plan | boolean | yes | Active for Plan | exactly one per plan should be true |
description | text | no | Description | |
committed_at | date-time | no | Committed At | when actions were materialized into positions |
created_by_employee_id | reference | no | Created By | → employees (N:1) |
Relationships
- A
scenariobelongs to oneheadcount_plan(N:1, parent, cascade on delete). - A
scenariomay have a creatoremployee(N:1, optional, clear on delete). - A
scenariohas manyheadcount_actions(1:N, parent, cascade on delete).
3.9 headcount_actions — Headcount Action
Plural label: Headcount Actions
Label column: action_label
Audit log: yes
Description: A single staged change within a scenario. Three action types: add (create a new seat), eliminate (close an existing seat), transfer (move an existing seat between department/location/cost center). The caller populates action_label (e.g. “Add Sr SWE / Eng / Berlin / 2026-Q1”) to give the action a human-readable handle.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
action_label | string | yes | Label | caller populates |
scenario_id | parent | yes | Scenario | ↳ scenarios (N:1, cascade) |
action_type | enum | yes | Action Type | values: add, eliminate, transfer |
action_status | enum | yes | Status | values: proposed, in_review, approved, committed, rejected |
target_position_id | reference | no | Target Position | → positions (N:1); required for eliminate/transfer, null for add |
job_id | reference | no | Job | → jobs (N:1); required for add |
department_id | reference | no | Department | → departments (N:1); required for add, target dept for transfer |
location_id | reference | no | Location | → locations (N:1); required for add, target loc for transfer |
cost_center_id | reference | no | Cost Center | → cost_centers (N:1); required for add, target cc for transfer |
effective_date | date | yes | Effective Date | |
fte | float | no | FTE | for add |
budgeted_annual_cost | float | no | Budgeted Annual Cost | for add |
budget_currency | string | no | Budget Currency | ISO-4217 |
justification | text | no | Justification |
Relationships
- A
headcount_actionbelongs to onescenario(N:1, parent, cascade on delete). - A
headcount_actionmay target one existingposition(N:1, optional, clear on delete) — required foreliminate/transfer. - A
headcount_actionmay specify onejob,department,location,cost_center(each N:1, optional, clear on delete) — populated foradd/transferper the action type. - A
headcount_actionmay materialize as manypositionsonce committed (1:N, viapositions.originated_from_action_id).
3.10 hiring_requisitions — Hiring Requisition
Plural label: Hiring Requisitions
Label column: requisition_number
Audit log: yes
Description: A lightweight handoff record marking that an open position has been cleared to start recruiting. Tracks recruiter, hiring manager, target/actual fill dates, and an optional URL into the external ATS where the candidate pipeline lives. A future ATS module would replace this with a richer requisition + candidate model.
Fields
| Field name | Format | Required | Label | Reference / Notes |
|---|---|---|---|---|
requisition_number | string | yes | Requisition Number | unique, e.g. REQ-2026-0123 |
position_id | reference | yes | Position | → positions (N:1, restrict) |
requisition_status | enum | yes | Status | values: open, on_hold, filled, cancelled |
recruiter_employee_id | reference | no | Recruiter | → employees (N:1) |
hiring_manager_employee_id | reference | no | Hiring Manager | → employees (N:1) |
opened_date | date | yes | Opened Date | |
target_fill_date | date | no | Target Fill Date | |
filled_date | date | no | Filled Date | |
external_ats_url | url | no | External ATS URL | handoff link to the recruiting tool |
notes | text | no | Notes |
Relationships
- A
hiring_requisitionis for exactly oneposition(N:1, required, restrict on delete). - A
hiring_requisitionmay have a recruiteremployeeand a hiring manageremployee(each N:1, optional, clear on delete).
4. Relationship summary
| From | Field | To | Cardinality | Kind | Delete behavior |
|---|---|---|---|---|---|
departments | parent_department_id | departments | N:1 | reference | clear |
departments | head_employee_id | employees | N:1 | reference | clear |
cost_centers | primary_department_id | departments | N:1 | reference | clear |
cost_centers | owner_employee_id | employees | N:1 | reference | clear |
employees | manager_employee_id | employees | N:1 | reference | clear |
employees | home_location_id | locations | N:1 | reference | clear |
positions | job_id | jobs | N:1 | reference | restrict |
positions | department_id | departments | N:1 | reference | restrict |
positions | location_id | locations | N:1 | reference | restrict |
positions | cost_center_id | cost_centers | N:1 | reference | restrict |
positions | current_employee_id | employees | 1:1 | reference | clear |
positions | backfill_for_position_id | positions | N:1 | reference | clear |
positions | originated_from_action_id | headcount_actions | N:1 | reference | clear |
headcount_plans | owner_employee_id | employees | N:1 | reference | clear |
headcount_plans | approved_by_employee_id | employees | N:1 | reference | clear |
scenarios | headcount_plan_id | headcount_plans | N:1 | parent | cascade |
scenarios | created_by_employee_id | employees | N:1 | reference | clear |
headcount_actions | scenario_id | scenarios | N:1 | parent | cascade |
headcount_actions | target_position_id | positions | N:1 | reference | clear |
headcount_actions | job_id | jobs | N:1 | reference | clear |
headcount_actions | department_id | departments | N:1 | reference | clear |
headcount_actions | location_id | locations | N:1 | reference | clear |
headcount_actions | cost_center_id | cost_centers | N:1 | reference | clear |
hiring_requisitions | position_id | positions | N:1 | reference | restrict |
hiring_requisitions | recruiter_employee_id | employees | N:1 | reference | clear |
hiring_requisitions | hiring_manager_employee_id | employees | N:1 | reference | clear |
5. Enumerations
5.1 locations.location_type
officeremote_poolhybrid_hubfield
5.2 jobs.job_type
individual_contributorpeople_managerexecutive
5.3 employees.employment_type
full_timepart_timecontractorintern
5.4 employees.employment_status
pending_startactiveon_leaveterminated
5.5 positions.position_status
filledopenapproved_futureon_holdeliminated
5.6 headcount_plans.plan_status
draftin_reviewapprovedactivearchived
5.7 scenarios.scenario_type
baseoptimisticconservativecustom
5.8 scenarios.scenario_status
draftin_reviewapprovedarchived
5.9 headcount_actions.action_type
addeliminatetransfer
5.10 headcount_actions.action_status
proposedin_reviewapprovedcommittedrejected
5.11 hiring_requisitions.requisition_status
openon_holdfilledcancelled
6. Open questions
6.1 🔴 Decisions needed (blockers)
None.
6.2 🟡 Future considerations (deferred scope)
- Should
position_assignmentsbe added as a history entity to record every employee who has held a position over time, rather than only the current occupant onpositions.current_employee_id? - Should a
skills(orcompetencies) catalog be introduced, with M:N junctions tojobs(required skills) andemployees(held skills) to support skills-based planning? - Should
headcount_actions.action_typebe extended withpromote,reclassify, andcomp_changeto cover non-seat changes within a scenario, or are those better modeled as a separatecompensation_actionsentity? - Should a
legal_entitiesentity be added to support multi-subsidiary planning (withcost_centersandemployeesrolling up to a legal entity)? - Should attrition assumptions (e.g. expected voluntary attrition % per department per quarter) be modeled as planning inputs, possibly as an
attrition_assumptionsentity attached toscenarios? - Should
scenariosbe able to stage org-structure changes (new departments, splits, merges) in addition to position changes, or are department changes always made directly on the live org? - Should the lightweight
hiring_requisitionsbe replaced or extended by a full ATS module (candidates,applications,interview_stages,offers) when recruiting moves into this system? - Should
employeescarry an optionaluser_idlink to the platform’s built-inusersfor SSO/login integration, with deduplication handled at deploy time? - Should
cost_centers↔departmentsbecome an M:N junction to support orgs where a department is funded by multiple cost centers, rather than the current singleprimary_department_idreference? - Should
compensation_currencyonjobsandbudget_currencyonpositions/headcount_actionsbe promoted from a string field to acurrencieslookup entity once the active currency list stabilizes?
7. Implementation notes for the downstream agent
- Create one module named
workforce_planningand two baseline permissions (workforce_planning:read,workforce_planning:manage) before any entity. - Create entities in this order so that referenced entities exist first:
locations,departments,jobs,cost_centers,employees,positions,headcount_plans,scenarios,headcount_actions,hiring_requisitions. Note thatdepartments,employees,cost_centers, andpositionsall contain forward references to entities created later in the list — create the entity first with all non-FK fields, then add the FK fields in a second pass once the target tables exist. - For each entity: set
label_columnto the snake_case field marked as label in §3, passmodule_id,view_permission(workforce_planning:read),edit_permission(workforce_planning:manage). Setaudit_log: trueonemployees,positions,headcount_plans,scenarios,headcount_actions, andhiring_requisitions(per §3); leave the others at the defaultfalse. Do not manually createid,created_at,updated_at, or the auto-label field. - For each field in §3: pass
table_name,field_name,format,title(the Label column), and forreference/parentfields alsoreference_tableand areference_delete_modeconsistent with §4. Forpositions.current_employee_id, setunique_value: trueto enforce the 1:1 business rule (an employee fills at most one position). TheRequiredcolumn is analyst intent; the platform manages nullability internally. - Fix up each entity’s auto-created label-column field title.
create_entityauto-creates a field whosefield_nameequals the entity’slabel_column, and itstitledefaults tosingular_label(e.g. entitydepartmentswithsingular_label: "Department"andlabel_column: "department_name"yields an auto-fielddepartments.department_namewith title"Department"). For every entity in this model, the §3 Label for the label-column row differs fromsingular_label, soupdate_fieldmust be called for each. Use the composite string id"{table_name}.{field_name}"(passed as a string, not an integer):"departments.department_name"→ title"Name""locations.location_name"→ title"Name""cost_centers.cost_center_code"→ title"Code""jobs.job_name"→ title"Name""employees.employee_full_name"→ title"Full Name""positions.position_code"→ title"Position Code""headcount_plans.plan_name"→ title"Name""scenarios.scenario_name"→ title"Name""headcount_actions.action_label"→ title"Label""hiring_requisitions.requisition_number"→ title"Requisition Number"
- Deduplicate against Semantius built-in tables. This model is self-contained but does not currently declare any entity that overlaps with the Semantius built-ins (
users,roles,permissions, etc.). No deduplication action is required for this model. If a future extension declares any built-in (e.g. linkingemployees.user_idtousers), read Semantius first and reuse the built-in as thereference_tabletarget rather than recreating it. - After creation, spot-check: every
label_columnresolves to a real field; everyreference_tabletarget exists; theis_active_for_planboolean onscenarioshas a uniqueness expectation perheadcount_plan_id(enforce via application logic if the platform does not support partial unique indexes);positions.current_employee_idhasunique_value: trueset.