Skip to content

Commit 5b68524

Browse files
fix(metadata-protocol): an environment kernel that hydrates its own store writes a published body through to its registry, so the security catalog serves it at once (#23345)
Fixes #23298 Fixes #23233 Clause-②: no — the registry comes to agree with the stored row, its body and its package binding; no schema, export or accept set moves. ## What this changes On a host kernel, and on an environment kernel that hydrates its own `sys_metadata` into its own engine registry, a permission set published through the metadata door is in the security catalog at once, with its new body and under the package it is bound to: - a **narrowed** set resolves to its new body on its next resolution, with no rebuild (before, on an environment kernel: the boot-time, wider body until the kernel was rebuilt); - a **new** set published after boot is in the registry and the catalog at once, and an `isDefault` set records its `everyone` suggestion with no list read and no rebuild (before, on an environment kernel: in no reader at all; on a host kernel: unowned until a list read). #23298's change is `284777c4f1` (with its changeset, pin and wording commits `081af82c65`, `f8f9806db9`, `13cbde5d19`); #23233's stamp is its own commit, `1b8c66d437`; `6c48c8e116` and `dd1ff6ce1b` update three existing pins for the stamp (below). ## #23298 — the body: the write-through asks whose registry it is `metadata-protocol`'s `applyRegistryWriteThrough` returned for every type except `object` whenever the protocol had an `environmentId`. Its stated reason — a project-scoped row must not be registered into a registry that unscoped (control-plane) callers share — holds only for a shared registry. A kernel whose boot hydrates its own store (`loadMetaFromDb`) put every stored body into its registry at boot and then never updated it. - **New predicate `registryTakesStoredRows()`**: yes on an unscoped kernel (as before); yes on an environment kernel once this protocol has hydrated its own store into its registry; no on any other environment kernel. - **The ownership signal is the composition's own hydration call.** `ObjectQLPlugin.start()` calls `protocol.loadMetaFromDb()` on an environment kernel only when the composition declares `hydrateMetadataFromDb`, whose stated precondition is that the kernel's registry is per-instance isolated and its `sys_metadata` lives on its own driver. `loadMetaFromDb` records that on the instance (`storeHydratedIntoRegistry`). Both mounts (the built-in assembly and `createMetadataProtocolPlugin`) reach it, because the plugin resolves `protocol` lazily. ⛔ `environmentId` alone is not read as the answer. No runtime or composition line changes. - **The write-through** asks the predicate instead of `environmentId`. - **The delete's heal** (`restoreArtifactRegistryView`, tier 2: re-register a MetadataService baseline) asks the same predicate. Without it, a save that now registers its body, followed by a reset of an item whose baseline the MetadataService holds, left the deleted body in the registry (ablation C). - **The `object` branch is unchanged.** It never carried the kernel-scope gate (no packaged-object overlay exists at any scope; `assertObjectRegistered` fails closed); that reason is untouched. - `getMetaItems`' list-read hydration keeps its `environmentId === undefined` gate. ### Stage 8's note, now true on a hydrating environment kernel `packages/plugins/plugin-security/src/security-plugin.ts` (C3 stage 8, `66949b7729`): > [ADR-0131 D3, C3 stage 8] No `permission` mutation projector: the metadata door's own registry write-through puts a saved set in the security catalog, and no row mirrors it. That held on an unscoped kernel only. It now holds on every kernel whose registry takes its stored rows, and with the set's owner. On an environment kernel whose store is never hydrated the write-through still writes nothing (the registry may be shared); the retired projection echo is not restored. ## #23233 — the owner: the shared hydrator stamps the row's package binding `hydrateOverlayIntoRegistry` is the one hydrator the boot (`loadMetaFromDb`) and the write-through share. It registered a runtime package's body without the row's package binding, so the security catalog served the set unowned — on a host kernel until a list read stamped it, on a hydrating environment kernel indefinitely — and since PR #23311 the suggestion collector reads the owner from the catalog entry alone, so an `isDefault` set recorded no `everyone` suggestion. - The registered body now carries the row's binding as `_packageId` (`withRowBinding`), the way `getMetaItems` stamps it from the row's `package_id` before it hydrates. - ⛔ No owner is derived: the binding is only read. A row bound to no package (empty or the `sys_metadata` sentinel) stays unowned; a body that already names `_packageId` keeps it; an artifact's envelope still wins (`mergeArtifactProtection`); a view container's expansions are registered from the unstamped row, as before. ## Measured (merged head, real `ObjectQL` engine and `SchemaRegistry`, real protocol, real `saveMetaItem` publish, real `createSecurityCatalogReader`, real `syncAudienceBindingSuggestions`) | case | BEFORE | AFTER | |---|---|---| | narrowed set, hydrating environment kernel: catalog resolution after the publish | `protocol.ts` at `404fc477e5`: source `registry`, `allowDelete: true`, no `isDefault` (the boot-time body) | source `registry`, `allowDelete: false`, `isDefault: true`, equal to what a rebuild reads | | view re-published, hydrating environment kernel: `registry.getItem` | old label | new label, equal to what a rebuild reads | | new `isDefault` set, hydrating environment kernel, body without `packageId` | `protocol.ts` at `404fc477e5`: registry `false`, catalog `null`, suggestions `[]`; with #23298 only, on the merged tree (PR #23311 landed; that run's body still declared its `packageId`): registry held, catalog entry unowned, suggestions `[]` | registry held, catalog source `registry` owned by the runtime package, one `pending` `everyone` suggestion | | new `isDefault` set, host kernel, no list read | owner unread (stamp dropped, ablation D): catalog unowned, suggestions `[]` | one `pending` `everyone` suggestion; catalog and protocol reads both name the runtime package | ## Pins - `packages/objectql/src/protocol-environment-kernel-registry-write-through.test.ts` (dist-resolved protocol): narrowed set and view on a hydrating environment kernel read the new body with no rebuild, equal to a rebuild's; a reset of an item with a MetadataService baseline puts the baseline back; a shared registry never receives a project-scoped permission set or view (control: the control-plane protocol's own publish reaches it). - `packages/plugins/plugin-security/src/permission-set-env-kernel-registry-write-through.test.ts` (source-aliased protocol), every set published with no body `packageId`: - #23298: on a hydrating environment kernel, a new set is in the registry and the catalog under its package and its `isDefault` records one pending `everyone` suggestion; - #23233, host kernel, right after the publish: exactly one `everyone` suggestion; the catalog read and the protocol read name the same owner; - controls: a code package's default set is unchanged (its artifact, its owner, its suggestion) on both kernels; a set saved into no package stays unowned with no suggestion. ## Existing pins the owner stamp changed (the CI reds on `1b8c66d437`) - `Test Core (6/6)`, `metadata-protocol` — `protocol.register-item-call-sites.test.ts`, "the calls in the source are exactly the recorded ones" and "each registration that skips a name another package ships asks anotherPackageShips": first error "a registration with no recorded disposition". Cause: the ledger keys each `registerItem(` call by its source text, and the hydrator's call now passes `withRowBinding(data, options.packageId)`. Fix: the recorded call text is updated; its disposition (a view row bound to a package skips a name another package ships) is unchanged, and the `path` text names the write-through's new kernels. - `Test Core (5/6)`, `objectql` — `protocol-boot-hydration-scoped.test.ts`, "grafts NO artifact envelope when artifacts have not loaded yet": first error "expected 'com.acme.b' to be undefined". Cause: the pin asserted `_packageId` absent because only an artifact graft could set it. Fix: it asserts the row's own binding; `_lock` absent and `_provenance: 'org'` still prove no graft. - `objectql` — `protocol-derived-provenance-doors.test.ts`, "with NO artifact present the restatement stands and nothing is invented": first error "expected 'app.sdbh' to be undefined". Same cause; it asserts the row's binding and adds `_packageVersion` absent. Both `objectql` pins encoded the missing-owner shape, and ablation D turns both red. No test was skipped, deleted or loosened. ## Ablations (`scripts/ablation-replace.mjs`; every restore proven: blob equal to HEAD, `git diff HEAD` empty, whole tree clean) `objectql` reads `@objectstack/metadata-protocol` from `dist/`, so each leg rebuilt it and `scripts/ablation-dist-preflight.mjs` proved the marker in or out of the artifact; `plugin-security` aliases it to source. - **A — the bare `environmentId` gate restored** (`1b8c66d437`): narrowed set, view and reset pins red; the environment-kernel new-set pin red; the shared-registry pins, the host-kernel #23233 pins and the controls green. - **B — the shared-registry guard removed** (`1b8c66d437`): only the shared-registry pin red (the project-scoped set reached the shared registry). - **C — the heal's re-registration back to control-plane-only** (`1b8c66d437`): only the reset pin red (the deleted body stayed registered). - **D — the stamp dropped** (`1b8c66d437`, again at `dd1ff6ce1b`): the environment-kernel new-set pin, both host-kernel #23233 pins, and (at `dd1ff6ce1b`) the two updated `objectql` pins red; the code-package and unowned controls and every #23298 `objectql` pin green. - **Restore** (`dd1ff6ce1b`): the stamp call present in `dist/`, the A, B and C markers absent, tree clean; `objectql` 35 of 35, `plugin-security` 6 of 6. ## Tests (final head `dd1ff6ce1b`; `main` merged at `abf96ffac8`) - `metadata-protocol` full: 235 files passed, 3 skipped; 28353 tests passed. - `objectql` full in three chunks: 140 files / 3045 tests at `1b8c66d437` (no source or file in that chunk changed after), 139 / 2186 and 136 / 2840 at `dd1ff6ce1b`. - `plugin-security` full: 190 files, 3872 passed, 45 skipped. - `runtime` env_local and registry-reading suites (13 files, 340 tests) and `service-automation` `flow-name-shadowing` (14 tests). - `typecheck`: `metadata-protocol`, `objectql` (test layer held at its ledger), `plugin-security` (test layer 0 errors). - `dispatch-gates --ran` at `dd1ff6ce1b`: 71 derived, 71 run, every recorded exit 0 (`check:dual-build-cjs-loads` and `check:i18n` first refused on an unbuilt tree, exit 3, and passed after a full build). - Lint at `dd1ff6ce1b`, a measured narrowing: the six changed source files are in the `eslint.config.mjs` population; `--format json` reports 6 files, 0 errors, 0 warnings; the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move an untouched file's verdict. The repo-wide `pnpm lint` is CI's. - The `cli` integration layer and the dogfood battery are declared to CI. ## Docs drift check `content/docs/kernel/cluster.mdx` (lines 340–360 and 420–460, which name `applyRegistryWriteThrough` at 352, 433 and 440) and `content/docs/concepts/metadata-lifecycle.mdx` (lines 40–64, which name `ObjectStackProtocolImplementation` at 47, the write path at 70–100, and the peer note at 205–225) were read on the merged head. Neither states the old rule: neither says the write-through is skipped on environment kernels, nor that a saved permission set reaches the registry only at boot or on a list read. No docs edit. ## Acceptance notes - **Every type, not only `permission`.** On a hydrating environment kernel every non-`object` type now writes through, as on an unscoped kernel and as the boot already registers it; measured for a permission set and a view. A view container's republish reconciles its expansions there by the same code path (not separately measured on an environment kernel). - **Every hydrated row now carries its binding** as `_packageId` on the boot and the write-through, as a list read already did on a host kernel; registry readers that filter by package see the row in its package from the first registration. - **Relation to #23334 (#23308).** Its metadata-service echo covers environment kernels that do not hydrate and registers only names the engine registry does not serve; the two touch no common file, and the later lander re-runs the other's pins. - The new `plugin-security` test file is outside `metadata-protocol`; the suggestion collector it drives lives there. --- _Generated by [Claude Code](https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 707b3cd commit 5b68524

7 files changed

Lines changed: 814 additions & 36 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): an environment kernel that hydrates its own `sys_metadata` writes a published body through to its engine registry, so a narrowed permission set stops granting its old body without a restart
6+
7+
Clause-②: no
8+
9+
- **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.
10+
- **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.
12+
- **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.register-item-call-sites.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,11 @@ const SITES: ReadonlyArray<Site & {
9595
+ 'and a row bound to another package is refused (D9.9, OBJECT_OVERLAY_PACKAGE_MISMATCH)',
9696
},
9797
{
98-
member: 'hydrateOverlayIntoRegistry', args: 'type, mergeArtifactProtection(type, stateTenantAuthorship(data), envelope), \'name\' as any',
98+
// [#23233] The body carries the row's package binding (`withRowBinding`); the disposition is unchanged.
99+
member: 'hydrateOverlayIntoRegistry', args: 'type, mergeArtifactProtection(type, stateTenantAuthorship(withRowBinding(data, options.packageId)), envelope), \'name\' as any',
99100
disposition: 'yes, view only: skips a name another package ships',
100-
path: 'the list\'s hydration and the write-through on an unscoped kernel; the boot\'s loadMetaFromDb on every kernel',
101+
// [#23298] The write-through runs wherever the registry takes the kernel's stored rows.
102+
path: 'the list\'s hydration on an unscoped kernel; the write-through on an unscoped kernel and on an environment kernel that hydrated its own store; the boot\'s loadMetaFromDb on every kernel',
101103
why: 'a stored row: a view row bound to a package is not registered where another package ships its name, and the '
102104
+ 'reads answer it from its row; a package-less row, and every other type, register as before (#4624)',
103105
viewOnlyBy: 'const bound = canonicalType === \'view\' ? boundPackageOf(options.packageId) : undefined;',

0 commit comments

Comments
 (0)