Skip to content

Commit 1b8c66d

Browse files
committed
fix(metadata-protocol): the hydrator the boot and the write-through share stamps the row's package binding as _packageId
A runtime package's isDefault set published through the metadata door is owned in the security catalog from the publish onward and records its everyone suggestion, on a host kernel and on a hydrating environment kernel. The binding is read, never derived; an unbound row stays unowned. Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo Co-authored-by: Claude <noreply@anthropic.com>
1 parent abf96ff commit 1b8c66d

3 files changed

Lines changed: 156 additions & 50 deletions

File tree

‎.changeset/23298-env-kernel-registry-write-through.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ Clause-②: no
88

99
- **What was wrong.** The protocol's registry write-through skipped every type except `object` whenever the protocol had an `environmentId`. A kernel that hydrates its own store at boot (the standalone stack's `env_local`, or a hosted kernel with `hydrateMetadataFromDb`) put every stored body into its registry at boot and then never updated it. A save was accepted and served by `GET /api/v1/meta/:type/:name`, but the engine registry kept the boot-time body until a restart, and held nothing for an item created after boot. The security catalog reads that registry first, so a permission set narrowed through `PUT /api/v1/meta/permission/:name` kept resolving its earlier, wider body, and a permission set published after boot was not in the catalog at all: a user granted it was refused, and an `isDefault` set recorded no `everyone` suggestion.
1010
- **What changed.** The write-through now asks whose registry it is, not whether the kernel has an environment. It registers the published body wherever the registry takes this kernel's stored rows: on an unscoped kernel, as before, and on an environment kernel once the boot has hydrated that kernel's own store into it (`loadMetaFromDb`). The delete's heal re-registers a MetadataService baseline on the same kernels, so a save followed by a reset does not leave the deleted body in the registry.
11+
- **The owner.** The registry entry of a stored row now carries the row's package binding as `_packageId` on the boot and on the write-through, as a list read already stamped it, so a runtime package's `isDefault` set published through the metadata door is owned in the security catalog at once and records its `everyone` suggestion, on a host kernel and on a hydrating environment kernel alike; a row bound to no package stays unowned, and an artifact's own envelope still wins.
1112
- **Unchanged.** An environment kernel whose store is never hydrated into its registry still writes nothing into it, so a registry shared with unscoped callers never receives a project-scoped row. The `object` branch and the organization-scope refusal are unchanged. What the registry holds after a save is what it holds after a restart.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2402,6 +2402,20 @@ function stateTenantAuthorship(data: unknown): unknown {
24022402
return { ...(data as Record<string, unknown>), _provenance: 'org' };
24032403
}
24042404

2405+
/**
2406+
* [#23233] A stored row's body with its package binding stated as `_packageId`,
2407+
* the way `getMetaItems` stamps a row from its `package_id` column. The binding
2408+
* is read, never derived: a row bound to no package ({@link boundPackageOf}),
2409+
* or a body that already names a `_packageId`, is returned as it is. A copy,
2410+
* so the caller's body is never written.
2411+
*/
2412+
function withRowBinding(data: unknown, packageId: unknown): unknown {
2413+
const binding = boundPackageOf(packageId);
2414+
if (binding === undefined || !data || typeof data !== 'object' || Array.isArray(data)) return data;
2415+
if ((data as { _packageId?: unknown })._packageId !== undefined) return data;
2416+
return { ...(data as Record<string, unknown>), _packageId: binding };
2417+
}
2418+
24052419
/**
24062420
* [#21639, #21638] Is this artifact a view ITEM — a view a package ships under
24072421
* its own name, `viewKind` set (the spec's ViewItem) — rather than a container
@@ -19250,6 +19264,24 @@ export class ObjectStackProtocolImplementation implements
1925019264
* package-less view row, and a view row of a name only its own package
1925119265
* ships or no package ships, register as before too.
1925219266
*
19267+
* ## [#23233] The entry carries the row's package binding
19268+
*
19269+
* The registered body is stamped with the row's package binding as
19270+
* `_packageId` ({@link withRowBinding}), as `getMetaItems` stamps it from
19271+
* the row's `package_id` before it hydrates. This hydrator is the one the
19272+
* boot and the write-through share, so every registration of a stored row
19273+
* now names the package the row is bound to, not only the one a list read
19274+
* makes. Before this, the write-through and the boot registered a runtime
19275+
* package's body with no `_packageId`, and every registry reader that takes
19276+
* the owner from it (the security catalog, and through it the `everyone`
19277+
* suggestion of an `isDefault` set) saw the item as unowned: on a host
19278+
* kernel until the next list read, and on an environment kernel that
19279+
* hydrates its own store until never. ⛔ No owner is derived: the binding is
19280+
* only read, a body that already names a `_packageId` keeps it, a row with
19281+
* no binding stays unowned, and an artifact's envelope still wins over the
19282+
* stamp ({@link mergeArtifactProtection}). The expansions of a view
19283+
* container are registered from the unstamped row, as before.
19284+
*
1925319285
* ## [#9111] `type` is an ASSERTED input, not a silently-trusted one
1925419286
*
1925519287
* The spelling handed in is the spelling the registry entry is minted
@@ -19397,7 +19429,11 @@ export class ObjectStackProtocolImplementation implements
1939719429
// and its receipt rendered both as the package. Registered as the
1939819430
// tenant row it is, it is reported as one and never armed.
1939919431
const envelope = canonicalType === 'flow' ? undefined : artifact;
19400-
registry.registerItem(type, mergeArtifactProtection(type, stateTenantAuthorship(data), envelope), 'name' as any);
19432+
registry.registerItem(
19433+
type,
19434+
mergeArtifactProtection(type, stateTenantAuthorship(withRowBinding(data, options.packageId)), envelope),
19435+
'name' as any,
19436+
);
1940119437
await this.hydrateExpandedViewItems(type, data, options);
1940219438
return true;
1940319439
}

‎packages/plugins/plugin-security/src/permission-set-env-kernel-registry-write-through.test.ts‎

Lines changed: 118 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,37 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* #23298 — on an environment kernel that hydrates its own `sys_metadata`, a
5-
* permission set published through the metadata door is in the security
6-
* catalog at once: the engine registry holds it, the catalog resolves it, and
7-
* an `isDefault` set declared by its package records its `everyone`
8-
* suggestion. No list read and no rebuild happen in between.
4+
* #23298 and #23233 — a permission set published through the metadata door is
5+
* in the security catalog at once, under the package it is bound to, on a host
6+
* kernel and on an environment kernel that hydrates its own `sys_metadata`. The
7+
* engine registry holds it, the catalog resolves it with its owner, and an
8+
* `isDefault` set records its one `everyone` suggestion. No list read and no
9+
* rebuild happen in between.
910
*
1011
* ## Why this lives here
1112
*
1213
* The suggestion collector (`syncAudienceBindingSuggestions`) is this
13-
* package's. The write that feeds it is `metadata-protocol`'s registry
14-
* write-through (aliased to source in `vitest.config.ts`), so the suite runs
15-
* the protocol in this checkout. The engine is the real `ObjectQL` (aliased to
16-
* source too) with its real `SchemaRegistry`, over an in-memory driver.
14+
* package's. The writes that feed it are `metadata-protocol`'s (aliased to
15+
* source in `vitest.config.ts`), so the suite runs the protocol in this
16+
* checkout. The engine is the real `ObjectQL` (aliased to source too) with its
17+
* real `SchemaRegistry`, over an in-memory driver.
1718
*
18-
* ## The topology
19+
* ## The two halves of the write
1920
*
20-
* An ENVIRONMENT kernel (`environmentId` set) booted as `ObjectQLPlugin.start()`
21-
* boots one with `hydrateMetadataFromDb`: the protocol's own store hydrated
22-
* into the engine's own registry (`loadMetaFromDb`). The catalog reads that
23-
* registry first, and the door's own registry write-through is what puts a
24-
* saved set there. Before #23298 that write-through skipped every type but
25-
* `object` on any kernel with an `environmentId`; with the plugin's
26-
* permission-set echo no longer registered, a set published after boot was
27-
* then in no reader at all.
28-
*
29-
* The set's body declares the spec's `packageId` (ADR-0086 D3), as the input
30-
* that was measured does. A set whose body names no package is served by the
31-
* catalog without an owner, because the registry entry carries no
32-
* `_packageId`: that is the open host-kernel remainder of the permission-set
33-
* owner card, which this suite neither pins nor changes.
21+
* - **#23298, the body.** The registry write-through skipped every type but
22+
* `object` on any kernel with an `environmentId`, so on an environment
23+
* kernel a set published after boot was in no reader at all (the plugin's
24+
* permission-set echo is no longer registered). It now registers wherever
25+
* the registry takes the kernel's stored rows: an unscoped kernel, or an
26+
* environment kernel that hydrated its own store (`loadMetaFromDb`, which
27+
* `ObjectQLPlugin.start()` calls under `hydrateMetadataFromDb`).
28+
* - **#23233, the owner.** The hydrator the boot and the write-through share
29+
* registered the body without the row's package binding, so the catalog
30+
* served a runtime package's set unowned (on a host kernel until a list
31+
* read stamped it), and the suggestion collector, which reads the owner
32+
* from the catalog entry alone, skipped it. The hydrator now stamps the
33+
* binding as `_packageId`. The bodies below name no `packageId` of their
34+
* own: the owner comes from the binding only.
3435
*/
3536

3637
import { randomUUID } from 'node:crypto';
@@ -137,12 +138,23 @@ function makeMetadataService() {
137138
return { get: (_type: string, _name: string) => undefined, list: (_type: string) => [] };
138139
}
139140

141+
/** An installed code package that ships its own default set. */
142+
const CODE_PKG = 'com.example.crm';
143+
const CODE_SET = 'crm_readonly';
144+
145+
type Kernel = 'host' | 'environment';
146+
140147
/**
141-
* One environment kernel: the engine with the tables this flow writes, the
142-
* protocol's own store hydrated into the engine's own registry, the catalog
143-
* bound to the engine, and the built-in `everyone` position declared.
148+
* One kernel: the engine with the tables this flow writes and a code package
149+
* installed, the protocol (an `environmentId` for an environment kernel),
150+
* booted as `ObjectQLPlugin.start()` boots it (`loadMetaFromDb`: a host
151+
* kernel always, an environment kernel under `hydrateMetadataFromDb`), the
152+
* catalog bound to the engine, and the built-in `everyone` position declared.
144153
*/
145-
async function bootEnvironmentKernel(stores: Stores) {
154+
async function boot(kind: Kernel) {
155+
const stores: Stores = new Map([
156+
['sys_organization', new Map([[DEFAULT_ORG, { id: DEFAULT_ORG, slug: 'default', name: 'Default' }]])],
157+
]);
146158
const engine = new ObjectQL();
147159
engine.registerDriver(makeMemoryDriver(stores), true);
148160
await engine.init();
@@ -152,32 +164,40 @@ async function bootEnvironmentKernel(stores: Stores) {
152164
]) {
153165
engine.registry.registerObject(o as any, 'test-package');
154166
}
167+
engine.registerApp({
168+
id: CODE_PKG,
169+
name: 'example_crm',
170+
version: '1.0.0',
171+
permissions: [{ name: CODE_SET, label: 'CRM read-only', isDefault: true, objects: {} }],
172+
});
155173
const metadata = makeMetadataService();
156174
const protocol = new ObjectStackProtocolImplementation(
157175
engine as never,
158176
() => new Map<string, unknown>([['metadata', metadata]]),
159-
ENV,
177+
kind === 'environment' ? ENV : undefined,
160178
);
161-
// `ObjectQLPlugin.start()` on a kernel with `hydrateMetadataFromDb`.
162179
expect(await protocol.loadMetaFromDb()).toMatchObject({ errors: 0, storeUnavailable: false });
163180
const catalog = createSecurityCatalogReader({ registry: engine.registry as never, metadata });
164181
bindSecurityCatalogReader(engine, catalog);
165182
declareEveryoneBaseline(engine.registry, SECURITY_PACKAGE, ['member_default']);
166-
return { engine, protocol, catalog };
183+
return { stores, engine, protocol, catalog };
167184
}
168185

169186
const suggestionRows = (stores: Stores, set: string) =>
170187
Array.from(stores.get('sys_audience_binding_suggestion')?.values() ?? [])
171188
.filter((row) => row.permission_set_name === set);
172189

173-
/** A new default set, its body declaring the package it is published into. */
174-
const newDefaultSet = (): Row => ({
175-
name: SET,
176-
label: 'Workspace member',
177-
isDefault: true,
178-
packageId: RUNTIME_PKG,
179-
objects: {},
180-
});
190+
/** A runtime package's new default set. Its body names no `packageId`: the owner is the row's binding. */
191+
const newDefaultSet = (): Row => ({ name: SET, label: 'Workspace member', isDefault: true, objects: {} });
192+
193+
/** The metadata-door publish of the set, bound to the runtime package. */
194+
async function publishIntoRuntimePackage(protocol: ObjectStackProtocolImplementation) {
195+
const body = newDefaultSet();
196+
expect(body, 'precondition: the body names no package').not.toHaveProperty('packageId');
197+
expect(await protocol.saveMetaItem({
198+
type: 'permission', name: SET, item: body, packageId: RUNTIME_PKG, mode: 'publish',
199+
})).toMatchObject({ success: true });
200+
}
181201

182202
const savedWritable = process.env.OS_METADATA_WRITABLE;
183203
beforeEach(() => {
@@ -193,22 +213,16 @@ afterEach(() => {
193213
});
194214

195215
describe('#23298 — a NEW permission set published on an environment kernel is in the security catalog at once', () => {
196-
it('the registry holds it, the catalog resolves it, and its isDefault records one pending everyone suggestion', async () => {
197-
const stores: Stores = new Map([
198-
['sys_organization', new Map([[DEFAULT_ORG, { id: DEFAULT_ORG, slug: 'default', name: 'Default' }]])],
199-
]);
200-
const { engine, protocol, catalog } = await bootEnvironmentKernel(stores);
216+
it('the registry holds it, the catalog resolves it under its package, and its isDefault records one pending everyone suggestion', async () => {
217+
const { stores, engine, protocol, catalog } = await boot('environment');
201218
expect(await catalog.resolve('permission', SET), 'precondition: a new set').toBeUndefined();
202219

203-
expect(await protocol.saveMetaItem({
204-
type: 'permission', name: SET, item: newDefaultSet(), packageId: RUNTIME_PKG, mode: 'publish',
205-
})).toMatchObject({ success: true });
220+
await publishIntoRuntimePackage(protocol);
206221

207222
// No list read, no rebuild: straight to the readers.
208223
expect(engine.registry.getItem('permission', SET)).toMatchObject({ name: SET, isDefault: true });
209224
expect(await catalog.resolve('permission', SET)).toMatchObject({
210-
source: 'registry',
211-
definition: { name: SET, isDefault: true, packageId: RUNTIME_PKG },
225+
source: 'registry', packageId: RUNTIME_PKG, definition: { name: SET, isDefault: true },
212226
});
213227

214228
await syncAudienceBindingSuggestions(engine);
@@ -219,3 +233,58 @@ describe('#23298 — a NEW permission set published on an environment kernel is
219233
]);
220234
});
221235
});
236+
237+
describe('#23233 — on a host kernel, the set is owned from the publish onward, with no list read in between', () => {
238+
it('records exactly one pending everyone suggestion, under the package the set is bound to', async () => {
239+
const { stores, engine, protocol } = await boot('host');
240+
await publishIntoRuntimePackage(protocol);
241+
242+
await syncAudienceBindingSuggestions(engine);
243+
expect(suggestionRows(stores, SET)).toEqual([
244+
expect.objectContaining({
245+
package_id: RUNTIME_PKG, anchor: 'everyone', status: 'pending', deployment_decision: false,
246+
}),
247+
]);
248+
});
249+
250+
it('the catalog read and the protocol read name the same owner', async () => {
251+
const { protocol, catalog } = await boot('host');
252+
await publishIntoRuntimePackage(protocol);
253+
254+
// The catalog first: the protocol's by-name read is not a list read, but
255+
// it is read second so nothing it does can stand in for the publish.
256+
const resolved = await catalog.resolve('permission', SET);
257+
const served: any = await protocol.getMetaItem({ type: 'permission', name: SET });
258+
259+
expect(resolved).toMatchObject({ source: 'registry', packageId: RUNTIME_PKG });
260+
expect(served?.item?._packageId).toBe(RUNTIME_PKG);
261+
});
262+
});
263+
264+
describe('#23233 — controls: the owner is the binding, never invented', () => {
265+
it.each(['host', 'environment'] as const)(
266+
'a code package\'s default set is unchanged (%s kernel): its artifact, its owner and its suggestion',
267+
async (kind) => {
268+
const { stores, engine, catalog } = await boot(kind);
269+
270+
expect(await catalog.resolve('permission', CODE_SET)).toMatchObject({ source: 'registry', packageId: CODE_PKG });
271+
await syncAudienceBindingSuggestions(engine);
272+
expect(suggestionRows(stores, CODE_SET)).toEqual([
273+
expect.objectContaining({ package_id: CODE_PKG, anchor: 'everyone', status: 'pending' }),
274+
]);
275+
},
276+
);
277+
278+
it('a set saved into no package stays unowned and records no suggestion', async () => {
279+
const { stores, engine, protocol, catalog } = await boot('host');
280+
expect(await protocol.saveMetaItem({
281+
type: 'permission', name: 'org_member_default', item: { ...newDefaultSet(), name: 'org_member_default' },
282+
})).toMatchObject({ success: true });
283+
284+
const resolved = await catalog.resolve('permission', 'org_member_default');
285+
expect(resolved).toMatchObject({ source: 'registry' });
286+
expect(resolved).not.toHaveProperty('packageId');
287+
await syncAudienceBindingSuggestions(engine);
288+
expect(suggestionRows(stores, 'org_member_default')).toEqual([]);
289+
});
290+
});

0 commit comments

Comments
 (0)