Skip to content

Commit aa71c4d

Browse files
feat(plugin-security,plugin-auth,verify): sys_user_permission_set gains the permission-set name column, written by every grant writer (ADR-0131 C2 S4a) (#22100)
Part of #15196 Clause-②: yes (widening) **Accepted set and read shape.** Every read of `sys_user_permission_set` gains one field, `permission_set`. It is read-only text, at most 100 characters, and nullable. It holds the `name` of the `sys_permission_set` row that `permission_set_id` points at, or `NULL` on a grant written before this change. On writes, the key used to be undeclared, and a write naming it was refused for every caller with `400 INVALID_FIELD` (measured below). Now a write may carry it when it equals the name derived from the row's id: the payload's `permission_set_id`, or else the stored one. A write is refused with `400 VALIDATION_FAILED` and `invalid_value` at `permission_set` when the name names any other set. For a non-system caller, a write is also refused when the id resolves to no set the caller's organization can see. A system writer's name beside an id that resolves nowhere is kept, because seed ordering can write the grant before the set row exists. A write that omits the key is accepted exactly as before, and the platform stores the derived name. A `null` or blank value is read as omitted. No write that was accepted before is refused now. No reader reads the column yet, and the grant-equivalence golden below gives the same result on the pre-change tree. Stage S4a of ADR-0131 C2 (claim amendment `6039096736` on the card). Not in this PR: - no reader changes; - no backfill of existing rows (S4b); - no drop of the id column (C8); - nothing in `packages/spec`; - nothing in C4 stage 1's `plugin-auth` files (`phone-sms-texts.ts`, `auth-plugin.ts`). ## What changes - **`sys_user_permission_set.permission_set`** (`plugin-security`, `objects/sys-user-permission-set.object.ts`): `Field.text`, `readonly`, `maxLength: 100`, and not required. Rows written before this change carry `NULL` until S4b rewrites them. - **System-written, by two engine hooks** (`plugin-security`, new `grant-permission-set-name.ts`, bound in `SecurityPlugin.start` and unbound in `destroy`): - `beforeInsert` and `beforeUpdate` on the grant object, for every caller, system context included. - Each write that carries `permission_set_id` gets the derived name stamped. A supplied name that names a different set refuses the write. - An update that writes only the name is judged against the stored id. An update that writes neither column leaves the name alone, so no backfill rides an unrelated edit. - The catalog read is `ctx.api.sudo()`: the writer's own context with `isSystem` set, never a bare `{ isSystem: true }`. So it stays inside the writer's organization and transaction, and the answer for another organization's set is the same as for a set that does not exist. - The refusal never echoes the derived name. - **Why hooks and not a middleware (measured):** on a non-system update the engine hides the caller's value for a `readonly` column from the hooks. It hands the value back after the hooks, and its static strip then drops it silently. - So the update hook judges `ctx.submitted`, which is the submission as sent. - The stamp has to be a hook write to survive that strip. A middleware stamp is not recorded as one. Measured: an id change that echoes the agreeing name lands both values only because the stamp is a hook write. - Ablation A3 below shows what reading the payload alone misses. - **Every writer writes both columns:** - `auto-org-admin-grant.ts` (reconcile); - `bootstrap-platform-admin.ts` (promote); - `auth-manager.ts` (self-registration, which stores the set row's own name); - `packages/verify/src/rls.ts` (the RLS persona). - **Docs and bookkeeping:** - `content/docs/permissions/system-context.mdx`: the hook's `isSystem` read is census row 23c, and the counts are regenerated by `gen:system-context-census`. - An ADR-0131 anchor for the new module. - The four `plugin-security` i18n bundles are regenerated with `check-i18n-bundles --write`, and the label and help are hand-translated in zh-CN, ja-JP and es-ES. - A changeset: `minor` for `plugin-security`, `plugin-auth` and `verify`. ## The column's name: `permission_set` ADR-0131 does not name the column. `permission_set` mirrors the sibling assignment `sys_user_position.position`, which D3 and D4 name in the same sentence as this one. - Once D10 drops the id column, the grant row reads `user_id, permission_set, organization_id`, exactly as the position assignment reads `user_id, position, organization_id`. - The other in-package precedent, `sys_audience_binding_suggestion.permission_set_name`, is a suggestion row, not an assignment. - The width matches `sys_permission_set.name` (100), as `position` matches `sys_position.name`. - The label is "Permission Set Name", so it does not collide with the id column's "Permission Set". ## Writers, measured on current `main` The measurement round's AST census tool was re-run on `origin/main` `3d9188502e`. These are the row writes it finds on the grant object: | Writer | Writes | In this PR | |:--|:--|:--| | promote (`bootstrapPlatformAdmin`) | insert | both columns | | reconcile (`reconcileOrgAdminGrant`) | insert, plus 3 deletes | both columns | | self-registration (`AuthManager.settleSelfRegistrationGrant`) | insert | both columns | | verify RLS persona (`provisionRlsProbePersona`) | insert | both columns | | `cleanup-package-permissions.ts` | delete | C7's (a delete writes no name) | - **Correction to the dispatch list:** `ensure-default-organization.ts` is a reader, not a writer. It makes two reads: the admin set by name, then the unscoped grants by id. Its only inserts are `sys_organization` and `sys_member`. So S4a does not change it; it is a REWRITE-C2 reader for S5b. - **Writers outside the list:** - the data door (Setup, delegated-admin direct grants, any API client); - any system writer outside this repository (seed datasets, a deployment's own code); - the dogfood harness's own grant inserts. All of them write the id alone, and the hook stamps the name. The dogfood run below includes a file whose system inserts carry no name. ## What the write door does with a supplied name (measured, real engine, SQL driver, real `SecurityPlugin`) | Write | Pre-change tree (no column) | Column, hooks unbound | This PR | |:--|:--|:--|:--| | insert naming the key, non-system | `400 INVALID_FIELD` | stored verbatim, even when it names another set | stamped if omitted; agreeing kept; otherwise `400 VALIDATION_FAILED` | | insert naming the key, system | `400 INVALID_FIELD` | stored verbatim | same as above; an unresolvable id keeps the name | | update, name only, non-system | refused (undeclared) | dropped silently | `400` unless it agrees with the stored id | | update, name only, system | refused (undeclared) | stored | `400` unless it agrees | | id written alone | no column | name `NULL` | derived name stamped | The first column comes from a pre-change-tree probe: the object file and `security-plugin.ts` were restored to `3d9188502e` by blob, and the write was refused `400 INVALID_FIELD` for both callers. The second column comes from a probe on this branch with the hooks unregistered. Both probes were scratch files, restored by blob hash. ## Pins, and the ablation for each (all at head `870717aca6`) | Leg | Mutation (through `scripts/ablation-replace.mjs`, wrap mode, restored and proven by blob) | Red | |:--|:--|:--| | A1 | the client's disagreeing name is put through instead of refused | 5 of 18 in `grant-permission-set-name.test.ts`: insert, batch, stale old name, name-only update, system writer | | A2 | the unresolvable-id branch accepts the client's name | 2 of 18: a name beside an id that names no set; another organization's set id | | A3 | the update hook ignores `ctx.submitted` | 2 of 18: stale old name, name-only update. The engine had hidden the value, so the write passed silently | | S1 | no stamp | 10 of 18 | | W1 | promote omits the name | `grant-permission-set-name.writers.test.ts`: promotion | | W2 | reconcile omits the name | writers test: reconcile, both postures | | W3 | self-registration omits the name | `plugin-auth` `audience-posture.test.ts`: the new pin | | W4 | the RLS persona omits the name | `verify` `rls-persona-grant.test.ts`: both branches (set found, set created) | | G1 | reconcile writes the wrong set's name | `grant-permission-set-name.equivalence.test.ts`, both postures. The hook refuses the system write, the grant never lands, and the suite turns red at the reconcile's own outcome, ahead of the golden | The writer pins measure the writer's own payload. The `plugin-security` writer tests use a real engine with no `SecurityPlugin`, and the `plugin-auth` and `verify` doubles store what they are handed, so no hook fills a name the writer left out. Every leg's restore is proven by blob equal to `HEAD` and an empty `git diff HEAD`. The first A3 attempt was refused by the tool, because the replacement contained the anchor. It was re-anchored and re-run, and it is the A3 row above. ## Grant equivalence (no principal's grants change) `grant-permission-set-name.equivalence.test.ts` resolves four principals through the real writers and the real resolver (`resolveUserAuthzGrants`), in `single` and `isolated`: - platform administrator: the `single` promotion, or the walled config owner; - organization administrator: the reconcile; - member: a set granted through the data door; - agent: an OAuth agent acting for the organization administrator with the actions consent. The whole envelope is compared with sorted arrays: positions, permissions, system permissions, tab permissions, posture and organization reach. - **Pre-change leg:** the four changed `plugin-security` sources were restored to `3d9188502e` by blob, and the new module was absent. Result 2/2 green. - **This head:** 2/2 green. ## Verification The dependency closure was built first. `870717aca6` adds only the ADR anchor JSON on top of `633a4c534c`, and the `plugin-security` non-test sources are unchanged since `0abc83cdde`. - `plugin-security`: - `vitest run` at `0abc83cdde`: 172 files, 3663 passed and 45 skipped. - The three new suites, re-run at `870717aca6`: 23/23. - `typecheck` at `633a4c534c` is green, and the test layer compiles with 0 debt. - `plugin-auth`: - `vitest run` at `870717aca6`: 126 files, 2613 passed and 10 skipped. - The earlier run at `e6d2b91a1d` had 1 red: a minimal test fixture that declares the grant object without the new column. The fixture now declares it. - `typecheck` is green, with the test-layer debt ledger unchanged at 10 files / 94 errors. - `verify`: `vitest run` at `633a4c534c`, 18 files, 133 passed; `typecheck` green. - Dogfood at `633a4c534c`, the files that sign up, promote or write grants: 11 files, 84 passed and 2 skipped. The skips are the suite's own `skipIf(!organizationsAvailable)`, in `rls-multitenant`. The files: - `admin-platform-admin-standing` - `org-admin-affordance-reach` - `showcase-permission-zoo` - `membership-actor-attribution` - `me-apps-and-everyone-baseline` - `showcase-permission-seeding` - `rls-runner` - `rls-fixture` - `rls-multitenant` - `showcase-client-liaison-fixtures` - `invitation-ledger-row-scope` - Gates at `870717aca6`: `dispatch-gates --commands` was re-derived at this head (107 commands). Each one was run with its exit code captured before any pipe: 107 of 107 exited 0, plus `check:i18n-coverage` (exit 0). `dispatch-gates --ran` reconciles: 107 derived, 107 run, 0 not measured, 0 unrun. The 54 commands derived at dispatch are a subset of these. - Lint: `eslint --no-inline-config --format json` over the 17 changed TS files: 17 files linted, 0 errors, 0 warnings, none ignored. The population is the 17 TS paths in `git diff 3d91885`. The repo config enables no type-aware linting (no `parserOptions.project`) and no cross-file rule; it reads only two baseline JSONs, both unchanged. So a change to these files cannot move the result for an untouched file. The full `pnpm lint` run is CI's. ## Acceptance notes - The Setup related list on `sys_user.page.ts` and the record title (`display_title`) still show `permission_set_id`. S4a makes no reader change; S5 and C9 own those. - No index on `permission_set` yet. S5's readers bring it with the reads that need it. - A `sys_permission_set` row renamed at engine level would leave its grants naming the old name. The data door refuses renames (ADR-0094), the projector upserts by name, and no in-repo writer renames a set row. The S4b backfill's verification is where a mismatch would show. - A platform writer that names the wrong set is refused by the hook, and the reconcile's `tryInsert` reports it as `skipped` (ablation G1). The writer pins are what keep that from shipping. - Test fixtures that declare their own minimal grant object: one in `plugin-auth` drove the self-registration writer and now declares the column. Eleven others (in `client`, `runtime`, `plugin-approvals` and `qa/http-conformance`) drive no grant writer: none of those tests composes `SecurityPlugin` or a self-registration set. - `security-plugin.ts` is touched for the registration only (18 lines), outside the claim amendment's file list, because the data door is a writer outside the census list. --- _Generated by [Claude Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac9f8bd commit aa71c4d

20 files changed

Lines changed: 1369 additions & 9 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/plugin-auth": minor
4+
"@objectstack/verify": minor
5+
---
6+
7+
`sys_user_permission_set` gains `permission_set`, the name of the permission set a grant holds, written beside `permission_set_id` (ADR-0131 D4)
8+
9+
Clause-②: yes (widening)
10+
11+
- **The column.** `permission_set` is a read-only text column, at most 100 characters, holding the `name` of the `sys_permission_set` row that `permission_set_id` points at. It is readable everywhere the grant row is readable. A grant written before this release has `NULL` here until the backfill stage rewrites it. No reader uses the column yet: the grant is still resolved from `permission_set_id`, which stays until it is dropped in a later major (ADR-0131 D10).
12+
- **The platform writes it, on every write that carries `permission_set_id`, for every caller.** Two `@objectstack/plugin-security` engine hooks (`beforeInsert` and `beforeUpdate` on `sys_user_permission_set`) look up the set by id and store its name. A write that sends only the id, which is how the data door and the Setup forms write, gets the name filled in.
13+
- **A name that names a different set is refused** with `400 VALIDATION_FAILED`, `invalid_value` at `permission_set`. This covers a name that disagrees with the id written beside it, or with the id already stored when only the name is written. For a non-system caller it also covers a name beside an id that names no set this caller's organization can see. A name that agrees is accepted. A cleared name (`null`) is not stored as a clear: the derived name is written back. Before this change the column did not exist, so a write naming it was refused with `400 INVALID_FIELD`. No write that was accepted before is refused now.
14+
- **Every platform grant writer writes both columns:** the organization-admin reconcile and the platform-admin promotion in `@objectstack/plugin-security`, the self-registration grant in `@objectstack/plugin-auth`, and the RLS probe persona in `@objectstack/verify`.
15+
- **Nothing to migrate.** No principal's grants change. To fill the column on grants written by your own code, write the set's name as `permission_set`, or leave it out and the platform fills it in. Do not write any other value there.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
1010
service self-write, a migration.
1111

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **114
13+
because the flag is not one concept: it is a single boolean read at **115
1414
distinct sites across 19 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -127,6 +127,7 @@ that silently does not happen.
127127
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing. One exception: a `formula` value, which the `computed` strip takes from every caller, so strict still refuses it | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
128128
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine.ts#assertReferencesResolve` |
129129
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no `sys_position` row is not refused | plugin-security | Get: a system writer can store an assignment naming no catalog row — the seed loader writes `stack.data` from `AppPlugin.start()`, before `kernel:ready` seeds the declared position catalog, so a refusal there would fail every authored assignment seed on a fresh boot. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when no catalog row the writer's context reaches carries the name: its organization's rows plus the organization-less ones, read as `{ ...context, isSystem: true }`, never a bare `{ isSystem: true }`, so a name only another organization carries is refused like one nobody carries. It is the same stand-down, and the same tenant scope, as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
130+
| 23c | **Grant-name check relaxed for an unresolvable id** — a `sys_user_permission_set` write that supplies `permission_set` beside a `permission_set_id` naming no set the writer's context reaches keeps the supplied name | plugin-security | Get: a system writer can store a grant naming a set whose row does not exist yet — seed replay and boot provisioning may write the grant before the set row, the ordering the engine's referential-integrity check also stands down for. Lose: such a row's name is unchecked until a backfill names it. Everything else is judged for every caller, system included: the name is derived from the id on every write that carries one, and a supplied name that names any other set is refused `400 VALIDATION_FAILED` / `invalid_value`; a non-system caller's name beside an unresolvable id is refused the same way, read as `{ ...context, isSystem: true }` through the hook's `ctx.api.sudo()`, never a bare `{ isSystem: true }` (ADR-0131 D4) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#settle` |
130131
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine.ts#buildDriverOptions` |
131132
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `#assertEngineOwnedWriteAllowed` |
132133
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` |
@@ -138,7 +139,7 @@ that silently does not happen.
138139

139140
### 3. Sharing (`plugin-sharing`)
140141

141-
The largest single consumer — **17 of the 114 sites**.
142+
The largest single consumer — **17 of the 115 sites**.
142143

143144
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
144145
|:--|:---|:---|:---|
@@ -281,7 +282,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
281282
independent decisions, and a seed loader plausibly wants the first two but not
282283
the third. The concept is nevertheless **staying as one boolean**:
283284

284-
- **Shipped semantics.** `isSystem` is a published contract with 114 read sites
285+
- **Shipped semantics.** `isSystem` is a published contract with 115 read sites
285286
in 19 packages. Splitting it is a breaking contract change across all of them.
286287
(The ruling was taken when the census read 80 sites in 18 packages; the count
287288
has grown, which strengthens rather than weakens the argument.)
@@ -355,16 +356,16 @@ still holds equal to the census on every pull request:
355356
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
356357
| — parsed as a declaration | 27 | ✅ |
357358
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
358-
| — parsed as a property **read** | 120 | ✅ |
359+
| — parsed as a property **read** | 121 | ✅ |
359360
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
360361
| — the remainder: text inside comments and string literals | 358 | — |
361362
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
362-
| Of those reads: reads of `ExecutionContext.isSystem` | **114** | ✅ |
363-
| — behaviour-bearing (rows 1–61 above) | 111 | ✅ |
363+
| Of those reads: reads of `ExecutionContext.isSystem` | **115** | ✅ |
364+
| — behaviour-bearing (rows 1–61 above) | 112 | ✅ |
364365
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
365366
| Packages containing at least one elevation read | **19** | ✅ |
366-
| Files containing at least one elevation read | 52 | ✅ |
367-
| — the distinct symbols those reads live in — what this page anchors | 97 | ✅ |
367+
| Files containing at least one elevation read | 53 | ✅ |
368+
| — the distinct symbols those reads live in — what this page anchors | 98 | ✅ |
368369
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
369370

370371
The six rows marked — are a **dated decomposition, not a live claim**: they were
@@ -428,7 +429,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`.
428429
Renaming a symbol is now a loud red instead of a silent misdirection.
429430

430431
⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
431-
cannot say WHICH read inside a function it means, and **8** of the **52**
432+
cannot say WHICH read inside a function it means, and **8** of the **53**
432433
anchored files hold more than one read inside a single symbol. So the population
433434
check runs per file at symbol granularity: every file the census finds a read in
434435
must be anchored, and the set of symbols this page cites into that file must

‎packages/plugins/plugin-auth/src/audience-posture.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,27 @@ describe('end of the chain: better-auth pipeline over the memory engine (#11739)
747747
expect((engine.tables.get('sys_user') ?? []).some((u: any) => u.email === 'user@mail.acme.com')).toBe(false);
748748
});
749749

750+
it('[ADR-0131 D4] the self-registration grant writes both columns, and they agree', async () => {
751+
// The fake engine stores exactly the payload the writer sends — no platform
752+
// hook stands behind it here — so this pins the writer's own payload.
753+
const engine = createMemoryEngine();
754+
seedExistingUser(engine);
755+
seedPermissionSet(engine, 'portal_user');
756+
const manager = makeManager(engine, {
757+
audience: { posture: 'open', selfRegistrationPermissionSet: 'portal_user' },
758+
});
759+
const admitted = await signUp(manager, 'joiner@anywhere.com');
760+
expect(admitted.status).toBeLessThan(300);
761+
await vi.waitFor(() => {
762+
const grants = engine.tables.get('sys_user_permission_set') ?? [];
763+
expect(grants.length).toBe(1);
764+
expect(grants[0].permission_set_id).toBe('ps_portal_user');
765+
expect(grants[0].permission_set).toBe('portal_user');
766+
const setRow = (engine.tables.get('sys_permission_set') ?? []).find((r: any) => r.id === grants[0].permission_set_id);
767+
expect(setRow?.name).toBe(grants[0].permission_set);
768+
});
769+
});
770+
750771
it('email_domain forces email verification on: the wired flag, the public config, and the minted session agree', async () => {
751772
const engine = createMemoryEngine();
752773
seedExistingUser(engine);

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5281,6 +5281,11 @@ export class AuthManager {
52815281
id,
52825282
user_id: userId,
52835283
permission_set_id: row.id,
5284+
// [ADR-0131 D4] Both columns, agreeing: the row's own name, never the
5285+
// declared spelling — the read above is by name, but a case-folding
5286+
// collation may answer with a differently-cased row, and the name the
5287+
// grant stores is the one its id resolves to.
5288+
permission_set: row.name,
52845289
...(organizationId ? { organization_id: organizationId } : {}),
52855290
});
52865291
this.config.logger?.info?.('[audience] granted the declared self-registration permission set', {

‎packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ const sysUserPermissionSet = {
9090
id: { name: 'id', type: 'text' as const, primaryKey: true },
9191
user_id: { name: 'user_id', type: 'text' as const },
9292
permission_set_id: { name: 'permission_set_id', type: 'text' as const },
93+
// [ADR-0131 D4] The grant's set by NAME, which `settleSelfRegistrationGrant`
94+
// writes beside the id; undeclared, the engine refuses that insert.
95+
permission_set: { name: 'permission_set', type: 'text' as const },
9396
organization_id: { name: 'organization_id', type: 'text' as const },
9497
},
9598
};

‎packages/plugins/plugin-security/src/auto-org-admin-grant.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,9 @@ export async function reconcileOrgAdminGrant(
781781
id: genId('ups'),
782782
user_id: userId,
783783
permission_set_id: permSetId,
784+
// [ADR-0131 D4] Both columns, agreeing: `permSetId` was resolved BY
785+
// this name (`resolvePermissionSetId(ql, grantSetName, …)`).
786+
permission_set: grantSetName,
784787
organization_id: orgId,
785788
// [#4586] The provenance the row already had a column for. `granted_by`
786789
// is a `sys_user` lookup: the human whose better-auth call triggered the

‎packages/plugins/plugin-security/src/bootstrap-platform-admin.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,6 +1134,9 @@ export async function bootstrapPlatformAdmin(
11341134
id: genId('ups'),
11351135
user_id: chosen.id,
11361136
permission_set_id: adminPsId,
1137+
// [ADR-0131 D4] Both columns, agreeing: `adminPsId` is the row seeded
1138+
// under exactly this name (`seeded[PLATFORM_ADMIN_PERMISSION_SET_NAME]`).
1139+
permission_set: PLATFORM_ADMIN_PERMISSION_SET_NAME,
11371140
organization_id: null,
11381141
granted_by: null,
11391142
});

0 commit comments

Comments
 (0)