Skip to content

Commit 0b997ea

Browse files
fix(objectql): register stack-declared positions under their package so the save door refuses overrides (#22262)
Fixes #22203 Clause-②: no ## What changes `ObjectQL.registerApp()` and the nested-plugin seam now register a stack's `positions` collection into the engine SchemaRegistry under the owning package. They stamp the same ADR-0010 provenance that `permissions` and `capabilities` already get (`METADATA_ARRAY_KEYS`, `packages/objectql/src/engine.ts`). The metadata save door's existing type-level packaged-base check then covers `position` the same way it covers the other `allowOrgOverride: false` types. Landing: the producer side, in `packages/objectql`. The save door in `packages/metadata-protocol` was correct and was given the wrong input, so its code is unchanged. There is no `packages/spec` edit; the flag was already declared. ## Measured: why the type-level check answered for one type and not the other - **The check is already type-level.** The packaged-base check reads the registry's `allowOrgOverride` in two places: `refusePackagedBaseOverride` inside `saveMetaItem` (environment-scoped kernel) and `SysMetadataRepository.assertAllowed` under the `override-artifact` intent (host-config kernel). - **Its input comes from the engine registry.** Both decide "a code package ships this item" through `isArtifactBacked` → `lookupArtifactItem` → `SchemaRegistry.getArtifactItem`. That lookup only finds entries a package registered with `_packageId`. - **The input was missing for positions.** The only seam that puts stack collections into that registry under their package is `registerMetadataCollections` over `METADATA_ARRAY_KEYS`. That list had `permissions` and `capabilities`. For positions it still had the retired `roles` spelling: ADR-0090 D3's rename reached `ARTIFACT_FIELD_TO_TYPE` (`packages/metadata/src/plugin.ts`) and never reached this list. `check:stack-collection-maps` recorded the absence as a waiver. The waiver's reason pointed at the metadata service's registry, not the SchemaRegistry. - **Result.** For every stack-declared position, `isArtifactBacked('position', NAME)` answered false. The save took the `runtime-only` intent, and `allowRuntimeCreate: true` accepted it. - **Where the permission-set 403 comes from.** On the showcase, `plugin-security`'s packaged permission-set lock answers first. That lock is `registerPackagedPermissionSetLockGate`, an authoring gate registered for `permission` only. Under it, the type-level door also refuses a package-declared permission set. The pin below shows this with no security plugin composed. The lock stays as it is, a stricter type-specific layer on top of the type-level door. Population of the pin, read from the registry: every `domain: 'security'` row with `allowOrgOverride: false`, which today is `permission`, `position` and `capability`. ## Door table Showcase (`pnpm dev -- --fresh`), host-config kernel, seeded admin. The same requests were sent both times: - **without fix:** this branch with the one added `positions` entry ablated. objectql was rebuilt, and the preflight proved the change reached `dist/`. - **with fix:** head `7ed88a6081` (merged `origin/main` `f4bed58341`). The same position, permission, capability and control answers were also measured before the merge, at base `7b926f7600` and head `f7d8d0e172`. | item | request | without fix | with fix | |---|---|---|---| | package-declared position | PUT | 200, saved | 403 `NOT_OVERRIDABLE` | | package-declared position | PUT naming the package (`?package=`) | 422 `WRITABLE_PACKAGE_REQUIRED` | 403 `ITEM_LOCKED` | | package-declared position | GET by name after the PUT | serves the environment row | serves the package's position | | built-in position (`plugin-security`) | PUT | 403 `NOT_OVERRIDABLE` | 403 `NOT_OVERRIDABLE` (unchanged) | | package-declared permission set | PUT, with or without `?package=` | 403 `NOT_OVERRIDABLE` | 403 `NOT_OVERRIDABLE` | | package-declared capability | PUT | 403 `NOT_OVERRIDABLE` | 403 `NOT_OVERRIDABLE` | | position no package declares | PUT | 200 | 200 | | package-declared dashboard (`allowOrgOverride: true`) | PUT | 200 overlay | 200 overlay | | position list | GET `/meta/position` | 16 names | 16 names, no duplicates | Boot diagnostics: 4 warnings on both boots, the same four. The seeded `sys_position` rows carry the declared labels and descriptions. ## Pins - **New: `packages/objectql/src/engine-security-catalog-package-door.test.ts`.** It uses a real `ObjectQL`, a real `ObjectStackProtocolImplementation` and the population above, read from `DEFAULT_METADATA_TYPE_REGISTRY`. For each type it checks: - the provenance seam registers the stack-declared item under its package; - a save over the package-declared item is refused with envelope `{ code: 'NOT_OVERRIDABLE', status: 403 }` and stores nothing, on both topologies. It also checks that the by-name read still serves the package's position after the refusal. Controls: - a position no package declares still saves, on both topologies; - `email_template` (`allowOrgOverride: true`) still saves over its packaged item, on both topologies. - **Re-measured: `packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts`.** Its positions case asserted the old absence on an artifact that declared no position. The probe artifact now declares one, and the case asserts the registry holds it under the artifact's package beside the six built-ins. This is a real `createStandaloneStack` boot with `SecurityPlugin`. - **Widened: `packages/objectql/src/engine-nested-plugin-collections.test.ts`.** `positions` joins the property candidates: both seams register it identically. - **Gate: `scripts/check-stack-collection-maps.mjs`.** The stale `missing: ['positions']` waiver on `METADATA_ARRAY_KEYS` is removed. The gate fails on a stale waiver, and its reason was wrong about which registry it meant. ## Reverse verification (ablation) The run was committed first, then mutated through `scripts/ablation-replace.mjs`. The mutation removed `'positions'` from `METADATA_ARRAY_KEYS`: anchor count 1 → 0, blob `8465f68a50a1` → `c4414cf91029`. objectql was then rebuilt, and `scripts/ablation-dist-preflight.mjs @objectstack/objectql '"positions"' --absent --source-marker="'positions'"` exited 0. Predicted before running: 4 red in the new pin (seam, both topology refusals, by-name read) and 1 red in the runtime pin, everything else green. Measured exactly that: - objectql: `Tests 4 failed | 47 passed (51)`; - runtime: `Tests 1 failed | 12 passed (13)`. Restore leg: blob == HEAD, `git diff HEAD` empty. objectql was rebuilt, and the preflight in default mode found the marker present in 4 built files with a clean tree. Both suites were green again: 51/51 and 13/13. The new pin was also run at base `7b926f7600` before any fix existed. Exactly the 4 position cases were red, and the by-name read returned the environment fork. ## Local verification All at head `7ed88a6081` unless noted. - `pnpm --filter @objectstack/objectql test`: 381 files / 7536 tests passed, at `5188b2ad45`. The merge brought no `objectql`, `metadata-protocol` or `core` change. - `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter @objectstack/runtime typecheck`: OK, test layers included. - Post-merge, targeted runs: - objectql pin, seam and capability-provenance suites: 58/58; - runtime `standalone-stack`, `standalone-stack-seeder-declaration-copy`, `standalone-stack-security-registrar` and `app-plugin-artifact-forward-conversion`: 48/48; - verify `artifact-collections`: 8/8; - `pnpm --filter @objectstack/plugin-security test`: 3739 passed, 45 skipped; - dogfood `security-catalog-showcase`, `showcase-declarative-rbac-seeding`, `position-address-readers` and `rls-runner`: 53/53. These resolve `dist/`, rebuilt at this head. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands` gives 86 families, all run at `7ed88a6081` and reconciled with `--ran`. - 84 exit 0. - `check-empty-changeset` exits 1, by design. See the next section. - `pnpm check:pm-dispatch-gates`: exit 0, with all 1976 self-test cases passing. The 900 s per-gate cap in the batch runner killed it first, so it was re-run on its own with its exit code captured. - ESLint, narrowed: - **What was checked:** the 5 changed `.ts`/`.mjs` files, at `7ed88a6081`. - **Result:** `--format json` reports 5 files linted, 0 errors, 0 warnings, none ignored. - **Why the narrowing excludes nothing:** `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot change the verdict on any file it does not touch. - Integration tier, full lint and the rest of the farm: declared to CI. ## A pending release note this PR corrects: please confirm `.changeset/15196-core-security-catalog-read.md` (pending, `@objectstack/core`) says the engine registry carries "no stack-declared position, and the metadata service carries the stack-declared positions". This PR makes that sentence false, so the sentence is rewritten in place to say the engine registry also carries stack-declared positions. No other text changed. `check-empty-changeset` stays red on it until a person confirms the correction. That is the gate's own route for a deliberate correction, and the file is not restored. If the seat prefers, the correction can be split into its own docs-only PR instead. ## For #22220 (serial, same region) This PR does not touch `saveMetaItem`, `refusePackagedBaseOverride`, `packagedBaseRefusal` or `SysMetadataRepository.assertAllowed`. The override check keeps its position and order, where its inputs come from, and its emitters. What changes is the value of one input: `isArtifactBacked(type, name)` now answers true for a stack-declared position. So the check now fires for positions where it already fired for permission sets: in `saveMetaItem` behind `environmentId`, and in the repository's `assertAllowed` on a host-config kernel. #22220's reorder of the package door against the authoring gate will see `position` behave like `permission` on the type-level path, without the `plugin-security` authoring lock, which is registered for `permission` only. ## Acceptance notes (noted, not filed) - **Older artifacts that spell the collection `roles`: still open, measured, not fixed here.** An artifact whose protocol floor predates ADR-0090 D3 can declare positions under the older collection key. Such a position still takes the runtime-create tier at the save door: - the artifact door converts the key, but only for the metadata service copy; - the engine registers the raw manifest bytes (`registerMetadataCollections`), so no SchemaRegistry entry exists for it. Measured on a scratch `createStandaloneStack` boot. Control: the canonical key on the same boot is refused 403 `NOT_OVERRIDABLE`. The scratch file was deleted. This belongs to the #14491 / #12892 raw-copy divergence family and is handed to the seat in the report. - **Stale comment in `packages/metadata-protocol/src/protocol.ts`.** The `isNestedArtifactField` TSDoc cites the #7743 census, which lists `position` among types that "genuinely ship no artifacts at all". That is no longer true. Carrier: #22220, the next PR in that file. - **Stale text in `packages/core/src/security/security-catalog.ts`.** The module doc's measurement table is dated to `3d9188502e` and the construction `TypeError` rationale says the engine registry holds no stack-declared position. The read order and the result set are unchanged: the dogfood catalog suite stays green, and its header anticipates this exact move. Carrier: #15196 (ADR-0131 C2). - **Refusal wording for `position`.** On a host-config kernel the refusal for `position` uses the repository's generic sentence, which mentions `OS_METADATA_WRITABLE`. `position` has no ADR-0126 regime row (`permission` has one: clone). No new wording is added here. - **Not in scope, per triage.** The cold-boot ordering boundary described on the card. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c8bb3c8 commit 0b997ea

7 files changed

Lines changed: 326 additions & 18 deletions

‎.changeset/15196-core-security-catalog-read.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ feat(core): one by-name read of the security catalog (`createSecurityCatalogRead
77
Clause-②: yes (widening)
88

99
- **What is new.** `createSecurityCatalogReader({ registry, metadata })` returns a reader with two members: `resolve(type, name)`, the definition a position, permission set or capability name resolves to (or `undefined`), and `list(type)`, one entry per name. `type` is `'position' | 'permission' | 'capability'`. Each entry is `{ type, name, definition, source, packageId? }`. The types `SecurityCatalogType`, `SecurityCatalogSourceName`, `SecurityCatalogRegistry`, `SecurityCatalogMetadataService`, `SecurityCatalogSources`, `SecurityCatalogEntry` and `SecurityCatalogReader` are exported with it.
10-
- **Where it reads.** ObjectQL's `SchemaRegistry` (`engine.registry`) first, then the kernel `metadata` service for the names the registry does not hold. Neither holds the whole catalog: the engine registry carries the platform's own permission sets and every package manifest's catalog items but no stack-declared position, and the metadata service carries the stack-declared positions but not the platform's permission sets. Both are required; construction refuses a missing one.
10+
- **Where it reads.** ObjectQL's `SchemaRegistry` (`engine.registry`) first, then the kernel `metadata` service for the names the registry does not hold. Neither holds the whole catalog: the engine registry carries the platform's own permission sets and every package manifest's catalog items, stack-declared positions included, and the metadata service carries the security collections an app registers in memory but not the platform's permission sets. Both are required; construction refuses a missing one.
1111
- **A name two packages ship** resolves the way the registry's by-name read does today: a stored override first, else the first-registered package's body.
1212
- **What it does not answer.** Whether an item is in effect: the row `active` flag stays the authority, and no definition carries it. The position → permission-set binding. Organization scope: the catalog is environment-level.
1313
- **Failures are loud.** A reader that throws, or a metadata read that lost a loader and found nothing, raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) instead of answering "no such item". A definition owned by a disabled package answers neither member.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a stack-declared position is registered under its package, so the metadata save door refuses a save over it like every other non-overridable security type
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** `PUT /api/v1/meta/position/NAME` naming a position an installed package declares answered `200`, and the saved environment row then won the by-name read (`GET /api/v1/meta/position/NAME`). The type registry declares `position` `allowOrgOverride: false`, as it declares `permission` and `capability`, so the save door should refuse it.
10+
- **Why.** The save door decides "a code package ships this item" from the engine's SchemaRegistry entry that a package registered. `ObjectQL.registerApp()` puts a stack collection into that registry under its package only for the collections it enumerates. That list carried `permissions` and `capabilities`, but still carried the retired `roles` instead of `positions`. So no package-declared position had an entry, and the save took the runtime-create path.
11+
- **What changes.** `registerApp()` (and the nested-plugin seam) now registers a stack's `positions` under the owning package, with the same ADR-0010 provenance as its permission sets. A save over a package-declared position is refused `403 NOT_OVERRIDABLE` on every topology. A save that names the read-only package (`?package=`) is refused `403 ITEM_LOCKED`, which is how a save naming a read-only package is refused for any non-overridable type. The by-name read keeps serving the package's position.
12+
- **What does not change.** A position no package declares still saves (`allowRuntimeCreate`). Permission sets and capabilities are refused as before. The declared-positions seeder in `@objectstack/plugin-security` now reads the stack's positions from the engine registry rather than the metadata service, as it does for permission sets. It seeds the same names, labels and descriptions.
13+
- **To customize a packaged position,** create a position with a different name.

‎packages/objectql/src/engine-nested-plugin-collections.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ describe('the two registration seams enumerate ONE collection list (#7049)', ()
188188
const CANDIDATES = [
189189
'actions', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'themes',
190190
'flows', 'webhooks', 'jobs',
191-
'permissions', 'capabilities', 'sharingRules',
191+
'positions', 'permissions', 'capabilities', 'sharingRules',
192192
'agents', 'tools', 'skills', 'apis',
193193
'hooks', 'mappings', 'analyticsCubes', 'connectors',
194194
'emailTemplates', 'docs', 'books',
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22203] Every package-declared item of a `security`-domain type that the
5+
* type registry declares `allowOrgOverride: false` is refused at the metadata
6+
* save door — and the refusal is the type-level one, fed by the provenance
7+
* seam, not a lock written for one type.
8+
*
9+
* ## What was wrong, measured
10+
*
11+
* The save door's packaged-base check is already type-level: it refuses a write
12+
* onto an item a code package ships when the type has no overlay channel
13+
* (`refusePackagedBaseOverride` in `saveMetaItem` on an environment-scoped
14+
* kernel, `SysMetadataRepository.assertAllowed`'s `override-artifact` intent on
15+
* a host-config one), and both read `allowOrgOverride` off
16+
* `DEFAULT_METADATA_TYPE_REGISTRY`. What decides "a code package ships it" is
17+
* `isArtifactBacked`, which asks this engine's SchemaRegistry for an entry a
18+
* package registered (`getArtifactItem`, `_packageId` provenance).
19+
*
20+
* The only seam that puts a stack collection into that registry under its
21+
* package is `registerMetadataCollections` over `METADATA_ARRAY_KEYS`. That list
22+
* carried `permissions` and `capabilities`, and the retired `roles` instead of
23+
* `positions` (ADR-0090 D3 renamed the collection; the rename reached the
24+
* artifact door's map and never this one). So a stack-declared position had no
25+
* registry entry, `isArtifactBacked('position', name)` answered false, the save
26+
* took the `runtime-only` intent, and `allowRuntimeCreate: true` let it through:
27+
* `PUT /api/v1/meta/position/NAME` over a package's position answered 200 and
28+
* the saved row then won the by-name read. A permission set was refused on the
29+
* same door because its collection was on the list.
30+
*
31+
* ## What this file pins
32+
*
33+
* The set under test is ENUMERATED FROM THE REGISTRY, not written out: every
34+
* `domain: 'security'` row with `allowOrgOverride: false`. For each, a real
35+
* `ObjectQL` registers a manifest that declares one item through the type's
36+
* stack collection, and a real `ObjectStackProtocolImplementation` over that
37+
* engine is asked to save over it — on both topologies, because the two answer
38+
* at different layers. The rejection is asserted on the envelope (`code` +
39+
* `status`), and nothing may be stored.
40+
*
41+
* No security plugin is composed here, so the permission-set refusal below is
42+
* the protocol's own type-level door, not plugin-security's packaged
43+
* permission-set lock (which stays a stricter layer on top of it).
44+
*
45+
* Controls, so the refusals cannot pass for the wrong reason: a position no
46+
* package declares still saves (the `allowRuntimeCreate` tier is untouched); an
47+
* `allowOrgOverride: true` type still saves over its packaged item; and the
48+
* by-name read after a refusal still serves the package's declaration.
49+
*/
50+
51+
import { describe, expect, it } from 'vitest';
52+
import type { ServiceObject } from '@objectstack/spec/data';
53+
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
54+
import { singularToPlural } from '@objectstack/spec/shared';
55+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
56+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';
57+
import { ObjectQL } from './engine.js';
58+
59+
const PKG = 'com.acme.security_catalog';
60+
const NAME = 'probe_item';
61+
62+
/** The pin's population, read off the registry: every non-overridable `security` type. */
63+
const SECURITY_NON_OVERRIDABLE = DEFAULT_METADATA_TYPE_REGISTRY
64+
.filter((entry) => entry.domain === 'security' && entry.allowOrgOverride === false)
65+
.map((entry) => entry.type);
66+
67+
/**
68+
* A schema-valid body per type. A type that joins the population without one
69+
* fails the first case below by name, rather than dropping out of the pin.
70+
*/
71+
const BODIES: Record<string, Record<string, unknown>> = {
72+
permission: { name: NAME, label: 'Probe', objects: {} },
73+
position: { name: NAME, label: 'Probe' },
74+
capability: { name: NAME, label: 'Probe' },
75+
};
76+
77+
/** The overlayable control: `allowOrgOverride: true`, declared through a stack collection. */
78+
const OVERLAYABLE_TYPE = 'email_template';
79+
const OVERLAYABLE_BODY = { name: NAME, label: 'Probe', subject: 'Probe', bodyHtml: '<p>Probe</p>' };
80+
81+
const sysMetadataObject: ServiceObject = {
82+
name: 'sys_metadata',
83+
label: 'System Metadata',
84+
fields: {
85+
id: { name: 'id', label: 'ID', type: 'text' as const },
86+
type: { name: 'type', label: 'Type', type: 'text' as const, required: true },
87+
name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
88+
organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const },
89+
package_id: { name: 'package_id', label: 'Package', type: 'text' as const },
90+
metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const },
91+
checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 },
92+
state: { name: 'state', label: 'State', type: 'text' as const },
93+
version: { name: 'version', label: 'Version', type: 'number' as const },
94+
created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const },
95+
updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const },
96+
},
97+
};
98+
99+
/** In-memory driver, copied from `save-meta-response-conformance.test.ts`; equality-only WHERE. */
100+
function makeMemoryDriver() {
101+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
102+
const storeFor = (obj: string) => {
103+
let s = stores.get(obj);
104+
if (!s) { s = new Map(); stores.set(obj, s); }
105+
return s;
106+
};
107+
let nextId = 0;
108+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
109+
// driver ANDs them (#7620).
110+
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
111+
if (!where || typeof where !== 'object') return true;
112+
for (const [k, v] of Object.entries(where)) {
113+
if (k === '$and' && Array.isArray(v)) {
114+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
115+
continue;
116+
}
117+
if (k === '$or' && Array.isArray(v)) {
118+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
119+
continue;
120+
}
121+
if (k.startsWith('$')) continue;
122+
const rowVal = row[k];
123+
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
124+
const a = rowVal === undefined ? null : rowVal;
125+
const b = expected === undefined ? null : expected;
126+
if (a !== b) return false;
127+
}
128+
return true;
129+
};
130+
const driver: any = {
131+
name: 'memory', version: '0.0.0', supports: {} as any,
132+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
133+
async execute() { return null; },
134+
async find(object: string, ast: any) {
135+
const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where));
136+
// The caller's bound, after the filter, by presence (`check:objectql-double-limit`).
137+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
138+
},
139+
async findOne(object: string, ast: any) {
140+
for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;
141+
return null;
142+
},
143+
async create(object: string, data: Record<string, unknown>) {
144+
nextId += 1;
145+
const id = (data.id as string) ?? `r_${nextId}`;
146+
const row = { ...data, id };
147+
storeFor(object).set(id, row);
148+
return row;
149+
},
150+
async update(object: string, id: string, data: Record<string, unknown>) {
151+
const s = storeFor(object);
152+
const cur = s.get(id);
153+
if (!cur) throw new Error(`not found: ${object}/${id}`);
154+
const updated = { ...cur, ...data, id };
155+
s.set(id, updated);
156+
return updated;
157+
},
158+
async upsert(object: string, data: Record<string, unknown>) {
159+
const id = data.id as string | undefined;
160+
if (id && storeFor(object).has(id)) return this.update(object, id, data);
161+
return this.create(object, data);
162+
},
163+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
164+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
165+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
166+
return Promise.all(rows.map((r) => this.create(object, r)));
167+
},
168+
async bulkUpdate() { return []; }, async bulkDelete() {},
169+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
170+
async commit() {}, async rollback() {},
171+
};
172+
return { driver, stores };
173+
}
174+
175+
/** One manifest declaring `NAME` through every security collection under test, plus the control. */
176+
function securityManifest(): Record<string, unknown> {
177+
const manifest: Record<string, unknown> = { id: PKG, name: 'security_catalog' };
178+
for (const type of SECURITY_NON_OVERRIDABLE) {
179+
if (BODIES[type]) manifest[singularToPlural(type)] = [BODIES[type]];
180+
}
181+
manifest[singularToPlural(OVERLAYABLE_TYPE)] = [OVERLAYABLE_BODY];
182+
return manifest;
183+
}
184+
185+
type Topology = 'environment-scoped' | 'host-config';
186+
const TOPOLOGIES: readonly Topology[] = ['environment-scoped', 'host-config'];
187+
188+
async function boot(topology: Topology) {
189+
const engine = new ObjectQL();
190+
const { driver, stores } = makeMemoryDriver();
191+
engine.registerDriver(driver, true);
192+
await engine.init();
193+
engine.registry.registerObject(sysMetadataObject, 'test-package');
194+
for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
195+
engine.registry.registerObject(o as any, 'test-package');
196+
}
197+
engine.registerApp(securityManifest());
198+
const protocol = new ObjectStackProtocolImplementation(
199+
engine,
200+
() => new Map(),
201+
topology === 'environment-scoped' ? 'env_prod' : undefined,
202+
);
203+
const storedRows = () => Array.from(stores.get('sys_metadata')?.values() ?? []);
204+
return { engine, protocol, storedRows };
205+
}
206+
207+
describe('[#22203] security-domain allowOrgOverride:false — the package door is type-level', () => {
208+
it('the population is read from the registry, and every member has a fixture', () => {
209+
// The three the registry declares today. Asserted as a superset so a
210+
// fourth `security` type joins the pin instead of breaking it — and the
211+
// per-type loop below then demands a body for it by name.
212+
expect(SECURITY_NON_OVERRIDABLE).toEqual(expect.arrayContaining(['permission', 'position', 'capability']));
213+
for (const type of SECURITY_NON_OVERRIDABLE) {
214+
expect(BODIES[type], `no fixture body for security type '${type}'`).toBeDefined();
215+
}
216+
});
217+
218+
it.each(SECURITY_NON_OVERRIDABLE)('the provenance seam registers a stack-declared %s under its package', async (type) => {
219+
const { engine } = await boot('host-config');
220+
// The input `isArtifactBacked` reads: an entry a package registered.
221+
const artifact = engine.registry.getArtifactItem<any>(type, NAME);
222+
expect(artifact?._packageId).toBe(PKG);
223+
expect(artifact?._provenance).toBe('package');
224+
});
225+
226+
for (const topology of TOPOLOGIES) {
227+
it.each(SECURITY_NON_OVERRIDABLE)(`${topology}: a save over a package-declared %s is refused 403 NOT_OVERRIDABLE and stores nothing`, async (type) => {
228+
const { protocol, storedRows } = await boot(topology);
229+
230+
const err: any = await protocol
231+
.saveMetaItem({ type, name: NAME, item: { ...BODIES[type], label: 'Environment fork' } })
232+
.then(() => undefined, (e: unknown) => e);
233+
234+
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
235+
expect(storedRows()).toHaveLength(0);
236+
});
237+
}
238+
239+
it('the by-name read after the refusal still serves the package\'s position', async () => {
240+
const { protocol } = await boot('host-config');
241+
await protocol
242+
.saveMetaItem({ type: 'position', name: NAME, item: { name: NAME, label: 'Environment fork' } })
243+
.catch(() => undefined);
244+
245+
const read: any = await protocol.getMetaItem({ type: 'position', name: NAME });
246+
expect(read?.item?.label).toBe('Probe');
247+
});
248+
249+
// ── controls ─────────────────────────────────────────────────────────
250+
251+
it.each(TOPOLOGIES)('%s: a position no package declares still saves (the allowRuntimeCreate tier)', async (topology) => {
252+
const { protocol, storedRows } = await boot(topology);
253+
254+
const result: any = await protocol.saveMetaItem({
255+
type: 'position', name: 'env_only_position', item: { name: 'env_only_position', label: 'Env only' },
256+
});
257+
258+
expect(result?.success).toBe(true);
259+
expect(storedRows().map((r: any) => `${r.type}/${r.name}`)).toEqual(['position/env_only_position']);
260+
});
261+
262+
it.each(TOPOLOGIES)(`%s: an allowOrgOverride:true type (${OVERLAYABLE_TYPE}) still saves over its packaged item`, async (topology) => {
263+
const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === OVERLAYABLE_TYPE);
264+
expect(entry?.allowOrgOverride).toBe(true);
265+
const { engine, protocol, storedRows } = await boot(topology);
266+
// The control is only a control if its item is package-declared too.
267+
expect(engine.registry.getArtifactItem<any>(OVERLAYABLE_TYPE, NAME)?._packageId).toBe(PKG);
268+
269+
const result: any = await protocol.saveMetaItem({
270+
type: OVERLAYABLE_TYPE, name: NAME, item: { ...OVERLAYABLE_BODY, label: 'Environment overlay' },
271+
});
272+
273+
expect(result?.success).toBe(true);
274+
expect(storedRows().map((r: any) => `${r.type}/${r.name}`)).toEqual([`${OVERLAYABLE_TYPE}/${NAME}`]);
275+
});
276+
});

0 commit comments

Comments
 (0)