Skip to content

fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id - #24885

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24775-person-named-titles
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24775-person-named-titles

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24775

Clause-②: no

This PR covers one of the card's two objects, sys_user_permission_set (plugin-security). The other, sys_notification_preference (service-messaging), needs a ruling first, so #24775 remains open after this merges. Open choice 1 below gives that ruling's options. Related: #24701 (Record Shares recipient, closed; its seat ruling 6282390889 is the precedent read for this card).

What changes

sys_user_permission_set.display_title is the object's nameField. It composed the stored foreign key, record.user_id + ' → ' + record.permission_set. So Setup's list of a user's permission sets and the assignment's record page read like ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap → qa_fls_rep (HotCRM pass, F-25).

It now names the person:

(isBlank(trim(record.user_id.name)) ? record.user_id.email : record.user_id.name) + ' → ' + record.permission_set
  • The user's name, or their email where the name is blank. That is the platform's own name || email rule for a person's handle (auth-manager.ts, approval-service.ts).
  • The value is read one hop through user_id. That is the formula read path's one-hop traversal (ADR-0079 addendum, 2026-10-18), read under the reader's own authority.
  • No stored column, no spec change, no objectui change, no console name mapping.
  • user_id is read only through the hop. A bare read beside the traversal would be the traversal-bare-and-traversed refusal.
  • titleFormat ({user_id} → {permission_set}) is unchanged. It is the rung below the declared pointer in the console's H1 ladder: objectui packages/components/src/renderers/layout/containers.tsx:2542 at the pin 616df6598b ("The declared pointer OUTRANKS titleFormat"). A renderer reaches it only when the title has no value.
  • The field's label and description are unchanged, so no translation bundle moves.

Files:

  • packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts: the expression and its comments.
  • packages/plugins/plugin-security/src/objects/sys-user-permission-set-person-title.test.ts (new): the pins.
  • packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts: sys_user_permission_set leaves its case table, since the title no longer equals the titleFormat text. sys_user_position keeps every assertion it had.
  • .changeset/24775-permission-set-assignment-person-title.md: @objectstack/plugin-security patch.

Premises, measured (on main at 6b9e6df909, then merged to b1676441ba)

  • H1, holds. Both objects declare nameField: 'display_title', a text formula with no stored column. The read door serves it: on a real engine (driver-sql, better-sqlite3), findOne, a find projecting only display_title, and the insert response all carry it.
  • H2, read. packages/spec/src/data/display-name.ts lists lookup and user in TITLE_INELIGIBLE_TYPES. So the pointer stays on the text formula; no pointer moves.
  • H3. The console already names the person in a lookup column's chip. For the title, though, the platform's server-side resolvers read the title field's value: resolveRecordDisplayName, and servedRecordTitle in record-title.ts. So the person's name has to be in the formula's value. The formula's one hop is the platform's own way to put it there.
  • H4, holds (it was NOT MEASURED in plugin-sharing / webhooks: the by_object groupings of sharing rules and webhooks head each group with an API name, and a record share's heading is its recipient's raw id (the #24501 / #24611 family) #24701's report). A formula reads one hop through a reference. This shipped with d537a318b and 1bf5f6e17c, and the ADR-0079 addendum records it. Measured on the real engine with the real sys_user, the hop formula gives Alice Liddell → crm_edit on the insert response, on findOne and on a projected find. A grant whose user does not exist reads null.
  • sys_notification_preference cannot take the same route. Its user_id is Field.text (maxLength 255). It holds a sys_user.id or the literal *, the admin-global default preference-resolver.ts reads as WILDCARD. The read path serves a hop only through a reference type (REFERENCE_VALUE_TYPES: lookup, master_detail, user, tree, in field-value.zod.ts). Measured: a text user_id read as record.user_id.name gives null. See open choice 1.

Open choice 1: how sys_notification_preference can name the person

No route names the person without one of three things: re-typing user_id, a stored copy, or a formula-language feature. The dispatch's hard stops forbid the last two in this lane.

  • A. Re-type user_id as a user reference, and spell the admin-global default as an empty reference instead of *. That is ADR-0118 D1's representation of a non-user in a user column: null, never a sentinel; the display is a render rule. The title then reads the person one hop, as here, and shows the empty reference as the default's label. The Setup form gets a user picker instead of a raw-id text box, and the list column a person chip.
    • Business need: the Setup grid is the measured producer (HotCRM F-25). Zero code producers write * rows: git grep finds the resolver's constant, its tests and one driver-sql test.
    • Long-term: contract-first. It removes a sentinel from a user column.
    • AI mistakes: a typed reference refuses a non-user id at the write door. Today any string is stored as a "user id" and silently matches nobody.
    • Startup scope: no new column or capability. But it narrows the accept set (* and any non-user string refused), so it is Clause-②: yes (narrowing) and BREAKING. It owes a stored-row conversion (* to null), the resolver change, and a NULL-safe user key part in the unique index (ADR-0120 D3 makes only the organization key part NULL-safe). Effort M, as its own card.
  • B. A console display rule for the column only (a widget hint on user_id). No such widget exists at the pin. The shipped hints are recipient-picker, object-ref, position-ref, permission-ref and filter-condition. And a hint on user_id cannot reach the composite title, so the H1 and the list's title column stay raw. It does not close the finding.
  • C. A stored name column: refused by the dispatch (it goes stale on rename).
  • D. A by-id lookup function over a text column: the spec and formula lane's. It would be a second traversal dialect beside the one-hop rule.
  • Recommendation: A, as its own service-messaging card. The long-term and AI-safety axes both favour typing the column. The cost is a breaking narrowing with a data conversion, and that is the maintainer's to rule.

Open choice 2: the hop costs a read on every authenticated request of a grant holder

The authorization prelude reads the caller's grants with no field projection: tryFind(ql, 'sys_user_permission_set', { user_id: userId }, 100) in packages/core/src/security/resolve-authz-context.ts:1457. A read with no projection computes every formula, so display_title's hop now costs one batched sys_user read there.

Measured with auth-prelude-query-count.pin.test.ts's own rig (real engine, real AuthManager, resolveAuthzContext), old formula against new. The probe was a scratch file and is not committed:

principal old formula this PR
no direct grant 8 reads, 4 round trips 8 reads, 4 round trips
one direct grant 8 reads, 4 round trips 9 reads, 5 round trips (sys_user 2 to 3)

The committed pin measures a principal with no direct grant, so it stays green and cannot see this. The grants cache that would amortise it is off by default (OS_AUTHZ_GRANTS_CACHE_TTL_MS=0). Other reads of the table that name no projection pay the same single read per page: the explain engine, the platform-admin scan, the last-admin guard and the auth manager's grant reads. They are admin-time or boot-time paths.

  • A. Land as is. Every request of an organization admin or platform admin pays one more indexed read and one more round trip, for a title nobody on that path reads.
  • B. Land after (or with) a core-lane change that projects the prelude's grant read to the columns the resolver reads, plus a grant-holder case in the prelude pin. The title is still declared once, on the object, and the hot path reads only what it uses. Which columns the resolver reads is that lane's to measure. A wrong projection would drop a column a security decision reads.
  • C. Withdraw the hop. The title stays the raw id.
  • Recommendation: B.
    • Business need: the title serves Setup pages, while the cost lands on every request.
    • Long-term: B avoids a hidden coupling, where a display formula costs authorization a round trip.
    • AI mistakes: the extended pin makes the next such cost visible.
    • Startup scope: one projection and one pin case, and no new gate.

Tests (head 2deb9e32e6; every run under scripts/pm/os-verify-lock.sh, exit read from its VERDICT line)

  • Closure: pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' --filter '@objectstack/service-messaging^...' build gave VERDICT command-exit 0. Then pnpm --filter @objectstack/plugin-security build gave VERDICT command-exit 0; check-dts-emitted: 1/1, and dist/index.mjs carries the new expression (count 1).
  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 (whole package, code tree of 07c9bf2315): Test Files 196 passed (196), Tests 4049 passed | 45 skipped (4094).
  • pnpm --filter @objectstack/plugin-security typecheck (on 2deb9e32e6's tree): VERDICT command-exit 0. It includes check:test-typecheck: OK, whose tsconfig.test.json includes src/**/*, the new pin file among them.
  • The two title files on 2deb9e32e6's tree: Test Files 2 passed (2), Tests 14 passed (14) (8 in the new file, 6 for sys_user_position).
  • The new pins, with real sys_user and the real assignment definition on driver-sql:
    • the registered pointer, and the formula reads permission_set, user_id.email and user_id.name, nothing bare;
    • the person's name on the insert response, on findOne and on a find projecting only display_title. resolveRecordDisplayName and servedRecordTitle agree, and the row still holds the foreign key;
    • the control: a user whose stored name is empty, or only spaces, is titled by their email;
    • read as the reader, under a sys_user row-scope middleware in the shape the security plugin composes:
      • a readable user is named;
      • an unreadable one leaves the title null, and neither the name nor the email appears in the served row;
      • SYSTEM reads the name;
      • a missing user reads null;
    • the write door refuses a row missing either title column;
    • no stored column and no search companion.
  • sys_notification_preference is untouched, so no service-messaging test or typecheck is owed.

Ablation (one leg: the one pinned behaviour this PR changes)

  • The mutation: node scripts/ablation-replace.mjs replaced the new expression with the old one, inside a script carrying trap 'git checkout HEAD -- "$ABS"' EXIT INT TERM over an absolute path.
  • Proof it landed on disk: anchor count 1 to 0, replacement count 0 to 1, blob 72b5b4afc1 to 712f8d6419.
  • The subject is imported from src/ by a relative path, so no dist/ was involved.
  • Mutated run of sys-user-permission-set-person-title.test.ts: Tests 6 failed | 2 passed (8). That is the expected direction, red. The two still green pin unchanged behaviour: the missing-column refusal and no stored column.
  • Restored with git checkout HEAD -- "$ABS": blob after restore 72b5b4afc1, equal to HEAD's blob; git diff HEAD empty; git status --porcelain empty.

Gates

  • Derivation. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) on 2deb9e32e6 derived 68 commands. That is 4 changed paths against merge base b1676441b, 264 changed lines. The seat's planned-surface count was 51.
  • First sweep on 9ffef3b9a4: 65 commands exit 0.
    • pnpm check:query-options-erasure exit 1: the new pin erased three option bags (236 to 239 test sites). Typed in 2deb9e32e6; the re-run on that tree exits 0 at 236.
    • pnpm check:dual-build-cjs-loads exit 3: PREREQUISITE NOT MET, every package's dist/ (pnpm build). NOT MEASURED locally.
    • pnpm check:i18n exit 3: PREREQUISITE NOT MET, the CLI plus the closure of ten packages. NOT MEASURED locally. No label or description moved, so no bundle should.
  • Re-run of the union on 2deb9e32e6: in the report on the card. CI's check-runs are the record for every family.

Acceptance notes

  • Same family, not in this card's file surface. The seat should fold these into the family's close-out card, not file single cards:
    • sys_user_position.display_title (record.user_id + ' → ' + record.position, sys-user-position.object.ts:76). user_id is a lookup there, so this PR's route applies. It is pinned today, on the engine read door, as usr_alice → sales_manager.
    • sys_notification_delivery (record.channel + ' → ' + record.recipient_id) and sys_notification_subscription (record.principal + ' · ' + record.topic). Both are text columns, so they share open choice 1's fork.
  • The prelude pin's blind spot (a principal with no direct grant) is open choice 2's. Noted, not filed.

Seat's append (domain:services seat 1, session_011TdZ8GjixNZFLz1xJD9zzK):

Round 2 (head 66a590fd88)

Merge. origin/main at 865d2c8cf4 is merged in as 9474db510e, with no rebase and no force-push. It brings #24941 (644e4b2c54): the authz prelude's grant read now names permission_set, organization_id, valid_from and valid_until. Git reported no conflict and no os-regen deferral.

One semantic conflict, settled in one line (894860ed7a). #24941's pin, packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts, has a pair of direct-grant cases: "as declared" and "with a title that reads one hop through user_id". Before it counts anything, the "as declared" case checks the value of the declared title, and it expected the raw user id. On this branch the declared title is the person-named one. So on the merge commit that check read Prelude → prelude_editor, and the case went red (Expected: "USER_ID → prelude_editor", Received: "Prelude → prelude_editor"). The count assertions after it never ran. The line now expects Prelude → prelude_editor in both cases. No count, rig or other assertion changed.

One sentence the merge made false (66a590fd88). The changeset's Cost bullet said the prelude rises to 9 reads in 5 round trips for a grant holder. It now says the prelude's grant read names its columns, so it computes no title and stays at 8 reads in 4 round trips. The rest of the changeset is unchanged.

The prelude, re-measured on the merged tree. This uses this PR's real sys_user_permission_set definition, on the rig of the pin above, by round 1's method. The probe was a scratch file, deleted and never committed.

read, for a principal holding one direct grant reads round trips
the prelude (resolveAuthzContext), round 1 on b1676441ba 9 5
the prelude (resolveAuthzContext), now 8 4
control: the same grant read, no projection 2 (sys_user_permission_set 1, sys_user 1) 2
control: the same grant read, projected to the four columns 1 1
  • The prelude's reads match the pin's PRELUDE_READS exactly: sys_session 1, sys_user 2, sys_member 2, sys_user_position 1, sys_user_permission_set 1, sys_metadata_activation 1.
  • The unprojected control returns Prelude → prelude_editor. So the hop is live on this engine, and the instrument counts it. The projected control returns exactly the four columns.
  • So on main now, the hop adds no read and no round trip to the prelude.
  • After the one-line change, the pin passes on the merged tree: all 7 cases, both direct-grant cases at 8 reads in 4 round trips.

Tests on 66a590fd88. Every run went through scripts/pm/os-verify-lock.sh, and each exit is read from its VERDICT line.

  • Closure build: pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' --filter '@objectstack/plugin-auth^...' build gave VERDICT command-exit 0 (30 of 85 projects).
  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: Test Files 197 passed (197), Tests 4077 passed | 45 skipped (4122).
  • pnpm --filter @objectstack/plugin-security typecheck: VERDICT command-exit 0, check:test-typecheck: OK.
  • The two title files: Test Files 2 passed (2), Tests 14 passed (14).
  • plugin-auth is now a touched package:
    • pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: Test Files 172 passed (172), Tests 3289 passed | 10 skipped (3299).
    • pnpm --filter @objectstack/plugin-auth typecheck: VERDICT command-exit 0, after pnpm --filter @objectstack/plugin-auth build. The first attempt stopped at TS2307 in examples/basic-usage.ts because the package's own dist/ was missing, so it measured nothing.
  • No ablation this round, because no tested behaviour changed. Round 1's ablation of the title stands.

Gates on 66a590fd88.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 69 commands: 5 paths against merge base 865d2c8cf, 267 changed lines.
  • They ran as one runner under the lock. 67 exited 0 on the first pass. pnpm check:dual-build-cjs-loads and pnpm check:i18n exited 3 (PREREQUISITE NOT MET, no dist/). Both exited 0 on a second pass, after the union's check:type-check-debt had built the workspace.
  • --ran exited 0: Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.
  • CI on 66a590fd88: Lint & Repo Gates, TypeScript Type Check, Build Core and Governed Surface Queue Guard succeeded. Test Core (1/6) through (6/6) succeeded; the aggregate Test Core was in_progress at report time.

Superseded in the body above.


Generated by Claude Code

…rson's name, not the raw user id

`sys_user_permission_set.display_title` read `record.user_id + ' → ' +
record.permission_set`, so Setup listed and headed every assignment with
the stored user id. It now reads the user's name (their email where the
name is blank) one hop through `user_id`, the traversal the formula read
path serves under the reader's own authority (ADR-0079 addendum). No
stored column.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…on-set assignment title

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…itle pin instead of erasing them

check:query-options-erasure counted three erased option bags (236 to 239).

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx (via SysUserPermissionSet (symbol, a top-level const object))
What this run could not see

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8cfe1f94cb19c02e522e6163c532d4416f9cff80 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3bb8fe8fe68b470b6a7fb75dea4db3952ddbe0b9 — the merge of head 66a590fd88f7ba138d739e10c721c15d152a3139 into base 8cfe1f94cb19c02e522e6163c532d4416f9cff80, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3bb8fe8fe68b470b6a7fb75dea4db3952ddbe0b9 && git checkout 3bb8fe8fe68b470b6a7fb75dea4db3952ddbe0b9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8cfe1f94cb19c02e522e6163c532d4416f9cff80 66a590fd88f7ba138d739e10c721c15d152a3139 && git checkout -B drift-repro 8cfe1f94cb19c02e522e6163c532d4416f9cff80 && git merge --no-ff 66a590fd88f7ba138d739e10c721c15d152a3139

node scripts/docs-audit/affected-docs.mjs --json 8cfe1f94cb19c02e522e6163c532d4416f9cff80

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8cfe1f94cb19c02e522e6163c532d4416f9cff80 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Brings #24941 (644e4b2): the authz prelude's sys_user_permission_set
grant read projects the columns it decides on, so this branch's one-hop
display_title is no longer computed on that read. Clean merge, no
conflicts, no os-regen deferral.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…erson-named one

Semantic merge conflict with #24941's pin, resolved in one line. Its
direct-grant case "as declared" read the declared sys_user_permission_set
title as the raw user id, a precondition asserted before the count. On
this branch the declared title IS the one-hop person title, so both cases
of the pair now read 'Prelude → prelude_editor'. The counts the pin
holds (8 reads in 4 round trips) are unchanged and green.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…ected prelude read

Since #24941 the authz prelude's grant read names the columns it decides
on, so it computes no display_title and the prelude stays 8 reads in 4
round trips for a principal holding a direct grant. The changeset said
9 in 5, a figure measured on the pre-#24941 base.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-20T19:18Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main 2941a1929d, not against the reports (os-dev reports 6291835014 and 6299091948 on #24775). Head under review:

66a590fd88f7ba138d739e10c721c15d152a3139
  1. Shape.
  2. The title, read in the code (sys-user-permission-set.object.ts). display_title names the person: the user's name, or their email where the name is blank, then ' → ' and the permission set. It reads one hop through user_id. titleFormat is the rung below it, for a reader who cannot read the user.
  3. The ruling's bar, measured on the merged tree.
  4. Pins. The round-1 pins hold: the two title files have 14 tests. fix(core): the authz prelude's grant read projects the columns it decides on #24941's prelude pin passes on the merged tree, with both direct-grant cases at 8 in 4.
  5. Changeset, @objectstack/plugin-security patch, read sentence by sentence. The Cost bullet now says 8 reads in 4 round trips, with its reason: the prelude's grant read names its columns. That matches 3.
  6. Gates.
    • At ACCEPT, every check-run on this head is success or an expected skip, among them TypeScript Type Check, Lint & Repo Gates, Test Core 1–6/6, Build Core, Governed Surface Queue Guard and Check Documentation Links.
    • Locally, 69 of 69 derived families exited 0; --ran reads 0 NOT MEASURED.
    • plugin-security: 197 files, 4077 tests passed.
    • plugin-auth: 172 files, 3289 tests passed.
  7. Accepted deviations.
    • One line of fix(core): the authz prelude's grant read projects the columns it decides on #24941's pin (plugin-auth/src/auth-prelude-query-count.pin.test.ts:284, commit 894860ed7a), outside the claim's surface. The merge made it red: its "as declared" case expected the raw-id title, which this PR replaces. Only the title precondition changed; no count or rig changed. The file is in this lane, and the engine seat was told on [PM seat] domain:engine — ⏳ vacant #6367.
    • The changeset's Cost sentence, which the merge made false (66a590fd88).
    • One merge of main, and a scratch probe that was never committed.
    • sys_user_position is not taken: the prelude reads it unprojected (resolve-authz-context.ts:1484). The engine lane was told.
    • Model-free trailers.
  8. Landing clause. Tier S: not governed, under the human-merge threshold. Next step: ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 19:20
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 19:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 6440cf1 Oct 20, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24775-person-named-titles branch October 20, 2026 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Setup: Notification Preferences and permission-set assignments are titled by raw user ids, not the person's name

2 participants