Repository navigation
fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id - #24885
Conversation
…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>
…rson-named-titles
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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): Which tree this was computed onThis run read A worktree cut from an older # 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
|
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>
席位核对 · ACCEPT ·
|
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 ruling6282390889is the precedent read for this card).What changes
sys_user_permission_set.display_titleis the object'snameField. 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 likeISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap → qa_fls_rep(HotCRM pass, F-25).It now names the person:
name, or theiremailwhere the name is blank. That is the platform's ownname || emailrule for a person's handle (auth-manager.ts,approval-service.ts).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.user_idis read only through the hop. A bare read beside the traversal would be thetraversal-bare-and-traversedrefusal.titleFormat({user_id} → {permission_set}) is unchanged. It is the rung below the declared pointer in the console's H1 ladder: objectuipackages/components/src/renderers/layout/containers.tsx:2542at the pin616df6598b("The declared pointer OUTRANKStitleFormat"). A renderer reaches it only when the title has no value.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_setleaves its case table, since the title no longer equals thetitleFormattext.sys_user_positionkeeps every assertion it had..changeset/24775-permission-set-assignment-person-title.md:@objectstack/plugin-securitypatch.Premises, measured (on
mainat6b9e6df909, then merged tob1676441ba)nameField: 'display_title', a text formula with no stored column. The read door serves it: on a real engine (driver-sql, better-sqlite3),findOne, afindprojecting onlydisplay_title, and the insert response all carry it.packages/spec/src/data/display-name.tslistslookupanduserinTITLE_INELIGIBLE_TYPES. So the pointer stays on the text formula; no pointer moves.lookupcolumn's chip. For the title, though, the platform's server-side resolvers read the title field's value:resolveRecordDisplayName, andservedRecordTitleinrecord-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.d537a318band1bf5f6e17c, and the ADR-0079 addendum records it. Measured on the real engine with the realsys_user, the hop formula givesAlice Liddell → crm_editon the insert response, onfindOneand on a projectedfind. A grant whose user does not exist readsnull.sys_notification_preferencecannot take the same route. Itsuser_idisField.text(maxLength 255). It holds asys_user.idor the literal*, the admin-global defaultpreference-resolver.tsreads asWILDCARD. The read path serves a hop only through a reference type (REFERENCE_VALUE_TYPES: lookup, master_detail, user, tree, infield-value.zod.ts). Measured: a textuser_idread asrecord.user_id.namegivesnull. See open choice 1.Open choice 1: how
sys_notification_preferencecan name the personNo 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.user_idas auserreference, 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.*rows:git grepfinds the resolver's constant, its tests and one driver-sql test.*and any non-user string refused), so it isClause-②: 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.user_id). No such widget exists at the pin. The shipped hints arerecipient-picker,object-ref,position-ref,permission-refandfilter-condition. And a hint onuser_idcannot reach the composite title, so the H1 and the list's title column stay raw. It does not close the finding.service-messagingcard. 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)inpackages/core/src/security/resolve-authz-context.ts:1457. A read with no projection computes every formula, sodisplay_title's hop now costs one batchedsys_userread there.Measured with
auth-prelude-query-count.pin.test.ts's own rig (real engine, realAuthManager,resolveAuthzContext), old formula against new. The probe was a scratch file and is not committed:sys_user2 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.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.Tests (head
2deb9e32e6; every run underscripts/pm/os-verify-lock.sh, exit read from itsVERDICTline)pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' --filter '@objectstack/service-messaging^...' buildgaveVERDICT command-exit 0. Thenpnpm --filter @objectstack/plugin-security buildgaveVERDICT command-exit 0;check-dts-emitted: 1/1, anddist/index.mjscarries the new expression (count 1).pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2(whole package, code tree of07c9bf2315):Test Files 196 passed (196),Tests 4049 passed | 45 skipped (4094).pnpm --filter @objectstack/plugin-security typecheck(on2deb9e32e6's tree):VERDICT command-exit 0. It includescheck:test-typecheck: OK, whosetsconfig.test.jsonincludessrc/**/*, the new pin file among them.2deb9e32e6's tree:Test Files 2 passed (2),Tests 14 passed (14)(8 in the new file, 6 forsys_user_position).sys_userand the real assignment definition on driver-sql:permission_set,user_id.emailanduser_id.name, nothing bare;findOneand on afindprojecting onlydisplay_title.resolveRecordDisplayNameandservedRecordTitleagree, and the row still holds the foreign key;sys_userrow-scope middleware in the shape the security plugin composes:null, and neither the name nor the email appears in the served row;null;sys_notification_preferenceis untouched, so noservice-messagingtest or typecheck is owed.Ablation (one leg: the one pinned behaviour this PR changes)
node scripts/ablation-replace.mjsreplaced the new expression with the old one, inside a script carryingtrap 'git checkout HEAD -- "$ABS"' EXIT INT TERMover an absolute path.72b5b4afc1to712f8d6419.src/by a relative path, so nodist/was involved.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.git checkout HEAD -- "$ABS": blob after restore72b5b4afc1, equal to HEAD's blob;git diff HEADempty;git status --porcelainempty.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) on2deb9e32e6derived 68 commands. That is 4 changed paths against merge baseb1676441b, 264 changed lines. The seat's planned-surface count was 51.9ffef3b9a4: 65 commands exit 0.pnpm check:query-options-erasureexit 1: the new pin erased three option bags (236 to 239 test sites). Typed in2deb9e32e6; the re-run on that tree exits 0 at 236.pnpm check:dual-build-cjs-loadsexit 3: PREREQUISITE NOT MET, every package'sdist/(pnpm build). NOT MEASURED locally.pnpm check:i18nexit 3: PREREQUISITE NOT MET, the CLI plus the closure of ten packages. NOT MEASURED locally. No label or description moved, so no bundle should.2deb9e32e6: in the report on the card. CI's check-runs are the record for every family.Acceptance notes
sys_user_position.display_title(record.user_id + ' → ' + record.position,sys-user-position.object.ts:76).user_idis a lookup there, so this PR's route applies. It is pinned today, on the engine read door, asusr_alice → sales_manager.sys_notification_delivery(record.channel + ' → ' + record.recipient_id) andsys_notification_subscription(record.principal + ' · ' + record.topic). Both are text columns, so they share open choice 1's fork.Round 2 (head
66a590fd88)Merge.
origin/mainat865d2c8cf4is merged in as9474db510e, with no rebase and no force-push. It brings #24941 (644e4b2c54): the authz prelude's grant read now namespermission_set,organization_id,valid_fromandvalid_until. Git reported no conflict and noos-regendeferral.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 readPrelude → 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 expectsPrelude → prelude_editorin 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_setdefinition, on the rig of the pin above, by round 1's method. The probe was a scratch file, deleted and never committed.resolveAuthzContext), round 1 onb1676441baresolveAuthzContext), nowsys_user_permission_set1,sys_user1)PRELUDE_READSexactly:sys_session1,sys_user2,sys_member2,sys_user_position1,sys_user_permission_set1,sys_metadata_activation1.Prelude → prelude_editor. So the hop is live on this engine, and the instrument counts it. The projected control returns exactly the four columns.mainnow, the hop adds no read and no round trip to the prelude.Tests on
66a590fd88. Every run went throughscripts/pm/os-verify-lock.sh, and each exit is read from its VERDICT line.pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' --filter '@objectstack/plugin-auth^...' buildgave 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.Test Files 2 passed (2),Tests 14 passed (14).plugin-authis 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, afterpnpm --filter @objectstack/plugin-auth build. The first attempt stopped at TS2307 inexamples/basic-usage.tsbecause the package's owndist/was missing, so it measured nothing.Gates on
66a590fd88.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 69 commands: 5 paths against merge base865d2c8cf, 267 changed lines.pnpm check:dual-build-cjs-loadsandpnpm check:i18nexited 3 (PREREQUISITE NOT MET, nodist/). Both exited 0 on a second pass, after the union'scheck:type-check-debthad built the workspace.--ranexited 0:Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.66a590fd88:Lint & Repo Gates,TypeScript Type Check,Build CoreandGoverned Surface Queue Guardsucceeded.Test Core (1/6)through(6/6)succeeded; the aggregateTest Corewas in_progress at report time.Superseded in the body above.
packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts(the one line above).6291928519and landed by fix(core): the authz prelude's grant read projects the columns it decides on #24941. The table above is its measurement onmain.2deb9e32e6. The round-2 figures are above.Generated by Claude Code