Skip to content

Commit 250dec8

Browse files
feat(metadata-protocol): a stored page naming an absent plugin is reported at load, and draft promotion re-stamps requires (#21121)
Fixes #20870 Clause-②: no ## Summary Stage ③, engine half, of #20312 (ruling `5881821895`, letter A, staged ①②③; ruling `5902378057`, A + E). Stages ① and ② landed as #20852 (`b531c7bf0`). The spec half (liveness row, describe, docs) is #20871 and is not in this PR. - **At load, report.** `loadMetaFromDb`, the boot hydration of stored `sys_metadata` rows, now prints one `warn` line for each stored page whose `requires` names a namespace that no component in the deployment's SDUI manifest carries. The line names the page and every such namespace, under the marker `[page_requires_plugin_absent]`. The report runs only after the page has loaded, and it changes nothing about the page: the page is registered and served. The manifest is read through `resolveSduiManifest()`, the same `SDUI_MANIFEST_SERVICE` read the save door makes. The namespace set comes from one helper, `manifestNamespaces()` in `runtime-authoring-gate.ts`, which the save door's `requires` judgement now uses too. There is no second manifest reader. - **Draft to active promotion re-stamps `requires`.** `promoteDraftForPublish` serves both `publishMetaItem` (`POST /meta/:type/:name/publish`) and `publishPackageDrafts`. It now passes `SysMetadataRepository.promoteDraft` a new optional `deriveActiveBody`. That callback applies the save door's own `stampHtmlPageRequires` to the draft row being promoted, against the manifest read at publish time. The active row therefore stores what an active save of the same body would store. - **Measure first (the Studio):** see the readings below. The Studio does not compute `requires` on the client. It does send back the stored `requires` it last read, and that sent-back stamp is what goes stale. Example line (from the built `dist`, on this branch): ```text [Protocol] [page_requires_plugin_absent] stored page/landing requires 'plugin-kanban', a namespace no component in this deployment's SDUI component manifest carries: the plugin that provides it is not loaded in the console this deployment serves. The page is loaded and served anyway, and what its source draws from that plugin will not render. Install it in that console, or take its components out of the page's source and save the page without `requires` — it is derived from the source at save. ``` ## Measurements (taken before the code) **H1 — the load path and its ordering.** - A stored page reaches the runtime in one place: `loadMetaFromDb` (env-wide `active` rows), called from `ObjectQLPlugin.start()` → `restoreMetadataFromDb`. - `page` is `allowOrgOverride: false` (`packages/spec/src/kernel/metadata-plugin.zod.ts:856`), so no org-scoped page row is minted. Legacy org-scoped residue is already reported by `reportUnhydratableOrgScopedRows`. - Package install (`installPackage`) records the package's own manifest in the registry and writes no page row. Code-package pages are not stored rows, and `os build` stamps no `requires` on them. - `os serve` registers the manifest before the first `kernel.use(...)` (`packages/cli/src/commands/serve.ts`, the block above "Load plugins from configuration"). `os dev` compiles and spawns `serve`. Hydration runs in a `start()`, after every `init()`. So the manifest is readable when pages load. - **Hook chosen: the hydration itself (`loadMetaFromDb`), not `kernel:ready`.** It is literally the moment stored pages load. It inherits hydration's own topology gating, so a project kernel that skips hydration also skips the report, and it needs no second `sys_metadata` read. The conclusion it records is drawn from a registered manifest's contents, never from "nothing is registered". So the `check:startup-registry-verdict` shape does not apply. It passed in the gate run below. - **Residual:** a host that registers the manifest after `start()` gets no load report for that boot. No in-repo host does this. cloud#2482 should register it the way `os serve` does. **H2 — no manifest.** The save door judges nothing without a manifest, and the host says so at boot. The load report takes the same posture: `findPageRequiresAbsentFromManifest` answers `null` and nothing is printed. The `[page_requires_plugin_absent]` line can appear only when a usable manifest is registered. An unusable registered value gets the protocol's existing single warning and is not read. **H3 — what promotion did before this PR.** The new pins were run against unmodified `origin/main` `fed0db8f6`. Result: 4 red, 3 green (the 3 green are controls). - A draft saved before a manifest existed was promoted with **no** `requires` (`expected undefined to deeply equal [ 'ui', 'plugin-kanban' ]`), on both `publishMetaItem` and `publishPackageDrafts`. - An agreeing draft list was **carried forward as spelled** (`[ 'plugin-kanban', 'ui', 'ui' ]`). - A **disagreeing** list was already refused: the gate runs on the draft body before `promoteDraft` (existing pin "a draft is not gated but its publish is"). So "re-stamps" here means the promoted body carries the recomputed list where it used to carry the draft's. Nothing new is refused, which is why the line stays `Clause-②: no`. **H4 — does the Studio send a client-computed `requires`?** Read from objectui at the console pin `e420df310f5b` (`.objectui-sha`): - **No client computation.** The only client `compile` call on a page is the render path, `packages/components/src/renderers/layout/page.tsx:606`. It destructures `tree` and `diagnostics` and drops `requires`. No non-test source in `packages/app-shell`, `packages/components`, `packages/react` or `apps/console` assigns a page's `requires`; a word-bounded grep finds only an action-preview i18n key. - **But the stored stamp is sent back.** `SourcePageEditor.tsx:149` patches only `{ source }` into the draft. `ResourceEditPage.tsx:1033-1038` seeds that draft from the served document. `ResourceEditPage.tsx:1443` and `:1477-1481` send the whole draft back with `mode: 'draft'`. No per-type `fromDraft` exists for `page`. - **What that costs, measured on this branch** (a one-off probe, file restored to its HEAD blob afterwards): save a page whose components are all in the `ui` namespace (stamped `['ui']`) and publish it. Then add a kanban component to its source and send the whole document back. The draft keeps `['ui']`, and the publish answers `422 INVALID_METADATA`, `page-requires-disagrees-with-source: 'plugin-kanban' is used by the source but not listed`. The author never wrote `requires`. This PR does not change that refusal (stage ②'s accept set); it is reported to the PM as a finding and an open question. ## Pins `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, block `stored html page requires at load and at draft promotion (#20312)`, 7 cases: - at load, a stored page naming a plugin the manifest does not carry is reported (page and plugin named, the present `ui` not named) and still loads (`loaded: 1`, registered); - at load, all plugins present: no report (the control); - at load, no manifest: no report (H2); - a draft saved before the manifest arrived is promoted with exactly `stampHtmlPageRequires(...)`'s `requires`; - an agreeing draft list is promoted as the save door spells it; - the `publishPackageDrafts` batch re-stamps too; - a promotion with no manifest stores the draft as written. **Ablations** (one-shot, through `scripts/ablation-replace.mjs`; each restore verified as blob equal to HEAD with `git diff HEAD` empty): - The load-report call disabled (`... && false`): 1 of 7 red, the reporting case. - `deriveActiveBody` ignored (`const activeBody = draft.body;`): 3 of 7 red, the single, agreeing and batch promotion cases. ## Verification At `c7b8b6399` (the head this PR opens on, after merging `origin/main`): - `@objectstack/metadata-protocol` full suite: 196 files passed / 3 skipped, 2938 tests passed / 19 skipped; `typecheck` exit 0 (`--listFiles` includes the gate test file). - Downstream consumers of `@objectstack/metadata-protocol` (the `...@objectstack/metadata-protocol` direction), the files that drive `promoteDraft` and boot hydration, at the first merge `7402cf72a`: objectql (`--project local`) 6 files / 174 tests, rest 2 / 27, runtime 1 / 5, all green. - `node scripts/pm/dispatch-gates.mjs --commands` (no paths, from the merge base): 62 families. All 62 exit 0 at `c7b8b6399`. The three that read every package's `dist/` (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`) ran after a full `turbo run build` of `./packages/*` on that head; before that build they answered `PREREQUISITE NOT MET` (exit 3), which is recorded here as a non-measurement, not a pass. `--ran` reconciliation: 62 run, 0 NOT MEASURED, every line recorded with its exit code. - Driver conformance ledger: 50 / 0 / 0 before and after. - Lint, narrowed and declared. The population is eslint's own: `eslint.config.mjs` lints `packages/**` TS outside `packages/spec`. `--format json` counted 4 files (the four touched sources): 0 errors, 0 warnings. The config never enables type-aware linting (`eslint.config.mjs` notes no `parserOptions.project` and no typed rules), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. ## Declared deviation from the claimed file surface The claim names the pins beside `protocol.runtime-authoring-gate.test.ts`, `protocol.ts`'s publish path, `runtime-authoring-gate.ts` and the changeset. This PR also edits `packages/metadata-protocol/src/sys-metadata-repository.ts`. The draft-to-active write happens inside `SysMetadataRepository.promoteDraft`, which reads the draft row itself. The new optional `deriveActiveBody` applies the stamp to that same row, so the derivation and the write read one row, not two. The option is additive: omitted, the draft body is promoted byte for byte. It is named in the changeset, which bumps `@objectstack/metadata-protocol` `minor`. No other lane is touched: the load path is in the metadata layer, not `packages/runtime` or `packages/cli`. ## Acceptance notes - **The Studio's sent-back `requires` (H4).** This is the reason a stale stamp exists at all in Studio editing. Proposed fix: objectui's page editor omits `requires` from the body it saves, as the save door's own hint says ("omit it"). Handed to the PM as a finding with the evidence above. Not fixed here. - The host's no-manifest boot line (`packages/cli/src/utils/sdui-manifest.ts:270`) still says "not validated at save". It is still true. With this PR the load report is skipped on that host as well, so the line could say "at save or load". Not edited here: cross-lane, pinned in the CLI's tests, and not false. - On the `package-author` authoring channel (the control-plane assembly; gate off), a disagreeing draft `requires` is promoted as written. That matches what the save door stores on that channel. - `POST /packages/:id/publish` (`MetadataManager.publishPackage`) is the loader-plane snapshot publish over the in-memory registry. It is not a `sys_metadata` draft promotion and never ran the save door, so it is untouched. - The load report is printed once per boot per affected page. It is not deduplicated across boots, so each boot restates it until the page or the console changes. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 840ec9d commit 250dec8

5 files changed

Lines changed: 332 additions & 9 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
A stored page whose `requires` names a plugin the deployment's console does not load is reported when the page loads, and a draft → active promotion re-stamps an html page's `requires` with the save door's own computation (ADR-0080 §5: `requires` is validated at save and load, and derived from the source).
6+
7+
Clause-②: no
8+
9+
**At load.** The boot hydration of stored metadata (`loadMetaFromDb`) prints one `warn` line for each stored page whose `requires` lists a namespace no component in the deployment's SDUI component manifest carries, naming the page and every such namespace under the marker `[page_requires_plugin_absent]`. It is a report, never a refusal: the page has already loaded when the line is printed, and it is served. The manifest is read through the same `SDUI_MANIFEST_SERVICE` key the save door reads; `os serve` registers it before any plugin initialises, so it is there when stored pages load. A host that registers no manifest judges no page at load, exactly as it judges none at save.
10+
11+
**At promotion.** `publishMetaItem` and `publishPackageDrafts` now store the promoted body with the `requires` that an active save of the same body would store, computed against the manifest registered at the moment of the publish. Before, the draft's `requires` was carried into the active row as it was. A draft saved before the host had a manifest reached `active` with no `requires`, and an agreeing list kept the draft's own spelling (its order and any repeats). Nothing is newly refused: wherever the runtime authoring gate runs, it already refused a draft whose source does not compile, or whose `requires` disagrees with its source, and it still does. A host with no manifest promotes the draft as written.
12+
13+
`SysMetadataRepository.promoteDraft` takes an optional `deriveActiveBody(draftBody)` that derives the active row's body from the draft row it promotes. When it is omitted, the draft body is promoted unchanged, as before.

‎packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { validateSemanticRoles } from '@objectstack/lint';
3939
import { ObjectStackProtocolImplementation } from './protocol.js';
4040
import type { MetadataAuthoringChannel } from './protocol.js';
4141
import { SDUI_MANIFEST_SERVICE } from './index.js';
42+
import { stampHtmlPageRequires } from './runtime-authoring-gate.js';
4243

4344
/** The issue's body. Zod-valid: `approvers[].value` is just a string to the schema. */
4445
const brokenApprovalFlow = () => ({
@@ -1232,3 +1233,166 @@ describe('html page source compiled at the save door against the SDUI manifest (
12321233
expect(lines).toHaveLength(1);
12331234
});
12341235
});
1236+
1237+
/**
1238+
* [#20312] ADR-0080 §5 — `requires` is "validated at save and load", and it is
1239+
* derived from the source, never carried. The save door's half is pinned in
1240+
* the block above; this block pins the other two moments a stored page's
1241+
* `requires` meets the deployment's manifest:
1242+
*
1243+
* - **At load** (`loadMetaFromDb`, the boot hydration of stored rows): a page
1244+
* whose `requires` names a namespace no component in the manifest carries —
1245+
* a plugin this deployment's console does not load — is reported with the
1246+
* page and the namespace named, and the page still loads. It reads the same
1247+
* `SDUI_MANIFEST_SERVICE` key the save door reads; with no manifest
1248+
* registered nothing is judged, exactly as at the save door.
1249+
* - **At draft → active promotion** (`publishMetaItem` and
1250+
* `publishPackageDrafts`, both through `promoteDraftForPublish`): the
1251+
* promoted body carries the `requires` the save door computes for it
1252+
* (`stampHtmlPageRequires`), not the draft's.
1253+
*/
1254+
describe('stored html page `requires` at load and at draft promotion (#20312)', () => {
1255+
const slot = { name: 'children', type: 'slot' };
1256+
const manifest = (withKanban = true) => ({
1257+
components: {
1258+
flex: { type: 'flex', namespace: 'ui', isContainer: true, inputs: [slot] },
1259+
box: { type: 'box', namespace: 'ui', isContainer: true, inputs: [slot] },
1260+
...(withKanban ? { kanban: { type: 'kanban', namespace: 'plugin-kanban', inputs: [] } } : {}),
1261+
},
1262+
});
1263+
const htmlPage = (source: string, extra: Record<string, unknown> = {}) => ({
1264+
name: 'landing', label: 'Landing', kind: 'html', source, ...extra,
1265+
});
1266+
const WITH_KANBAN = '<flex><box>a</box><kanban /></flex>';
1267+
1268+
/** A protocol whose services table the test holds, plus the registry writes boot hydration makes. */
1269+
function hostWith(services: Map<string, unknown>) {
1270+
const { engine, rows } = makeStubEngine();
1271+
const registered: Array<{ type: string; name: unknown }> = [];
1272+
engine.registry.registerItem = (type: string, item: { name?: unknown }) => {
1273+
registered.push({ type, name: item?.name });
1274+
};
1275+
const protocol = new ObjectStackProtocolImplementation(engine, () => services, 'env_test') as any;
1276+
return { protocol, rows, registered };
1277+
}
1278+
const storedPage = (rows: Map<string, Row>, state = 'active') => {
1279+
const row = Array.from(rows.values()).find((r) => r.type === 'page' && r.state === state);
1280+
return row ? JSON.parse(row.metadata) : undefined;
1281+
};
1282+
const savePage = (protocol: any, item: unknown, extra: Record<string, unknown> = {}) =>
1283+
protocol.saveMetaItem({ type: 'page', name: 'landing', item, ...extra });
1284+
const loadReports = (warn: ReturnType<typeof vi.spyOn>) =>
1285+
(warn.mock.calls as unknown[][]).map((c) => String(c[0])).filter((m) => m.includes('[page_requires_plugin_absent]'));
1286+
1287+
let warn: ReturnType<typeof vi.spyOn>;
1288+
beforeEach(() => {
1289+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1290+
delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES;
1291+
});
1292+
afterEach(() => {
1293+
warn.mockRestore();
1294+
});
1295+
1296+
// ── At load ──────────────────────────────────────────────────────────
1297+
1298+
it('at load, a stored page naming a plugin the manifest does not carry is reported — page and plugin named — and still loads', async () => {
1299+
const services = new Map<string, unknown>();
1300+
const { protocol, rows, registered } = hostWith(services);
1301+
// Stored on a host with no manifest, so stored as written…
1302+
await savePage(protocol, htmlPage(WITH_KANBAN, { requires: ['ui', 'plugin-kanban'] }));
1303+
expect(storedPage(rows)?.requires).toEqual(['ui', 'plugin-kanban']);
1304+
// …and the deployment's console now carries no `plugin-kanban` component.
1305+
services.set('sdui-manifest', manifest(false));
1306+
1307+
const result = await protocol.loadMetaFromDb();
1308+
1309+
expect(result).toMatchObject({ loaded: 1, errors: 0, storeUnavailable: false });
1310+
expect(registered).toContainEqual({ type: 'page', name: 'landing' });
1311+
const lines = loadReports(warn);
1312+
expect(lines, JSON.stringify(warn.mock.calls)).toHaveLength(1);
1313+
expect(lines[0]).toContain('page/landing');
1314+
expect(lines[0]).toContain(`'plugin-kanban'`);
1315+
expect(lines[0]).not.toContain(`'ui'`);
1316+
});
1317+
1318+
it('at load, a stored page whose every plugin is present is not reported (the control)', async () => {
1319+
const services = new Map<string, unknown>();
1320+
const { protocol, registered } = hostWith(services);
1321+
await savePage(protocol, htmlPage(WITH_KANBAN, { requires: ['ui', 'plugin-kanban'] }));
1322+
services.set('sdui-manifest', manifest(true));
1323+
1324+
const result = await protocol.loadMetaFromDb();
1325+
1326+
expect(result).toMatchObject({ loaded: 1, errors: 0 });
1327+
expect(registered).toContainEqual({ type: 'page', name: 'landing' });
1328+
expect(loadReports(warn)).toEqual([]);
1329+
});
1330+
1331+
it('at load, a host with no manifest judges nothing — the save door\'s posture', async () => {
1332+
const { protocol, registered } = hostWith(new Map());
1333+
await savePage(protocol, htmlPage(WITH_KANBAN, { requires: ['ui', 'plugin-absent'] }));
1334+
1335+
const result = await protocol.loadMetaFromDb();
1336+
1337+
expect(result).toMatchObject({ loaded: 1, errors: 0 });
1338+
expect(registered).toContainEqual({ type: 'page', name: 'landing' });
1339+
expect(loadReports(warn)).toEqual([]);
1340+
});
1341+
1342+
// ── At draft → active promotion ──────────────────────────────────────
1343+
1344+
it('a draft saved before the manifest arrived is promoted with the `requires` the save door computes', async () => {
1345+
const services = new Map<string, unknown>();
1346+
const { protocol, rows } = hostWith(services);
1347+
const body = htmlPage(WITH_KANBAN);
1348+
await savePage(protocol, body, { mode: 'draft' });
1349+
expect(storedPage(rows, 'draft')?.requires).toBeUndefined();
1350+
services.set('sdui-manifest', manifest());
1351+
1352+
await expect(protocol.publishMetaItem({ type: 'page', name: 'landing' }))
1353+
.resolves.toMatchObject({ success: true });
1354+
1355+
const saveDoor = stampHtmlPageRequires('page', body, manifest()) as { requires?: unknown };
1356+
expect(saveDoor.requires).toEqual(['ui', 'plugin-kanban']);
1357+
expect(storedPage(rows)?.requires).toEqual(saveDoor.requires);
1358+
});
1359+
1360+
it('an agreeing draft `requires` is promoted as the save door spells it, not as the draft carried it', async () => {
1361+
const services = new Map<string, unknown>();
1362+
const { protocol, rows } = hostWith(services);
1363+
const body = htmlPage(WITH_KANBAN, { requires: ['plugin-kanban', 'ui', 'ui'] });
1364+
await savePage(protocol, body, { mode: 'draft' });
1365+
services.set('sdui-manifest', manifest());
1366+
1367+
await expect(protocol.publishMetaItem({ type: 'page', name: 'landing' }))
1368+
.resolves.toMatchObject({ success: true });
1369+
1370+
expect(storedPage(rows)?.requires)
1371+
.toEqual((stampHtmlPageRequires('page', body, manifest()) as { requires?: unknown }).requires);
1372+
expect(storedPage(rows)?.requires).toEqual(['ui', 'plugin-kanban']);
1373+
});
1374+
1375+
it('the package batch promotion re-stamps too', async () => {
1376+
const services = new Map<string, unknown>();
1377+
const { protocol, rows } = hostWith(services);
1378+
const body = htmlPage(WITH_KANBAN);
1379+
await savePage(protocol, body, { mode: 'draft', packageId: 'com.example.pages' });
1380+
services.set('sdui-manifest', manifest());
1381+
1382+
const result = await protocol.publishPackageDrafts({ packageId: 'com.example.pages' });
1383+
1384+
expect(result, JSON.stringify(result.failed)).toMatchObject({ success: true, publishedCount: 1 });
1385+
expect(storedPage(rows)?.requires)
1386+
.toEqual((stampHtmlPageRequires('page', body, manifest()) as { requires?: unknown }).requires);
1387+
});
1388+
1389+
it('a promotion on a host with no manifest stores the draft as written — the save door\'s posture', async () => {
1390+
const { protocol, rows } = hostWith(new Map());
1391+
await savePage(protocol, htmlPage(WITH_KANBAN, { requires: ['plugin-absent'] }), { mode: 'draft' });
1392+
1393+
await expect(protocol.publishMetaItem({ type: 'page', name: 'landing' }))
1394+
.resolves.toMatchObject({ success: true });
1395+
1396+
expect(storedPage(rows)?.requires).toEqual(['plugin-absent']);
1397+
});
1398+
});

0 commit comments

Comments
 (0)