Plan: Worker Display Names

On this page

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 _name fields with UUID fallback

Done (2026-03-20) — MR !43

10

Seed generator — populate _name columns

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_unit is 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 _name columns to all 14 tables across 6 services that display worker identity in the web UI

  • Populate _name from claims.preferred_username in all API write handlers

  • Backfill existing seed data with display names

  • Update all 19 web templates to render _name with UUID fallback

  • Update E2E assertions

Out of scope:

  • craig-reporting worker columns (reviewed_by, approved_by on 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 _name fields 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 1: Plan file and nav entry

Create this plan file and link in nav.adoc under Active.

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

store/referrals.rs

create_referral, update_referral

created_by_name, updated_by_name

store/investigations.rs

create_investigation, assign_investigation

assigned_worker_name

store/safety.rs

create_safety_assessment

assessed_by_name

store/cases.rs

create_case, update_case

assigned_worker_name, supervisor_name

store/case_plans.rs

create_case_plan, approve_case_plan

created_by_name, approved_by_name

store/contacts.rs

create_contact

recorded_by_name

store/court_orders.rs

create_court_order

recorded_by_name

store/contact_attachments.rs

create_attachment

uploaded_by_name

For each store function:

  1. Add _name: &str parameter (or Option<&str> for nullable columns)

  2. Add the column to the INSERT/UPDATE SQL

  3. 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)

api/referrals.rs

create_referral, update_referral

api/investigations.rs

create_investigation, assign_investigation

api/cases.rs

create_case, update_case

api/case_plans.rs

create_case_plan, approve_case_plan

api/contacts.rs

create_contact

api/court_orders.rs

create_court_order

api/contact_attachments.rs

create_attachment

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.rscreate_placement gains created_by_name parameter and SELECT.

API: api/placements.rscreate_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

store/agreements.rs

approved_by_name in approve handler

store/transactions.rs

initiated_by_name in create

store/icpc.rs

created_by_name in create, assessed_by_name in home study

store/icpc_attachments.rs

uploaded_by_name in create

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

routes/cases/mod.rs (CaseView)

assigned_worker_name: Option<String>, supervisor_name: Option<String>

routes/cases/mod.rs (CasePlanView)

created_by_name: Option<String>, approved_by_name: Option<String>

routes/cases/mod.rs (ContactView)

recorded_by_name: Option<String>

routes/cases/mod.rs (CourtOrderView)

recorded_by_name: Option<String>

routes/intake/mod.rs (InvestigationView)

assigned_worker_name: Option<String>

routes/intake/referrals.rs (ReferralView)

created_by_name: Option<String>

routes/intake/reports.rs (PublicReportView)

reviewed_by_name: Option<String>

routes/exchange.rs (TransactionView)

initiated_by_name: Option<String>

routes/exchange.rs (IcpcRequestView)

created_by_name: Option<String>

routes/exchange.rs (IcpcHomeStudyView)

assessed_by_name: Option<String>

routes/exchange.rs (AgreementView)

approved_by_name: Option<String>

routes/financial.rs (PaymentView)

created_by_name: Option<String>, approved_by_name: Option<String>

routes/financial.rs (AdjustmentView)

requested_by_name: Option<String>, approved_by_name: Option<String>

routes/rules.rs (RuleSetView)

created_by_name: Option<String>, updated_by_name: Option<String>

routes/security.rs (NistControlView)

assessed_by_name: Option<String>

routes/security.rs (ArchiveView)

archived_by_name: Option<String>

Template changes — 19 files:

Template Changes

cases/_tab_summary.html

Lines 18, 22: assigned_worker, supervisor → use _name with fallback

cases/_tab_case_plan.html

Lines 37, 40: approved_by, created_by

cases/_tab_contacts.html

Line 30: recorded_by

cases/contact_attachments_fragment.html

Line 11: uploaded_by

cases/list.html

Line 85: assigned_worker column

intake/worklist.html

Line 87: assigned_worker column

intake/investigation.html

Lines 11, 27: assigned_worker

intake/referrals.html

Line 81: created_by column

intake/referral_detail.html

Line 47: created_by

intake/safety_assessment.html

Line 11: assigned_worker

intake/report_detail.html

reviewed_by field

exchange/icpc_detail.html

Lines 62, 105: created_by, assessed_by

exchange/transactions.html

Line 80: initiated_by column

exchange/partner_detail.html

Lines 78, 171: approved_by, initiated_by

exchange/agreements.html

Line 60: approved_by column

financial/payment_detail.html

Lines 65, 79, 125, 135: approved_by, created_by, requested_by, approved_by

rules/list.html

Line 58: updated_by column

rules/detail.html

Lines 34, 40: created_by, updated_by

security/nist.html

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 _name fields in endpoint response descriptions

  • CHANGELOG.adoc — entry under == Unreleased

  • .claude/CLAUDE.md — update "Worker identity" note to reflect _name columns

  • docs/modules/ROOT/pages/roadmap.adoc — tick "Worker display names on detail pages"

  • docs/modules/ROOT/pages/data-model-cases.adoc — add _name columns to ER diagrams

  • docs/modules/ROOT/pages/data-model-placement.adoc — add _name column

  • docs/modules/ROOT/pages/data-model-exchange.adoc — add _name columns

  • docs/modules/ROOT/pages/data-model-financial.adoc — add _name columns

  • User guides — no changes needed (display is transparent)

  • This plan — update status to Complete, move to archive

Files Touched

File Change

services/craig-cases/migrations/

1 new migration (11 ALTER TABLE ADD COLUMN)

services/craig-cases/src/store/*.rs

Add _name params to write functions, add to SELECTs (8 files)

services/craig-cases/src/api/*.rs

Extract preferred_username in handlers (7 files)

services/craig-placement/migrations/

1 new migration (1 column)

services/craig-placement/src/store/placements.rs

Add _name param + SELECT

services/craig-placement/src/api/placements.rs

Extract preferred_username

services/craig-exchange/migrations/

1 new migration (5 columns)

services/craig-exchange/src/store/*.rs

Add _name params (4 files)

services/craig-exchange/src/api/*.rs

Extract preferred_username (3 files)

services/craig-financial/migrations/

1 new migration (4 columns)

services/craig-financial/src/store/*.rs

Add _name params (2 files)

services/craig-financial/src/api/*.rs

Extract preferred_username (2 files)

services/craig-rules/migrations/

1 new migration (2 columns)

services/craig-rules/src/store/rule_sets.rs

Add _name params + SELECT

services/craig-rules/src/api/rule_sets.rs

Extract preferred_username

services/craig-intake/migrations/

1 new migration (3 columns)

services/craig-intake/src/store/*.rs

Add _name params (3 files)

services/craig-intake/src/api/*.rs

Extract preferred_username (3 files)

services/craig-security/migrations/

1 new migration (2 columns)

services/craig-security/src/store/*.rs

Add _name params (2 files)

services/craig-security/src/api/*.rs

Extract preferred_username (2 files)

services/craig-web/src/routes/*.rs

Add _name fields to ~16 view model structs

services/craig-web/templates/*.html

19 templates: render _name with fallback

tools/craig-seed/src/datagen.rs

Add worker name constants, populate _name fields

tools/craig-seed/src/model.rs

Add _name fields to ~16 seed structs

tools/craig-seed/src/sql.rs

Add _name columns to ~10 INSERT renderers

tests/e2e/specs/*.spec.ts

Update assertions matching on worker identity

CHANGELOG.adoc

Entry under Unreleased

docs/modules/ROOT/pages/roadmap.adoc

Tick worker display names item

docs/modules/ROOT/pages/data-model-*.adoc

Add _name columns to ER diagrams

Verification

  1. cargo nextest run --workspace --lib — unit tests pass

  2. cargo xtask dev reload (schema changes require full reload)

  3. cargo nextest run --workspace — integration tests pass

  4. cargo xtask e2e — run 5x locally, all 121 E2E tests pass

  5. Verify on case detail page: assigned worker shows jane.doe not UUID

  6. Verify on investigation detail: assigned worker shows display name

  7. Verify on ICPC detail: created_by and assessed_by show display names

  8. Verify on payment detail: created_by and approved_by show display names

  9. Verify on rules list/detail: updated_by and created_by show display names

  10. SCREENSHOTS=1 cargo xtask e2e --project=setup --project=screenshots — regenerate all screenshots

  11. Visual inspection: no raw UUIDs visible on any screenshot

Documentation Updates

  • .claude/docs/services.md — note _name fields

  • 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

Edit this page · latest