Plan: Worker Display Names
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1: Plan file and nav entry
- Step 2: craig-cases — 7 tables, 11 columns
- Step 3: craig-placement — 1 table, 1 column
- Step 4: craig-exchange — 4 tables, 5 columns
- Step 5: craig-financial — 2 tables, 4 columns
- Step 6: craig-rules — 1 table, 2 columns
- Step 7: craig-intake — 2 tables, 3 columns
- Step 8: craig-security — 2 tables, 2 columns
- Step 9: craig-web template updates — 19 templates
- Step 10: Seed generator — populate
_namecolumns - Step 11: E2E test assertion updates
- Step 12: Regenerate screenshots
- Step 13: Documentation updates, issue, commit, push, MR
- Files Touched
- Verification
- Documentation Updates
Status
| Step | Description | Status |
|---|---|---|
1 |
Plan file and nav entry |
Done (2026-03-20) — MR !43 |
2 |
craig-cases migration + store + API handler changes |
Done (2026-03-20) — MR !43 |
3 |
craig-placement migration + store + API handler changes |
Done (2026-03-20) — MR !43 |
4 |
craig-exchange migration + store + API handler changes |
Done (2026-03-20) — MR !43 |
5 |
craig-financial migration + store + API handler changes |
Done (2026-03-20) — MR !43 |
6 |
craig-rules migration + store + API handler changes |
Done (2026-03-20) — MR !43 |
7 |
craig-intake migration + store + API handler changes |
Done (2026-03-20) — MR !43 |
8 |
craig-security migration + store + API handler changes |
Done (2026-03-20) — MR !43 |
9 |
craig-web template updates — render |
Done (2026-03-20) — MR !43 |
10 |
Seed generator — populate |
Done (2026-03-20) — MR !43 (, !44) |
11 |
E2E test assertion updates |
Done (2026-03-20) — MR !43 |
12 |
Regenerate screenshots |
Done (2026-03-20) — MR !43 |
13 |
Documentation updates, issue, commit, push, MR |
Done (2026-03-20) — MR !43 |
Issues: TBD (create on start)
Branch: feature/worker-display-names
Context
All worker identity columns across CRAIG store Keycloak claims.sub UUIDs (e.g., 00000000-0000-0000-0000-000000000001).
The web UI renders these raw UUIDs on detail pages, list pages, and tables — making the interface unusable for real caseworkers.
The architecture decision (CLAUDE.md) states: "API handlers use claims.sub (UUID), web UI uses preferred_username (display)."
The claims.sub half is implemented; the display name half is not.
Approach: Denormalized _name Columns
Each worker UUID column gets a paired _name TEXT column populated at write time from claims.preferred_username.
The API handler already has the full Claims struct available when writing.
Why denormalize instead of runtime lookup:
-
No runtime dependency on Keycloak availability for page rendering
-
No N+1 lookups or batch-lookup endpoints needed
-
Follows the existing pattern —
admin_unitis already denormalized TEXT -
Stale names are acceptable (worker renames are rare; display name is informational)
Trade-offs:
-
Name changes in Keycloak don’t auto-propagate (acceptable — worker renames are rare)
-
Adds ~22 nullable TEXT columns across 14 tables (minimal storage impact)
-
API response payloads grow slightly (one extra string field per worker reference)
Scope
In scope:
-
Add
_namecolumns to all 14 tables across 6 services that display worker identity in the web UI -
Populate
_namefromclaims.preferred_usernamein all API write handlers -
Backfill existing seed data with display names
-
Update all 19 web templates to render
_namewith UUID fallback -
Update E2E assertions
Out of scope:
-
craig-reporting worker columns (
reviewed_by,approved_byon submissions) — not displayed in web UI -
case_milestones.generated_by— always "rules-engine", not a user -
kinship_options.recorded_by— not displayed in web UI -
Runtime Keycloak lookup or sync mechanism
-
API response schema changes (the
_namefields are additive — existing clients ignore them)
Design
Migration Pattern
Each service gets one additive migration adding nullable TEXT columns. Nullable because existing rows have no display name — the template falls back to the UUID.
ALTER TABLE {table} ADD COLUMN {column}_name TEXT;
No NOT NULL constraint — existing data has no names.
New writes always populate both UUID and name.
API Handler Pattern
Every handler that writes claims.sub to a worker column also writes claims.preferred_username to the _name column.
Before:
let worker = claims.sub.to_string();
sqlx::query!("INSERT INTO cases (..., assigned_worker) VALUES (..., $N)", ..., worker)
After:
let worker = claims.sub.to_string();
let worker_name = claims.preferred_username.clone().unwrap_or_default();
sqlx::query!("INSERT INTO cases (..., assigned_worker, assigned_worker_name) VALUES (..., $N, $M)", ..., worker, worker_name)
The Claims struct already has preferred_username: Option<String> — verified in crates/craig-auth/src/claims.rs.
Template Pattern
Templates render the _name field with UUID fallback:
<!-- Before -->
<span class="kv-val">{{ case.assigned_worker }}</span>
<!-- After -->
<span class="kv-val">{{ case.assigned_worker_name.as_deref().unwrap_or(&case.assigned_worker) }}</span>
For list table cells:
<td>{{ c.assigned_worker_name.as_deref().unwrap_or(&c.assigned_worker) }}</td>
API Response Changes
Existing response structs gain new optional fields. This is additive — no breaking changes to existing clients.
pub struct CaseResponse {
// ... existing fields ...
pub assigned_worker: String,
pub assigned_worker_name: Option<String>, // NEW
pub supervisor: Option<String>,
pub supervisor_name: Option<String>, // NEW
}
Steps
Step 2: craig-cases — 7 tables, 11 columns
Migration (services/craig-cases/migrations/YYYYMMDDHHMMSS_worker_display_names.sql):
-- Worker display names: denormalized preferred_username from Keycloak claims
ALTER TABLE referrals ADD COLUMN created_by_name TEXT;
ALTER TABLE referrals ADD COLUMN updated_by_name TEXT;
ALTER TABLE investigations ADD COLUMN assigned_worker_name TEXT;
ALTER TABLE safety_assessments ADD COLUMN assessed_by_name TEXT;
ALTER TABLE cases ADD COLUMN assigned_worker_name TEXT;
ALTER TABLE cases ADD COLUMN supervisor_name TEXT;
ALTER TABLE case_plans ADD COLUMN created_by_name TEXT;
ALTER TABLE case_plans ADD COLUMN approved_by_name TEXT;
ALTER TABLE contacts ADD COLUMN recorded_by_name TEXT;
ALTER TABLE court_orders ADD COLUMN recorded_by_name TEXT;
ALTER TABLE contact_attachments ADD COLUMN uploaded_by_name TEXT;
Store changes — files and columns to update:
| File | Function(s) | Columns to Add |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
For each store function:
-
Add
_name: &strparameter (orOption<&str>for nullable columns) -
Add the column to the INSERT/UPDATE SQL
-
Add the column to the SELECT query so it appears in responses
For each SELECT query (get, list), add the _name column to the select list.
The response struct in the store module gains the _name field as Option<String>.
API handler changes — extract preferred_username from claims alongside sub:
| File | Handler(s) |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Pattern for each handler:
let worker_name = claims.preferred_username.clone().unwrap_or_default();
// Pass worker_name to store function alongside claims.sub
Step 3: craig-placement — 1 table, 1 column
Migration (services/craig-placement/migrations/YYYYMMDDHHMMSS_worker_display_names.sql):
ALTER TABLE placements ADD COLUMN created_by_name TEXT;
Store: store/placements.rs — create_placement gains created_by_name parameter and SELECT.
API: api/placements.rs — create_placement handler extracts preferred_username.
placements.created_by is not currently displayed in templates, but adding the column now ensures consistency.
kinship_options.recorded_by is out of scope (not displayed).
|
Step 4: craig-exchange — 4 tables, 5 columns
Migration (services/craig-exchange/migrations/YYYYMMDDHHMMSS_worker_display_names.sql):
ALTER TABLE data_sharing_agreements ADD COLUMN approved_by_name TEXT;
ALTER TABLE exchange_transactions ADD COLUMN initiated_by_name TEXT;
ALTER TABLE icpc_requests ADD COLUMN created_by_name TEXT;
ALTER TABLE icpc_home_studies ADD COLUMN assessed_by_name TEXT;
ALTER TABLE icpc_attachments ADD COLUMN uploaded_by_name TEXT;
Store changes:
| File | Columns |
|---|---|
|
|
|
|
|
|
|
|
API: api/agreements.rs, api/transactions.rs, api/icpc.rs — extract preferred_username.
Step 5: craig-financial — 2 tables, 4 columns
Migration (services/craig-financial/migrations/YYYYMMDDHHMMSS_worker_display_names.sql):
ALTER TABLE payments ADD COLUMN created_by_name TEXT;
ALTER TABLE payments ADD COLUMN approved_by_name TEXT;
ALTER TABLE payment_adjustments ADD COLUMN requested_by_name TEXT;
ALTER TABLE payment_adjustments ADD COLUMN approved_by_name TEXT;
Store: store/payments.rs (create, approve), store/adjustments.rs (create, approve).
API: api/payments.rs, api/adjustments.rs.
Step 6: craig-rules — 1 table, 2 columns
Migration (services/craig-rules/migrations/YYYYMMDDHHMMSS_worker_display_names.sql):
ALTER TABLE rule_sets ADD COLUMN created_by_name TEXT;
ALTER TABLE rule_sets ADD COLUMN updated_by_name TEXT;
Store: store/rule_sets.rs (create, update) — add name columns to INSERT/UPDATE and SELECT.
API: api/rule_sets.rs — extract preferred_username.
rule_evaluations.evaluated_by is internal (not displayed) — skip.
|
Step 7: craig-intake — 2 tables, 3 columns
Migration (services/craig-intake/migrations/YYYYMMDDHHMMSS_worker_display_names.sql):
ALTER TABLE public_reports ADD COLUMN reviewed_by_name TEXT;
ALTER TABLE api_keys ADD COLUMN created_by_name TEXT;
ALTER TABLE signer_keys ADD COLUMN approved_by_name TEXT;
Store: store/reports.rs (claim, screen_out, convert), store/api_keys.rs (create), store/signer_keys.rs (approve).
API: api/internal.rs (report review handlers), api/admin.rs (api key creation), api/signer_keys.rs (approve).
Step 8: craig-security — 2 tables, 2 columns
Migration (services/craig-security/migrations/YYYYMMDDHHMMSS_worker_display_names.sql):
ALTER TABLE archive_records ADD COLUMN archived_by_name TEXT;
ALTER TABLE nist_controls ADD COLUMN assessed_by_name TEXT;
Store: store/archive.rs (archive record functions), store/nist.rs (update assessment).
API: api/archive.rs, api/nist.rs.
review_evidence.uploaded_by is not displayed in the current UI — skip.
|
Step 9: craig-web template updates — 19 templates
Update all templates that display worker identity fields to use _name with UUID fallback.
Pattern:
{{ field_name.as_deref().unwrap_or(&field) }}
View model struct changes — add _name: Option<String> fields:
| File | Fields to Add |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Template changes — 19 files:
| Template | Changes |
|---|---|
|
Lines 18, 22: assigned_worker, supervisor → use |
|
Lines 37, 40: approved_by, created_by |
|
Line 30: recorded_by |
|
Line 11: uploaded_by |
|
Line 85: assigned_worker column |
|
Line 87: assigned_worker column |
|
Lines 11, 27: assigned_worker |
|
Line 81: created_by column |
|
Line 47: created_by |
|
Line 11: assigned_worker |
|
reviewed_by field |
|
Lines 62, 105: created_by, assessed_by |
|
Line 80: initiated_by column |
|
Lines 78, 171: approved_by, initiated_by |
|
Line 60: approved_by column |
|
Lines 65, 79, 125, 135: approved_by, created_by, requested_by, approved_by |
|
Line 58: updated_by column |
|
Lines 34, 40: created_by, updated_by |
|
Line 67: assessed_by |
Step 10: Seed generator — populate _name columns
File: tools/craig-seed/src/datagen.rs
The seed generator already uses JANE_DOE_SUB and BOB_SMITH_SUB constants.
Add corresponding name constants and populate _name fields:
const JANE_DOE_NAME: &str = "jane.doe";
const BOB_SMITH_NAME: &str = "bob.smith";
const ADMIN_NAME: &str = "admin";
const WORKER_NAMES: &[(&str, &str)] = &[
(JANE_DOE_SUB, JANE_DOE_NAME),
(BOB_SMITH_SUB, BOB_SMITH_NAME),
];
Helper function:
fn worker_name(sub: &str) -> &'static str {
WORKER_NAMES.iter()
.find(|(s, _)| *s == sub)
.map(|(_, n)| *n)
.unwrap_or("unknown")
}
Update all model structs to add _name fields.
Update all SQL renderers to include _name columns in INSERT statements.
Seed model changes (tools/craig-seed/src/model.rs):
Add _name: String fields to all seed structs that have worker identity columns:
SeedReferral, SeedInvestigation, SeedSafetyAssessment, SeedCase, SeedCasePlan, SeedContact, SeedCourtOrder, SeedPlacement, SeedExchangeTransaction, SeedIcpcRequest, SeedPayment, SeedPaymentAdjustment, SeedRuleSet, SeedAuditEntry, SeedNistControl, SeedArchiveRecord.
Seed SQL changes (tools/craig-seed/src/sql.rs):
Add _name columns to all INSERT statements that write worker fields.
Step 11: E2E test assertion updates
Update E2E assertions that match on worker UUIDs.
After this change, worker fields show jane.doe or bob.smith instead of 00000000-0000-0000-0000-000000000001.
Key files:
* tests/e2e/specs/cases.spec.ts — case detail worker display
* tests/e2e/specs/intake-review.spec.ts — reviewed_by field
* tests/e2e/specs/financial.spec.ts — payment detail
* tests/e2e/specs/exchange.spec.ts — ICPC detail
Run E2E 5x locally before pushing.
Step 12: Regenerate screenshots
After all changes:
1. cargo xtask dev reload
2. SCREENSHOTS=1 cargo xtask e2e --project=setup --project=screenshots
3. Verify all 47 PNGs have valid headers
4. Visually confirm worker names appear instead of UUIDs on detail pages
Step 13: Documentation updates, issue, commit, push, MR
-
.claude/docs/services.md— note_namefields in endpoint response descriptions -
CHANGELOG.adoc— entry under== Unreleased -
.claude/CLAUDE.md— update "Worker identity" note to reflect_namecolumns -
docs/modules/ROOT/pages/roadmap.adoc— tick "Worker display names on detail pages" -
docs/modules/ROOT/pages/data-model-cases.adoc— add_namecolumns to ER diagrams -
docs/modules/ROOT/pages/data-model-placement.adoc— add_namecolumn -
docs/modules/ROOT/pages/data-model-exchange.adoc— add_namecolumns -
docs/modules/ROOT/pages/data-model-financial.adoc— add_namecolumns -
User guides — no changes needed (display is transparent)
-
This plan — update status to Complete, move to archive
Files Touched
| File | Change |
|---|---|
|
1 new migration (11 ALTER TABLE ADD COLUMN) |
|
Add |
|
Extract |
|
1 new migration (1 column) |
|
Add |
|
Extract |
|
1 new migration (5 columns) |
|
Add |
|
Extract |
|
1 new migration (4 columns) |
|
Add |
|
Extract |
|
1 new migration (2 columns) |
|
Add |
|
Extract |
|
1 new migration (3 columns) |
|
Add |
|
Extract |
|
1 new migration (2 columns) |
|
Add |
|
Extract |
|
Add |
|
19 templates: render |
|
Add worker name constants, populate |
|
Add |
|
Add |
|
Update assertions matching on worker identity |
|
Entry under Unreleased |
|
Tick worker display names item |
|
Add |
Verification
-
cargo nextest run --workspace --lib— unit tests pass -
cargo xtask dev reload(schema changes require full reload) -
cargo nextest run --workspace— integration tests pass -
cargo xtask e2e— run 5x locally, all 121 E2E tests pass -
Verify on case detail page: assigned worker shows
jane.doenot UUID -
Verify on investigation detail: assigned worker shows display name
-
Verify on ICPC detail: created_by and assessed_by show display names
-
Verify on payment detail: created_by and approved_by show display names
-
Verify on rules list/detail: updated_by and created_by show display names
-
SCREENSHOTS=1 cargo xtask e2e --project=setup --project=screenshots— regenerate all screenshots -
Visual inspection: no raw UUIDs visible on any screenshot
Documentation Updates
-
.claude/docs/services.md— note_namefields -
CHANGELOG.adoc— entry under== Unreleased -
.claude/CLAUDE.md— update worker identity note -
docs/modules/ROOT/pages/roadmap.adoc— tick worker display names -
docs/modules/ROOT/pages/data-model-*.adoc— ER diagram updates (cases, placement, exchange, financial) -
This plan — status to Complete, move nav entry to archive