Context templates — phase B (working spec)¶
Status: in progress on
feature/context-templates(stacked onfeature/core-ontology, PR #1369). This page is the build spec for the branch; it becomes the design record when the PR merges. Decisions by Wim, 2026-10-20. Proposal and impact analysis: Identity_Atlas_Voorstel_type-templates.md (Teams).
Phase A (ontology 2.0.0) declared four context templates. Phase B makes them real in the data,
the plugins and the UI, and gives an organizational unit and a logical application the people who
answer for them. It is one pull request, with no behaviour change to assignmentType anywhere.
1. Decisions¶
| Decision | Outcome |
|---|---|
Location |
Segment (a slice) |
CostCenter |
OrganizationalUnit |
| Projects, programs | OrganizationalUnit, named in its description; no built-in source emits them yet |
| Roles on a context | Two kinds: Accountable (at most one) and Responsible (many). Optional, never required: "don't restrict what we can read" |
| Who a role points at | An account (principalId) or a person (identityId), exactly one, as ResourceAssignments does |
| Which templates carry roles | OrganizationalUnit and LogicalApplication. Segment and Tag carry none |
Contexts.ownerUserId |
On a manual context: the app user who created it. Not a role; a creator is not a responsible. On a synced context: the owner the source names, which becomes the context's Accountable (addedBy = 'sync') — see Roles from a source (decided 2026-10-22) |
| Root context types | Removed: a root is a context without a parent. The tag root is Tag with extendedAttributes.tagRoot = true |
| Scope hierarchy nodes | Scope, with the resource's own type in extendedAttributes.resourceType; no more borrowed resource types |
ContextRecipe |
Not a kind of thing but a provenance. The recipe stores a template chosen at save time (default LogicalApplication); auto-detection and confirmation in the assistant is a follow-on |
| A unit with several variants | Valid and expected: a project, team or customer can exist as its people, its accounts and its resources — three contexts, each holding one kind of member, linked pairwise by the symmetric SameUnitAs (renamed from SameUnitAs, 2026-10-20). An Org Info list (a customer list with its people and resources) projects to the three variants and links them |
| Owner survey, best-guess owners | Later. A role the source still produces keeps its row and id across plugin re-runs, and every role change is in _history, so a claim (POC step 2) can refer to one |
2. Data¶
Migration 095_context_templates.sql:
Contexts.contextCategory TEXT NULL CHECK IN ('OrganizationalUnit','LogicalApplication','Tag','Segment'), backfilled fromcontextTypefor every known value; unknown values stay NULL (the UI shows them under "Other").ContextRoles(iduuid,contextId→ Contexts cascade,principalId→ Principals cascade |identityId→ Identities cascade (exactly one),roleCHECK IN ('Accountable','Responsible'),addedByCHECK IN ('sync','algorithm','analyst'),createdAt,updatedAt); unique per (context, holder, role); partial unique index oncontextIdwhererole = 'Accountable'.ContextRelationships(id,contextId,relatedContextId,relationshipTypeCHECK IN ('SameUnitAs'),addedBy,createdAt); unique per triple;contextId <> relatedContextId.- Backfill
Accountable(addedBy = 'algorithm') on every manager-hierarchy node whoseexternalIdis an existing principal id. - Root rows:
PrincipalTypeRoot → PrincipalType,ResourceTypeRoot → ResourceType,EntraGroupRoot → EntraGroupCategory,SystemMembershipRoot → SystemMembership,ScopeHierarchy → Scope,TagGroup → Tag + tagRoot. Scope-hierarchy children: type intoextendedAttributes.resourceType,contextType = 'Scope'. GraphTagsview recreated to leave the tag root out (GraphTagAssignmentsneeds no change: the root has no members).
Ingest (validation.js): CONTEXT_CATEGORIES, CONTEXT_ROLE_TYPES, CONTEXT_RELATIONSHIP_TYPES; contexts.contextCategory optional enum field.
managerExternalId on a synced context still lands in extendedAttributes (known gap 2 of the ontology); resolving it to an Accountable role at ingest is the follow-on, so Omada and midPoint managers arrive the same way the manager plugin's do.
Roles from a source¶
A synced context's ownerUserId is the owner its source names — a logical application's
owner in the SQL crawler's IdentityIQ catalogue, an org unit's owner in a CSV. The contexts
ingest states it as the context's Accountable (app/api/src/ingest/contextOwners.js),
after every single-batch load and at the end of every contexts sync session, for the
contexts of that load:
- Resolution, one set-based statement per load: the principal whose
externalIdis the owner — a live row over a soft-deleted one, the context'sscopeSystemIdover any other system, then the id (the same tie-break as the read endpoints' owner join,OWNER_PRINCIPAL_ORDER) — else the one live principal whoseemployeeIdis the owner. Two or more by employee number is ambiguous and resolves to nobody. Both are index joins (ix_Principals_externalId, migration 076;ix_Principals_employeeId, migration 001), so no index was added. - Which contexts:
variant = 'synced'on a role-bearing template (OrganizationalUnit, LogicalApplication). A manual context never gets a role fromownerUserId; a generated one gets its roles from its plugin. - What it writes: no Accountable yet → add one (
addedBy = 'sync'); asyncAccountable for someone else → point it at the resolved principal; no owner, or one that resolves to nobody → remove thesyncAccountable (the source no longer names one). AnanalystoralgorithmAccountable is never touched — it wins over the source until someone removes it. Responsibles are never touched. - Response:
accountable: { set, replaced, removed, unresolved }on the ingest result. - Existing databases: migration
097_synced_context_accountables.sqlran the insert-only half once (contexts with an owner and no Accountable yet). - Order: an owner whose account arrives later than the context resolves on the next contexts load, not when the account lands.
3. Plugins and runner¶
- A plugin declares
contextCategory(one of the four); a node may override it (node.contextCategory, used by the recipe). The runner writesnode.contextCategory ?? plugin.contextCategoryon insert and update. - A plugin may return
roles: [{ contextExternalId, principalId | identityId, role }]; the runner replaces theaddedBy = 'algorithm'roles of the produced contexts, as it does for members. Analyst and sync roles survive re-runs. - manager-hierarchy:
Accountable= the manager principal on every manager node.externalIdkeeps the manager id (the member-move route reads it) — the role is the stated fact, the externalId the key. - principal-type-tree, resource-type-tree, entra-group-category-tree, system-membership-*, scope-hierarchy: the root takes the child type; scope-hierarchy writes
Scope+extendedAttributes.resourceType, and dropscontextTypesFromResourceType. - context-recipe:
node.contextCategoryfromrecipe.template(defaultLogicalApplication);validateRecipeaccepts the field.
4. API¶
Existing (changed): GET /api/contexts and /api/contexts/:id return contextCategory; list accepts ?contextCategory= and ?sourceAlgorithm=<name>; POST/PATCH /api/contexts accept contextCategory (default: the parent's, else null). Member move (/contexts/:id/members/:memberId/move) and /api/org-chart key on sourceAlgorithmName = 'manager-hierarchy', not on the type. Tags routes and bootstrap use contextType = 'Tag' plus the tagRoot marker.
New:
- GET /api/contexts/:id/roles → [{ id, role, principalId, identityId, displayName, addedBy }]
- PUT /api/contexts/:id/roles body { role, principalId | identityId } → upserts (addedBy = 'analyst'); an Accountable replaces the existing one. Refused (400) on a Segment or Tag context.
- DELETE /api/contexts/:id/roles/:roleId
- GET /api/contexts/:id/relationships → [{ id, relationshipType, relatedContextId, relatedDisplayName, direction }] (both directions)
- POST /api/contexts/:id/relationships body { relatedContextId, relationshipType }; DELETE /api/contexts/:id/relationships/:relId
- Permission data.write.contexts for writes, as for members. OpenAPI entries for all of the above (openapi.drift.test.js).
5. UI¶
contextStyles.js:CATEGORY_META(label, badge, short description) for the four templates plus "Other" for null.- Tree selector: grouped by template (Organisation · Applications · Tags · Segments · Other); within a group, the tree's source (plugin display name or the synced system) as caption instead of the raw
contextType (targetType). - Member move enabled when
selectedRoot.sourceAlgorithmName === 'manager-hierarchy'; the matrix "By Manager Hierarchy" sort lists roots via?sourceAlgorithm=manager-hierarchy. - Context detail: template badge; for OrganizationalUnit and LogicalApplication a People block showing Accountable and Responsible, with add (search
/api/users?search=) and remove; parent and child units are already shown by the tree. - New-context wizard and manual editor: a template select (default from the parent).
6. Ontology (2.1.0)¶
ia:ContextCategoryvalue list with four members, eachia:denotesClassits template;ia:contextCategorycolumn onia:Context. Check: the list equals the templates underia:Context.ia:ContextRoleedge (Context → Principal | Identity) withia:ContextRoleType(Accountable, Responsible);ia:ContextRelationshipedge (Context → Context) with relationship typeia:sameUnitAs. Both in the Structure layer; neither reaches a resource, so the holding rule holds.- Six root classes removed;
ScopeContextdescribed;CostCenterContextmoved under OrganizationalUnit. - Plugin check: a plugin's
contextCategorynames a template underia:Context, and every type in itscontextTypessits under that template.