ums

ADR-0073: UMS SDK — Multi-Runtime Client Integration Surface (.NET / TypeScript / NestJS)

UMS-specific: No Evolith upstream. This ADR governs the UMS-published SDK surface for consuming the Authorization Graph. The SDK contracts, schema versioning, and runtime distributions are exclusive to the UMS product.

Status: Accepted Date: 2026-05-31 Decision Owner: Architecture Related:


Context

ADR-0071 established the AuthorizationGraph as the self-contained artifact UMS delivers to client systems after authentication. The graph is rich (context, authentication metadata, menus, domain permissions, feature flags, effective configuration, scopes) and is designed to be cached client-side for the session duration, eliminating per-request authorization queries to UMS.

In practice, every client system must:

  1. Call POST /api/v1/client/authenticate, parse the response, store the JWT
  2. Deserialize the graph and hold it in scoped storage
  3. Apply the deny-wins / override-takes-precedence resolution rules every time it checks a permission
  4. Re-evaluate validUntil before each use and trigger re-authentication when expired
  5. Map the four graph sections (scopes, menuAccess, domainPermissions, featureFlags) onto their own authorization model

If every client implements this independently, the rules drift, denial semantics get implemented wrong, and the value of centralizing authorization in UMS erodes. The repeated boilerplate is non-trivial — particularly in service methods, where the same if (graph.scopes.Contains("X.Y")) pattern appears in hundreds of places.

UMS is consumed by systems written in .NET (the platform’s native runtime), TypeScript (both Node and browser), and NestJS (an opinionated TypeScript server framework with first-class decorator/guard support). Each ecosystem has idiomatic conventions for authorization (attributes, decorators, guards) that differ enough to make a single distribution impractical, but conceptually they implement the same model.

A unified Software Development Kit — published as a family of language-specific distributions sharing a single canonical contract — is the natural answer.


Decision

1. Establish the UMS SDK as the official client integration surface

UMS will ship a Software Development Kit (SDK) covering three runtimes as first-class targets:

Runtime Distribution Registry
.NET Ums.Sdk.* NuGet
TypeScript (Node + browser) @ums/sdk-* npm
NestJS @ums/sdk-nestjs npm

JavaScript (without TypeScript) is covered implicitly: every @ums/sdk-* package publishes both .js and .d.ts artifacts, so a JS consumer imports exactly the same packages but loses compile-time typing. JS is not a first-class target with separate idiomatic APIs.

2. Contract-first: the AuthorizationGraph JSON Schema is the canonical contract

The contract between server (UMS) and SDKs is not any language’s interface — it is the JSON Schema of the AuthorizationGraph payload defined in auth-graph.md §8.

The schema, the canonical catalog of AUTH_xxx error codes, and a library of golden fixture JSONs are produced as language-neutral artifacts that all three SDKs and the UMS server consume. They live at:

src/libs/sdk/contracts/
├── auth-graph.schema.json     ← JSON Schema 2020-12
├── error-codes.yaml           ← canonical AUTH_xxx catalog
├── fixtures/                  ← golden JSON files
│   ├── local-auth-success.json
│   ├── idp-auth-success.json
│   ├── deny-wins.json
│   ├── override-allow.json
│   ├── empty-permissions.json
│   ├── expired-graph.json
│   ├── feature-flag-matched.json
│   ├── feature-flag-missed-context.json
│   └── multi-tenant-rejection.json
└── SCHEMA_VERSIONING.md       ← summary of ADR-0074

The UMS server’s AuthorizationGraphBuilderService and every SDK’s deserializer/validator must round-trip the same fixtures successfully. CI enforces this.

3. Live inside the UMS monorepo under src/libs/sdk/

The SDK does not get its own repository. It lives in the existing ums/ monorepo as a sibling of src/libs/shell/:

src/libs/
├── shell/              ← reusable infrastructure (DDD, Factory, AOP, Bootstrapper)
└── sdk/                ← UMS product integration surface
    ├── contracts/
    ├── dotnet/
    ├── typescript/
    └── nestjs/

This co-location guarantees:

Extraction policy: the SDK stays in the monorepo until a concrete, documented reason justifies splitting it out (e.g., third-party contributors, independent release cadence demanded by external consumers, repository size). Extraction is explicitly not anticipated for v1.

4. Package layout per runtime

Each runtime ships a small family of focused packages. Inter-package dependencies form a DAG rooted at Contracts.

.NET (NuGet, namespace Ums.Sdk.*):

Package Depends on Purpose
Ums.Sdk.Contracts DTOs mirroring the graph schema, error code constants
Ums.Sdk.Authorization Contracts Pure validator (deny-wins, override, expiry), IAuthGraphAccessor port
Ums.Sdk.Authorization.Aop Authorization, Shell.Aop Attributes ([RequiresScope] etc.) + aspect over DispatchProxy
Ums.Sdk.Authorization.Testing Authorization AuthGraphBuilder fluent API for unit tests of consumer code

TypeScript (npm, scope @ums):

Package Depends on Purpose
@ums/sdk-contracts Types generated from JSON Schema, error code constants
@ums/sdk-authorization sdk-contracts Pure validator, accessor abstraction (Node AsyncLocalStorage and browser-friendly variants)
@ums/sdk-testing sdk-authorization Graph builder for tests

NestJS (npm, extends TypeScript):

Package Depends on Purpose
@ums/sdk-nestjs @ums/sdk-authorization, @nestjs/common UmsSdkModule, UmsAuthGuard (CanActivate), decorators using Reflector metadata

Phase 1 explicitly scopes out: HTTP client packages (Ums.Sdk.Client, @ums/sdk-client), ASP.NET integration (Ums.Sdk.Authorization.AspNetCore), Express middleware. They are valid Phase 2 deliverables.

5. Common conceptual model — four authorization attributes / decorators

Every runtime exposes the same four authorization primitives, mapped one-to-one to the four authorization-bearing sections of the graph:

Primitive Graph section Allow rule
RequiresScope("RESOURCE.ACTION") scopes[] Scope present in scopes[] and not explicitly Denied
RequiresMenuOption("OPTION_CODE") menuAccess[]…options[] Option found with effect = "Allow"
RequiresDomainAccess("RESOURCE", "ACTION") domainPermissions[] Resource+action found with effect = "Allow"
RequiresFeatureFlag("FLAG_CODE") featureFlags[] Flag found with isEnabled = true

Universal pre-check (applies to all four): if graph.validUntil < now, the decision is Expired regardless of content. Deny always beats Allow (Axiom A3 — same rule as the server-side resolution).

6. Denial behavior is configurable per attribute

Two modes, selectable per attribute use site:

A global AuthorizationValidator.Mode = AuditOnly flag bypasses denial entirely and only logs structured AuthorizationDeniedEvent records — used for progressive rollouts.

7. Versioning — schema and packages versioned independently

NuGet uses Ums.Sdk.<Pkg> versions; npm uses @ums/sdk-<pkg> versions. They do not need to share numbers across registries — each ecosystem has its own release cadence.

8. Bilingual documentation, mirroring the rest of UMS docs

All SDK documentation under docs/sdk/ is published in both English and Spanish, following the same pattern as the rest of UMS (*.md for EN, *.es.md or docs/sdk-es/ for ES depending on convention chosen in the portal). The MASTER_INDEX gets a new top-level entry Phase 06 — UMS SDK alongside Architecture / Domain / Governance / Operations.


Rationale

Why an SDK and not just published docs

The graph contract is documented today in markdown (auth-graph.md §8) — but markdown is not enforceable. As soon as the server’s serializer adds a field or renames one, clients break silently unless they have a tight integration loop. An SDK turns the contract into:

Docs remain — but they describe the SDK’s behavior, not the wire contract directly.

Why three runtimes and not just .NET

UMS is consumed by the platform’s own .NET applications and by external NestJS services and TypeScript-based BFFs/SPAs. Restricting the SDK to .NET leaves the larger fraction of integration surface uncovered. Three runtimes is honest scope: .NET (platform-native), TypeScript (broad ecosystem coverage including JS), NestJS (highest-leverage server framework in the TS ecosystem, with decorators/guards that map directly to the conceptual model).

Why contract-first instead of API-first

API-first means each runtime designs its own ergonomic API, and the union of those APIs implicitly defines the contract. This works at first and drifts inevitably: small differences in error code naming, in the shape of effective config, in nullable handling. Contract-first inverts the dependency: the schema is the source of truth, language APIs are projections of it. CI enforces parity by round-tripping fixtures through every SDK.

Why monorepo placement under src/libs/sdk/

Two failure modes were considered:

  1. Separate repo (ums-sdk/): clean separation but high coordination cost. Schema changes require two PRs and a coordinated release. Drift is a question of when, not if.
  2. Inside src/libs/sdk/: schema, server consumer, and three SDK consumers all reachable by one CI run. Single ADR registry. One bilingual docs policy. Coordination cost is zero for changes that span server and SDK.

Option 2 wins decisively for v1. Extraction stays an option for the future, gated on concrete demand.

Why NestJS extends TypeScript instead of being independent

NestJS is implemented on TypeScript, uses TypeScript’s type system, and consumes TypeScript libraries natively. Treating @ums/sdk-nestjs as a thin adapter on top of @ums/sdk-authorization (Guards + Decorators that delegate to the framework-agnostic validator) reduces the surface to design and maintain, and guarantees behavioral parity between a “plain TS” consumer and a “NestJS” consumer — they share a validator literally.

Why JavaScript is implicit, not first-class

A first-class JS API would mean designing decorator-free ergonomics (HOFs, middleware) — a meaningful but ancillary surface. The TS packages already publish .js + .d.ts, so JS consumers can use them with full functionality, only losing compile-time type checks. The cost/benefit of a separate JS-idiomatic API does not justify Phase 1 inclusion.


Consequences

Positive

Trade-offs

Neutral


Implementation

Phase A — Documentation (precedes any code)

Deliverable Path
ADR-0073 (EN + ES) docs/architecture/adrs/0073-ums-sdk-multi-runtime{.es,}.md
ADR-0074 (EN + ES) docs/architecture/adrs/0074-auth-graph-schema-versioning{.es,}.md
SDK portal index docs/sdk/index.md + docs/sdk/index.es.md
Contract docs docs/sdk/contracts/{schema-overview,error-codes,versioning,fixtures}.md (+ .es.md)
.NET SDK guide docs/sdk/dotnet/README.md + quickstart (+ .es.md)
TypeScript SDK guide docs/sdk/typescript/README.md + quickstart (+ .es.md)
NestJS SDK guide docs/sdk/nestjs/README.md + quickstart (+ .es.md)
Index updates MASTER_INDEX.md, MASTER_INDEX.es.md, architecture/adrs/index.md
Cross-links domain/identity/auth-graph.md references the SDK portal

Phase B — Implementation (after Phase A is reviewed and merged)

Deliverable Path
Contract artifacts src/libs/sdk/contracts/
.NET packages src/libs/sdk/dotnet/Ums.Sdk.*/ (4 csproj + tests)
TypeScript packages src/libs/sdk/typescript/sdk-*/ (3 npm packages + tests)
NestJS package src/libs/sdk/nestjs/sdk-nestjs/ (1 npm package + tests)
CI integration Workflow validates fixtures round-trip through all SDKs
Server alignment ums.api emits schemaVersion and references Ums.Sdk.Contracts DTOs

ADR Registry