Skip to content

Commit 234d1d8

Browse files
fix(plugin-security): a cloned permission set is not reported as an unowned declaration on boot (#21669) (#21692)
Fixes #21669 Clause-②: no ## What was wrong `bootstrapDeclaredPermissions` (plugin-security) walks every `permission` item in the engine SchemaRegistry. That registry also holds the permission sets an environment authored itself: `loadMetaFromDb` hydrates every env-wide `sys_metadata` `permission` row into the same collection. A set made with Setup's **Clone** action is one of them, and it has no package id there, because it has none. The loop judged "no owning package" before it looked at the set's row, so every boot, and every `metadata:reloaded`, logged `[permission_set_declaration_unowned] declared permission set "…" has no owning package — not materialized … the Setup admin surface reads sys_permission_set and cannot see this set` for a set whose `managed_by: admin` row Setup lists and edits. ## Reproduced first, on the real showcase composition Boot 1 of `bootStack(showcaseStack, { databaseFile })`: the admin signs in and clones `showcase_contributor` through `POST /api/v1/data/sys_permission_set`, with the shipped `clone_permission_set` action's payload. Then a cold boot on the same file. Measured on `a4fd82a8e6`, with the plugin-security `dist` built from that tree: | reading | value | |:--|:--| | clone POST | `201` | | clone row | `managed_by: admin`, `package_id: null`, `organization_id: null` | | clone `sys_metadata` row | one row, `state: active`, `organization_id: null`, `package_id: null` | | boot 2, registry item for the clone | present; no `_packageId`, no `packageId`; `_provenance: 'org'` (stamped at hydration) | | boot 2, the other 17 registry items | every one carries `_packageId` (`com.example.showcase` or `com.objectstack.plugin-security`) and `_provenance: 'package'` | | boot 2, unowned lines | **1**, naming the clone | | boot 2, seeding summary | `skippedUnowned: 1`, `skippedEnvAuthored: 8`, `total: 18` (boot 1: `skippedUnowned: 0`, `total: 17`) | The line that reported it is `upsertPackagePermissionSet`'s `if (!packageId)` branch (`reportPermissionSetDeclarationUnowned`), reached from the boot loop with `packageId = ps._packageId ?? ps.packageId`, which is undefined for the clone. The report became visible with `cf39b83c09`; `git log -S` on the call finds that commit alone. The same steps with this branch's `dist`: **0** unowned lines, and the seeding summary reads `skippedUnowned: 0`, `skippedEnvAuthored: 9`. ## The fix Before the loop judges ownership, it checks whether the environment already owns a row under the name. A registry item with no package id, whose `sys_permission_set` row is environment-owned, is the environment's own set. It lands in `skippedEnvAuthored`, where a package declaration over an environment row already lands, and nothing is logged. **Which signal, and why.** The signal is the row's `managed_by`, read through the existing env-authored verdict. That is the one definition of environment ownership this seeder already had: the last branch of `upsertPackagePermissionSet`, where a row that is not `managed_by: 'package'` is environment-authored and never clobbered (ADR-0086 two-doors). It is now named `isEnvironmentOwnedRow`, and both that branch and the new check call it, so there is still exactly one spelling. Two alternatives were measured and not taken: - **The registry item's `_provenance: 'org'`.** It is on the clone's SchemaRegistry item. But the metadata service's copy of the same clone carries no `_provenance`; it carries the projection's `_envProjection` marker instead. A check keyed on it would depend on which registry copy the loop reads, and that question is still open on #14491. Nothing in plugin-security reads `_provenance` today, so it would also be a second definition. - **The lock's `declaredPackageIdOf`.** It answers "is this an artifact?", not "who owns this row?". It would leave the same "unowned" verdict for the clone. The check reads from the batched existence read the loop already makes, so no query is added. On a per-organization pass, it also counts the organization-less row the environment door writes. `permission-set-projection.ts` is deliberately not per-organization, so a clone's row has no `organization_id` there. If the read cannot answer, that is not proof the environment owns the name, so the unowned refusal stands, exactly as before. The publish-time materializer (ADR-0086 P2) is unchanged. **The warning stays honest.** A declaration with no owning package and no environment row still logs `permission_set_declaration_unowned`, with the same text, and still counts as `skippedUnowned`. `seed-refusal-diagnostics.ts` is untouched. ## #14491 (the third registry copy) was read first It **shares the registry walk**, and the fix does **not** depend on which copy the loop reads. - `readDeclared(ql, 'permission')` reads the one SchemaRegistry `permission` collection. On boot 2 that collection held 17 items from `manifest.register` (the unparsed third copy #14491 describes, each with `_packageId`), plus the clone, which `loadMetaFromDb` hydrated from `sys_metadata`. So #14491's copy feeds this loop, but the clone does not come from that copy. - The fix uses only the item's name and the absence of a package id. It decides ownership from the `sys_permission_set` row. The metadata service's copy of the clone also has no package id (measured above), so the decision would be the same if the loop read that copy instead. - No `packages/objectql` or `packages/metadata*` file is touched. ## Pins - `bootstrap-declared-permissions.test.ts`, under `[#21669]`, 7 cases: - a cloned set (the measured item shape over a `managed_by: admin` row) logs nothing on any of the five console channels, counts `skippedEnvAuthored: 1`, and its row is untouched; - the same on a per-organization pass; - a truly unowned declaration (no package id, no row) still warns, with the token and the first sentence `declared permission set "crm_orphan" has no owning package — not materialized.`; the same on a per-organization pass; - controls: an unowned item over a `managed_by: package` row still warns; an unreadable read still warns; - conservation with a clone in the walk. - A boot-level pin, `packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts`. The unit seam cannot show that the real boot puts the clone in the walk, so this pin boots the showcase twice: - It clones through the shipped action's own declaration: its `target`, its `bodyExtra`, its typed `label`/`name`, and every `defaultFromRow` facet. - It asserts the precondition that boot 2's walk holds the clone with no package id. - Control: the capture saw the seeding summary line. - It asserts no `permission_set_declaration_unowned` line names the clone. ## Ablations (one-time, nothing left in the tree) Each leg ran through `scripts/ablation-replace.mjs` with an anchor that must hit, on top of the committed fix (`067aa4f58f`). Each leg rebuilt `@objectstack/plugin-security`, and `scripts/ablation-dist-preflight.mjs` confirmed the marker had reached `dist/` before any result was read. The direction was predicted before each run. | leg | mutation | predicted | observed | |:--|:--|:--|:--| | A: guard removed | `environmentOwnsName` returns false | only the clone rows go red | unit: 3 red, 27 green: the clone (single), the clone (per-organization), and conservation-with-clone. Boot pin: 1 red, 2 green; the absence case failed with one unowned line. | | B: guard admits all | `environmentOwnsName` returns true | every input with no package id and no environment row goes red, i.e. the new truly-unowned rows, the package-row and unreadable controls, and the pre-existing #18091 / #18571 unowned pins of the same shape; the clone rows stay green | unit: 12 red (5 new, 7 pre-existing, all of that shape), 18 green, both clone rows among them. Boot pin: 3 of 3 green. | Restore, both legs: `ablation-replace` reported that the file's blob equals HEAD (`56a5addec78e`) and that `git diff HEAD` is empty. A rebuild then followed, and `ablation-dist-preflight --absent` reported both markers absent from all 6 built files and the tree clean. ## Verification, on HEAD `067aa4f58f` - `pnpm --filter @objectstack/plugin-security test`: `Test Files 164 passed (164)`, `Tests 3534 passed | 45 skipped (3579)`. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. `check:test-typecheck` OK, and `tsc -p tsconfig.test.json --listFiles` includes the edited test file. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0, and the new pin is in the program. - The boot pin: `Tests 3 passed (3)`. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 69 commands; all 69 exited 0. Reconciled with `--ran`: `69 run, 0 NOT-MEASURED (a DERIVED zero — all 69 recorded an exit code and none of them is 3)`. - `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`: 8 unrelated packages had no `dist/`). After those were built it exited 0. - Two families take values only CI supplies and were not run locally: `check-shard-attestation` (dogfood shard matrix) and `check-issue-citations --census`. - Lint: narrowed to the 3 touched TS files, `eslint --no-inline-config --format json`: 3 file results, 0 errors, 0 warnings. `eslint.config.mjs` uses no `parserOptions.project` (no type-aware rules), so this diff cannot move the verdict on any untouched file. The repo-wide lint is CI's. - Docs: `content/docs/**` (outside `releases/`) and `skills/**` have no mention of `permission_set_declaration_unowned` or "no owning package", so no sentence was made false. ## Acceptance notes - **Walled posture, at boot level: NOT MEASURED.** The per-organization case is pinned at the unit seam only. A walled boot here needs the enterprise organizations package, which no framework package declares. The reasoning is the environment door's documented organization-less shape: `permission` is `allowOrgOverride: false`, so the clone's `sys_metadata` row lands env-wide and is hydrated in every posture. - **Not measured, noted only: the sibling capability seeder.** `bootstrap-declared-capabilities.ts` has the same order: "unowned" is judged before the row is read (`capability_declaration_unowned`). Whether an environment-authored capability ever reaches that registry walk without a package id was not measured. #15204 (ADR-0131 C3) retires that seeder and will touch the file. - **Not measured, noted only: metadata-door package sets.** A permission set published through the metadata door with a package binding (ADR-0070, materialized by the ADR-0086 P2 path) is hydrated from `sys_metadata`. The write door strips derived provenance from it, so its registry item would also have no `_packageId`, while its row is `managed_by: package`. This fix deliberately does not treat such a row as environment-owned, so that set would still be reported as unowned. That population was not measured. - #14491 is not addressed here; that card remains open on its own surviving question. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d4fc63 commit 234d1d8

4 files changed

Lines changed: 392 additions & 3 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a permission set the environment cloned no longer logs `permission_set_declaration_unowned` on every boot (#21669)
6+
7+
Clause-②: no
8+
9+
The declared-permission seeding pass walks every `permission` item in the engine registry. That registry also holds the permission sets an environment authored itself, which boot hydration loads from their `sys_metadata` rows. A set made with Setup's **Clone** action is one of them, and it carries no package id because it has none. The pass judged "no owning package" before it looked at the set's row, so on every boot, and on every `metadata:reloaded`, it logged one warning per cloned set: `[permission_set_declaration_unowned] declared permission set "…" has no owning package — not materialized … the Setup admin surface reads sys_permission_set and cannot see this set`. That is false for a clone. Its row exists (`managed_by: admin`), and Setup lists it and edits it.
10+
11+
The pass now checks the row first. A registry item with no package id whose `sys_permission_set` row the environment owns (`managed_by` other than `package`) is the environment's own set. It is counted as `skippedEnvAuthored`, the count a package declaration over an environment row already gets, and no warning is logged. The pass reads that row from the existence read it already makes, so no query is added. On a per-organization pass, the environment door's organization-less row counts too.
12+
13+
Unchanged: a declaration with no owning package and no environment row still logs `permission_set_declaration_unowned`, with the same text, and still counts as `skippedUnowned`. If the row could not be read, the warning still fires, because an unreadable row does not prove the environment owns the set. The publish-time materializer is unchanged. Nothing is written or granted differently: only the false warning stops, and the clone moves from the `skippedUnowned` count to the `skippedEnvAuthored` count in the pass's summary line.

‎packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,3 +500,156 @@ describe('[#18571] the unowned permission-set refusal moves a counter', () => {
500500
expect(ql.rows.some((r) => r.name === 'crm_orphan')).toBe(false);
501501
});
502502
});
503+
504+
// ───────────────────────────────────────────────────────────────────────────
505+
// [#21669] An ENVIRONMENT-owned set in the registry walk is not an unowned
506+
// declaration. The engine registry the boot loop walks also holds every
507+
// env-wide `sys_metadata` `permission` row `loadMetaFromDb` hydrates — a set
508+
// made by Setup's Clone action among them — and such an item carries no
509+
// package id because it has none. The loop judged "unowned" before it read
510+
// the row, so a set whose `managed_by:'admin'` row Setup lists and edits was
511+
// reported, on every boot, as one Setup "cannot see".
512+
//
513+
// The item below is the shape measured on a real showcase cold boot after a
514+
// Clone (the boot-level pin is
515+
// `packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts`):
516+
// no `_packageId`, no `packageId`, `_provenance: 'org'`.
517+
// ───────────────────────────────────────────────────────────────────────────
518+
519+
const CLONE_NAME = 'crm_sales_rep_local';
520+
521+
/** The registry item a hydrated clone is — measured, not invented. */
522+
const clonedItem = () => ({
523+
name: CLONE_NAME,
524+
label: 'Sales Rep (local)',
525+
objects: { crm_lead: { allowRead: true, allowCreate: true } },
526+
fields: {},
527+
systemPermissions: [],
528+
rowLevelSecurity: [],
529+
tabPermissions: {},
530+
_provenance: 'org',
531+
});
532+
533+
/** The row the Clone action leaves: the environment's own set. */
534+
const clonedRow = (over: Record<string, any> = {}) => ({
535+
id: 'ps_clone', name: CLONE_NAME, managed_by: 'admin', package_id: null, active: true,
536+
object_permissions: '{"crm_lead":{"allowRead":true,"allowCreate":true}}',
537+
...over,
538+
});
539+
540+
/** The unowned refusal's first sentence — the text an operator reads, unchanged by this card. */
541+
const unownedFirstSentence = (name: string) =>
542+
`declared permission set "${name}" has no owning package — not materialized.`;
543+
544+
describe('[#21669] an environment-owned set is told apart from an unowned declaration', () => {
545+
it('a CLONED set (row managed_by admin) boots with no warning on any channel, and its row is untouched', async () => {
546+
const ql = makeQl([clonedItem()]);
547+
ql.rows.push(clonedRow());
548+
const before = JSON.stringify(ql.rows);
549+
550+
const cap = captureAllConsole();
551+
let r: Awaited<ReturnType<typeof bootstrapDeclaredPermissions>>;
552+
try {
553+
r = await bootstrapDeclaredPermissions(ql, undefined);
554+
} finally {
555+
cap.restore();
556+
}
557+
558+
expect(cap.seen.filter((l) => l.includes(PERMISSION_SET_DECLARATION_UNOWNED))).toEqual([]);
559+
expect(cap.seen).toEqual([]);
560+
// Where a package declaration over an environment row already lands —
561+
// never clobbered, and counted, so CONSERVATION still holds.
562+
expect(r).toMatchObject({ skippedEnvAuthored: 1, skippedUnowned: 0, seeded: 0, updated: 0, unchanged: 0 });
563+
expect(JSON.stringify(ql.rows)).toBe(before);
564+
});
565+
566+
it('the same, on a per-organization pass: the environment door\'s organization-less row is the environment\'s', async () => {
567+
// `permission-set-projection.ts` (the ENVIRONMENT door) is deliberately not
568+
// per-organization, so a clone's row carries no organization_id and a
569+
// walled pass sees it only beside its own rows.
570+
const ql = makeQl([clonedItem()]);
571+
ql.rows.push(clonedRow());
572+
573+
const warn = vi.fn();
574+
const r = await bootstrapDeclaredPermissions(ql, undefined, {
575+
logger: { info: () => {}, warn }, organizationId: 'org_a', platformBucketNames: [],
576+
});
577+
578+
const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean);
579+
expect(events).not.toContain(PERMISSION_SET_DECLARATION_UNOWNED);
580+
expect(r).toMatchObject({ skippedEnvAuthored: 1, skippedUnowned: 0, seeded: 0 });
581+
expect(ql.rows).toHaveLength(1);
582+
});
583+
584+
it('⛔ a TRULY unowned declaration — no package id, no row — still warns, with the same code and text', async () => {
585+
const ql = makeQl([declaredSet({ name: 'crm_orphan', _packageId: undefined })]);
586+
587+
const cap = captureAllConsole();
588+
let r: Awaited<ReturnType<typeof bootstrapDeclaredPermissions>>;
589+
try {
590+
r = await bootstrapDeclaredPermissions(ql, undefined);
591+
} finally {
592+
cap.restore();
593+
}
594+
595+
expect(cap.seen).toHaveLength(1);
596+
expect(cap.seen[0]).toContain(`[${PERMISSION_SET_DECLARATION_UNOWNED}]`);
597+
expect(cap.seen[0]).toContain(unownedFirstSentence('crm_orphan'));
598+
expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 });
599+
expect(ql.rows).toHaveLength(0);
600+
});
601+
602+
it('⛔ the same on a per-organization pass with no row at all', async () => {
603+
const ql = makeQl([declaredSet({ name: 'crm_orphan', _packageId: undefined })]);
604+
const warn = vi.fn();
605+
606+
const r = await bootstrapDeclaredPermissions(ql, undefined, {
607+
logger: { info: () => {}, warn }, organizationId: 'org_a', platformBucketNames: [],
608+
});
609+
610+
const unowned = warn.mock.calls.filter((c) => (c[1] as any)?.event === PERMISSION_SET_DECLARATION_UNOWNED);
611+
expect(unowned).toHaveLength(1);
612+
expect(String(unowned[0]![0])).toContain(unownedFirstSentence('crm_orphan'));
613+
expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 });
614+
});
615+
616+
it('⛔ a row the PACKAGE door owns is not the environment\'s: an unowned item over it still warns', async () => {
617+
// The guard asks who owns the ROW. A `managed_by:'package'` row under the
618+
// name is a package's record, so it proves nothing about an item that
619+
// carries no owner of its own.
620+
const ql = makeQl([declaredSet({ _packageId: undefined })]);
621+
ql.rows.push({ id: 'ps_pkg', name: 'crm_sales_rep', managed_by: 'package', package_id: 'com.example.crm' });
622+
623+
const warn = vi.fn();
624+
const r = await bootstrapDeclaredPermissions(ql, undefined, { logger: { info: () => {}, warn } });
625+
626+
const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean);
627+
expect(events).toEqual([PERMISSION_SET_DECLARATION_UNOWNED]);
628+
expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 });
629+
});
630+
631+
it('⛔ a read that cannot answer proves nothing: the unowned refusal stands, exactly as before', async () => {
632+
const ql = unreadableQl([clonedItem()]);
633+
const warn = vi.fn();
634+
635+
const r = await bootstrapDeclaredPermissions(ql, undefined, { logger: { info: () => {}, warn } });
636+
637+
const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean);
638+
expect(events).toContain(PERMISSION_SET_DECLARATION_UNOWNED);
639+
expect(r).toMatchObject({ skippedUnowned: 1, skippedEnvAuthored: 0 });
640+
});
641+
642+
it('⭐ CONSERVATION, with a clone in the walk: every named item lands in exactly one counter', async () => {
643+
const owned = declaredSet({ name: 'crm_new', _packageId: 'com.a' });
644+
const orphan = declaredSet({ name: 'crm_orphan', _packageId: undefined });
645+
const ql = makeQl([owned, clonedItem(), orphan]);
646+
ql.rows.push(clonedRow());
647+
648+
const out = await bootstrapDeclaredPermissions(ql, undefined, { logger: mute() });
649+
650+
expect(out).toMatchObject({ seeded: 1, skippedEnvAuthored: 1, skippedUnowned: 1, unreadable: 0 });
651+
const counted = out.seeded + out.updated + out.unchanged
652+
+ out.skippedEnvAuthored + out.skippedForeign + out.skippedUnowned + out.unreadable;
653+
expect(counted).toBe(3);
654+
});
655+
});

‎packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts‎

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,48 @@ export function readDeclared(engine: any, type: string): any[] {
182182
return [];
183183
}
184184

185+
/**
186+
* ADR-0086 two-doors — is this `sys_permission_set` row ENVIRONMENT-owned?
187+
*
188+
* The one spelling of the fact this seeder already acted on: a row the PACKAGE
189+
* door does not own — `managed_by` `admin`/`platform`, the legacy `user`, or
190+
* absent (pre-provenance rows, including `bootstrapPlatformAdmin`'s platform
191+
* defaults) — is environment-authored config that package materialization
192+
* never clobbers. {@link upsertPackagePermissionSet}'s last branch and the boot
193+
* loop's unowned-vs-environment split both ask THIS predicate, so the two can
194+
* never disagree about which rows are the environment's.
195+
*/
196+
function isEnvironmentOwnedRow(row: any): boolean {
197+
return !!row && typeof row === 'object' && row.managed_by !== 'package';
198+
}
199+
200+
/**
201+
* [#21669] Does the environment already own a row under this name?
202+
*
203+
* The engine registry the boot loop walks holds MORE than package
204+
* declarations: `loadMetaFromDb` hydrates every env-wide `sys_metadata`
205+
* `permission` row into the same collection, and a set an environment
206+
* authored itself — the Setup Clone action, a Setup "New", a metadata-door
207+
* save — carries no package id there, because none exists. Read as a
208+
* declaration, such an item is "unowned"; it is not a declaration at all. Its
209+
* row stands, the environment door owns it, and Setup lists it.
210+
*
211+
* Answered from the batched oracle the loop already holds — no extra read.
212+
* The row is this pass's own, or — on a per-organization pass — the
213+
* organization-less row the ENVIRONMENT door writes (`permission-set-
214+
* projection.ts` is deliberately not per-organization; see this file's
215+
* header). `unknown` is NOT an answer: a read that failed cannot prove the
216+
* environment owns the name, so the caller falls through to the unowned
217+
* refusal exactly as before.
218+
*/
219+
async function environmentOwnsName(existingByName: ExistingByNameIndex, name: string): Promise<boolean> {
220+
const lookup = await existingByName.get(name);
221+
const row = lookup.status === 'present'
222+
? lookup.row
223+
: lookup.status === 'absent' ? lookup.organizationLessResidue : undefined;
224+
return isEnvironmentOwnedRow(row);
225+
}
226+
185227
/**
186228
* Upsert ONE declared/published PermissionSet body into `sys_permission_set`
187229
* under the owning `packageId`, applying the ADR-0086 provenance rules
@@ -297,7 +339,7 @@ export async function upsertPackagePermissionSet(
297339
return out;
298340
}
299341

300-
if (existing.managed_by === 'package') {
342+
if (!isEnvironmentOwnedRow(existing)) {
301343
if (!permissionSetNameIsForeign(existing.package_id, packageId)) {
302344
// Our own row — re-seed so the record always reflects the shipped/published
303345
// declaration (idempotent; covers version bumps without bookkeeping).
@@ -359,8 +401,9 @@ export async function upsertPackagePermissionSet(
359401
return out;
360402
}
361403

362-
// `platform`/`user` — or absent (legacy rows, incl. bootstrapPlatformAdmin
363-
// defaults): env-authored config. Never clobbered by package materialization.
404+
// {@link isEnvironmentOwnedRow}: `admin`/`platform`/`user` — or absent
405+
// (legacy rows, incl. bootstrapPlatformAdmin defaults): env-authored config.
406+
// Never clobbered by package materialization.
364407
out.skippedEnvAuthored += 1;
365408
return out;
366409
}
@@ -417,6 +460,20 @@ export async function bootstrapDeclaredPermissions(
417460
// Registry provenance first (ADR-0010 `_packageId`), author-declared
418461
// spec `packageId` (ADR-0086 D3) as fallback.
419462
const packageId: string | undefined = ps._packageId ?? ps.packageId ?? undefined;
463+
// [#21669] Tell an ENVIRONMENT-owned set apart from a package declaration
464+
// BEFORE judging ownership. A registry item with no package id whose row
465+
// the environment already owns (a Setup clone: `managed_by:'admin'`, no
466+
// `package_id`) is not an unowned declaration — it is not a declaration —
467+
// and the `permission_set_declaration_unowned` warning told the operator,
468+
// on every boot and every `metadata:reloaded`, that Setup could not see a
469+
// set Setup lists and edits. It lands where every package declaration
470+
// over an environment row lands: `skippedEnvAuthored`, never clobbered.
471+
// ⛔ A declaration with no owner AND no environment row still reaches the
472+
// unowned refusal below, unchanged — that warning is true.
473+
if (!packageId && await environmentOwnsName(existingByName, String(ps.name))) {
474+
out.skippedEnvAuthored += 1;
475+
continue;
476+
}
420477
const r = await upsertPackagePermissionSet(ql, ps, packageId, options.logger, { existingByName, organizationId, residue, refusals, collisions });
421478
out.seeded += r.seeded;
422479
out.updated += r.updated;

0 commit comments

Comments
 (0)