Solving Duplicate Student Records: How an AI Agent Used psLens MCP to Reverse-Engineer PeopleSoft Name Search Normalization in 3 Minutes

How an AI coding agent connected to the psLens MCP server traced PeopleSoft’s LAST_NAME_SRCH and FIRST_NAME_SRCH normalization logic across subrecords and Application Packages to stop an integration from creating duplicate EMPLIDs.

Duplicate person records (EMPLIDs) are one of the most expensive data-integrity problems in PeopleSoft Campus Solutions and HCM. Once a student or employee is assigned two EMPLIDs, financial aid disbursements, enrollment history, transcripts, and residency records split across two identities.

Recently, a college running PeopleSoft Campus Solutions 9.2 on Microsoft SQL Server reported that their real-time Continuing Education integration was generating duplicate EMPLIDs—specifically for students with apostrophes or punctuation in their names, such as Denise O’Rourke.

The integration followed a standard PeopleSoft enrollment flow:

  • Run a Search/Match query against PS_PERSON and PS_NAMES using FIRST_NAME_SRCH, LAST_NAME_SRCH, and BIRTHDATE.
  • If a match returns (HTTP 200), stop and route to human review or reuse the existing EMPLID.
  • If no match returns (HTTP 404), automatically invoke the Component Interface to create a new person record.

For Denise O’Rourke, the Search/Match query missed her existing record every time and created duplicate EMPLIDs (3154281 and 3154282).

To fix the integration permanently, we needed exact answers to two questions:

“How does PeopleSoft populate FIRST_NAME_SRCH and LAST_NAME_SRCH under the hood, and what exact characters does it strip or replace?”

In a traditional PeopleSoft workflow, answering that requires opening Application Designer, expanding subrecords on NAMES, hunting through SavePreChange and FieldFormula events, and scrolling through thousands of lines of Application Package PeopleCode.

Instead, we handed the problem to an AI coding agent connected to the psLens MCP Server. In under three minutes—without opening Application Designer—the agent traced the entire execution chain, extracted the exact character replacement rules, verified PeopleTools Meta-SQL limitations, and updated the integration specification.


The Prompt and Autonomous Discovery Flow

We gave the AI agent in Google Antigravity a single prompt pointing to our local API documentation and the psLens MCP server:

We have a Search/Match bug on SQL Server where apostrophes are causing duplicate names.
The search match queries the _SRCH name fields, but inbound parameters are not stripping
special characters. Use the psLens MCP server to find how LAST_NAME_SRCH and FIRST_NAME_SRCH
are populated, catalog what characters are stripped out, and propose a fix.

Here is the sequence of MCP tool calls the agent executed autonomously to trace the logic from the database table all the way down to the character-by-character translation array:

%%{init: {"theme": "redux-color"}}%%
flowchart TD
    P["User Prompt:\nHow are FIRST_NAME_SRCH & LAST_NAME_SRCH populated?"]
    T1["list_databases\nIdentify connected CS 9.2 instance (CS92DEV-CHG)"]
    T2["get_definition (record: NAMES)\nDiscover fields live on subrecord NAMEGBL_SBR"]
    T3["search_code_sql (NAMEGBL_SBR)\nLocate SavePreChange hook on COUNTRY_NM_FORMAT"]
    T4["get_peoplecode (NAMEGBL_SBR.COUNTRY_NM_FORMAT)\nTrace FormatName() to App Package HCR_NAME:NameFormat"]
    T5["get_peoplecode (HCR_NAME:NameFormat)\nInspect RefreshName() -> RemoveAccent() -> ReplaceCharacter()"]
    T6["get_peoplecode (HCR_FUNCLIB:CharacterReplacement)\nExtract exact ASCII ranges, Unicode mappings & stripping rules"]
    R["Complete Normalization Spec & Integration Fix"]

    P --> T1 --> T2 --> T3 --> T4 --> T5 --> T6 --> R

Tracing the Logic with psLens MCP

Resolving the Subrecord Hierarchy (NAMES → NAMEGBL_SBR)

The agent began by calling list_databases to identify the connected Campus Solutions 9.2 database (CS92DEV-CHG, PeopleTools 8.61.03), then called get_definition on the NAMES record:

{
  "tool": "get_definition",
  "arguments": {
    "db": "CS92DEV-CHG",
    "type": "record",
    "name": "NAMES"
  }
}

Because psLens automatically expands subrecords in record definitions, the agent immediately saw that LAST_NAME_SRCH and FIRST_NAME_SRCH are not top-level fields on NAMES—they live inside the Global Names subrecord NAMEGBL_SBR (fields 5.9 and 5.10):

#FieldTypeLengthSearchList
5NAMEGBL_SBRSubRecord-
5.9-> LAST_NAME_SRCHCharacter30Yes
5.10-> FIRST_NAME_SRCHCharacter30Yes
5.11-> LAST_NAMECharacter30
5.12-> FIRST_NAMECharacter30
5.15-> SECOND_LAST_SRCHCharacter30Yes

Finding the Entry Point in Subrecord PeopleCode

Next, the agent ran search_code_sql for NAMEGBL_SBR and spotted Declare Function references pointing to NAMEGBL_SBR.COUNTRY_NM_FORMAT.SavePreChange. Calling get_peoplecode on that event exposed the entry point:

{
  "tool": "get_peoplecode",
  "arguments": {
    "db": "CS92DEV-CHG",
    "context_type": "record_field",
    "name": "NAMEGBL_SBR.COUNTRY_NM_FORMAT",
    "event_or_step": "SavePreChange",
    "start_line": 1,
    "max_lines": 100
  }
}

The returned PeopleCode showed that NAMEGBL_SBR.COUNTRY_NM_FORMAT.SavePreChange instantiates the Application Package class HCR_NAME:NameFormat:

import HCR_NAME:NameFormat;

Component HCR_NAME:NameFormat &oNameFormat;

Function RemoveAccent(&NAME_IN As string, &COUNTRY_FORMAT_IN As string, &NAME_OUT As string, &LeaveWildcardSearchChars As boolean)
   If &oNameFormat = Null Then
      &oNameFormat = create HCR_NAME:NameFormat();
   End-If;
   &NAME_OUT = &oNameFormat.RemoveAccent(&NAME_IN, &COUNTRY_FORMAT_IN, &LeaveWildcardSearchChars);
End-Function;

Following the Call Chain into HCR_NAME:NameFormat

HCR_NAME:NameFormat is a 2,623-line Application Class. Instead of pulling all 2,623 lines into context at once, the agent used search_code_sql to pinpoint method FormatName (line 529) and method RefreshName (line 440), then paged directly to those lines using get_peoplecode:

{
  "tool": "get_peoplecode",
  "arguments": {
    "db": "CS92DEV-CHG",
    "context_type": "app_package",
    "name": "HCR_NAME:NameFormat",
    "start_line": 435,
    "max_lines": 100
  }
}

Lines 481–491 of HCR_NAME:NameFormat.RefreshName() revealed the exact assignments for every _SRCH column on PS_NAMES:

&inrecName.LAST_NAME_SRCH.Value = %This.RemoveAccent(&inrecName.LAST_NAME.Value, &inNameFormat, False);
&inrecName.FIRST_NAME_SRCH.Value = %This.RemoveAccent(&inrecName.FIRST_NAME.Value, &inNameFormat, False);
&inrecName.SECOND_LAST_SRCH.Value = %This.RemoveAccent(&inrecName.SECOND_LAST_NAME.Value, &inNameFormat, False);

If &inrecName.Name = Record.NAMES Then
   &inrecName.NAME_DISPLAY_SRCH.Value = %This.RemoveAccent(&inrecName.NAME_DISPLAY.Value, &inNameFormat, False);
   &inrecName.PREF_FIRST_NM_SRCH.Value = %This.RemoveAccent(&inrecName.PREF_FIRST_NAME.Value, &inNameFormat, False);
   &inrecName.PREF_LAST_NM_SRCH.Value = %This.RemoveAccent(&inrecName.PREF_LAST_NAME.Value, &inNameFormat, False);
   &inrecName.PREF_MIDL_NM_SRCH.Value = %This.RemoveAccent(&inrecName.PREF_MIDDLE_NAME.Value, &inNameFormat, False);
End-If;

Reading RemoveAccent and ReplaceCharacter at line 630 of HCR_NAME:NameFormat showed that when &inLeaveWildcardSearchChars is False (which is always true when saving a person record), HCR_NAME:NameFormat delegates character processing to HCR_FUNCLIB:CharacterReplacement.

Extracting the Character Engine (HCR_FUNCLIB:CharacterReplacement)

With one more get_peoplecode call against HCR_FUNCLIB:CharacterReplacement (642 lines), the agent extracted the entire normalization algorithm:

method ReplaceCharacter
   /+ &inChar as String +/
   /+ Returns String +/
   
   Local integer &idx, &Code;
   
   &Code = Code(Upper(&inChar));
   
   If (&Code > 64 And &Code < 91) Or
         /* CJK Radicals, Symbols, Katakana, Hiragana, Unified Ideographs */
         (&Code >= 11868 And &Code <= 11995) Or
         (&Code >= 12288 And &Code <= 12543) Or
         (&Code >= 12800 And &Code <= 19903) Or
         (&Code >= 19932 And &Code <= 40879) Or
         (&Code >= 65313 And &Code <= 65439) Then
      Return Upper(&inChar);
   End-If;
   
   %This.LoadCharReplacementArray();
   
   &idx = &arr2Rep.Find(&inChar);
   If &idx > 0 Then
      Return &arr2Rep [&idx, 2];
   End-If;
   
   If %CharType_NotMatched = ContainsOnlyCharType(&inChar, %CharType_AlphaNumeric) Then
      Return Upper(&inChar);
   End-If;
   
   Return "";
end-method;

Catalog of PeopleSoft’s _SRCH Name Normalization Logic

From that three-minute trace, the agent produced a complete, authoritative specification of how PeopleSoft populates FIRST_NAME_SRCH, LAST_NAME_SRCH, SECOND_LAST_SRCH, NAME_DISPLAY_SRCH, and the preferred name search columns:

Retained Characters

  • Standard Latin Letters (A–Z, a–z): Uppercased and retained via ASCII code check (&Code > 64 And &Code < 91).
  • CJK & Japanese Character Blocks: Retained in uppercase across specific Unicode ranges (11868–11995, 12288–12543, 12800–19903, 19932–40879, and halfwidth/fullwidth forms 65313–65439).
  • Wildcard Search Characters (%, _, \): Retained only during interactive SearchSave page events when &inLeaveWildcardSearchChars = True. They are stripped when storing data to PS_NAMES.

Translated Characters (Diacritic & Accent Mapping)

LoadCharReplacementArray() loads over 280 Unicode Latin diacritics into &arr2Rep and maps each one to its base uppercase ASCII letter(s):

  • Single-character base mappings: À, Á, Â, Ã, Ä, Å, Æ → A; Ç, Ć, Č → C; È, É, Ê, Ë → E; Ì, Í, Î, Ï → I; Ñ, Ń → N; Ò, Ó, Ô, Õ, Ö, Ø → O; Ù, Ú, Û, Ü → U; Ý, Ÿ → Y; Ł → L; Š → S; Ž → Z; Ð → D; Þ → T.
  • Multi-character expansion: German Eszett (ß, U+00DF) expands to two characters: SS.

Stripped Characters (Replaced with "")

Every character that does not match the rules above falls through to Return "" and is completely removed from the _SRCH field:

Character TypeExamplesInput NameStored in LAST_NAME_SRCH
Apostrophes & Single Quotes', ’O'RourkeOROURKE
Hyphens & Dashes-, –Smith-JonesSMITHJONES
SpacesDe La CruzDELACRUZ
Periods & Commas., ,St. JohnSTJOHN
Digits & Symbols0–9, @, #, /III, Jr.III, JR

Catching the Second Trap: Dynamic SQL vs. PeopleTools Meta-SQL

Understanding the normalization explained why the integration created duplicate records, and it also exposed a second bug when testing live payloads.

The integration queried PeopleSoft through a generic XML query service (N_DATA_QUERY) with a dynamic <whereClause>:

A.BIRTHDATE = %DATEIN('{{DOB}}') AND
EXISTS (SELECT 1 FROM PS_NAMES NAMES WHERE
  A.EMPLID = NAMES.EMPLID AND
  NAMES.FIRST_NAME_SRCH LIKE %Substring(%Upper('{{FIRST}}'), 1, 4) || '%' AND
  NAMES.LAST_NAME_SRCH LIKE %Substring(%Upper('{{LAST}}'), 1, 4) || '%'
)

When 'O'Rourke' was injected into '{{LAST}}', two separate failures occurred depending on how the string was passed:

  • Unescaped Apostrophe ('O'Rourke'): Crashed at SQL compilation time with SQL error in DescribeCol. (2,279) because the internal apostrophe prematurely closed the SQL string literal before any SQL REPLACE() function could even run.
  • SQL-Escaped Apostrophe ('O''Rourke'): Compiled cleanly, but evaluated to NAMES.LAST_NAME_SRCH LIKE 'O''R%'. Because PeopleSoft stored the value as 'OROURKE', the query returned 0 rows (HTTP 404), triggering the person-creation Component Interface to mint a duplicate EMPLID.

During the session, we asked the agent two follow-up questions that developers normally spend half an afternoon testing by trial and error:

  • “Is there a PeopleTools Meta-SQL function like %Replace that is agnostic to Oracle and SQL Server?”
  • “Isn’t there a PeopleSoft %Quote Meta-SQL construct that handles escaping single quotes?”

Instead of guessing or hallucinating a nonexistent Meta-SQL macro, the agent used search_code_sql across the entire PeopleSoft codebase for %Replace and %Quote:

{
  "tool": "search_code_sql",
  "arguments": {
    "db": "CS92DEV-CHG",
    "search_term": "%Quote",
    "scope": "all"
  }
}

Both searches returned 0 matches across all delivered PeopleCode, SQL Objects, and Application Engine programs, while a code search for Quote( confirmed that Quote(&str) is strictly a PeopleCode built-in function—not a Meta-SQL macro available inside CreateSQL() strings.

With the full picture verified against live PeopleSoft metadata, the agent updated our integration specification with the exact pre-query sanitization rules required for the middleware layer (stripping /[^A-Za-z]/g, translating diacritics, and uppercasing before building the XML request) and documented the portable SQL REPLACE() + %Concat pattern for defense in depth.


Why Engineering Teams Use psLens MCP

Without psLens, resolving this ticket requires a senior PeopleSoft developer to stop what they are doing, log into a Windows workstation or remote desktop, launch Application Designer, open NAMES, expand NAMEGBL_SBR, inspect PeopleCode events across multiple fields, open HCR_NAME and HCR_FUNCLIB, and manually read through 3,200+ lines of PeopleCode to catalog the character rules.

With the psLens MCP Server, your AI coding agent does the entire investigation inside your IDE in seconds—while citing every record, subrecord, class, method, and line number:

MCP Tool InvokedWhat It Answered in Seconds
list_databasesIdentified the active Campus Solutions 9.2 instance (CS92DEV-CHG, PT 8.61.03)
get_definitionExpanded NAMES and proved FIRST_NAME_SRCH / LAST_NAME_SRCH belong to NAMEGBL_SBR
search_code_sqlLocated NAMEGBL_SBR.COUNTRY_NM_FORMAT.SavePreChange, found FormatName / RefreshName line numbers, and verified %Replace and %Quote do not exist in Meta-SQL
get_peoplecodeExtracted the exact normalization logic from HCR_NAME:NameFormat and the 280+ character mappings in HCR_FUNCLIB:CharacterReplacement

Whether you are building integrations, debugging batch processes, auditing security, or tracing custom PeopleCode, psLens turns your AI assistant into a PeopleSoft architect with live, read-only visibility into your environment.


See psLens MCP on Your Environment

The psLens MCP server is embedded directly in the single psLens binary with read-only table whitelisting and zero changes to your PeopleSoft infrastructure:


Subscribe for Updates

If you found this article helpful, subscribe to get notified of new PeopleSoft technical notes and psLens updates.

Stay Updated

Receive new articles directly in your inbox.

Tracing Every Place a PeopleSoft Field Is Updated Using an AI Agent and the psLens MCP Server

A real-world walkthrough of how an AI coding agent used the psLens Model Context Protocol (MCP) server to trace every PeopleCode program, App Package, App Engine, record default, and COBOL SQL statement that sets STDNT_CAR_TERM.TUIT_CALC_REQ to ‘Y’.

Every PeopleSoft developer and functional analyst has been asked a version of this question:

“Where in the system does STDNT_CAR_TERM.TUITION_CALC_REQ get set to 'Y'?”

On a traditional codebase stored in Git, ripgrep answers that question in two seconds. In PeopleSoft, a single flag can be flipped across seven different architectural layers:

  • Record Field Defaults stored in PSRECFIELD (DEFFIELDNAME / DEFRECNAME)
  • Record Field PeopleCode (RowInit, FieldChange, SavePreChange, SavePostChange)
  • Scroll Buffer Functions (UpdateValue() targeting the record or a view over the record)
  • Database Views (STDNT_CAR_TE_VW) that write through to the underlying table
  • Inline SQLExec Statements with hardcoded UPDATE PS_STDNT_CAR_TERM strings
  • Application Packages & Application Engine Programs (both PeopleCode and SQL actions)
  • COBOL Stored SQL Statements in PS_SQLSTMT_TBL
  • Online Pages and Component Interfaces where users or integrations toggle the field directly

Worse, raw text searches in Application Designer or against PSPCMTXT routinely return false positives from commented-out code (/* ... */ or REM), miss updates that happen through SQLExec("UPDATE PS_STDNT_CAR_TERM ..."), and ignore COBOL stored SQL entirely.

Here is what happens when you hand that exact prompt to an AI coding agent connected to a PeopleSoft Campus Solutions 9.2 environment through the psLens MCP Server.


The Prompt

The entire investigation started with a single one-line prompt in the IDE:

Use pslens to find all areas where STDNT_CAR_TERM.TUITION_CALC_REQ gets set to "Y".

Notice the subtle trap in the prompt itself: the human asked for TUITION_CALC_REQ (after the page label “Tuition Calc Required”), whereas the actual PeopleSoft column name on STDNT_CAR_TERM is truncated to 13 characters: TUIT_CALC_REQ.

Here is the exact sequence of MCP tool calls the agent executed to resolve the schema, trace every dependency layer, verify the source code, and eliminate false positives.

%%{init: {"theme": "redux-color"}}%%
flowchart TD
    P["User Prompt:\nFind where STDNT_CAR_TERM.TUITION_CALC_REQ = 'Y'"]
    T1["list_databases\nIdentify connected CS 9.2 instance"]
    T2["get_record_schema\nInspect STDNT_CAR_TERM fields & defaults"]
    T3["analyze_record_field_impact\nBlast radius across pages, CIs, views & PeopleCode"]
    T4["search_code_sql\nSearch PeopleCode, App Engine SQL & SQL Objects"]
    T5["query_table (SQLSTMT_TBL)\nInspect COBOL stored UPDATE/INSERT statements"]
    T6["get_peoplecode\nRead full event source & filter commented-out code"]
    R["Complete Cross-Layer Impact Report"]

    P --> T1 --> T2 --> T3 --> T4 --> T5 --> T6 --> R

Step-by-Step: How the Agent Used the psLens MCP Tools

Discovering the Environment and Resolving the Field Name

First, the agent called list_databases to see which PeopleSoft environments were connected, followed by get_record_schema on STDNT_CAR_TERM:

{
  "tool": "get_record_schema",
  "arguments": {
    "db": "CS92DEV-CHG",
    "record_name": "STDNT_CAR_TERM"
  }
}

From get_record_schema, the agent immediately spotted two critical facts without human intervention:

  • The column on STDNT_CAR_TERM is TUIT_CALC_REQ (not TUITION_CALC_REQ).
  • In PSRECFIELD, STDNT_CAR_TERM.TUIT_CALC_REQ has a Record Field Default pointing to BUS_UNIT_TBL_SF.TERM_ACT_INIT_SET (Term Activation Initial Setting). Whenever TERM_ACT_INIT_SET = 'Y' on the Student Financials Business Unit, any new STDNT_CAR_TERM row or SetDefault(STDNT_CAR_TERM.TUIT_CALC_REQ) call automatically initializes TUIT_CALC_REQ to 'Y'.

Running a Record.Field Blast Radius Analysis

Next, the agent called analyze_record_field_impact to map every PeopleSoft object that references STDNT_CAR_TERM.TUIT_CALC_REQ:

{
  "tool": "analyze_record_field_impact",
  "arguments": {
    "db": "CS92DEV-CHG",
    "record_name": "STDNT_CAR_TERM",
    "field_name": "TUIT_CALC_REQ"
  }
}

In a single round trip, analyze_record_field_impact returned:

  • Online Pages displaying STDNT_CAR_TERM.TUIT_CALC_REQ (STDNT_TUITION_CALC, WITHDRAWAL, QUICK_ADMIT_WRK2, OEE_ENRL_DATES) and whether each page field is editable or display-only.
  • Component Interfaces exposing the field and their access mode (ENRL_GRADES and ENRL_TERM_ACTIVTN as Read/Write; CHI_STDNT_ACTIVATION as Read-Only).
  • Direct PeopleCode References to STDNT_CAR_TERM.TUIT_CALC_REQ.

Searching PeopleCode, App Engine SQL, and SQL Objects

Direct RECORD.FIELD metadata references only tell part of the story. In Campus Solutions, many programs update PS_STDNT_CAR_TERM via raw SQLExec("UPDATE PS_STDNT_CAR_TERM SET TUIT_CALC_REQ = 'Y' ..."), through the view STDNT_CAR_TE_VW, or via Application Engine SQL actions.

The agent invoked search_code_sql across peoplecode, appengine, and sqlobject scopes:

{
  "tool": "search_code_sql",
  "arguments": {
    "db": "CS92DEV-CHG",
    "query": "TUIT_CALC_REQ",
    "scope": "all"
  }
}

This surfaced 89 PeopleCode matches and all Application Engine SQL steps referencing TUIT_CALC_REQ, including batch SQL updates in the Student Group process (SCC_STD_GRP steps MVOVRD.MVOVRD45 and MVTRGT.MVTRGT45) and SQLExec updates buried inside Residency (RESIDENCY_OFF), Optional Fees (OPT_FEE_STDNT), Student Equation Variables (STDNT_EQUTN_VAR), Student Groups (STDNT_GRPS), and Student Waivers (SSF_STD_WAIVER / SSF_SWVR_PS).

Checking COBOL Stored SQL in SQLSTMT_TBL

Campus Solutions relies heavily on COBOL batch programs (SRPCENRL, SRPCCUMM, SFPBCALC, SFPRCALC) whose SQL statements live in PS_SQLSTMT_TBL, completely outside App Designer’s PeopleCode and SQL definitions.

Using query_table, the agent queried SQLSTMT_TBL for statements touching TUIT_CALC_REQ or updating PS_STDNT_CAR_TERM:

{
  "tool": "query_table",
  "arguments": {
    "db": "CS92DEV-CHG",
    "record_name": "SQLSTMT_TBL",
    "fields": ["PGM_NAME", "STMT_TYPE", "STMT_NAME", "STMT_TEXT"],
    "filters": [
      { "field": "STMT_TEXT", "op": "like", "value": "%TUIT_CALC_REQ%" }
    ],
    "limit": 100
  }
}

That query separated the COBOL programs that clear the flag (SFPBCALC, SFPBHECS, SFPCCALC, SFPCLCAN, SFPRCALC, SFPREFND, which all run SET TUIT_CALC_REQ = 'N') from the COBOL program that updates it during enrollment and term statistics processing: SRPCCUMM (STMT_NAME = 'CUMTERM'), which executes:

UPDATE PS_STDNT_CAR_TERM
   SET ...,
       FA_STATS_CALC_REQ = 'Y',
       TUIT_CALC_REQ = :54,
       ...
 WHERE EMPLID = :63
   AND ACAD_CAREER = :64
   AND INSTITUTION = :65
   AND STRM = :66

Reading Full Event Context and Catching Commented-Out Code

A substring search for TUIT_CALC_REQ matched STDNT_CAR_SEQ.STDNT_CAR_NBR.SavePreChange at line 156:

UpdateValue(STDNT_CAR_TE_VW.TUIT_CALC_REQ, &I, "Y");

If you only look at a single matching line from a grep or SQL query on PSPCMTXT, you would list STDNT_CAR_SEQ.STDNT_CAR_NBR.SavePreChange as an active trigger. Instead, the agent called get_peoplecode to inspect the surrounding program:

{
  "tool": "get_peoplecode",
  "arguments": {
    "db": "CS92DEV-CHG",
    "name": "STDNT_CAR_SEQ.STDNT_CAR_NBR",
    "event_or_step": "SavePreChange"
  }
}

Inspecting the full PeopleCode event revealed that Oracle had wrapped lines 150–159 of STDNT_CAR_SEQ.STDNT_CAR_NBR.SavePreChange inside a /* ... */ block comment and moved the active logic to DERIVED_CS.ROW_NUMBER.SavePreChange! The agent also used get_peoplecode on STDNT_CAR_TERM.REG_CARD_DATE.SavePreChange to extract the exact list of 20 FieldChanged(...) conditions that trigger tuition recalculation when a student’s term record is edited.


The Complete Research Output

Within seconds, the agent produced a complete inventory of every mechanism that sets STDNT_CAR_TERM.TUIT_CALC_REQ to 'Y':

Record Field Default (PSRECFIELD)

  • STDNT_CAR_TERM.TUIT_CALC_REQ (and STDNTCARTERM_DR.TUIT_CALC_REQ) defaults from BUS_UNIT_TBL_SF.TERM_ACT_INIT_SET. When TERM_ACT_INIT_SET = 'Y', any new row or SetDefault(STDNT_CAR_TERM.TUIT_CALC_REQ) call (such as in STDNT_CAR_TERM.STRM.FieldChange line 41 and STDNT_CAR_TERM.STDNT_CAR_NBR.FieldChange line 40) sets TUIT_CALC_REQ to 'Y'.

Record Field PeopleCode

  • STDNT_CAR_TERM.EMPLID.RowInit (line 362): Sets STDNT_CAR_TERM.TUIT_CALC_REQ = "Y" when %Mode = "A" (Add mode).
  • STDNT_CAR_TERM.REG_CARD_DATE.SavePreChange (line 33): In TERM_HISTORY or STDNT_ACTIVATION, sets STDNT_CAR_TERM.TUIT_CALC_REQ = "Y" on an existing term row if any of these 20 fields on STDNT_CAR_TERM changed: ACADEMIC_LOAD, ACAD_LEVEL_BOT, ACAD_LEVEL_PROJ, ACAD_LOAD_APPR, STDNT_CAR_NBR, ACAD_PROG_PRIMARY, ACAD_YEAR, BILLING_CAREER, CUR_RESIDENT_TERMS, TRF_RESIDENT_TERMS, ELIG_TO_ENROLL, FORM_OF_STUDY, FULLY_ENRL_DT, LAST_DATE_ATTENDED, PROJ_BILL_UNT, OVRD_BILL_UNITS, WITHDRAW_CODE, WITHDRAW_DATE, INSTITUTION, or STRM.
  • STDNT_CAR_TERM.OVRD_TUIT_GROUP.FieldChange (line 3): In STDNT_ACTIVATION or TUITION_CALC, sets STDNT_CAR_TERM.TUIT_CALC_REQ = "Y" when OVRD_TUIT_GROUP changes.
  • DERIVED_CS.ROW_NUMBER.SavePreChange (line 26): In ACAD_PLAN (Student Program/Plan), sets STDNT_CAR_TE_VW.TUIT_CALC_REQ = "Y" for terms >= &TERM when ACAD_PROG.PROG_STATUS is "AC", "AD", or "AP".
  • DERIVED_CS.UPDATE_CUM.SavePreChange (line 23) & FUNCLIB_CS.TOT_CUMULATIVE.FieldFormula (get_cur_term, line 426): Set STDNT_CAR_TERM.TUIT_CALC_REQ to "Y" via UpdateValue(STDNT_CAREER.EMPLID, ..., STDNT_CAR_TERM.TUIT_CALC_REQ, 1, "Y") when updating term/cumulative enrollment statistics.
  • STDNT_ENRL_OEE.END_DT.FieldChange (lines 2–3): In OEE_CALENDAR, sets GetLevel0()(1).STDNT_CAR_TERM(1).STDNT_CAR_TERM.TUIT_CALC_REQ.Value = "Y".
  • SSR_HECS_ELEC.SSR_HECS_XMPT_STAT.SavePreChange (line 3): Sets STDNT_CAR_TERM.TUIT_CALC_REQ = "Y" when FieldChanged(SSR_HECS_ELEC.SSR_HECS_XMPT_STAT).
  • RESIDENCY_OFF.INSTITUTION.SavePostChange (lines 14, 16, 21): In RESIDENCY_ADMA, RESIDENCY_ADMP, or RESIDENCY_PERS, when DERIVED_CC.TUIT_CALC_REQ = "Y" (set by FieldChange on RESIDENCY_OFF.EFFECTIVE_TERM, INSTITUTION, RESIDENCY, or TUITION_RES) or when a residency row is deleted, runs SQLExec("UPDATE PS_STDNT_CAR_TERM SET TUIT_CALC_REQ = 'Y' WHERE EMPLID = :1 AND ACAD_CAREER = :2 AND INSTITUTION = :3 AND STRM >= :4", ...).
  • OPT_FEE_STDNT.VALID_VALUE.SavePreChange (line 5): When RecordChanged(OPT_FEE_STDNT.VALID_VALUE), runs SQLExec("update ps_stdnt_car_term set tuit_calc_req = 'Y' where emplid = :1 and institution = :2 and strm = :3 and billing_career = :4", ...).
  • STDNT_EQUTN_VAR.STRM.SavePostChange (lines 7, 10): Runs SQLExec("UPDATE PS_STDNT_CAR_TERM SET TUIT_CALC_REQ = 'Y' WHERE EMPLID = :1 AND BILLING_CAREER = :2 AND INSTITUTION = :3 AND STRM = :4", ...) for both STDNT_EQUTN_VAR.STRM and &PRIOR_TERM.
  • STDNT_GRPS.INSTITUTION.SavePostChange (lines 32, 49, 71): In STDNT_GROUPS_ADMA, STDNT_GROUPS_ADMP, or STDNT_GROUPS_PERS, when &TUIT_RECALC_REQ = "Y" (or a student group row is deleted), runs SQLExec("UPDATE PS_STDNT_CAR_TERM SET TUIT_CALC_REQ = 'Y' WHERE EMPLID = :1 AND INSTITUTION = :2 AND ACAD_CAREER = :3 AND STRM = :4", ...) for terms with TERM_BEGIN_DT >= &STDNT_GROUP_EFFDT.

Application Package PeopleCode

  • SSR_CAREER_TERM:Accessors:StudentTerm (OnExecute):
    • Lines 1284 / 4268: Sets %This.TUIT_CALC_REQ = &recBUS_UNIT_TBL_SF.TERM_ACT_INIT_SET.Value during term creation.
    • Lines 1785 / 4769: Sets %This.TUIT_CALC_REQ = "Y" using the same field-change rules as STDNT_CAR_TERM.REG_CARD_DATE.SavePreChange.
    • Lines 2629 / 5613: Property setter set TUIT_CALC_REQ assigns %This.data.TUIT_CALC_REQ.Value = &NewValue.
  • SSR_FA_DATA_UPD:ProcessFAUnits (OnExecute, line 244): Sets &REC_stdntcarTerm.TUIT_CALC_REQ.Value = "Y" and updates STDNT_CAR_TERM.
  • SCC_POP_UPDATE:APEngine:STDNT_EQUTN_VAR (line 63), SCC_POP_UPDATE:APEngine:PopUpdateCommonProcessing (lines 734, 1234), and SCC_POP_UPDATE:APEngine:RowByRowProcessing (lines 734, 1234):
    • When Population Update inserts or updates STDNT_EQUTN_VAR, it loads STDNT_CAR_TERM by key, sets &StudentCarTerm.TUIT_CALC_REQ.Value = "Y", and calls .Update().
    • When Population Update modifies STDNT_CAR_TERM.OVRD_TUIT_GROUP, it also sets TUIT_CALC_REQ to "Y".
  • SSF_TUITION_AND_FEES:Components:SSF_STD_WAIVER (OnExecute, line 414): Runs SQLExec("UPDATE PS_STDNT_CAR_TERM SET TUIT_CALC_REQ = 'Y' WHERE EMPLID = :1 AND INSTITUTION = :2 AND STRM = :3", SSF_STDNT_WVR.EMPLID, &Institution, SSF_STDNT_WVR.STRM) when saving student waivers.

Application Engine Programs

  • SSF_SWVR_PS (LINEPROC.OnExecute PeopleCode, line 11): Runs SQLExec("UPDATE PS_STDNT_CAR_TERM SET TUIT_CALC_REQ = 'Y' WHERE EMPLID = :1 AND INSTITUTION = :2 AND STRM = :3", SSF_SWVR_PS_AET.EMPLID, &Institution, SSF_SWVR_PS_AET.STRM).
  • SCC_STD_GRP (MVOVRD.MVOVRD45 and MVTRGT.MVTRGT45 SQL actions): Updates PS_STDNT_CAR_TERM A SET A.TUIT_CALC_REQ = 'Y' where STDNT_GRP_AET.TUIT_CALC_REQ_LBL = 'Y', A.TUIT_CALC_REQ = 'N', and TERM_BEGIN_DT >= ASOFDATE.

COBOL Stored SQL (SQLSTMT_TBL)

  • SRPCCUMM (CUMTERM): Updates PS_STDNT_CAR_TERM SET ..., FA_STATS_CALC_REQ = 'Y', TUIT_CALC_REQ = :54, ... during enrollment and cumulative statistics processing (SRPCENRL / SRPCENVB).

Online Pages & Component Interfaces

  • Online Pages: STDNT_CAR_TERM.TUIT_CALC_REQ is directly editable on STDNT_TUITION_CALC (components STDNT_ACTIVATION and TUITION_CALC), WITHDRAWAL (component TERM_HISTORY), and QUICK_ADMIT_WRK2 (component QUICK_ADMIT).
  • Component Interfaces: Exposed as Read/Write (BCACCESS = 2) on ENRL_GRADES and ENRL_TERM_ACTIVTN.

Why This Changes PeopleSoft Impact Analysis

Answering this question manually in Application Designer typically takes an experienced PeopleSoft developer an hour or more of cross-referencing Find Definition References, Find In across PeopleCode and SQL, checking record field properties, and writing custom SQL against PS_SQLSTMT_TBL — assuming they even remember to check views like STDNT_CAR_TE_VW and COBOL stored statements.

With the psLens MCP Server, an AI agent in Google Antigravity, Claude Code, Cursor, or VS Code has read-only, whitelisted access to the entire PeopleSoft metadata graph and code repository:

MCP Tool UsedRole in the Investigation
list_databasesIdentified the target Campus Solutions 9.2 database (CS92DEV-CHG)
get_record_schemaResolved TUITION_CALC_REQ → TUIT_CALC_REQ and uncovered the BUS_UNIT_TBL_SF.TERM_ACT_INIT_SET default
analyze_record_field_impactMapped every page, component, Component Interface, and direct PeopleCode reference
search_code_sqlFound inline SQLExec("UPDATE PS_STDNT_CAR_TERM ...") calls, view updates (STDNT_CAR_TE_VW), and App Engine SQL actions
query_tableInspected COBOL stored SQL statements in PS_SQLSTMT_TBL (SRPCCUMM vs. SFPBCALC)
get_peoplecodeVerified full event logic, extracted the 20 trigger fields on REG_CARD_DATE.SavePreChange, and filtered out commented-out legacy code

Try It on Your Own Environment

The embedded MCP server ships inside psLens with zero extra infrastructure required:


Subscribe for Updates

If you found this article helpful, subscribe to get notified of new PeopleSoft technical notes and psLens updates.

Stay Updated

Receive new articles directly in your inbox.

Connecting AI Coding Agents to PeopleSoft with Model Context Protocol (MCP)

How psLens uses an embedded Model Context Protocol (MCP) server to let Claude Code, Cursor, and Claude Desktop inspect PeopleSoft metadata, audit security, and diff environments in real time.

PeopleSoft has always been a black box to modern AI coding assistants.

Tools like Claude Code, Cursor, and Claude Desktop work by reading files on disk, navigating Git repositories, and parsing project trees. PeopleSoft does not work like that. Its object definitions, record structures, SQL objects, and PeopleCode live inside relational database tables (PSRECDEFN, PSRECFIELD, PSPROGMETA, PSAEAPPLDEFN), not on the filesystem.

Until now, getting that context to an LLM required writing ad-hoc SQL queries, taking screenshots of Application Designer, or manually copying and pasting code blocks into a chat window. That workflow is slow, breaks context across object hierarchies, and makes autonomous coding assistants impossible to use with PeopleSoft.

To solve this, psLens includes an embedded Model Context Protocol (MCP) server.

AI agents can now connect directly to your PeopleSoft environments over Streamable HTTP and Server-Sent Events (SSE). Without direct database access, two-tier client installs, or heavy middleware configuration, your AI assistants can inspect schemas, audit security permissions, diff environments, and triage batch issues in real time.


What Is Model Context Protocol (MCP)?

The Model Context Protocol is an open standard created by Anthropic. It standardizes how AI applications connect to external tools, data sources, and services. Instead of building custom API integrations for every AI client, an MCP server provides tools, prompts, and resources through a standardized JSON-RPC protocol.

When you configure an MCP server in Claude Code or Cursor, the LLM discovers the available tools and their schemas automatically. During a conversation or task, the LLM decides when to invoke a tool, passes typed parameters, and consumes structured results.

%%{init: {"theme": "redux-color"}}%%
flowchart LR
    subgraph Agents ["AI Coding Agents"]
        direction TB
        CC["Claude Code CLI"]
        CD["Claude Desktop"]
        CR["Cursor IDE"]
    end

    subgraph Server ["psLens Standalone Go Server"]
        direction TB
        MCP["Embedded MCP Server\n(Streamable HTTP / SSE)"]
        SWS_C["SWS Query Client\n(Bounded Whitelist)"]
        MCP --> SWS_C
    end

        direction TB
        DEV["HCM DEV (PT 8.59)"]
        TEST["HCM TEST (PT 8.60)"]
        PROD["HCM PROD (PT 8.61)"]
        SWS_C -->|"HTTPS SWS Requests"| DEV
        SWS_C -->|"HTTPS SWS Requests"| TEST
        SWS_C -->|"HTTPS SWS Requests"| PROD

  

    Agents -->|"JSON-RPC / Bearer PAT"| MCP

How psLens Implements MCP

The psLens MCP server runs directly inside the psLens Go process. It requires no external Python bridge, no local daemon, and no changes to your PeopleSoft infrastructure.

  1. Authentication: Secured via cryptographically generated Personal Access Tokens (PATs) generated in Settings → MCP Tokens (/settings/mcp). Tokens are SHA-256 hashed and stored in the internal NATS KV store.
  2. Access Control: Queries run through our Simple Web Services (SWS) framework using a customer-reviewed table whitelist (CHG_PSLENS_WL). The MCP server is read-only; it cannot mutate PeopleSoft database state.
  3. Transports: Supports both Streamable HTTP (/mcp) for standard tool calls and HTTP+SSE (/mcp/sse) for event-stream transports.

Connecting Claude Code takes a single command in your terminal:

claude mcp add --transport sse pslens https://{{your_subdomain}}.pslens.com/mcp/sse --header "Authorization: Bearer psl_mcp_YOUR_TOKEN_HERE"

Connecting Claude Desktop uses standard JSON configuration:

{
  "mcpServers": {
    "pslens": {
      "url": "https://{{your_subdomain}}.pslens.com/mcp",
      "headers": {
        "Authorization": "Bearer psl_mcp_YOUR_TOKEN_HERE"
      }
    }
  }
}

What AI Agents Can Do

The psLens MCP server exposes 20+ specialized tools across four functional areas:

1. Metadata & PeopleCode Exploration

Agents can inspect definitions and retrieve source code across all PeopleSoft container types:

  • search_objects: Fast wildcard search across records, fields, pages, components, menus, App Engines, App Packages, queries, projects, and service operations.
  • get_object_definition: Returns structured metadata, field lists, keys, and subrecords. With expand_subrecords: true, it recursively flattens nested subrecords into a single effective field list.
  • get_peoplecode: Extracts formatted source code for Record Events, App Package Classes, Component PostBuild/SaveEdit, Page Events, and App Engine Action steps.
  • get_object_dependencies: Computes forward and reverse where-used graphs. An agent can discover every record containing a field (PSRECFIELD), every page displaying a field (PSPAGEFIELD), and every PSQuery referencing a field (PSQRYFIELD).
  • search_code_sql: Performs fast substring searches across PeopleCode, SQL objects, and App Engine action SQL.

2. Reverse Security Graph & Access Auditing

Understanding access paths in PeopleSoft typically requires querying PSROLEUSER, PSROLECLASS, PSAUTHITEM, and PSMENUITEM. psLens traverses these graphs automatically:

  • who_has_access: Backward traversal from a component, menu, or service operation to identify every authorized permission list, role, and user ID.
  • user_access_summary: Generates a consolidated security profile for an OPRID, including roles, permission lists, account status, and row-level security.
  • audit_dangerous_access: Identifies elevated administrative privileges, full-access permission lists, and exposed web services.
  • user_login_audit: Inspects authentication logs and failed sign-in attempts from PSPTLOGINAUDIT.

3. Cross-Environment Comparison & Migration Drift

Because psLens connects to multiple databases simultaneously (DEV, TEST, PROD), an agent can inspect cross-environment drift in a single prompt:

  • compare_project: Diffs object definitions, field structures, and PeopleCode across two environments (e.g. HCMDEV vs. HCMPROD).
  • find_missing_projects: Identifies projects present in a development environment that have not yet been migrated to production.
  • compare_recurring_processes: Flags differences in Process Scheduler recurrence schedules and runtime parameters between environments.

4. Operational Incident Triage

When production alerts trigger, AI assistants can evaluate system health without needing access to PIA:

  • get_system_health: Real-time status of Process Scheduler dispatchers, queue latency, and database responsiveness.
  • triage_process_scheduler: Retrieves stuck, error, or long-running process instances with human-readable status decoders.
  • triage_integration_broker: Identifies failed or timed-out asynchronous and synchronous transaction contracts.
  • list_active_alerts: Surfaces active operational alerts configured in psLens.

Built for Agent Ergonomics and Token Efficiency

psLens optimizes payloads specifically for LLM context windows and autonomous execution:

  • Self-Describing Numeric Enums: Objects include human-readable decoders (RunStatusDesc: "Error", FieldTypeDesc: "Character", RecTypeDesc: "SQL Table") alongside raw PeopleTools integers.
  • Safe Annotations: Tools include standard MCP annotations (readOnlyHint: true, destructiveHint: false, idempotentHint: true), preventing repetitive confirmation prompts in Claude Desktop and Cursor.
  • Credential Protection & PII Masking: Data queries via query_table automatically strip sensitive password hashes (OPERPSWD, OPERPSWDSALTED), encryption keys, and tokens, and mask sensitive PII fields (***).
  • Structured Outputs: Metadata and code outputs are structured and formatted for efficient LLM ingestion.

PeopleTools 8.63 Native MCP vs. psLens MCP

Oracle introduced native Model Context Protocol server support in PeopleTools 8.63 (released July 2026 for OCI, with on-premises availability following in late 2026), positioning it as an “Integration Broker for AI.”

Both implementations use the open MCP standard, but they address different layers:

CapabilityPeopleTools 8.63 Native MCPpsLens Embedded MCP Server
Primary AudienceEnd users, functional analysts, App Designer developersSystem administrators, DBAs, developers, security auditors, SREs
PeopleTools VersionRequires PeopleTools 8.63+ exclusivelyVersion Agnostic: PeopleTools 8.53 through 8.63+
Environment ScopeSingle database: Queries only the hosted 8.63 instanceMulti-environment: Inspects and diffs DEV, TEST, and PROD in one session
Core FocusNatural Language Search (OpenSearch), transactional application services, App Designer code assistDeep metadata introspection, reverse where-used graphs, PeopleCode extraction, reverse security access, cross-environment drift, live batch triage
Cross-Environment DiffsNot supported nativelyBuilt-in (compare_project, find_missing_projects, compare_recurring_processes)
InfrastructureFull PT 8.63 stack (PIA, Tuxedo, OpenSearch, Integration Broker)Standalone Go service querying via SWS with database table whitelisting

How They Coexist

  • Use PeopleTools 8.63 Native MCP for conversational end-user AI (such as employee self-service transactions and OpenSearch document search) once your organization upgrades to 8.63.
  • Use psLens MCP when your developers, administrators, auditors, and AI coding assistants need deep metadata analysis, security auditing, cross-environment drift detection, and operational triage across your existing PeopleTools environments today.

Get Started

The embedded MCP server is available in psLens now.

  1. Review the MCP Server Setup Guide for configuration examples and the full tool catalog.
  2. Read the AI Enablement Use Case to see how structured Markdown exports and MCP complement each other.
  3. Book a demo to see psLens and Claude Code running live against a PeopleSoft environment.

Subscribe for Updates

If you found this article helpful, subscribe to get notified of new PeopleSoft technical notes and psLens updates.

Stay Updated

Receive new articles directly in your inbox.

Why psLens Runs on Go, a Hypermedia UI Stack, and SWS Instead of Living Inside PeopleSoft

Why psLens uses Go, templ, Datastar, hypermedia, and SWS: safer PeopleSoft access, simpler deployment, and faster iteration than a bolt-on PeopleTools app.

When I started building psLens, I was not trying to create another PeopleSoft bolt-on. I wanted a tool that could sit beside PeopleSoft, search metadata fast, run audits, monitor Process Scheduler and Integration Broker, and stay easy to deploy and maintain.

That requirement set ruled out a lot of obvious choices.

psLens now runs as a standalone Go application with server-rendered UI components built in templ, live updates handled by Datastar (GitHub), and a PeopleSoft-side access layer provided by SWS. That combination is not accidental. Each part solves a specific problem that PeopleSoft teams run into when they try to build admin and security tooling the old way.

Start with the Real Constraint: Safe Access to PeopleSoft

The most important product decision was not Go or Datastar. It was the access boundary.

psLens does not connect directly to the PeopleSoft database. It talks to the SWS framework over HTTPS, and SWS enforces what psLens is allowed to read.

That matters because PeopleSoft environments contain security metadata, operator data, process information, Integration Broker configuration, PeopleCode references, and everything else you would expect in a live enterprise system. If you are going to put a modern web console in front of that, the access path has to be narrow and reviewable.

SWS is what makes that possible.

SWS Is the Enabler for psLens

SWS is the PeopleSoft-side product that makes psLens practical. It installs as a standard PeopleTools project, exposes a bounded REST API, and restricts queries to a whitelist of PeopleTools metadata tables that the customer reviews and loads.

That gives psLens a few properties that I care about a lot:

  • No direct database credentials in the app.
  • No write path back into PeopleSoft.
  • No ability to query arbitrary tables outside the approved metadata surface.
  • A clean HTTPS integration point that works across DEV, TEST, and PROD.

In other words, SWS turns PeopleSoft into something an external tool can query safely. Without it, psLens would either need direct database access, which I did not want, or it would need to live inside PeopleTools, which would drag the whole product back into the world I was trying to get out of.

This is the architecture in one picture:

%%{init: {"theme": "redux-color"}}%%
flowchart LR
    U["Browser"] --> P["psLens\nGo + templ + Datastar"]

    P -->|HTTP SWS requests| PSDEV["PeopleSoft DEV"]
    P -->|HTTP SWS requests| PSTEST["PeopleSoft TEST"]
    P -->|HTTP SWS requests| PSPROD["PeopleSoft PROD"]

If you want the short product pitch, this is it: SWS provides the controlled access layer, and psLens turns that access into a usable operations and security console.

Why Go Fits This Product

Once I knew psLens would be a standalone application, Go became the obvious backend choice.

psLens needs to do a few things at the same time and do them predictably:

  • serve normal web requests
  • hold open Server-Sent Events connections for live UI updates
  • run background alert checks
  • generate reports
  • stay simple to deploy for managed or self-hosted customers

Go is a good fit for that shape. I get a single compiled binary, straightforward concurrency, fast startup, and a deployment story that stays boring in the best way. That matters when the product may run on Fly.io, in a Docker container on a customer VM, or in a tighter self-hosted environment where the operator wants as few moving parts as possible.

I also wanted the codebase to stay readable. A lot of enterprise software ends up with unnecessary framework weight before it even solves the business problem. Go makes it easier to resist that.

Why I Kept the UI Server-Driven

One thing PeopleSoft got right early was the idea that the server should stay in control of application state. I did not want to throw that away just because I was building outside PeopleTools.

That is why psLens uses templ and Datastar instead of a heavy single-page application stack.

More specifically, psLens follows a hypermedia approach. The server owns the application state, returns HTML, and drives most interactions directly instead of splitting the product into a separate JavaScript app and JSON API that have to stay in sync.

With templ, the UI lives in typed templates that compile into Go. If I reference the wrong field, the build fails. If I rename something in a handler, the compiler catches the breakage.

With Datastar, the browser stays light. The server renders HTML fragments, streams them over Server-Sent Events, and Datastar morphs the DOM in place. I do not need a separate frontend build pipeline, a JSON API for every interaction, or a second copy of application state living in the browser.

That is a good trade for an admin console. The product is about search, inspection, monitoring, and reporting. It is not a drawing canvas or a spreadsheet. I care more about predictable behavior, low client complexity, and keeping sensitive logic on the server than I do about shipping a giant JavaScript bundle.

Why Not a React Frontend and JSON API?

I could have built psLens as a React app backed by a JSON API. A lot of teams would start there by default because React has become the standard answer for web applications. Personally, I would not have reached for that approach for psLens, but I mention it because it is the comparison most teams expect.

My issue is not React itself so much as the ecosystem gravity around it. Once you go down that path, you usually inherit a larger stack: client-side routing, API clients, state libraries, form libraries, build tooling, hydration concerns, and a second application model in the browser that has to stay aligned with the server. That is a lot of machinery for an admin console.

Hypermedia drastically reduces that complexity by removing whole categories of coordination work. Instead of building a JavaScript application that talks to the server, I can keep the server as the application and let the browser render the result.

In the usual SPA model, every feature expands into multiple layers:

  • backend route
  • JSON types
  • API client code
  • client state management
  • component rendering logic

For psLens, that split buys me very little. The server already knows the query, the validation rules, the security constraints, and the final HTML I want to send. Datastar lets me keep that hypermedia flow intact.

%%{init: {"theme": "redux-color"}}%%
flowchart LR
    subgraph SPA ["SPA Stack"]
        direction TB
        subgraph SPA_BROWSER ["Browser"]
            s_ui["UI event"]
            s_state["Client state"]
            s_fetch["API request"]
            s_render["Component re-render"]
            s_ui --> s_state --> s_fetch
            s_render --> s_state
        end

        subgraph SPA_SERVER ["Server"]
            s_api["JSON API"]
            s_logic["Validation + business logic"]
            s_data["Server-side state / data"]
            s_api --> s_logic --> s_data
        end

        s_fetch --> s_api
        s_api -->|"JSON response"| s_render
        s_state -. "must stay in sync" .- s_data
    end

    subgraph HM ["psLens Hypermedia Stack"]
        direction TB
        subgraph HM_BROWSER ["Browser"]
            h_ui["UI event"]
            h_req["Datastar request"]
            h_dom["DOM morph"]
            h_ui --> h_req
        end

        subgraph HM_SERVER ["Server"]
            h_handler["Go handler"]
            h_logic["Validation + business logic"]
            h_data["Server-side state / data"]
            h_html["templ HTML fragment"]
            h_handler --> h_logic --> h_data --> h_html
        end

        h_req --> h_handler
        h_html -->|"HTML over SSE"| h_dom
    end

The real difference is not just fewer boxes. In the SPA model, both the browser and the server end up owning state that has to stay aligned. In the hypermedia model, the server stays in charge and the browser mostly renders what it is told. That means less code to coordinate, fewer integration seams, and fewer ways for the UI and backend to drift apart.

Why This Stack Works Well for AI-Assisted Development

There is another advantage here that I did not fully appreciate until I started using AI coding tools heavily: this stack is easier for them to work with.

In psLens, a feature usually lives in one Go handler and one .templ file. The query logic, route behavior, and rendered output are close together. The compiler checks the result. That is a much better environment for AI-assisted changes than a stack where one feature is spread across six files in two languages plus an API contract in the middle.

This is not just a developer convenience. It affects product velocity. When the code is local, typed, and testable, I can add object support, reports, and admin features faster without turning the codebase into mush.

There is also a product-side AI angle. psLens exports PeopleSoft objects and reports as structured Markdown, which makes the content usable in tools like ChatGPT, Claude, Claude Code, and Cursor. SWS makes the metadata accessible. The Go and hypermedia stack turns it into a product. The Markdown export makes it portable.

The Short Answer to “Why Not Build psLens in PeopleTools?”

Because psLens is not trying to be another PeopleSoft application.

It is a separate operations and security console that happens to know a lot about PeopleSoft.

Building it outside PeopleTools gives me normal source control, repeatable deployment, automated tests, easier packaging, faster iteration, and a cleaner path for AI-assisted development. Building it on top of SWS keeps the PeopleSoft access model narrow and reviewable.

I still respect what PeopleTools is good at. If I were building transactional application logic inside PeopleSoft, I would use PeopleTools. But that is not this product. psLens is better as an external web application with a tightly controlled connection back to PeopleSoft.

Why These Choices Matter Commercially

This stack is not just an engineering preference. It supports the product promises psLens makes to customers.

  • Dedicated deployment per customer.
  • No direct database access.
  • Read-only behavior enforced through SWS.
  • Fast iteration without asking customers to rework PeopleSoft customizations.
  • Straightforward self-hosting or managed hosting.
  • A cleaner bridge from PeopleSoft metadata into modern documentation and AI workflows.

That is the real reason for the architecture. I wanted psLens to feel modern without asking customers to weaken their security posture or turn their PeopleSoft environment into a development host for yet another tool.

Closing

If you strip away the implementation details, the design comes down to one idea: keep the access path to PeopleSoft tight, and keep the product itself easy to ship.

SWS is what makes the access path credible. Go, templ, Datastar, and the hypermedia approach are what make the product practical.

If that combination sounds useful for your PeopleSoft environment, book a demo. I will show you the architecture, the security boundary, and the product running against a live system.


Subscribe for Updates

If you found this article helpful, subscribe to get notified of new PeopleSoft technical notes and psLens updates.

Stay Updated

Receive new articles directly in your inbox.

Which Service Operations Can Half Your Users Call?

PeopleSoft service operation access is granted by permission list (PSAUTHWS) and rides roles out to users. A single broadly-held role can expose a web service to hundreds of accounts. How to trace the chain, the gotchas that undercount it, and a faster way.

Ask a PeopleSoft security administrator “who can call this web service?” and you will usually get a pause. There is no delivered screen that ranks service operations by how many users can reach them. The grant is buried one level below where anyone looks: a service operation is authorized to a permission list, and permission lists ride roles out to users. A single broadly-held role can quietly put a data-moving web service in reach of hundreds of accounts that have no business calling it.

Why This Is a Security Problem, Not a Trivia Question

A service operation is not a report. Depending on its handler, calling one can read data straight out of the application tables or write data into them. The SOAPToCI family of operations, for example, turns any authorized caller into a Component Interface client that can create and update records over HTTP. An operation exposed to a wide audience is a wide write surface.

The exposure is easy to create and hard to see:

  • Web service access is granted at the permission list level, but people think about access at the role level. The permission list that grants a sensitive operation often lives on a role that also grants something everyone needs, so it gets assigned broadly.
  • There is no delivered inquiry that answers “rank my service operations by user reach.” You would have to write the join yourself, and most people never do.
  • Non-production environments can have unusually broad access because they inherit delivered grants and then accumulate extra user assignments, so the widest-open operations are often the ones nobody configured on purpose.

The Security Chain

Service operation access flows through a fixed chain, the same shape as component access but with a different starting table:

  1. PSAUTHWS — maps each service operation (IB_OPERATIONNAME) to the permission lists (CLASSID) that grant access to it
  2. PSROLECLASS — which roles carry each permission list
  3. PSROLEUSER — which users hold each role
  4. PSOPRDEFN — the user accounts, including ACCTLOCK

A user can call a service operation if any permission list on any of their roles has a PSAUTHWS row for it.

The Query

To rank service operations by the number of unlocked users who can reach them:

SELECT   ws.IB_OPERATIONNAME,
         COUNT(DISTINCT ru.ROLEUSER) AS user_count
FROM     PSAUTHWS    ws
JOIN     PSROLECLASS rc ON rc.CLASSID  = ws.CLASSID
JOIN     PSROLEUSER  ru ON ru.ROLENAME = rc.ROLENAME
JOIN     PSOPRDEFN   o  ON o.OPRID     = ru.ROLEUSER
WHERE    o.ACCTLOCK = 0
GROUP BY ws.IB_OPERATIONNAME
ORDER BY user_count DESC;

The operations at the top of that list are the ones to justify first. For any single operation, drop the GROUP BY and add WHERE ws.IB_OPERATIONNAME = 'YOUR_OP' to see the exact roles and users behind the count.

Four Gotchas That Undercount Your Exposure

The join above is the honest starting point, but every mistake here makes access look smaller than it is, which is the dangerous direction to be wrong in.

  1. Locked accounts cut both ways. ACCTLOCK = 0 answers “who can call it today.” Drop the filter and you answer “who is provisioned,” which is the right question for a cleanup audit. Decide which one you are running; they give different numbers.

  2. Dynamic roles. PSROLEUSER lists who holds a role right now. If a role is populated by a rule or query, the membership can change on the next refresh, so today’s user count is a floor, not a ceiling.

  3. The count is only half the risk. A service operation reachable by 300 users is a footnote if its handler does nothing sensitive, and a serious finding if it maps to a Component Interface that writes to PS_JOB. User reach tells you how many doors exist; you still have to look at what is behind each one by tracing the operation’s handler and routings.

The Faster Way

Writing the four-table join once is fine. Running it across every environment, keeping the primary-permission-list path in it, and re-checking after each refresh is the part that does not happen.

psLens ships this as the Web Service Operation Access Audit report. It traces PSAUTHWS → PSROLECLASS → PSROLEUSER → PSOPRDEFN for every operation and sorts by unlocked user count, so the widest-open operations are at the top, exported as audit-ready Markdown with each operation linked to its detail page. The SOAP to CI Access Audit does the same for the WEBLIB_SOAPTOCI write path and lists the Component Interfaces each user can actually reach.

The same chain is browsable in either direction: start from a service operation and see the permission lists, roles, and users behind it, or start from a user and see every web service they can call. No SQL access required, which also means the analyst asking the question does not need the database account that usually comes with it. You can read the exact output these reports produce before installing anything.


Subscribe for Updates

If you found this article helpful, subscribe to get notified of new PeopleSoft technical notes and psLens updates.

Stay Updated

Receive new articles directly in your inbox.

Your PeopleSoft Nodes Probably Don't Have Passwords

Most PeopleSoft environments have active message nodes with no authentication configured (AUTHOPTN=‘N’). How to check PSMSGNODEDEFN, why it matters, and what to fix first.

Here is a finding from a delivered Campus Solutions 9.2 development image: 62 active message nodes, 58 of them with no authentication configured. That is not an unusual environment. It is what delivered images look like, and refreshed non-prod environments inherit it.

Why It Matters

A message node with AUTHOPTN = 'N' accepts messages without authenticating the sender. Any system that can reach your Integration Broker gateway can send messages to or through that node. Whether that is exploitable depends on what routings and service operations the node participates in — but “unauthenticated by default and nobody has looked” is not a posture you want to explain to an auditor.

A prime example is the delivered ANONYMOUS node, which exists in all PeopleSoft databases. It acts as a fallback for inbound integrations when the incoming request does not specify a node or credentials. If the ANONYMOUS node is active, is associated with a high-privilege Default User ID, and has active any-to-local routings, anyone who can reach the gateway can run those service operations. For a detailed guide on securing this fallback mechanism, see Properly Securing the ANONYMOUS IB Node in Integration Broker: The Missing Manual.

Node authentication has three settings in PSMSGNODEDEFN.AUTHOPTN:

  • N — none. No authentication.
  • P — password. The node authenticates with a stored password.
  • C — certificate. The node authenticates with a certificate.

How to Check

SELECT MSGNODENAME, DESCR, NODE_TYPE, AUTHOPTN
FROM   PSMSGNODEDEFN
WHERE  ACTIVE_NODE = 1
AND    AUTHOPTN = 'N'
ORDER  BY MSGNODENAME;

Two follow-up checks worth running while you are there:

  1. Nodes with password auth but no password set. AUTHOPTN = 'P' with an empty password is functionally the same as no auth.
  2. Inactive nodes with no auth. Lower priority, but they are one activation away from being the first finding.

Tracing Which Service Operations Can Run

Even if you identify an unauthenticated node, understanding your actual exposure is difficult. You have to ask: What service operations can actually run through this node?

Answering this in native PeopleSoft is tedious because node authorization is split across two areas:

  1. Routings: The service operations that have explicit or any-to-local routings associated with the node.
  2. User Security: The service operations authorized to the node’s Default User ID (defined in PSMSGNODEDEFN.USERID).

To trace the user’s security manually, you would have to query the database to join the node’s User ID (PSOPRDEFN) to its assigned roles (PSROLEUSER), join those roles to their permission lists (PSROLECLASS), and then join those permission lists to authorized web services (PSAUTHWS).

How psLens Simplifies the Audit

psLens does the heavy lifting for you. Since every object in the catalog is deep-linkable, you can navigate from a node to its service operations in a single click:

  • User’s Service Operations Toggle: From any Message Node detail page, if a Default User ID is assigned, you can toggle User’s Service Operations in the sidebar. psLens instantly lists all service operations authorized for that user ID.
User's Service Operations in psLens

Message Node detail page showing properties and User’s Service Operations toggle in psLens

  • Deep Links: Click on the User ID to inspect its full security configuration, or click any service operation in the list to navigate to its details and inspect its handlers and active routings.
  • Automated Audit Report: The built-in Unauthenticated Node Service Operations report searches your entire environment for active no-auth nodes and maps them directly to the fully-active service operations they can run.

What to Fix First

Start with active nodes that participate in inbound routings — those are reachable from outside. Set AUTHOPTN to password or certificate and configure the credential on both sides of each integration. Do it in DEV first; a node password mismatch is a quiet way to break an integration.

Running This as a Repeatable Audit

psLens ships this exact check as the Nodes Without Passwords report: it categorizes active no-auth nodes, active nodes with auth but no password, and inactive no-auth nodes, and exports the findings as Markdown with links back to each node’s detail page. Results are stored for 90 days, so the next audit cycle can show the trend rather than a point-in-time screenshot.

Nodes with No Password report

Nodes with No Password report dashboard in psLens

You can read the exact output it produces — including the 62-node dev-image run above — before installing anything.


Subscribe for Updates

If you found this article helpful, subscribe to get notified of new PeopleSoft technical notes and psLens updates.

Stay Updated

Receive new articles directly in your inbox.

Who Can Access This PeopleSoft Component? The SQL, and the Gotchas

How to trace PeopleSoft component access through PSAUTHITEM, PSROLECLASS, and PSROLEUSER — the query, the four mistakes that produce wrong answers, and a faster way.

“Who can get to this component?” is one of the most common questions a PeopleSoft security administrator gets, and PeopleSoft has no delivered screen that answers it in one place. The data lives in three tables, and the joins are easy to get subtly wrong.

The Security Chain

PeopleSoft component access flows through a fixed chain:

  1. PSOPRDEFN — user accounts
  2. PSROLEUSER — which roles each user holds
  3. PSROLECLASS — which permission lists each role carries
  4. PSAUTHITEM — what each permission list grants: menu, component, and page entries with their authorized actions

A user can reach a component if any permission list on any of their roles has a PSAUTHITEM row for it.

The Query

To list users who can access a component (for example, USERMAINT):

SELECT DISTINCT RU.ROLEUSER, O.OPRDEFNDESC, RU.ROLENAME, RC.CLASSID
FROM   PSAUTHITEM AI
JOIN   PSROLECLASS RC ON RC.CLASSID = AI.CLASSID
JOIN   PSROLEUSER  RU ON RU.ROLENAME = RC.ROLENAME
JOIN   PSOPRDEFN   O  ON O.OPRID = RU.ROLEUSER
WHERE  AI.BARITEMNAME = 'USERMAINT'
AND    O.ACCTLOCK = 0
ORDER  BY RU.ROLEUSER;

In PSAUTHITEM, the component name is in BARITEMNAME; MENUNAME is the menu that exposes it, and PNLITEMNAME narrows a row to a specific page within the component.

Four Gotchas That Produce Wrong Answers

  1. Locked accounts. Without ACCTLOCK = 0 your audit lists users who cannot sign in. Fine for “who is provisioned,” wrong for “who can get in today.” Decide which question you are answering.
  2. The same component on multiple menus. A component can be attached to more than one menu, and each menu grants a separate PSAUTHITEM row. Filtering on MENUNAME alone undercounts.
  3. Page-level rows. A permission list may grant only specific pages within a component (PNLITEMNAME). If you are auditing access to a specific page, you need the page-level rows; component-level results overstate access.
  4. Display-only is still access. PSAUTHITEM.DISPLAYONLY = 1 means the user can see the page but not save. For a data-exposure audit, display-only access to sensitive data still counts.

And a fifth that isn’t in the SQL at all: dynamic roles. If a role is populated by a role query or rule, PSROLEUSER tells you who holds it now, not who could acquire it tomorrow.

The Faster Way

psLens answers this from the component’s own page: search the component, and its detail page shows the permission lists that grant it, the roles that carry those permission lists, and the users who hold those roles — each one a link, so you can keep tracing in either direction. The same chain works backwards from a user or a permission list. The Security Admin workflow shows the click path, and the User Access report produces the audit-ready Markdown version.

No SQL access required — which also means the analyst asking the question doesn’t need the database account that usually comes with it.


Subscribe for Updates

If you found this article helpful, subscribe to get notified of new PeopleSoft technical notes and psLens updates.

Stay Updated

Receive new articles directly in your inbox.