Skip to content

Commit f0e9ce4

Browse files
committed
fix(objectql): the platform's declaration of a built-in name is the built-in holder's own registration
At the item seam a built-in position or capability name is held by the platform, and plugin-security's registerBuiltinPositions (SecurityPlugin.start) is that holder declaring it. The environment catalog is no longer asked for a built-in name there: an environment item under a built-in name exists only where an environment save went over the platform's name (outside the ruling), and boot hydration (ObjectQLPlugin.start) registers it before the platform declares, so asking it refused the platform's own declaration and the boot. The stored definition keeps answering first from the bare slot (S2b's shadowing). A second PACKAGE registering a built-in name is still refused, in either order; non-built-in names are judged as before. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 19c86b7 commit f0e9ce4

3 files changed

Lines changed: 64 additions & 8 deletions

File tree

‎packages/objectql/src/registry-security-catalog-namespace.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,37 @@ describe('the built-in holders', () => {
155155
expect(await caught(() => registry.registerItem('position', body('position', 'everyone', 'Everyone'), 'name', 'com.objectstack.plugin-security'))).toBeUndefined();
156156
});
157157

158+
// An environment item under a built-in name exists only where an environment
159+
// save went over the platform's name (outside the ruling), and boot hydration
160+
// registers it BEFORE the platform's `start()` declares the built-ins. The
161+
// declaration is the built-in holder's own registration, not a second holder:
162+
// it is admitted, and the stored definition keeps answering first.
163+
it('the platform\'s declaration of a built-in name is admitted over an environment item under that name, which keeps answering first', async () => {
164+
const { registry } = await boot();
165+
registry.registerItem('position', body('position', 'org_admin', 'stored at the door'), 'name');
166+
expect(await caught(() => registry.registerItem('position', body('position', 'org_admin', 'Organization Admin'), 'name', 'com.objectstack.plugin-security'))).toBeUndefined();
167+
expect(registry.getItem<{ label: string }>('position', 'org_admin')?.label).toBe('stored at the door');
168+
});
169+
170+
it('…while a second PACKAGE registering a built-in name at the item seam is refused, in either order', async () => {
171+
const first = await boot();
172+
first.registry.registerItem('position', body('position', 'everyone', 'Everyone'), 'name', 'com.objectstack.plugin-security');
173+
const after = await caught(() => first.registry.registerItem('position', body('position', 'everyone', 'other'), 'name', 'com.acme.other'));
174+
expectRefusal(after, 'com.acme.other', { kind: 'package', packageId: 'com.objectstack.plugin-security' });
175+
176+
const second = await boot();
177+
second.registry.registerItem('position', body('position', 'everyone', 'other'), 'name', 'com.acme.other');
178+
const before = await caught(() => second.registry.registerItem('position', body('position', 'everyone', 'Everyone'), 'name', 'com.objectstack.plugin-security'));
179+
expectRefusal(before, 'com.objectstack.plugin-security', { kind: 'package', packageId: 'com.acme.other' });
180+
});
181+
182+
it('CONTROL — for a name that is NOT built in, an environment item still refuses a package-bound registration at the item seam', async () => {
183+
const { registry } = await boot();
184+
registry.registerItem('position', body('position', 'env_position', 'stored at the door'), 'name');
185+
const err = await caught(() => registry.registerItem('position', body('position', 'env_position', 'direct'), 'name', 'com.acme.direct'));
186+
expectRefusal(err, 'com.acme.direct', { kind: 'environment' });
187+
});
188+
158189
// The platform's permission sets are declared by plugin-security on its own
159190
// manifest, so they are held as that package's; which side the refusal stops
160191
// depends on which registers first, and both sides are named either way.

‎packages/objectql/src/registry.ts‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2272,15 +2272,16 @@ export class SchemaRegistry {
22722272
* Read, in this order: a built-in (only when `builtIns` — see
22732273
* {@link BUILT_IN_SECURITY_CATALOG_NAMES} for why the item seam does not ask);
22742274
* the bare slot (an override a package bound to itself, else an
2275-
* environment-authored item); every composite `<packageId>:<name>` slot; the
2276-
* package claims. A package that holds the name more than one way is one
2277-
* holder. A disabled package is still installed, and still holds its names.
2275+
* environment-authored item — the latter only when `environment`); every
2276+
* composite `<packageId>:<name>` slot; the package claims. A package that
2277+
* holds the name more than one way is one holder. A disabled package is still
2278+
* installed, and still holds its names.
22782279
*/
22792280
private securityCatalogHoldersOtherThan(
22802281
type: SecurityCatalogType,
22812282
name: string,
22822283
exceptPackageId: string | undefined,
2283-
opts: { builtIns: boolean },
2284+
opts: { builtIns: boolean; environment: boolean },
22842285
): SecurityCatalogHolder[] {
22852286
const found = new Map<string, SecurityCatalogHolder>();
22862287
const add = (holder: SecurityCatalogHolder) => {
@@ -2292,7 +2293,8 @@ export class SchemaRegistry {
22922293
for (const [key, item] of this.metadata.get(type) ?? []) {
22932294
const stamped = (item as { _packageId?: unknown } | null | undefined)?._packageId;
22942295
if (key === name) {
2295-
add(typeof stamped === 'string' && stamped !== '' ? { kind: 'package', packageId: stamped } : { kind: 'environment' });
2296+
if (typeof stamped === 'string' && stamped !== '') add({ kind: 'package', packageId: stamped });
2297+
else if (opts.environment) add({ kind: 'environment' });
22962298
} else if (key.endsWith(suffix)) {
22972299
add({
22982300
kind: 'package',
@@ -2329,7 +2331,7 @@ export class SchemaRegistry {
23292331
const key = `${type}|${name}`;
23302332
if (seen.has(key)) continue;
23312333
seen.add(key);
2332-
for (const existingHolder of this.securityCatalogHoldersOtherThan(type, name, selfId, { builtIns: true })) {
2334+
for (const existingHolder of this.securityCatalogHoldersOtherThan(type, name, selfId, { builtIns: true, environment: true })) {
23332335
conflicts.push({ catalogType: type, name, incomingPackageId: selfId, existingHolder });
23342336
}
23352337
}
@@ -3755,8 +3757,26 @@ export class SchemaRegistry {
37553757
// ⛔ A registration with no `packageId` is the bare slot — `sys_metadata`
37563758
// hydration, the metadata write-through — and is never judged here: an
37573759
// environment save over a package-held name is outside the ruling.
3760+
//
3761+
// A BUILT-IN name is held by the platform, and the registration of one
3762+
// here is the platform declaring its own name (`plugin-security`'s
3763+
// `registerBuiltinPositions`, in its `start()`) — the holder's own
3764+
// registration, never a second holder. Packages are refused built-in names
3765+
// at the package door, and a second package registering one here is still
3766+
// refused below (in either order: the other package's slot is a holder).
3767+
// What is NOT asked for a built-in name is the environment catalog: an
3768+
// environment item under a built-in name exists only because an
3769+
// environment save went over the platform's name — outside the ruling — and
3770+
// it is hydrated (`ObjectQLPlugin.start`, which every `start()` depending on
3771+
// the engine follows) BEFORE the platform declares, so asking it would
3772+
// refuse the platform's own declaration and with it the boot. The stored
3773+
// definition keeps answering first from the bare slot (ADR-0005); the
3774+
// declaration sits beside it.
37583775
if (packageId && isSecurityCatalogType(type)) {
3759-
const holders = this.securityCatalogHoldersOtherThan(type, baseName, packageId, { builtIns: false });
3776+
const holders = this.securityCatalogHoldersOtherThan(type, baseName, packageId, {
3777+
builtIns: false,
3778+
environment: !BUILT_IN_SECURITY_CATALOG_NAMES[type].has(baseName),
3779+
});
37603780
if (holders.length > 0) {
37613781
throw new SecurityCatalogNameConflictError(
37623782
holders.map((existingHolder) => ({ catalogType: type, name: baseName, incomingPackageId: packageId, existingHolder })),

‎packages/objectql/src/security-catalog-namespace.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,12 @@ const COLLECTION_KEY: Readonly<Record<SecurityCatalogType, string>> = Object.fre
9191
*
9292
* Applied at the package door ({@link declaredSecurityCatalogNames}'s caller),
9393
* never at the registry's own item seam: the platform declares these names to
94-
* the registry itself, under its own package id.
94+
* the registry itself, under its own package id, and that declaration is the
95+
* built-in holder's own registration — never a second holder. So the item seam
96+
* asks it only whether another PACKAGE holds the name. It does not ask the
97+
* environment catalog: an environment item under a built-in name exists only
98+
* where an environment save went over the platform's name, which is outside
99+
* the ruling, and it is hydrated before the platform declares.
95100
*/
96101
export const BUILT_IN_SECURITY_CATALOG_NAMES: Readonly<Record<SecurityCatalogType, ReadonlySet<string>>> = Object.freeze({
97102
position: new Set<string>([...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]),

0 commit comments

Comments
 (0)