Oshun Platform · Features

Tenant, Institution, and Operator Toolkit

A focused page within the Oshun Platform Features documentation. The full map and every sibling page live in the Features hub.

13sections20 minread5tables

On this page

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.

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. 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, and the Metis config feeds Metis — Education and Tutoring.

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 SeatAllocationErrors 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 and Agent Invocation, Budgets, Memory, and Feedback Loops.

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

SSO (identity/sso.ts)#

SSO_PROTOCOLS = ['saml2','oidc']. A tenant configures one or more SsoConnections, 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 RosterDiffEntrys with an adds/updates/removes summary, and surfaces referential conflicts — duplicate incoming externalIds, 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 TenantFederationEdges. 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 DryRunActions — 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 DryRunResults 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 RedactionRules (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 RowValidators (typed BulkValidationError codes: missing, invalid-type, out-of-bounds, duplicate, conflict-with-existing), counts in-batch duplicate externalIds, 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 tokenHashthe 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 ConnectorKinds. 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), 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 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.

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.