Adjust tree — a generated organisation tree you can correct¶
Status: first slice built (§9). Stacked on the context-templates POC (feature/context-change-requests).
Sections 1–8 are the design as proposed; §9 records the decisions taken and how v1 differs.
The manager-hierarchy plugin turns Principals.managerId into a tree of organisational units. It
assumes that the result is right. In practice it rarely is: the manager chain and the HR department
fields describe the organisation imperfectly, and the analyst who knows the real structure ends up
rebuilding it by hand in Excel or Power Query. This document describes how the tool takes over that
work: a better first tree from the data, a full-width screen to correct it, and corrections that are
kept as named, reviewable adjustments that survive every re-run — so the corrected tree keeps
following the source instead of becoming a stale copy of it.
1. What an analyst does today, outside the tool¶
Collected from a practitioner who builds these structures for customers:
- Pick the top deliberately. "Who has no manager?" also returns leavers, test accounts and dirty data. The analyst roots the tree on a job title (CEO) or on a named person — and often on the person and their department: the board (CEO, CFO, COO) is one department, while CFO and COO formally report to the CEO.
- Walk down the manager chain from that top to get a global structure.
- Read the unit names from the people, not from the manager. For each manager, look at the division, department and team values of the people reporting to them and distil which unit this is. (A manager's own department is usually the unit above the one they run.)
- Correct by hand. Override a single manager relation; split a manager who runs two teams into two units; re-hang a unit that sits elsewhere in reality. Interim management is common: a head of department temporarily manages one or two units that belong to a completely different branch.
- Validate against the formal organogram (slides or images on the intranet).
- Once the structure is trusted, map the sources onto it: how do the HR divisions/departments/teams, the directory and system X each relate to this master structure?
- Keep it current — in the tool, as a maintenance process, not in a spreadsheet.
Steps 1–4 and 7 are in scope for this design. Steps 5 and 6 are later phases (§8).
2. What exists today¶
On the POC branch the manager-hierarchy plugin (app/api/src/contexts/plugins/manager-hierarchy*.js)
emits a synthetic root plus one unit per manager (externalId = the manager's Principal id). The
parent of a unit is the manager's own manager; members are direct reports; the manager is the unit's
Accountable. Units are named from the manager's own attribute values (nameFields).
Three kinds of analyst edit already survive a re-run, each through its own mechanism:
| Edit | Mechanism | Where |
|---|---|---|
| Rename a unit | Contexts.userRenamed flag, UPDATE keeps the name |
migration 033, runner.js updateContextRow |
| Move a unit to another parent | Contexts.userReparented flag, parent kept |
migration 033, runner.js linkContextParents |
| Move a person to another team | ManagerHierarchyOverrides row (principal → effective manager) |
migration 035, routes/contexts/members.js |
Gaps this design closes:
- One unit per manager. No split, no merge, no interim manager of a unit elsewhere in the tree.
- The root is a catch-all. Everyone without a manager lands under the synthetic root, next to the real top of the organisation.
- Moving a manager (as a person) does not move their unit —
buildManagerContextsplaces units by the sourcemanagerId, not the override. - A re-run can silently lose edits. A unit that leaves the plugin output is deleted, and
ON DELETE CASCADEtakes manual sub-units and analyst-moved units under it along. - Double membership. Moving a person leaves an
addedBy='analyst'membership behind; when the source later changes that person's manager, they can sit in two teams. - Edits leave no trail. Two flags and a side table — there is no list of "what did we change, who, why", and no way to undo one edit.
- The screen. In the template UI the tree is a 384 px side panel shared with search and filters; drag-and-drop works only inside one source group; the run tab after "Create" only says Go there now → and does not even select the new tree.
3. The model: base tree + adjustments¶
The tree an analyst sees is
- The base tree is what the plugin derives from the source data on every run, exactly as today.
- An adjustment is one recorded correction: move unit X under Y, move person P to unit Z,
split unit X by department, rename, … Adjustments are keyed on the plugin's stable
externalIds, so they re-apply cleanly to every new base tree. - Applying happens in the runner, between the plugin and the reconcile — not in the UI. Every consumer (matrix, reports, access review, owner pages) sees the corrected tree, and the plugin stays a pure "derive from source" function.
This is the existing generated-variant reconcile (decision principle A4), with the three
ad-hoc mechanisms of §2 folded into one: userRenamed, userReparented and
ManagerHierarchyOverrides become adjustments, migrated once. 🟡 A4 is not fully settled — this
generalises an existing side table rather than inventing a new lifecycle, but it should get an
architect's nod.
3.1 The adjustments table¶
CREATE TABLE "ContextTreeAdjustments" (
id uuid PRIMARY KEY,
"algorithmId" uuid NOT NULL REFERENCES "ContextAlgorithms"(id) ON DELETE CASCADE,
"scopeSystemId" int REFERENCES "Systems"(id) ON DELETE CASCADE,
"sourceInstanceKey" text NOT NULL DEFAULT '', -- which tree (same key the runner uses)
seq bigint NOT NULL, -- apply order
op text NOT NULL, -- see 3.2
"subjectKey" text NOT NULL, -- externalId of the unit, or principal id
payload jsonb NOT NULL, -- op-specific
reason text, -- optional, shown in the log
"validUntil" date, -- interim arrangements
status text NOT NULL DEFAULT 'active', -- active | orphaned | expired | reverted
"statusDetail" text, -- why it no longer applies
"createdBy" text NOT NULL,
"createdAt" timestamptz NOT NULL DEFAULT (now() AT TIME ZONE 'utc'),
"lastAppliedRunId" uuid
);
Plus the usual _history trigger. A reverted adjustment stays in the table (status reverted) so the
log shows it; undo of a revert is a new adjustment.
3.2 Operations¶
| op | payload | Effect on the base tree |
|---|---|---|
moveUnit |
{ toParent } |
unit's parent becomes toParent (a unit key) |
renameUnit |
{ name } |
display name |
movePerson |
{ toUnit } |
person's membership moves to toUnit (replaces ManagerHierarchyOverrides, but targets a unit, not a manager — after a split one manager has several units) |
splitUnit |
{ by: attribute, values: [...] } or { members: [...], name } |
people matching are carved out into a new child unit with a derived key <unit>::<value>; it can then be moved like any unit |
mergeUnits |
{ into } |
the subject's members and children move into into; the subject disappears |
addUnit |
{ key, name, parent } |
a unit that does not exist in the data (e.g. a "Board" level above CEO, CFO and COO) |
dissolveUnit |
{} |
members and children go to the parent (a secretariat that is not a real unit) |
setManager |
{ principalId, kind: 'Accountable' \| 'Interim' } |
role on the unit; Interim carries validUntil |
apply() is a pure function (baseResult, adjustments) → { result, statuses } in its own module,
unit-tested op by op, with the runner only calling it.
3.3 When an adjustment no longer fits¶
The source keeps changing; an adjustment can lose its footing. Rules:
- Subject gone (the unit or person left the base tree) → status
orphaned, with the reason. Never silently dropped; shown in Needs attention. - Target gone (
moveUnit/movePersonto a unit that no longer exists) → the subject falls back to its generated place, adjustmentorphaned. - Already true (HR now has the person under that manager anyway) → status stays
activebut the screen marks it now matches the source — remove?. - Expired (
validUntilpassed) → statusexpired, effect no longer applied, shown for review. Interim arrangements end visibly instead of lingering. - A unit that leaves the output is no longer cascade-deleted with what hangs under it. Units that
are only there because of an adjustment (
addUnit, moved units, split units) re-attach to the nearest surviving ancestor and their adjustment is flagged. This replaces today's cascade loss. - Because
movePersonis applied before reconcile, the person is an ordinary algorithm member of exactly one unit — the double-membership bug disappears with it.
3.4 Manual and imported trees¶
- Manual trees have no base; the Adjust tree screen edits them directly (today's PATCH routes).
- Imported trees (crawled, uploaded lists) stay read-only — the source is edited, as the upload design already says. A later step can offer Use as the starting point for an adjustable tree.
4. A better base tree¶
New, optional parameters on the manager-hierarchy plugin. Defaults keep today's behaviour and
today's externalIds, so existing trees and their edits are untouched.
| Parameter | What it does | Why |
|---|---|---|
rootMode = auto | person | jobTitle |
person: rootPrincipalIds; jobTitle: rootJobTitlePattern (RE2) |
Step 1 — choose the top instead of "everyone without a manager" |
rootTakesDepartment |
the root person's department colleagues form the top unit together | the board case: CEO, CFO, COO in one top unit |
nameFrom = manager | members |
members: name a unit after the most common value among its members (fallback: manager's value) |
Step 3 — a CFO sits in the board's department; their unit is Finance |
splitBy (attribute) + splitMinMembers |
a manager whose reports span several values of that attribute gets one unit per value (≥ min members); the largest group keeps the manager's key | the "manages two teams" and the interim-manager cases |
excludeInactive |
leave disabled / departed accounts out | leavers polluting the top |
People who are not under the chosen root land in a Not placed unit next to the tree, instead of under the real top. It is visible, countable and a source of Needs attention items.
With splitBy off, the same detection still runs and produces split candidates in Needs
attention — one click turns a candidate into a splitUnit adjustment. So an analyst can start from
today's shape and split only where it matters.
Worked example (fictional): Bram heads IT. Two people from Facilities report to him on an
interim basis. With splitBy = department, Bram gets two units: IT (Bram) with the manager's key,
and Facilities (Bram) keyed bram::Facilities, initially under IT. One moveUnit adjustment hangs
Facilities (Bram) under Operations; a setManager … Interim, validUntil 2027-03-31 records that it
is temporary. When the interim ends and HR moves those people to their new manager, the split unit
disappears from the base, the moveUnit becomes orphaned and is shown for clean-up.
5. The Adjust tree screen¶
A full-width app tab, type adjust-tree, one per tree.
How you get there
- After New context tree → From the manager hierarchy: the run tab is the Adjust tree tab. While the
run is going it shows progress (today's RunDetailPage content); when it succeeds it turns into the
editor on the new tree — no more "Go there now →".
- From any generated or manual tree: ⋯ → Adjust tree.
- For an imported tree the action is shown disabled with "This tree is maintained in its source".
Layout
┌───────────────────────────────────────────────────────────────────────────────┐
│ Organisation (from Entra ID) · last refreshed 08:12 · 14 adjustments [Refresh] │
├───────────────────────────────────────────────┬───────────────────────────────┤
│ [search people or units] ☐ show people │ Unit | Needs attention (7) │
│ │ | Adjustments (14) │
│ ▾ Board (Anna) 3 · 2 412 │ | Since last refresh (5) │
│ ▾ Finance (Carl) ✎ 48 │ ───────────────────────────── │
│ ▸ Accounting (Dina) 21 │ Finance │
│ ▾ IT (Bram) 130 │ Manager Carl Jansen │
│ ▸ Workplace (Eva) 40 │ Why here reports to Anna; │
│ ▾ Operations (Frank) 700 │ department "Finance" │
│ ▸ Facilities (Bram) ⏱ interim 12 │ (46 of 48 members) │
│ ▸ Not placed 17 │ Adjusted renamed by Wim, │
│ │ 10 Oct — "HR typo" │
│ │ [Rename] [Split…] [Merge…] │
│ │ [Add sub-unit] [Set manager] │
└───────────────────────────────────────────────┴───────────────────────────────┘
- Tree (left, ~60 %): the whole tree in one drag-and-drop area (one
DndContext, @dnd-kit, already a dependency). Drag a unit onto a unit = move. Toggle show people to drag people between units (multi-select). Adjusted units carry a small mark; interim units a clock. Search finds people and units and expands to them. - Unit: who manages it, why it is where it is (the source facts that put it there), what was adjusted, and the actions. Each action can take an optional reason.
- Needs attention: people Not placed; split candidates; managers who also run units in another branch; units without a manager; orphaned and expired adjustments; adjustments that now match the source. Each item has its one-click fix.
- Adjustments: the log — what, who, when, why, status — with Undo per line.
- Since last refresh: units that appeared or went, people the source moved, adjustments that stopped applying.
Edits take effect immediately; there is no separate "publish" step in v1 (see open question Q2).
Vocabulary follows the template-UI rules: unit, manager, people — no plugin, variant,
principal, externalId. Only users with data.write.contexts get the screen.
6. API¶
| Route | Purpose |
|---|---|
GET /api/context-trees/:rootId/adjustments |
the log, with status |
POST /api/context-trees/:rootId/adjustments |
add one (validated per op), re-apply, return the changed units |
DELETE /api/context-trees/:rootId/adjustments/:id |
undo (status reverted), re-apply |
GET /api/context-trees/:rootId/attention |
the Needs attention list |
GET /api/context-trees/:rootId/changes?sinceRun= |
Since last refresh |
The existing PATCH /contexts/:id (rename / reparent) and PATCH …/members/:id/move on a
generated tree write an adjustment instead of the flags / override table, so older screens keep
working.
7. Delivery — a stack of PRs¶
- Adjustments model — migration (table + migrating 033/035 state), pure
apply()module, runner wiring, orphan/expiry rules, the cascade fix. API only. Unit tests per op and per orphan rule. - Better base tree — the §4 parameters, Not placed, split candidates. Existing trees unchanged (defaults), proven by a test that runs the old fixture with old params and compares keys.
- Adjust tree screen — the tab, tree + Unit + Adjustments, run tab turns into the editor, ⋯ entry.
- Needs attention + Since last refresh — the review side.
Later, separate designs:
- Map sources onto the tree — per source (HR division/department/team, directory department, an application's own org field), a mapping of its values onto the adjusted tree, with coverage and a side-by-side view. This is "how does the HR source relate to the master organogram".
- Check against the formal organogram — upload slides/images, a model reads them into a structure proposal (decision principle B3: a proposal for the analyst, never written as truth), diffed against the tree. Builds on the interview-extraction work.
- Maintenance by the people themselves — the manager survey of
Ownership survey: a unit's manager confirms "this is my team", and
corrections come back as adjustments with
createdBy = survey.
8. Open questions¶
- Q1 — Unit keys after a split. The largest group keeps the manager's key, the rest get
<manager>::<value>. If the department value is renamed in HR, the derived key changes and the adjustments on it orphan. Acceptable (they are shown), or key on a department code where one exists? - Q2 — Immediate apply at scale. Re-applying means re-running plugin + reconcile; at 200k people a full member replace is seconds, not milliseconds. Options: (a) keep the last base result per tree and re-apply only adjustments, with a diff-based member reconcile; (b) a draft mode that collects edits and applies them together. Recommendation: (a), measured on the scale rig before PR 3.
- Q3 — One source or several. v1 builds the base from one system (the HR source or the directory). Combining manager data from two systems (HR for the chain, directory for the team names) needs identity-level correlation and is deferred.
- Q4 — Other generated trees. The adjustments layer is generic (any plugin with stable keys). Should Adjust tree be offered on clustering / tag trees from day one, or only on organisation units?
9. Decisions and the first slice (built)¶
Decisions (2026-10-24):
- Apply immediately. Every edit is applied to the stored tree at once; a few seconds for a large move is acceptable. No draft mode.
- When the source renames or re-keys, warn and ask. An adjustment whose unit vanished is matched against the units that now hold most of its members; a split whose value matches nobody is matched against the value its people now share. Either way the adjustment goes to needs your decision with the proposal; nothing is followed silently. (Later: show the old and the new tree side by side for a large reorganisation.)
- Generic, not only organisation units. The same screen and adjustments work on any generated, imported or hand-built tree — for example grouping a flat list of logical applications under a new level ("Physical access" above the badge, door and camera applications).
What v1 does differently from sections 3–6:
- A tree is named by its source, not by a root id (
contexts/adjustments/treeKey.js):gen~<algorithmId>~<instanceKey>(a plugin tree),sync~<systemId>~<template>(every imported unit of one source and one template — a flat list of roots is one tree),ctx~<rootId>(a hand-built tree). Adjustments point at units by context id; ids are stable across runs and syncs, which also keeps change requests (#1452) valid. - Adjustments are applied to the stored rows after every rebuild, not to the plugin output before
reconcile: after a plugin run (
runner.js, inside the reconcile transaction) and after an ingest batch or session ofContexts/ContextMembersfor that source (afterSync.js). Every handler is idempotent; after any change all active adjustments of the tree are applied again in order. - Imported trees are adjustable (decision 3). A unit an adjustment adds is a
manualcontext, so a full sync of the source never deletes it. - Undo restores the recorded
before(old parent, old name, old Accountable, the unit a member came from), because an imported tree cannot be regenerated on demand. - Operations in v1: move unit, rename, move member, add unit, split (by attribute for accounts, by
picked members for any tree), set manager (optionally interim, with an end date). Merge and dissolve
are not in v1. The old flags (
userRenamed,userReparented) andManagerHierarchyOverridesstay as they are; adjustments set the flags so the plugin keeps a moved or renamed unit in place. - The plugin runner no longer cascade-deletes a unit that is not its own when its parent vanishes; it is lifted to the top level and its adjustment reports it.
- Manager hierarchy options (all optional, defaults unchanged):
topMode(auto/jobTitle/people),topJobTitle,topPrincipalIds,notPlacedUnit,nameFrom(manager/members),splitBy,splitMinMembers,excludeInactive. - Not in v1: Since last refresh, the side-by-side view of an old and a new tree, mapping sources onto the tree, reading organograms.