Skip to content

Commit 07c842d

Browse files
fix(plugin-security): a data-door edit of a permission set saved into a writable runtime package updates its own row instead of forking it (#21881)
Fixes #21861 Clause-②: no ## What this changes Setup saves a permission set through the data door (`PATCH /api/v1/data/sys_permission_set/:id`). The security plugin's write-through (`createPermissionSetWriteThrough`, `packages/plugins/plugin-security/src/permission-set-projection.ts`) redirects that edit into the metadata store. Its update leg merged the patch into the stored body and called `saveMetaItem` with no `packageId`. A `sys_metadata` row is keyed `(org, type, name, package_id)`, and a save that names no package targets the package-less row (`SysMetadataRepository.put`: an omitted `packageId` is the unbound row). So for a set saved into a writable runtime package (`PUT /api/v1/meta/permission/:name?package=PKG`), whose only stored row is bound to that package, the edit answered `200` and minted a second, package-less active row carrying the edit. The package-bound row stayed as it was. The update leg now reads the package binding of the row it edits and passes it as `packageId`: - The row is the `overlay` layer of the layered envelope the leg already holds. `effectiveBodyForRow` takes that layer as the base the patch merges into. - Its binding is read from the metadata door's own single-item read, `protocol.getMetaItem({ type: 'permission', name })`. That read serves the row through the same served-row resolution as the layered read (`findServedOverlayRow`, no package), and it states the row's `package_id` on the item as `_packageId` ("surface the persisted software-package binding", `protocol.ts`). The binding is not taken from the patch, from the projected record, or from a registry item. - No `overlay` layer means no stored row. Then there is nothing to fork, and the call is unchanged. - A package-less row carries no `_packageId`, so its save still names no package. - A code-shipped set is refused by the lock before this read (`assertPermissionSetNotPackageDeclared` runs in the pre-pass). The lock and its refusal do not move. - A failed binding read is not caught. Guessing the binding would choose which row the save lands in. One file of production code, one new non-exported helper. No `packages/spec` or `packages/metadata-protocol` edit, no new error code, no new exported symbol, and no second classifier: every target reaching the save has already been classified `org` by `classifyPackagedPermissionSet`, so the binding read only tells a package-bound `org` set from a package-less one. ## The dispatch hypotheses, measured - **H1 (the update leg saves package-less and forks): confirmed.** On `origin/main` `88a39c09` (this branch's base, which carries PR #21857), through the new door pin's own steps, the runtime-package set's first data-door edit answered `200`. Its active rows went from one, bound to the package, to two: `{ package_id: null, description: "Edited at the data door" }` and `{ package_id: "com.dogfood.bind21861", description: undefined }`. The update leg is the `saveMetaItem` call at `:1374` on this base. The `:1297` named in the dispatch is the insert leg's call. - **H2 (where the binding can be read): measured before any edit.** The projected `sys_permission_set` record reads `managed_by: "admin"`, `package_id: null`, so the projection does not carry the binding. The layered read's `overlay` layer has no `_packageId`, and its envelope `packageId` is null: the code layer is the projection echo. `getMetaItem`'s `item._packageId` is `com.dogfood.bind21861`, both through `GET /api/v1/meta/permission/:name` and through `protocol.getMetaItem` in-process. That existing reader is the one used here. - **H3 (only the runtime-package set needs the binding): held.** It is pinned at the door: the package-less set saves package-less, and the code-shipped `showcase_contributor` is still refused with `403 NOT_OVERRIDABLE` with its row count unchanged. - **H4 (other legs): none forks the same way, so none is changed.** Details are under Acceptance notes. ## Pins - **Door pin** (new file `packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts`, showcase). Every case counts the active `sys_metadata` rows for the name under both type spellings and in every scope, before and after the edit: - a runtime-package set: `200`, exactly one row before and after, still bound to the package and carrying the edit. Edited twice, before and after the list read (`GET /meta/permission`) that every Studio page load issues; - a package-less set: `200`, one package-less row before and after, carrying the edit; - a code-shipped set: `403` with `code: NOT_OVERRIDABLE`, and the stored rows are unchanged. - **Unit** (`permission-set-projection.test.ts`). The suite's existing protocol double now keys a stored row by its package, as the repository does, and serves the row's binding on `getMetaItem`. No new engine double, so the engine-double ledger does not move. Four cases: package-bound, package-less, a filtered edit spanning one of each (each saved into its own row), and the code-shipped refusal (`code` and `status` asserted, no save, the binding never read). Each case asserts the row count before and after. ## Ablation (committed fix first, then mutated, then restored) At `32048afa30`, `scripts/ablation-replace.mjs` replaced the anchor `item: body, ...packageArg, ...actorArg });` (1 hit, then 0) with `item: body, ...actorArg });` (0 hits, then 1). This puts the package-less save back. Blob `e77bd871` became `4186f983`. The run was wrapped in a `trap` restore on EXIT, INT and TERM. - Rebuild: `pnpm turbo run build --filter=@objectstack/plugin-security` exited 1, at DTS only (`TS6133: 'packageArg' is declared but its value is never read`). The JS was already emitted, and `node scripts/ablation-dist-preflight.mjs @objectstack/plugin-security "item: body, ...actorArg }"` found the marker in 2 built files (`dist/index.js`, `dist/index.mjs`). The ablation was live in the artifact the door pin consumes. - Unit: 2 failed, 72 passed. The first red is the row count: `expected [ …(2) ]`, the package-bound row plus `{ package_id: null, description: "edited at the data door" }`. - Door pin: 2 failed, 2 passed. The runtime-package set's two cases went red with two active rows, `{ package_id: "com.dogfood.bind21861", description: undefined }` and `{ package_id: null, description: "Edited at the data door" }`. The package-less and code-shipped cases stayed green, as they should. - Restore: blob equal to HEAD (`e77bd871`), `git diff HEAD` empty, `git status --porcelain` empty across the whole tree. After the rebuild, `--absent` found the marker in 0 of 6 built files. Unit 74 of 74 passed, door pin 4 of 4 passed. ## Tests (all at `32048afa30`) - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: 167 files, 3604 passed, 45 skipped. - `pnpm --filter @objectstack/plugin-security run typecheck`, which includes `check:test-typecheck` over `tsconfig.test.json`: pass. `pnpm --filter @objectstack/dogfood run typecheck`: pass. - After a fresh closure rebuild (`turbo run build --filter=@objectstack/dogfood^...`, 63 tasks): dist preflight shows the fix's spelling present and the ablation marker absent. `permission-set-projection.test.ts` 74 of 74 passed. The door pins `permission-set-write-through-package-binding` and `permission-set-lock-row-provenance` (PR #21857's) passed 18 of 18. - Narrowed lint: `eslint --no-inline-config --format json` over the three changed `.ts` files reported 3 files, 0 errors, 0 warnings. The population is the config's `**/*.{ts,…}` globs, and the fourth path is the `.md` changeset. The config never enables type-aware linting (no `parserOptions.project`), so the diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. - `integration`-tier tests are not relevant (no `packages/cli` path). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 70 commands at `32048afa30`. All 70 were run, and each exit code was recorded. 69 exited 0 on the first pass. `check:dual-build-cjs-loads` exited 3 (`PREREQUISITE NOT MET`: 8 packages outside the dogfood closure had no `dist/`). Those packages were built (all cache hits), and the gate exited 0 on the rerun. `--ran` reports: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. The derivation names 8 commands the dispatch-time list did not have, all changeset-related because this PR adds a changeset: `check-adr-0087-registration` (both), `check-empty-changeset` (both), `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `check:objectui-changeset` and `check:pm-changeset-deadline-census`. ## Acceptance notes - **The write-through's other legs (H4), read rather than changed.** - **Insert:** the duplicate-name check refuses whenever the name has a record. A stored row with no record is only transient, because the projector is awaited on every save and boot reconciliation creates a record for every overlay. No door was measured to fork through it. - **Restore:** it re-authors package-less after a delete. A runtime-package set's delete removes its bound row, because `deleteMetaItem` with no package matches the row in any package. So a restore would leave one package-less row: a lost binding, not two rows. That needs a different fix, since nothing holds the binding once the row is gone. The leg is also unreachable on a real engine, because no trash state exists (the leg's own comment). Carrier: none. - **Reconcile backfill:** only names with no metadata presence, so there is nothing to fork. - **Delete:** matches any package, so no fork. - **Degraded path, unchanged.** When the layered pre-read fails and the lock answers from the engine registry alone, the leg has no envelope. It reads no binding and saves package-less, as before. That needs a transient read failure followed by a successful save. Carrier: none. - **A stored row bound to a read-only package (a booted code package, or a `system`/`cloud`-scoped one) with no artifact.** For such a row, the save would now meet the protocol's own read-only-base refusal, which `PUT /meta?package=` meets too, instead of forking silently. No door mints such a row: `saveMetaItem`'s D1 check and `SysMetadataRepository.assertAllowed` refuse that binding at write time. Hence `Clause-②: no`. The accepted set does not move, and the edit still answers `200`. - **Base.** `origin/main` has moved past `88a39c09`. The incoming commits include #21871, a `metadata-protocol` change to the package-scoped list merge. It does not touch `getMetaItem`'s single-item stamp or `findServedOverlayRow`, which this fix reads. #21860 (PR #21873) has not landed, so main was not merged, per the dispatch. CI's merge ref covers the joint state. - **Docs.** No `content/docs` sentence describes a data-door edit forking a package-bound set, so none is made false. The older `permission-sets.mdx` drift about overlays of packaged sets is the one PR #21857's review already noted. It is untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e864db5 commit 07c842d

4 files changed

Lines changed: 371 additions & 11 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
A data-door edit of a permission set saved into a writable runtime package updates that set's stored definition instead of forking it
6+
7+
Clause-②: no
8+
9+
Setup saves a permission set through the data door (`PATCH /api/v1/data/sys_permission_set/:id`), which redirects the edit into the metadata store. A stored definition row is keyed by its package as well as its name, and the redirected save named no package. For a set saved into a writable runtime package (`PUT /api/v1/meta/permission/:name?package=<id>`), whose only stored row is bound to that package, the save therefore created a second, package-less row carrying the edit and left the package's row untouched: two active definitions for one name, with the package's copy no longer receiving the organization's edits. The save now goes into the package the edited row is bound to, read from that row through the metadata door's own item read, so the edit lands on the set's one row and the row stays in its package.
10+
11+
Unchanged: a set with no package binding still saves with none, and a set a code package ships is still refused with `403 NOT_OVERRIDABLE` before anything is read or written. The edit answers `200` as before; no error code, route or field moves.

‎packages/plugins/plugin-security/src/permission-set-projection.test.ts‎

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,21 +110,30 @@ function makeQl() {
110110
* Mock metadata protocol over the ql's sys_metadata table: env-scope active
111111
* overlays, layered read (overlay-wins over `declared`), and the ADR-0094
112112
* awaited mutation-projector seam.
113+
*
114+
* [#21861] The package dimension of a row is modelled, because the defect it
115+
* hid lives there. A save targets the ONE row keyed by its `packageId` (absent
116+
* = the package-less row), as `SysMetadataRepository.put` does, so a save that
117+
* names no package beside a package-bound row mints a second row instead of
118+
* quietly overwriting the only one. The reads serve the first env-wide row of
119+
* the name in any package (`findServedOverlayRow` with no package), and the
120+
* single-item read states that row's `package_id` on the item as `_packageId`,
121+
* as `getMetaItem` does.
113122
*/
114123
function makeProtocol(ql: any, declared: Record<string, any> = {}) {
115124
let projector: ((evt: any) => Promise<void>) | null = null;
116-
const overlayFor = (name: string) =>
117-
ql.metaRows.find(
118-
(r: any) =>
119-
r.type === 'permission' && r.name === name && r.state === 'active' && (r.organization_id ?? null) === null,
120-
);
125+
const isActiveEnvRow = (r: any, name: string) =>
126+
r.type === 'permission' && r.name === name && r.state === 'active' && (r.organization_id ?? null) === null;
127+
const overlayFor = (name: string) => ql.metaRows.find((r: any) => isActiveEnvRow(r, name));
128+
const rowAt = (name: string, packageId: string | null) =>
129+
ql.metaRows.find((r: any) => isActiveEnvRow(r, name) && (r.package_id ?? null) === packageId);
121130
const protocol = {
122131
saves: [] as any[],
123132
deletes: [] as any[],
124133
registerMutationProjector(_type: string, fn: (evt: any) => Promise<void>) {
125134
projector = fn;
126135
},
127-
async saveMetaItem(req: { type: string; name: string; item: any; actor?: string }) {
136+
async saveMetaItem(req: { type: string; name: string; item: any; actor?: string; packageId?: string | null }) {
128137
// [#6858 / ADR-0094 D5-R] ADR-0005's tier gate, first — ahead of schema
129138
// validation, exactly as `protocol.ts` orders it. `declared` IS this
130139
// stub's artifact registry, so `declared[name] !== undefined` is the
@@ -165,12 +174,15 @@ function makeProtocol(ql: any, declared: Record<string, any> = {}) {
165174
err.status = 422;
166175
throw err;
167176
}
168-
const existing = overlayFor(req.name);
177+
const packageId = req.packageId ?? null;
178+
const existing = rowAt(req.name, packageId);
169179
if (existing) existing.metadata = JSON.stringify(req.item);
170180
else {
171181
ql.metaRows.push({
172-
id: `meta_${req.name}`, type: 'permission', name: req.name, state: 'active',
173-
organization_id: null, metadata: JSON.stringify(req.item),
182+
id: packageId === null ? `meta_${req.name}` : `meta_${req.name}@${packageId}`,
183+
type: 'permission', name: req.name, state: 'active',
184+
organization_id: null, ...(packageId === null ? {} : { package_id: packageId }),
185+
metadata: JSON.stringify(req.item),
174186
});
175187
}
176188
protocol.saves.push({ ...req });
@@ -195,6 +207,20 @@ function makeProtocol(ql: any, declared: Record<string, any> = {}) {
195207
overlayScope: overlay ? 'env' : null, effective: overlay ?? code,
196208
};
197209
},
210+
getMetaItemCalls: 0,
211+
async getMetaItem(req: { type: string; name: string }) {
212+
protocol.getMetaItemCalls += 1;
213+
const o = overlayFor(req.name);
214+
const item = o
215+
? { ...JSON.parse(o.metadata), ...(o.package_id ? { _packageId: o.package_id } : {}) }
216+
: declared[req.name];
217+
if (item === undefined) {
218+
const err: any = new Error(`permission/${req.name} not found`);
219+
err.status = 404;
220+
throw err;
221+
}
222+
return { type: 'permission', name: req.name, item };
223+
},
198224
};
199225
return protocol;
200226
}
@@ -1071,6 +1097,111 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () =>
10711097
});
10721098
});
10731099

1100+
// ── [#21861] The update leg writes back into the row it edits ──────────────
1101+
1102+
describe('[#21861] a data-door UPDATE writes back into the stored row it edits — no package-less fork', () => {
1103+
const userCtx = { userId: 'usr_admin' };
1104+
const PKG = 'com.acme.runtime';
1105+
1106+
/** Every active stored row of `name`, as its scope, its binding and the description its body carries. */
1107+
const activeRows = (ql: any, name: string) =>
1108+
ql.metaRows
1109+
.filter((r: any) => r.name === name && r.state === 'active')
1110+
.map((r: any) => ({
1111+
organization_id: r.organization_id ?? null,
1112+
package_id: r.package_id ?? null,
1113+
description: JSON.parse(r.metadata).description,
1114+
}));
1115+
1116+
/** A stored set (one row, optionally package-bound) with its projected record. */
1117+
async function storedSet(packageId?: string) {
1118+
const ql = makeQl();
1119+
const protocol = makeProtocol(ql);
1120+
registerPermissionSetProjection(protocol, { ql });
1121+
await protocol.saveMetaItem({
1122+
type: 'permission', name: 'support_agent', item: envBody({ name: 'support_agent' }),
1123+
...(packageId ? { packageId } : {}),
1124+
});
1125+
protocol.saves.length = 0;
1126+
return { ql, protocol, rowId: ql.permRows[0].id as string };
1127+
}
1128+
1129+
it('a set stored in a writable runtime package: the edit lands on that row, which stays bound', async () => {
1130+
const { ql, protocol, rowId } = await storedSet(PKG);
1131+
expect(activeRows(ql, 'support_agent'), 'before').toEqual([{ organization_id: null, package_id: PKG, description: undefined }]);
1132+
1133+
const opCtx: any = {
1134+
object: 'sys_permission_set', operation: 'update', context: userCtx,
1135+
data: { id: rowId, description: 'edited at the data door' },
1136+
};
1137+
expect(await run(makeMiddleware(ql, protocol), opCtx)).toBe(false);
1138+
1139+
expect(activeRows(ql, 'support_agent'), 'after: still one row, same binding, carrying the edit')
1140+
.toEqual([{ organization_id: null, package_id: PKG, description: 'edited at the data door' }]);
1141+
expect(protocol.saves.map((s: any) => s.packageId), 'the save names the row\'s own package').toEqual([PKG]);
1142+
expect(opCtx.result?.description, 'the projected record follows').toBe('edited at the data door');
1143+
});
1144+
1145+
it('a package-less set: the save still names no package, and its one row carries the edit', async () => {
1146+
const { ql, protocol, rowId } = await storedSet();
1147+
expect(activeRows(ql, 'support_agent'), 'before').toEqual([{ organization_id: null, package_id: null, description: undefined }]);
1148+
1149+
await run(makeMiddleware(ql, protocol), {
1150+
object: 'sys_permission_set', operation: 'update', context: userCtx,
1151+
data: { id: rowId, description: 'edited' },
1152+
});
1153+
1154+
expect(protocol.saves).toHaveLength(1);
1155+
expect(protocol.saves[0], 'no binding is invented for a set that has none').not.toHaveProperty('packageId');
1156+
expect(activeRows(ql, 'support_agent'), 'after')
1157+
.toEqual([{ organization_id: null, package_id: null, description: 'edited' }]);
1158+
});
1159+
1160+
it('a filtered edit spanning a package-bound and a package-less set writes each back into its own row', async () => {
1161+
const ql = makeQl();
1162+
const protocol = makeProtocol(ql);
1163+
registerPermissionSetProjection(protocol, { ql });
1164+
await protocol.saveMetaItem({ type: 'permission', name: 'pkg_set', item: envBody({ name: 'pkg_set' }), packageId: PKG });
1165+
await protocol.saveMetaItem({ type: 'permission', name: 'org_set', item: envBody({ name: 'org_set' }) });
1166+
protocol.saves.length = 0;
1167+
const before = { pkg: activeRows(ql, 'pkg_set'), org: activeRows(ql, 'org_set') };
1168+
expect(before).toEqual({
1169+
pkg: [{ organization_id: null, package_id: PKG, description: undefined }],
1170+
org: [{ organization_id: null, package_id: null, description: undefined }],
1171+
});
1172+
1173+
await run(makeMiddleware(ql, protocol), {
1174+
object: 'sys_permission_set', operation: 'update', context: userCtx,
1175+
data: { description: 'bulk' }, options: { where: { name: { $in: ['pkg_set', 'org_set'] } } },
1176+
});
1177+
1178+
expect({ pkg: activeRows(ql, 'pkg_set'), org: activeRows(ql, 'org_set') }, 'after').toEqual({
1179+
pkg: [{ organization_id: null, package_id: PKG, description: 'bulk' }],
1180+
org: [{ organization_id: null, package_id: null, description: 'bulk' }],
1181+
});
1182+
expect(protocol.saves.map((s: any) => [s.name, s.packageId ?? null]))
1183+
.toEqual([['pkg_set', PKG], ['org_set', null]]);
1184+
});
1185+
1186+
it('a set a code package ships is still refused by the lock (403 NOT_OVERRIDABLE) before any binding read or save', async () => {
1187+
const ql = makeQl();
1188+
const declaredBody = { ...envBody({ name: 'crm_rep' }), _packageId: 'com.example.crm' };
1189+
(ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) };
1190+
const protocol = makeProtocol(ql, { crm_rep: declaredBody });
1191+
ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' });
1192+
const before = activeRows(ql, 'crm_rep');
1193+
1194+
await expect(run(makeMiddleware(ql, protocol), {
1195+
object: 'sys_permission_set', operation: 'update', context: userCtx,
1196+
data: { id: 'ps_pkg', description: 'customized' },
1197+
})).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
1198+
1199+
expect(activeRows(ql, 'crm_rep'), 'the refused edit stored nothing').toEqual(before);
1200+
expect(protocol.saves).toHaveLength(0);
1201+
expect(protocol.getMetaItemCalls, 'the binding is never read for a refused edit').toBe(0);
1202+
});
1203+
});
1204+
10741205
// ── Boot reconciliation + one-time backfill (ADR-0094 D4) ───────────────────
10751206

10761207
describe('reconcilePermissionSetProjection', () => {

‎packages/plugins/plugin-security/src/permission-set-projection.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,47 @@ export function createPermissionSetWriteThrough(
10911091
}
10921092
};
10931093

1094+
/**
1095+
* [#21861] The `saveMetaItem` argument that writes an update back into the
1096+
* stored row it edits: `{ packageId }` when that row is bound to a package,
1097+
* nothing when it is not.
1098+
*
1099+
* A `sys_metadata` row is keyed `(org, type, name, package_id)`, and a save
1100+
* that names no package targets the package-less row. So an update leg that
1101+
* named none, for a set whose only row is bound to a writable runtime
1102+
* package (`PUT /meta/permission/:name?package=`), minted a second,
1103+
* package-less row carrying the edit beside the untouched package-bound one:
1104+
* two active rows for one name.
1105+
*
1106+
* The row is the one the update merges its patch into — the `overlay` layer
1107+
* of `envelope`, which {@link effectiveBodyForRow} takes as the base — and its
1108+
* binding is read from the metadata door's own single-item read, which serves
1109+
* that row by the same served-row resolution the layered read uses and states
1110+
* the row's `package_id` on it as `_packageId`. ⛔ Never from the patch, the
1111+
* projected record (the projector writes no binding onto an admin row), or a
1112+
* registry item: those are copies, and the row is the fact.
1113+
*
1114+
* No `overlay` layer means no stored row, so there is nothing to fork and the
1115+
* save stays package-less, exactly as before. Every target reaching this read
1116+
* has already passed the lock (verdict `org`), so a code-shipped set never
1117+
* gets here. A read that fails is not caught: guessing the binding would
1118+
* choose which row the save lands in.
1119+
*/
1120+
const storedRowPackageArg = async (
1121+
protocol: any,
1122+
name: string,
1123+
envelope: unknown,
1124+
): Promise<{ packageId?: string }> => {
1125+
const overlay = (envelope as { overlay?: unknown } | null | undefined)?.overlay;
1126+
if (overlay === null || overlay === undefined) return {};
1127+
// A protocol with no single-item read (minimal embeddings, unit-test stubs)
1128+
// keys no row by package either.
1129+
if (typeof protocol.getMetaItem !== 'function') return {};
1130+
const served = await protocol.getMetaItem({ type: 'permission', name });
1131+
const packageId = served?.item?._packageId;
1132+
return typeof packageId === 'string' && packageId !== '' ? { packageId } : {};
1133+
};
1134+
10941135
const projectAndFetch = async (protocol: any, name: string): Promise<any> => {
10951136
// The awaited projector inside saveMetaItem/deleteMetaItem normally did
10961137
// this already — re-running is an idempotent upsert, and covers the
@@ -1368,10 +1409,13 @@ export function createPermissionSetWriteThrough(
13681409
const rowState = pickRowStateColumns(patch);
13691410
const results: any[] = [];
13701411
for (const row of targets) {
1371-
const base = await effectiveBodyForRow(protocol, ql, row, layeredByName.get(String(row.name)));
1412+
const envelope = layeredByName.get(String(row.name));
1413+
const base = await effectiveBodyForRow(protocol, ql, row, envelope);
13721414
const body = mergeRowPatchIntoBody(base, patch);
13731415
body.name = row.name;
1374-
await protocol.saveMetaItem({ type: 'permission', name: row.name, item: body, ...actorArg });
1416+
// [#21861] Into the row the base came from — see `storedRowPackageArg`.
1417+
const packageArg = await storedRowPackageArg(protocol, String(row.name), envelope);
1418+
await protocol.saveMetaItem({ type: 'permission', name: row.name, item: body, ...packageArg, ...actorArg });
13751419
// Row state rides along on the same patch but lands on the record, not
13761420
// in the definition (the projector above never touches these columns).
13771421
if (rowState) await tryUpdate(ql, 'sys_permission_set', { id: row.id, ...rowState });

0 commit comments

Comments
 (0)