@xemahq/identity-directory-nest
v0.9.3
Published
Consumer seam for identity-api's directory: expands an identity group to its human members and resolves a subject's organization role, so a service that addresses a TEAM or fences on an org role does not re-implement the walk — nor the empty-versus-unavai
Readme
@xemahq/identity-directory-nest
This package belongs to Layer 1 — it depends on Layer-0 kernel contracts
(@xemahq/kernel-contracts/decision for RecipientKind) plus the identity and
service-registry SDKs, and on no service-owned package.
What it is
The consumer seam for identity-api's directory: given a declared audience, it answers which concrete people is this addressed to.
const people = await directory.expandRecipients({
orgId,
recipients: [
{ kind: RecipientKind.HUMAN, target: { userId: 'u_1' } },
{ kind: RecipientKind.IDENTITY_GROUP, target: { groupId: 'g_oncall' } },
],
});resolveGroupMembers is the inner half, exposed for a caller that holds a group
id and nothing else.
Why it exists
Two services in xema-base address a team: the workflow inquiry engine
(RecipientKind.IDENTITY_GROUP on an inquiry) and decision-api (the same
recipient on an ask). They sit in different biomes and cannot import each other.
N = 2 is the bar the engineering constitution sets for extracting an
abstraction, and it is met exactly — this package was created at the moment the
second consumer appeared, not in anticipation of one.
What it removes from the second consumer is not the HTTP call; it is the four rules around it that are easy to re-derive slightly differently:
| Rule | What a second, independent copy gets wrong |
|---|---|
| order is preserved | "all the individuals, then all the teams" — not the author's ordering |
| de-duplication is cross-entry | one person named directly and via a team gets two notifications, and two slots in a quorum |
| mandatory is inherited by expansion | a team addressed as advisory silently acquires a veto |
| empty ≠ unavailable | a transport failure degrading to [] addresses the ask to nobody, and looks exactly like a group nobody is in |
Failure model
Typed errors, not HTTP exceptions — each consumer maps them onto the error
vocabulary its own callers are documented against (INQUIRY_GROUP_NOT_FOUND,
DECISION_RECIPIENT_GROUP_NOT_FOUND). A shared helper that threw
NotFoundException('INQUIRY_…') would make the second consumer report the
first one's domain, and would drag a framework exception filter into any
non-HTTP caller.
IdentityGroupNotFoundError— no such group, or outside the org's tree.IdentityGroupEmptyError— zero human members andallowEmptywas not set.IdentityDirectoryUnavailableError— unreachable, non-2xx, or an unrecognised payload. Never collapsed into "the group is empty".MalformedRecipientError— the caller's own recipient is not addressable. A 4xx that will fail identically forever, kept apart from the 5xx above so nobody debugs the wrong service.
Snapshot semantics
The expansion is a snapshot. Both consumers persist it, and are thereafter immune to membership changes: somebody who joins the team tomorrow is not silently added to an ask already in front of people, and somebody who leaves does not silently vacate a quorum slot that was counted when it was opened.
