# Tenant, Institution, and Operator Toolkit

The Tenant Toolkit is the per-tenant control plane of Oshun V1: the surfaces and
domain logic an institution, school, research lab, partner organization, or
managed cohort uses to run _its own slice_ of Oshun — members, content scopes,
identity, custom roles, audit, bulk data, integrations, notifications, help, and
status. Each slice is strictly isolated from every other tenant and from Oshun's
own platform-operator world. The toolkit serves tenant administrators (a
district IT lead, a school's roster coordinator, a lab PI's delegate, a
partner's integration engineer) and, in a more privileged read role, Oshun
platform operators who investigate across tenants. Almost the entire toolkit is
**shipped, real code**: the rendering app `apps/oshun/tenant-admin` is a thin
Next.js layer, and the substance lives in the library
`libs/oshun/tenant-console`, whose nine modules are deterministic, well-typed,
and tested. Backlog tracking for the console substrate is §20 in
[../TODOS.md](../TODOS.md).

> **Why a separate page from Admin Products.** The operator-facing platform
> console and on-call mobile app are documented on
> [Admin Products — Web and Mobile](./admin-products.md). That surface is
> platform-wide; _this_ one is per-tenant. Both are thin Next.js shells over the
> same nine `@oshun/tenant-console` modules — the toolkit described here is the
> tenant's half of that shared substrate, with hard isolation drawn between
> them.

## Where the Logic Actually Lives

The single most important architectural fact about this area — and one the
companion architecture docs understate — is that **the tenant console is a
library, not an app**. `apps/oshun/tenant-admin` is a standalone Next.js
application (the architecture glossary, `high-level-architecture.md`, and
`product-surfaces.md` all correctly place it there and tie it to §20), but it is
a _rendering and BFF layer_. The domain algorithms — SSO claim mapping, SCIM
diffing, role risk scoring, audit hash-chaining, bulk export serialization — all
live in `libs/oshun/tenant-console`, whose barrel `src/index.ts` re-exports
exactly nine modules:

| Module           | Path                  | Responsibility                                                                                   |
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------ |
| `tenant-model`   | `src/tenant-model/`   | Canonical tenant, hierarchy, policy inheritance, members/seats, content scopes, agentic defaults |
| `identity`       | `src/identity/`       | SSO, SCIM/OneRoster, federation, per-tenant auth policy                                          |
| `roles`          | `src/roles/`          | Custom role templates, permission diff + risk scoring, dry-run harness, recertification          |
| `audit-explorer` | `src/audit-explorer/` | Filtered, hash-chained, tenant-isolated audit access; redaction; export bundles                  |
| `bulk-ops`       | `src/bulk-ops/`       | Import dry-run + staged commit; multi-format export with integrity manifests                     |
| `integrations`   | `src/integrations/`   | API keys, webhooks, connector registry, sandbox/cert/openapi                                     |
| `notifications`  | `src/notifications/`  | Multi-channel delivery decisions, templates, digest, suppression                                 |
| `help-center`    | `src/help-center/`    | Contextual, role-aware help search, feedback, changelog subscriptions                            |
| `status-page`    | `src/status-page/`    | Component roll-up, banners, incidents, postmortems, SLA                                          |

That `apps/oshun/tenant-admin` genuinely consumes the library is verifiable:
`apps/oshun/tenant-admin/src/app/identity/page.tsx` imports `applyScimSync`,
`canFederateAuth`, `computeRosterDiff`, `evaluateAuthChallenge`, and
`processSsoLogin` directly from `@oshun/tenant-console`. So the architecture
note that says "Oshun Tenant Console at `apps/oshun/tenant-admin` (standalone
Next app; §20)" is correct but under-attributed — the _capability_ is the
library; the app is the view. This matters for reviewers: to verify a claim in
this page, read the library module, not the page component.

### The runtime-boundary caveat (stated honestly)

The identity functions are **deterministic pure functions** — there is no live
SAML XML parser or OIDC token validator inside the library. `processSsoLogin`
takes an already-parsed `SsoLoginAttempt` (asserted claims, a signature-valid
boolean) and computes the mapping/JIT/session result; it does not perform the
cryptographic assertion verification itself. The library is candid about this
seam: the `SsoConnection.oidcClientId` / `oidcJwksUrl` fields carry a doc
comment that the **OIDC live login runtime fails closed (HTTP 503) when those
are absent**, "so a config-only connection never pretends to be loginable." That
is a fail-loud seam in the no-fake-success sense, not a stub. The completeness
audit further notes that the tenant-admin _config UI_ — metadata-XML /
OIDC-discovery parsing, the full transform matrix, the auth-policy panel,
sandbox-probe verdicts — is only partially covered end-to-end. Everything below
is real library logic; where the surrounding UI or live transport is partial, it
is called out.

## Tenant and Organization Model

A tenant is the canonical organizational entity. `tenant-model/tenant.ts`
defines
`TENANT_KINDS = ['institution','school','class','cohort','lab', 'partner-org','district']`
and a strict containment grammar via `ALLOWED_CHILD_KINDS`: a `district` may
contain `school` or `institution`; an `institution` may contain `school`, `lab`,
`class`, or `cohort`; a `school` contains `class` or `cohort`; a `class`
contains `cohort`; `lab` and `partner-org` contain only `cohort`; `cohort` is a
leaf. `validateTenant` enforces this grammar and also rejects self-parenting,
empty display names, an empty data-residency region, and negative/non-integer
seat allocations, returning a typed `TenantValidationError[]`.
`buildHierarchyChain` walks `parentTenantId` upward, detecting `'cycle'`,
`'unknown-tenant'`, and `'depth-cap-exceeded'` against the
`HIERARCHY_DEPTH_CAP = 6`.

Onboarding metadata is concrete: `TenantContractMetadata` carries `contractId`,
`billingEntityId`, `region`, `dataResidencyRegion`, and a row of default-policy
pointers — `defaultPolicyBundleId`, `defaultPersonaPolicyId`,
`defaultVoicePolicyId`, `defaultAvatarPolicyId`,
`defaultAgenticPipelineCatalogId`, and an optional
`defaultMetisInstitutionalConfigId`. These are the seeds for everything a tenant
inherits — the persona/voice/avatar defaults feed
[Lilith Persona Policy](./lilith-persona-policy.md), and the Metis config feeds
[Metis — Education and Tutoring](./domain-metis.md).

### Policy inheritance, scopes, members, and agentic defaults

`tenant-model/policy-inheritance.ts` resolves a child tenant's effective policy
against its parent using
`POLICY_MERGE_KINDS = ['override','merge', 'tighten-only']`. The
`'tighten-only'` kind is the load-bearing one for multi-tenant safety:
`resolvePolicy` rejects a child overlay that _loosens_ a tighten-only key,
emitting `tighten-only-violation` (or `invalid-tighten-value` on a type
mismatch, or `unknown-key`). A district can therefore set a floor a school
cannot relax.

`tenant-model/content-scopes.ts` governs what a tenant can see and use, over
`SCOPE_OBJECT_KINDS = ['source','course','program','ritual','briefing', 'persona','voice-profile','avatar-pack','agentic-pipeline']`,
with `resolveScopeVisibility` / `bulkResolveScope` applying override and
inheritance rules. `tenant-model/members-and-seats.ts` defines
`MEMBER_STATUSES = ['active','invited','suspended','deactivated']`, a
`LicensePool`, and `allocateSeat` / `releaseSeat` with typed
`SeatAllocationError`s so seat accounting cannot silently overflow;
`effectiveRoleIds` resolves a member's roles across direct and group membership.
`tenant-model/agentic-config.ts` resolves a tenant's effective agentic pipeline
catalog, budgets, and gates via `effectiveConfigFor`, tying this surface to
[Agent Registry, Job Orchestration, and Multi-Agent Plans](./agentic-registry-jobs-plans.md)
and
[Agent Invocation, Budgets, Memory, and Feedback Loops](./agentic-invocation-budgets-grants.md).

## Identity: SSO, SCIM, OneRoster, Federation, Auth Policy

### SSO (`identity/sso.ts`)

`SSO_PROTOCOLS = ['saml2','oidc']`. A tenant configures one or more
`SsoConnection`s, each carrying IdP entity id, endpoints, a signing-cert
thumbprint, allowed flows, JIT toggle, and session/refresh lifetimes. The heart
is `processSsoLogin`, which:

1. Validates the clock and that `sessionLifetimeSeconds` is positive and
   `refreshLifetimeSeconds >= sessionLifetimeSeconds`.
2. Rejects an untrusted assertion signature (`signature-untrusted`) and a flow
   the connection forbids (`flow-not-permitted`) — `flowKind` is
   `'idp-initiated'` or `'sp-initiated'`, gated by `allowIdpInitiated` /
   `allowSpInitiated`.
3. Maps asserted claims through `SsoClaimMapping`. Each mapping names an
   `externalClaim`, an `internalAttribute` ∈
   `{email, displayName, familyName, givenName, groups, tenant-role, preferred-locale}`,
   a `required` flag, and a `transform` ∈
   `{identity, lowercase, csv-split, first-only}`. A missing _required_ claim
   yields `missing-required-claim`.
4. Performs **just-in-time provisioning** when no `existingUserId` is present:
   if `jitProvisioning` is off it returns `jit-disabled-and-user-not-found`; if
   the mapped `email` is missing/blank it returns
   `jit-email-missing-or-invalid`; otherwise it mints `jit-${tenantId}-${email}`
   and marks `jitProvisioned`.

On success it returns the resulting attribute map plus `issuedAt`,
`sessionExpiresAt`, and `refreshExpiresAt` as Unix seconds. The `csv-split`
transform splits and trims a comma-joined group string (common in SAML group
claims) into a clean array; `first-only` collapses a multi-valued claim to its
first element. As noted above, OIDC's _live_ path fails closed (503) without
`oidcClientId`/`oidcJwksUrl`.

### SCIM 2.0 and OneRoster (`identity/scim.ts`)

`SCIM_OPERATIONS = ['create','replace','patch','delete']`. `applyScimSync` is a
pure decision function over a `ScimSyncInput` and the existing resource, with
`ScimConflictResolution ∈ {reject, merge, idp-wins, local-wins}`, returning a
verdict:

| Situation                   | Conflict policy | Verdict                                                         |
| --------------------------- | --------------- | --------------------------------------------------------------- |
| `delete`, resource absent   | —               | `noop`                                                          |
| `delete`, resource present  | —               | `applied` (tombstoned: user `active:false`, group emptied)      |
| `create`, already exists    | `reject`        | `rejected-conflict`                                             |
| `create`, already exists    | `idp-wins`      | `applied` (incoming wins)                                       |
| `create`, already exists    | `local-wins`    | `noop`                                                          |
| `create`, already exists    | `merge`         | `merged` (union of groups/emails/members, incoming scalars win) |
| `replace`/`patch`, absent   | `reject`        | `rejected-conflict`                                             |
| `replace`/`patch` otherwise | any             | `applied`                                                       |

For Metis institutions, the same module models OneRoster rostering.
`OneRosterClassResource` and `OneRosterEnrollmentResource` both carry
`status ∈ {active, tobedeleted, inactive}` (the OneRoster status vocabulary),
with enrollments tracking `role` (`student`/`teacher`/`administrator`/`aide`/…),
`primary`, and begin/end dates. `computeRosterDiff` produces a dry-run preview:
it diffs incoming vs. existing users, groups, classes, and enrollments into
`add`/`update`/`remove` `RosterDiffEntry`s with an `adds`/`updates`/`removes`
summary, and surfaces **referential conflicts** — duplicate incoming
`externalId`s, and enrollments that reference an unknown class or unknown user.
This is the engine behind "OneRoster-grade rostering with periodic and on-demand
sync, dry-run preview, conflict reporting."

### Federation and per-tenant auth policy (`identity/federation-and-auth-policy.ts`)

`canFederateAuth` answers whether identities may flow from one tenant to
another, given a set of `TenantFederationEdge`s. Each edge records
`direction ∈ {one-way, mutual}`, the approving actor, approval time, and a
`policyBundleId`. A `mutual` edge satisfies federation in both directions; a
`one-way` edge only `from → to`. This is how district ↔ school ↔ class
federation is expressed with explicit policy and audit, and how isolation is the
_default_ (no edge → no federation).

`evaluateAuthChallenge` implements per-tenant authentication policy against a
`TenantAuthPolicy`:

- `mfaMethodsAllowed ∈ {totp, webauthn, sms, push}` and `mfaRequired`;
- `AUTH_STEP_UP_TRIGGERS = ['high-risk-action','export-sensitive-data', 'admin-override','incident-response','after-inactivity']`;
- a CIDR `ipAllowlist` (empty = no restriction), `requireDevicePosture`,
  `sessionRefreshSeconds`, and `maxInactivitySeconds`.

The evaluation order is deliberate and **deny-first**: an IP outside a non-empty
allowlist returns `{verdict:'deny', reason:'ip-blocked'}` immediately (a hard
network gate beats a step-up); then unmet MFA → `step-up-required` reason
`'mfa'`; then unattested device posture → `'device-posture'`; then inactivity
beyond `maxInactivitySeconds` → `'inactivity'`; then a matching step-up trigger
→ `'trigger'`; otherwise `{verdict:'allow'}`. CIDR matching is a real bit-masked
IPv4 implementation (`ipMatchesCidr`/`ipToInt`), not a string prefix check.

## Custom Roles, Permission Diffs, and the Dry-Run Harness

`roles/roles.ts` layers tenant-editable role templates on a canonical model and,
critically, **scores the risk of every deviation**. The doc mentions a "test
harness" only generically, so the concrete primitives are worth naming.

`diffTemplateAgainstCanonical` compares a `TenantRoleTemplate` against its
`CanonicalRole` and emits a `PermissionDiffEntry` per capability with a
`delta ∈ {unchanged, granted-by-tenant, revoked-by-tenant, scope-narrowed, scope-widened}`
and a per-entry risk weight:

| Delta               | Risk weight                    | Meaning                                                          |
| ------------------- | ------------------------------ | ---------------------------------------------------------------- |
| `revoked-by-tenant` | `RISK_WEIGHT_REVOKE = 1`       | Tenant removed a capability the baseline grants                  |
| `scope-narrowed`    | `RISK_WEIGHT_SCOPE_NARROW = 1` | Tenant tightened a scope restriction                             |
| `granted-by-tenant` | `RISK_WEIGHT_GRANT = 3`        | Tenant added a capability the baseline lacks                     |
| `scope-widened`     | `RISK_WEIGHT_SCOPE_WIDEN = 4`  | Tenant broadened (or made disjoint) a scope — the most dangerous |
| `unchanged`         | 0                              | No deviation                                                     |

The entries sum to a `riskScore`; the report sets
`requiresReviewerSignoff = true` when the canonical role `isElevated` **or** the
score meets `REVIEWER_SIGNOFF_THRESHOLD = 6`.
`validateTemplateWithinApprovedBounds` then rejects unapproved capability grants
(`unapproved-capability-grant`) and scope wideners (`unapproved-scope-widening`)
outside an operator-approved set, and emits `reviewer-signoff-required` when
signoff is needed but no `reviewerSignoffActorId` is supplied. Note the
conservative scope comparator: disjoint or partially-overlapping restriction
sets are treated as `'wider'`, because a set that is not a strict subset of the
canonical is more permissive than a strict subset would be.

`effectiveCapabilities` resolves a template's inheritance chain
(`inheritsFromTemplateIds`) by topological order, applying overrides leaf-last
so the most specific template wins.

### Recertification state machine

`ASSIGNMENT_STATES = ['pending','approved','active','expired','recertified', 'revoked']`.
`transitionAssignment` enforces a legal transition table
(`pending → approved|revoked`, `approved → active|revoked`,
`active → expired|recertified|revoked`, `expired → recertified|revoked`,
`recertified → expired|revoked`, `revoked →` ∅), plus two guardrails: an
approval cannot be self-served (`approver-required` when the actor is the
requester), and you cannot move into `active`/`recertified` past
`expiresAtUnixSeconds` (`past-expiry`). This is the workflow behind "role
assignment with approval, expiration, and recertification."

### The role test harness

`runDryRun` is the literal "dry-run a role against representative flows"
feature. Given a template's `effective` capabilities and a list of
`DryRunAction`s — each `{capabilityId, scope, expected:'allow'|'deny'}` — it
computes the _actual_ verdict (deny if the capability is absent/disallowed, or
if a scope restriction exists and the action's scope is `null` or not in the
allowed set; otherwise allow) and returns `DryRunResult`s with `matched`.
Mismatches are exactly the "unexpected escalations" an admin surfaces before
deploying a role change.

## Audit Log Explorer (`audit-explorer/audit-explorer.ts`)

Each `AuditLogRecord` carries actor, role, action,
`severity ∈ {info, warning, critical}`, timestamp,
`targetObjectClass`/`targetObjectId`, an optional `policyBindingId` and
`correlationId`, an opaque `payload`, and — the mechanism the doc abstracts as
"tamper-evident storage" — a `contentDigest` plus a `priorDigest`. Those two
fields form a **per-tenant hash chain**: each record's `priorDigest` must equal
the previous record's `contentDigest`. `verifyAuditChain` walks a segment and
returns `{valid:false, breaksAtRecordId}` at the first link that does not match,
so any insertion, deletion, or mutation is detectable. (The library computes the
digest with a caller-supplied secret key — it does not invent crypto inside the
pure function.)

`applyAuditFilter` filters by actor id, actor role, tenant id, action, severity,
time window, target object class, policy binding, and correlation id — and is
**viewer-scoped**: an `ExplorerViewer` is
`{role:'platform-operator' | 'tenant-admin', tenantId}`, and a `tenant-admin`
viewer can never see records outside its own `tenantId` regardless of filter.
That scoping is the hard isolation boundary between tenant audit and Oshun-only
operator audit.

Supporting machinery: `buildCorrelationThread` groups records sharing a
`correlationId` into a time-sorted thread (cross-event correlation for
support/security/DSAR investigations); `applyRedactions` applies
`RedactionRule`s (`mode ∈ {remove, mask, pseudonymize}`, optionally scoped to
target object classes) to nested payload fields during export and records what
was redacted; `SavedInvestigation` (with a hashed shareable token) and
`AuditExportBundle` (with a `chainOfCustody` of prior-bundle id, initial/final
digest, and retention expiry) back saved/shareable investigations and
retention-aware export bundles.

## Bulk Operations and Data Import/Export

### Import dry-run and staged commit (`bulk-ops/bulk-ops.ts`)

`BULK_RESOURCE_KINDS = ['users','rosters','classes','courses','content', 'sources','taxonomy','persona-assignments','entitlements']`
and `BULK_OP_KINDS = ['import','export','action']`. `dryRunImport` validates
each `BulkRow` against a list of `RowValidator`s (typed `BulkValidationError`
codes: `missing`, `invalid-type`, `out-of-bounds`, `duplicate`,
`conflict-with-existing`), counts in-batch duplicate `externalId`s, classifies
rows as adds vs. updates against `existingExternalIds`, and — importantly —
counts **residency violations**: `residencyViolationCount` increments whenever a
row's residency region differs from the tenant's.
`BulkDryRunReport.readyToCommit` is `true` only when there are zero errors _and_
zero residency violations. This is residency-aware import that cannot silently
cross a region boundary.

`planStagedCommit` applies rows in order, recording committed / failed / skipped
ids and a `resumeFromRowIndex` so an operator can resume after a partial
failure, and synthesizes per-row audit event ids. `gateBulkAction` enforces
safety on selection-set actions: it blocks with `rationale-required` on an empty
rationale and `exceeds-safety-limit` when the selection size exceeds the
action's `safetyLimit` — the "safety limits, rationale capture" promise made
real.

### Multi-format export with per-resource constraints (`bulk-ops/bulk-export.ts`)

`BULK_EXPORT_RESOURCE_KINDS = ['content','rosters','users','audit','rights', 'metis']`
and `BULK_EXPORT_FORMATS = ['json','csv','oneroster','xapi', 'caliper']`. The
doc flattens these into a single "format options" list, but the code enforces a
**per-resource-kind support matrix** (`BULK_EXPORT_FORMAT_SUPPORT`, checked by
`isBulkExportFormatSupported`):

| Resource kind | Allowed formats                  |
| ------------- | -------------------------------- |
| `content`     | `json`, `csv`, `oneroster`       |
| `rosters`     | `json`, `csv`, `oneroster`       |
| `users`       | `json`, `csv`, `oneroster`       |
| `audit`       | `json`, `csv`, `xapi`, `caliper` |
| `rights`      | `json`, `csv`                    |
| `metis`       | `json`, `csv`                    |

`executeBulkExport` fails loud with `format-not-supported` outside this matrix,
and also guards `mismatched-resource-kind`, `residency-violation` (a record
whose region differs from the export target), `empty-export`, and
`invalid-chunk-size`. It chunks records (`maxRecordsPerFile`, default 5000),
serializes each chunk deterministically (records sorted, fields canonicalized;
real xAPI 1.0.3 statements and IMS Caliper 1.2 events for audit, OneRoster 1.1
CSV column sets for users/rosters/classes), and emits a `BulkExportManifest`
(`manifestVersion:'oshun-bulk-export-v1'`) with a SHA-256 hash per file and over
the canonicalized manifest. The hash function is **dependency-injected** so the
pure module can be tested deterministically and backed by `node:crypto` at
runtime; `verifyManifest` re-derives every file hash, byte length, and record
count, plus the manifest hash, to validate an export's integrity end-to-end.
These are the "integrity manifests" the doc names.

## API Keys, Webhooks, and Outbound Integrations

### API keys (`integrations/api-keys.ts`)

An `ApiKey` stores a `prefix` and a `tokenHash` — **the full key is never
stored**. `authenticateApiKey` rejects with a typed reason ∈
`{unknown-key, revoked, expired, ip-not-allowed}`: an unknown key or a
token-hash mismatch both return `unknown-key` (no oracle distinguishing the
two); a revoked key returns `revoked`; an expired key
(`expiresAtUnixSeconds <= now`) returns `expired`; a non-empty `ipAllowlist`
that the caller's IP misses returns `ip-not-allowed` (same real CIDR matcher as
auth policy). `evaluateApiKeyRateLimit` implements a fixed 60-second window
against `rateLimitPerMinute` (treating an invalid limit as exhausted, and a
`null` limit as unlimited). `rotateApiKey` mints a successor linked by
`rotatedFromKeyId` and puts the prior key into a grace expiry; `revokeApiKey`
stamps `revokedAtUnixSeconds`.

### Webhooks (`integrations/webhooks.ts`)

`WebhookDelivery.status ∈ {pending, in-flight, success, failed, dead-letter}`.
`matchesTopicPattern` supports three pattern forms: an exact match, a `'.*'`
suffix prefix-match (`billing.*` matches `billing` and `billing.invoice.paid`),
and a global `'*'`. `subscriptionsForEvent` selects active, same-tenant
subscriptions whose patterns match the event topic. `applyDeliveryTransition`
encodes the retry semantics: a `success` closes the delivery; a
`transient-failure` retries with `nextBackoffSeconds` (exponential growth,
capped at `maxBackoffSeconds`, with jitter) until `attemptNumber >= maxRetries`,
at which point it becomes `dead-letter`; a `permanent-failure` goes straight to
`failed`. `replayDelivery` resets a delivery to `pending` at attempt 1 — the
dead-letter replay tool. Response bodies are excerpted to 200 chars for the
audit record.

### Connector registry (`integrations/connector-registry.ts`)

`CONNECTOR_KINDS = ['lms','calendar','identity','payment','telemetry', 'byom-ingest','slack','teams']`.
The features prose over-enumerates relative to this taxonomy — it lists "LMS
connectors (LTI 1.3, LTI Advantage, SCORM …)" as if those were distinct registry
kinds. They are not: LTI 1.3, LTI Advantage, and SCORM are _sub-flavors of the
single `lms` kind_, not separate `ConnectorKind`s. The eight kinds above are the
real coarse taxonomy; treat the detailed protocol list as aspirational
sub-typing inside `lms`.

Each `ConnectorRegistration` carries a pinned version, available versions, an
error budget per hour, and a circuit breaker. `evaluateCircuitBreaker` opens
(`budget-exhausted`) when errors in the current hour reach `errorBudgetPerHour`;
`recordConnectorError` rolls the hourly window and trips the breaker;
`upgradeConnector` refuses `unknown-version` and `version-deprecated`, making
good on the "version pinning and upgrade pathways" promise.

### Developer portal — under-described in the source

The integrations section says only "OpenAPI specs, code examples, sandbox
tenants, webhook simulators, and integration certification flows." The real
substrate is richer. `libs/oshun/developer-portal/src/index.ts` exports five
modules: `openapi-builder`, `code-example-generator`, `sandbox-tenant-policy`,
`certification-suite`, and `developer-docs-registry`.

- **`openapi-builder`** generates an **OpenAPI 3.1** document from declarative
  endpoint specs, converting Zod schemas via `zodToOpenApiSchema` (callers can
  override with hand-written JSON Schema where Zod isn't 1:1). Endpoint specs
  carry `auth ∈ {public, bearer-developer-key, bearer-admin-session}`,
  `idempotent`, and `rateLimitPerMinute`.
- **`certification-suite`** ships a concrete check catalog.
  `CERTIFICATION_CHECK_KINDS` includes `auth-token-rotation`,
  `webhook-signature-verification`, `webhook-replay-rejection`,
  `rate-limit-respect`, `idempotency-key-honor`,
  `pagination-cursor-correctness`, `partial-failure-envelope-parse`,
  `residency-region-honor`, `event-deduplication`,
  `oauth-pkce-on-public-client`, `scopes-least-privilege`, and
  `privacy-deletion-fanout`. Each `CertificationCheck` has a
  `gateLevel ∈ {required, recommended, optional}` and a remediation URL;
  `computeCertificationVerdict` produces the suite verdict.

These are surfaced through the admin app at `api/admin/developer-portal/*`
(articles, certification checks, sandbox policies).

## Notifications, Lifecycle Communications, and Templates (`notifications/notifications.ts`)

`NOTIFICATION_CHANNELS = ['in-app','push','email','sms','voice','webhook']` —
and yes, **`voice` is a real enum member**, not aspirational copy. Severities
are `NOTIFICATION_SEVERITIES = ['info','standard','important','critical']`.
`decideDelivery` resolves preferences with an overlay precedence — per-persona
override, then per-domain override, then base `perChannel` — and emits a
`DeliveryDecisionEntry` per channel with one of: `deliver`, `skip-disabled`,
`skip-quiet-hours`, `skip-below-severity`, `skip-bounced`, `skip-suppressed`,
`defer-to-digest`. The rules encode real edge cases: a `critical` notification
**breaks through quiet hours** and is never deferred to digest;
`digestMode ∈ {off, hourly, daily, weekly}` only defers non-critical _email_.
`renderTemplate` enforces locale fallback, `missing-variable`, and
`template-not-approved` (a template must be in `approved` state to render), and
`isUserSuppressedForChannel` honors unsubscribe/bounce/compliance/spam
suppression records, channel-specific or global. Sibling files in the module
handle A/B testing (`ab-testing.ts`), digests (`digest.ts`), and the customer
message center (`message-center.ts`).

## Help Center and Knowledge Base (`help-center/help-center.ts`)

`HELP_AUDIENCES = ['customer','admin','creator','partner']` and
`HELP_KINDS = ['article','walkthrough','video-script','faq','changelog']`.
`searchHelp` is **role- and context-aware**: it skips unpublished/archived
articles and articles whose audience or applicable-role set the viewer doesn't
satisfy, scores a term match (title hits are weighted higher), and adds a
contextual bonus when the article's `applicableDomains` includes the current
domain or one of its `applicableSurfacePaths` prefixes the current surface — so
contextual help surfaces even with an empty search term. `aggregateFeedback`
rolls up helpful/unhelpful counts and "what's missing" subject counts (feeding
the editorial backlog — see
[Editorial Calendar and Asset & Media Library](./editorial-and-asset-library.md)),
and `shouldDeliverChangelog` filters "what's new" by
`minSeverity ∈ {patch, minor, major}` and locale.

## System Status, Maintenance, and Public Communications (`status-page/status-page.ts`)

`COMPONENT_STATES = ['operational','degraded','partial-outage','major-outage', 'maintenance']`.
`rollUpComponentStates` reduces a component set to its worst state by a fixed
rank (`operational < maintenance < degraded < partial-outage < major-outage`),
and `buildStatusPageView` computes overall status, a per-region worst-state map,
active (unresolved) incidents, and time-sorted upcoming maintenance. An
`IncidentRecord` carries `severity ∈ {minor, major, critical}` and a list of
`publicUpdates` whose
`status ∈ {investigating, identified, monitoring, resolved}` — the standard
incident-comms lifecycle. `activeBannersFor` targets banners by audience tenant
ids, audience roles, time window, locale (with fallback), and per-viewer
dismissal; `postmortemVisibleTo` enforces
`visibility ∈ {public, tenant-scoped, internal}` (an `internal` postmortem is
visible only to a `platform-operator`). `status-page/sla.ts` and
`subscriptions.ts` round out SLA-breach and subscribe-by-channel handling.

## The Apps: Tenant-Admin Web and the Admin BFF Surface

`apps/oshun/tenant-admin` is the tenant-facing Next.js app; its
`src/app/identity/page.tsx` imports the identity functions named above directly
from `@oshun/tenant-console`, confirming the app is a genuine consumer of the
library rather than a parallel reimplementation.

The platform-operator app `apps/oshun/admin` (274 files under `src/components/`,
with [Admin Products — Web and Mobile](./admin-products.md) covering it in
depth) exposes a concrete BFF route surface under `src/app/api/admin/` that this
area's architecture description never enumerates. Representative handlers
include `signin` and `signout`; `search`; `notifications` (plus
`[notificationId]/seen`, `/dismiss`, `mark-all-seen`); `bulk-operations` (and
`[operationId]/transition`); `integrations/api-keys` (and `/revoke`),
`integrations/snapshot`, `integrations/connectors/upgrade`,
`integrations/webhooks/simulator`; `agentic-operations/kill-switches` and
`agentic-operations/snapshot`; `editorial/release-streams`; the
audit/bulk-export families (`audit-log/events`, `audit-log/investigations/*`,
`bulk-exports/*`); `communications/*` (banners, incidents, help-articles,
status-components); and `developer-portal/*`. These are real route handlers, not
placeholders.

The on-call companion `apps/oshun/admin-mobile` is a real Expo app (`app.json`,
`eas.json`) with modules including `urgent-queue`, `review` (queue / routing /
decision / evidence clients), `auth` (a session controller with step-up and
`expoSecureKeyValueStorage`), `incidents`, `offline`, and `notifications` (an
Expo escalation router). It is detailed on
[Admin Products — Web and Mobile](./admin-products.md).

## What Is Real vs. Spec-Only

| Capability                                                                                                                             | Status                                                               |
| -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Tenant model, hierarchy grammar, policy inheritance, scopes, seats                                                                     | Real (`tenant-model/*`)                                              |
| SSO claim mapping + JIT + flow gating (`processSsoLogin`)                                                                              | Real **pure-function** core; live SAML/OIDC parser is a runtime seam |
| OIDC live login                                                                                                                        | Fail-closed 503 without `oidcClientId`/`oidcJwksUrl` (honest seam)   |
| SCIM 2.0 + OneRoster diff (`applyScimSync`, `computeRosterDiff`)                                                                       | Real                                                                 |
| Cross-tenant federation (`canFederateAuth`)                                                                                            | Real                                                                 |
| Per-tenant auth policy + step-up (`evaluateAuthChallenge`)                                                                             | Real                                                                 |
| Role diff, risk scoring, dry-run, recertification                                                                                      | Real (`roles/roles.ts`)                                              |
| Hash-chained, tenant-isolated audit + redaction + export                                                                               | Real (`audit-explorer/*`)                                            |
| Bulk import dry-run, staged commit, multi-format export + manifests                                                                    | Real (`bulk-ops/*`)                                                  |
| API keys, webhooks, connector registry                                                                                                 | Real (`integrations/*`)                                              |
| Developer portal: OpenAPI 3.1, certification suite, sandbox                                                                            | Real (`developer-portal/*`)                                          |
| Notifications, help center, status page                                                                                                | Real (3 modules)                                                     |
| Tenant-admin SSO **config UI** (metadata-XML / OIDC-discovery parse, full transform matrix, auth-policy panel, sandbox-probe verdicts) | Partial end-to-end UI coverage                                       |

Nothing in this area reads as fabricated; the only honest caveats are at the
runtime boundary (pure functions and fail-loud live seams) and in the partial
config-UI coverage.

## Related

- [Admin Products — Web and Mobile](./admin-products.md) — the platform-operator
  web and mobile apps that share the same nine `@oshun/tenant-console` modules.
- [Privacy, Consent, Data Portability, and User Controls](./privacy-consent-portability.md)
  — DSAR/export and consent surfaces that the audit explorer and bulk export
  feed.
- [Review, Compliance, and Trust & Safety](./review-trust-safety.md) — the
  governance and review machinery behind role signoff and incident handling.
- [Support, Entitlements, Billing, and Customer Operations](./support-billing.md)
  — entitlements and seat/billing context for the tenant model.
- [Metis — Education and Tutoring](./domain-metis.md) — the institutional domain
  that OneRoster rostering and the Metis institutional config serve.
- [Lilith Persona Policy](./lilith-persona-policy.md) — where the tenant's
  default persona/voice/avatar policies resolve.
- [Architecture, Platform Foundations, and Security](./platform-foundations-and-security.md)
  — platform-wide isolation, hash-chain, and security context.
- [Subsystem Glossary](./glossary.md) and
  [Product Surfaces](./product-surfaces.md) — placement of the tenant console
  among V1 surfaces.
- Hub: [../features.md](../features.md). Backlog: §20.
