diff --git a/.changeset/runtime-gate-stored-metadata-universe.md b/.changeset/runtime-gate-stored-metadata-universe.md new file mode 100644 index 0000000000..fe25983f27 --- /dev/null +++ b/.changeset/runtime-gate-stored-metadata-universe.md @@ -0,0 +1,18 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +A dashboard bound to a dataset you just saved now publishes, without restarting the runtime. + +The author-time gate that runs on every `active` metadata publish resolves a widget's `dataset` (and a `type: 'page'` view's `pageName`, and the sibling collections the cross-collection security rules compare against) against a resolution universe the host gathers per write. That gather read the SchemaRegistry alone. The registry is filled at boot by code packages, and for every metadata type except `object` a runtime write does not reach it — so a dataset saved through `PUT /api/v1/meta/dataset` was invisible to the gate until the process restarted, while `GET /api/v1/meta/dataset` returned it in the same instant with `_diagnostics.valid: true`. + +Measured on the reported shape, in one process with no restart between the steps: the row is in `sys_metadata`, the read API lists six datasets, the registry lists the five code-package ones, and a three-widget board bound to the new dataset was refused `422` with three `widget-dataset-unknown` issues whose hint enumerated every dataset except the one just authored. The same request answered `200` after a restart, nothing else changed. + +The gather now folds the stored half onto the registry half for every collection it carries. What that does and does not do: + +- **Additive.** A stored row contributes a name the registry does not already carry and never displaces a registry entry — an object's registry copy is its resolved schema (base plus `extend` contributors) and a raw `sys_metadata` row is the base layer alone, so replacing it would trade this phantom for a subtler one. Where an org overlay redefines a code-package item, the gate still judges that item's content from the registry's version. +- **Active rows only.** A draft does not resolve. The refuse-at-publish ruling exists so an author can write the widget first and the dataset second; a draft dataset that satisfied a published board would invert it. +- **Scoped to the write's own partition** — environment-wide rows plus, when the write has one, its own organization. No other organization's overlays are visible to the gate, on any kernel. +- **A failed store read is reported, not swallowed.** Context gathering still never fails a write, but a read that fails for any reason other than an unprovisioned `sys_metadata` now says so once, naming the consequence — a gather that silently shrinks is how a phantom refusal is manufactured in the first place. + +The rules themselves are unchanged: a reference that resolves in neither home is still refused, with the same code, status and key path. diff --git a/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts b/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts new file mode 100644 index 0000000000..9955c9e064 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts @@ -0,0 +1,364 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #15950 — the runtime authoring gate resolves references against the LIVE + * metadata universe, which includes what a runtime author just SAVED. + * + * ## The defect, as measured end to end before anything was changed + * + * Driving the real write path over the harness below, in one process, with no + * restart between the steps: + * + * 1. `saveMetaItem({ type: 'dataset', name: 'p2008_users' })` → `success`, + * `state: 'active'`, one row in `sys_metadata`. + * 2. `registry.listItems('dataset')` → the five code-package datasets and + * NOTHING else. The five are the firing control: they are in the same + * read, so this is a measurement of an absence and not of a dead stub. + * 3. `getMetaItems({ type: 'dataset' })` → SIX, the authored one included. + * 4. `saveMetaItem({ type: 'dashboard' })`, three widgets bound to it → + * `422 INVALID_METADATA`, THREE `widget-dataset-unknown`, hint + * `"Declared datasets: sys_user_metrics, …"` — every dataset except the + * one the author had just saved. + * + * Steps 2 and 3 are the whole defect in two lines: two readers of the word + * "live" disagreeing about the same artifact in the same instant. `runtime-gate.ts` + * declares the field it fills as "The live dataset declarations", so the reader + * in breach was the gate's, which consulted the SchemaRegistry alone. The + * registry is a BOOT-time universe for every type except `object`: + * `applyRegistryWriteThrough` registers an object unconditionally and returns + * early for everything else on an environment-scoped kernel, so the row is + * invisible until a restart re-hydrates it. That is why the card's step 5 — + * restart, replay byte for byte — answered `200`. + * + * ## Why this is pinned HERE and not against the gate's arguments + * + * The gate is a pure function of its arguments and `runRuntimeAuthoringRules` + * is already pinned both ways against hand-built ones (`runtime-gate.*.test.ts` + * in `@objectstack/lint`). Those pins could not see this defect and never + * will: the ARGUMENTS were the wrong thing. What has to be exercised is the + * GATHER — `assertRuntimeAuthoringRules` building its own context from the + * host — so every test below drives `saveMetaItem`, the same door the card + * drove, and asserts on what that door answers. + * + * Harness: the real repository write path over a stub engine, the shape + * `protocol.dashboard-dataset-publish-gate.test.ts` (#7529) established for + * exactly this reason. Two things in it are modelled from declarations rather + * than guessed, because the measurement is worthless if they are wrong: + * `sys_metadata.state` carries `defaultValue: 'active'` + * (`metadata-core/src/objects/sys-metadata.object.ts`), and the protocol is + * constructed with an environment id — the ordinary tenant posture, and the one + * on which the write-through gate above returns early. + * + * Refusal assertions follow the ADR-0112 envelope discipline: `code` AND + * `status`, never a bare `rejects.toThrow()`. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +// The producer's OWN write-verb dispatch decisions, so the fake engine cannot +// accept a call ObjectQL would refuse. From `@objectstack/metadata-core` and +// never `@objectstack/objectql`: objectql depends on this package (#5619). +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, +} from '@objectstack/metadata-core'; +import { VIEW_PAGE_UNRESOLVED } from '@objectstack/lint'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const WIDGET_DATASET_UNKNOWN = 'widget-dataset-unknown'; + +/** + * The card's own hint, verbatim, is the list of code-package datasets this + * deployment declares — so they are the firing control for every "the authored + * one is missing" reading below. + */ +const CODE_DATASET_NAMES = [ + 'sys_user_metrics', + 'sys_organization_metrics', + 'sys_session_metrics', + 'sys_package_installation_metrics', + 'sys_audit_log_metrics', +] as const; + +const datasetBody = (name: string) => ({ + name, + label: name, + object: 'orders', + dimensions: [ + { name: 'status', field: 'status' }, + { name: 'region', field: 'region' }, + ], + measures: [{ name: 'order_count', aggregate: 'count' }], +}); + +/** The card's board: three widgets, all bound to the runtime-authored dataset. */ +const threeWidgetBoard = (dataset: string) => ({ + name: 'p2008_dash', + label: 'Pin smoke dashboard', + widgets: [ + { id: 'kpi', type: 'metric', title: 'Total', dataset, values: ['order_count'] }, + { + id: 'by_status', type: 'bar', title: 'By status', dataset, + dimensions: ['status'], values: ['order_count'], + chartConfig: { type: 'bar', xAxis: { field: 'status' }, yAxis: [{ field: 'order_count' }] }, + }, + { + id: 'by_region', type: 'donut', title: 'By region', dataset, + dimensions: ['region'], values: ['order_count'], + chartConfig: { type: 'donut', series: [{ name: 'order_count' }] }, + }, + ], +}); + +/** A standalone list overlay mounting a page, as `saveMetaItem` stores one. */ +const pageMountView = (pageName: string) => ({ + name: 'orders.dashboard', + object: 'orders', + viewKind: 'list', + type: 'page', + pageName, + columns: [], +}); + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + state: string; + metadata: string; + package_id?: string | null; +} + +/** + * ⚠️ The engine below is keyed BY TABLE, and that is a correctness property of + * the harness rather than tidiness. The stub this one is modelled on keeps one + * flat row map and skips `sys_metadata_audit` by name, which was invisible for + * as long as nothing read `sys_metadata` as a table: a draft save writes a + * `sys_metadata_history` row that carries no `state`, so a flat map served it + * back as an ACTIVE metadata row and a draft-only dataset resolved. Measured + * here — the draft test below failed for exactly that reason before the tables + * were separated, and it is the kind of green that would have looked like the + * product accepting a draft. + */ +function makeHarness() { + const tables = new Map(); + const tableOf = (name: string): Row[] => { + const existing = tables.get(name); + if (existing) return existing; + const created: Row[] = []; + tables.set(name, created); + return created; + }; + const rows = tableOf('sys_metadata'); + let nextId = 0; + const matches = (row: Row, where: Record): boolean => { + for (const [field, value] of Object.entries(where)) { + if (value === undefined) continue; + if ((row as unknown as Record)[field] !== value) return false; + } + return true; + }; + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + return tableOf(table).find((r) => matches(r, opts.where)) ?? null; + }, + async find(table: string, opts: { where?: Record; limit?: number }) { + // The caller's bound is applied AFTER the filter and BY PRESENCE — + // a double that silently ignores `limit` answers with more rows + // than the caller asked for and reads as a passing query + // (`check:objectql-double-limit`). + const hits = tableOf(table).filter((r) => matches(r, opts?.where ?? {})); + return typeof opts?.limit === 'number' ? hits.slice(0, opts.limit) : hits; + }, + async insert(table: string, data: Record) { + nextId += 1; + // The DECLARED column default the real store applies — + // `sys-metadata.object.ts`: `state: Field.select(…, { defaultValue: + // 'active' })`. Without it this harness answers "no active rows" to + // every store read and reports the repair as ineffective for a + // reason that exists nowhere but here. + const row = { id: `r_${nextId}`, state: 'active', ...(data as any) } as Row; + if (row.state === undefined) row.state = 'active'; + tableOf(table).push(row); + return { id: row.id }; + }, + async update(table: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const rowsOfTable = tableOf(table); + const idx = rowsOfTable.findIndex((r) => matches(r, opts.where)); + if (idx < 0) return { id: null }; + rowsOfTable[idx] = { ...rowsOfTable[idx], ...(data as any) }; + return { id: rowsOfTable[idx].id }; + }, + async delete(table: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const rowsOfTable = tableOf(table); + const idx = rowsOfTable.findIndex((r) => matches(r, opts.where)); + if (idx < 0) return { deleted: 0 }; + rowsOfTable.splice(idx, 1); + return { deleted: 1 }; + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + getItem: () => undefined, + isPackageDisabled: () => false, + // A BOOT-time universe: the code packages, and nothing a runtime + // author writes. That is not a simplification of the harness — it + // is the product behaviour this card is about, and the assertions + // below prove it still holds while they run. + listItems: (type: string) => { + if (type === 'object') { + return [{ + name: 'orders', + fields: [ + { name: 'amount', type: 'number' }, + { name: 'status', type: 'text' }, + { name: 'region', type: 'text' }, + ], + }]; + } + if (type === 'dataset') return CODE_DATASET_NAMES.map((n) => datasetBody(n)); + return []; + }, + }, + }; + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test'); + return { engine, rows, protocol }; +} + +const issuesOf = (err: unknown, rule: string) => + ((err as { issues?: { rule: string }[] } | null)?.issues ?? []).filter((i) => i.rule === rule); + +describe('#15950 — the authoring gate resolves against runtime-authored metadata', () => { + let warn: ReturnType; + beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); + afterEach(() => { warn.mockRestore(); }); + + it('publishes a board bound to a dataset saved moments earlier, in the same process', async () => { + const { engine, protocol } = makeHarness(); + + // ── The card's step 1 ──────────────────────────────────────────────── + const saved = await protocol.saveMetaItem({ + type: 'dataset', name: 'p2008_users', item: datasetBody('p2008_users'), + packageId: 'com.pin2008.smoke', + }); + expect(saved.success).toBe(true); + expect(saved.state).toBe('active'); + + // ── The disagreement, asserted so this test cannot go quietly vacuous ─ + // + // These two are the reason the test discriminates. If the registry ever + // starts carrying runtime-authored datasets (a write-through change), + // the first assertion fails and says so — at which point this test is + // passing for a NEW reason and must be re-derived, not deleted. A green + // run with a registry that already held `p2008_users` would prove + // nothing about the store leg at all. + const registered = [...engine.registry.listItems('dataset')].map((d: any) => d.name); + expect( + registered, + 'the firing control: the code-package datasets must be in this same read, ' + + 'or the assertion below is measuring a dead stub rather than an absence', + ).toEqual([...CODE_DATASET_NAMES]); + expect( + registered, + 'the SchemaRegistry is a boot-time universe for every type but `object` — ' + + 'if this changes, re-derive this test rather than trusting its green', + ).not.toContain('p2008_users'); + + const listed = await protocol.getMetaItems({ type: 'dataset' }); + expect( + (listed.items as { name: string }[]).map((d) => d.name), + 'the read API behind `GET /meta/dataset` answers from registry AND store', + ).toContain('p2008_users'); + + // ── The card's step 3, which used to be a 422 with three phantoms ──── + const result = await protocol.saveMetaItem({ + type: 'dashboard', name: 'p2008_dash', item: threeWidgetBoard('p2008_users'), + packageId: 'com.pin2008.smoke', + }); + expect( + result.success, + 'three phantom `widget-dataset-unknown` here is the whole card: the dataset is ' + + 'readable through the metadata API at this instant and the gate called it nonexistent', + ).toBe(true); + expect(result.advisories ?? [], 'and not demoted to an advisory either').toEqual([]); + }); + + it('still refuses a board bound to a dataset that exists in NEITHER home', async () => { + // The negative control. Widening a resolution universe must not be a + // way of switching the rule off — #7529's refusal is intact, with its + // key path named, and nothing lands. + const { protocol, rows } = makeHarness(); + + const err = await protocol.saveMetaItem({ + type: 'dashboard', name: 'p2008_dash', item: threeWidgetBoard('no_such_dataset_xyz'), + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(Error); + expect((err as any).status).toBe(422); + expect((err as any).code).toBe('INVALID_METADATA'); + const found = issuesOf(err, WIDGET_DATASET_UNKNOWN); + expect(found.length).toBe(3); + expect((found[0] as any).path).toBe('dashboards[0].widgets[0]'); + expect((found[0] as any).message).toMatch(/no_such_dataset_xyz/); + expect(rows.filter((r) => r.type === 'dashboard')).toEqual([]); + }); + + it('does not let a DRAFT dataset satisfy a published board', async () => { + // The scope of the store leg, pinned: `state: 'active'` only. #7529's + // ruling is refuse-at-publish precisely so an author can write the + // widget first and the dataset second; a draft dataset that resolved + // would publish a board that cannot render, which is the ruling + // inverted rather than implemented. + const { protocol } = makeHarness(); + const draft = await protocol.saveMetaItem({ + type: 'dataset', name: 'p2008_users', item: datasetBody('p2008_users'), mode: 'draft', + }); + expect(draft.success).toBe(true); + + const err = await protocol.saveMetaItem({ + type: 'dashboard', name: 'p2008_dash', item: threeWidgetBoard('p2008_users'), + }).catch((e: unknown) => e); + + expect((err as any)?.status).toBe(422); + expect(issuesOf(err, WIDGET_DATASET_UNKNOWN).length).toBe(3); + + // …and the same board publishes once the dataset itself is published. + await protocol.saveMetaItem({ type: 'dataset', name: 'p2008_users' , item: datasetBody('p2008_users') }); + const result = await protocol.saveMetaItem({ + type: 'dashboard', name: 'p2008_dash', item: threeWidgetBoard('p2008_users'), + }); + expect(result.success).toBe(true); + }); + + it('folds the store into EVERY context collection, not just `datasets`', async () => { + // The gather is one helper serving five collections, so the repair is + // one helper too. `pages` is the arm triage asked for a reading on: + // same shape, lower severity — `validateViewPageRefs` reports at + // `warning`, so the phantom rode in `advisories` instead of 422-ing the + // write. Measured both ways here. + const { protocol } = makeHarness(); + + const unknownMount = await protocol.saveMetaItem({ + type: 'view', name: 'orders.dashboard', item: pageMountView('never_authored_page'), + }); + expect( + (unknownMount.advisories ?? []).map((a: any) => a.rule), + 'the control: an unresolvable page mount is still reported', + ).toContain(VIEW_PAGE_UNRESOLVED); + + await protocol.saveMetaItem({ + type: 'page', name: 'sales_dashboard', item: { name: 'sales_dashboard', label: 'Sales' }, + }); + const authoredMount = await protocol.saveMetaItem({ + type: 'view', name: 'orders.dashboard', item: pageMountView('sales_dashboard'), + }); + expect(authoredMount.success).toBe(true); + expect( + (authoredMount.advisories ?? []).map((a: any) => a.rule), + 'a page saved through `PUT /meta/page` is a live page', + ).not.toContain(VIEW_PAGE_UNRESOLVED); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 1168cae064..d75be19bda 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4587,7 +4587,7 @@ export class ObjectStackProtocolImplementation implements * nothing to report, and "clean" is told apart from "nothing ran" by the * gate's own `rulesRun`, not by this. */ - private assertRuntimeAuthoringRules(evt: { + private async assertRuntimeAuthoringRules(evt: { type: string; name: string; state: 'draft' | 'active'; body: unknown; source?: string; /** * The organization partition of this write (`saveMetaItem`'s @@ -4616,7 +4616,7 @@ export class ObjectStackProtocolImplementation implements * the only one holding the answer to. */ pending?: RuntimePendingDeclarations; - }): RuntimeAuthoringIssue[] { + }): Promise { // [#6710] The ADR-0005 carve-out, now DECLARED instead of inferred. // // This line used to read `if (this.environmentId === undefined) @@ -4682,12 +4682,24 @@ export class ObjectStackProtocolImplementation implements // collections the three cross-collection security rules compare // against (RuntimeStackContext's own docblock carries the 38-vs-4 // measurement). Gathered PER WRITE like `objects` always was, never - // cached: the read is a registry map walk plus one array copy of item - // references, it runs only on an `active`-state publish (D1), and a - // cache would need invalidation across every org's overlay writes. + // cached: it runs only on an `active`-state publish (D1), and a cache + // would need invalidation across every org's overlay writes. // Each collection is guarded independently so a registry that can // answer one question still answers the others. - const listCollection = (singularType: string, pluralType: string): unknown[] => { + // + // [#15950] The cost sentence that stood here — "a registry map walk + // plus one array copy of item references" — described only the FIRST + // of the two halves below, and it was the reason the second one was + // never taken. It now reads: a registry map walk plus one array copy, + // PLUS one indexed `sys_metadata` read per collection, all five issued + // together. That is a real added cost and it is paid deliberately: the + // cheaper gather was answering with a universe the platform's own read + // API contradicts, and a per-write snapshot that is cheap and wrong + // refuses legitimate writes (the phantom this whole context exists to + // prevent). It is still bounded by the number of TENANT-AUTHORED rows + // of one type — code-package metadata lives in the registry and never + // reaches this read — and it never runs on a draft. + const listRegisteredCollection = (singularType: string, pluralType: string): unknown[] => { try { if (typeof this.engine.registry?.listItems !== 'function') return []; const items = [...this.engine.registry.listItems(singularType)]; @@ -4696,18 +4708,35 @@ export class ObjectStackProtocolImplementation implements return []; } }; - const objects = listCollection('object', 'objects'); - const permissions = listCollection('permission', 'permissions'); - const books = listCollection('book', 'books'); - // [#7529] The resolution universe validateWidgetBindings needs for a - // dashboard publish — without it every legitimate board reads as - // dangling (see RuntimeStackContext.datasets). - const datasets = listCollection('dataset', 'datasets'); - // [#13216] The resolution universe validateViewPageRefs needs for a - // `type: 'page'` view publish — without it every legitimate page mount - // reads as dangling (see RuntimeStackContext.pages). Gathered on the - // same terms as the four above: per write, on an `active` publish only. - const pages = listCollection('page', 'pages'); + // [#15950] …and the STORED half folded on top of it. The registry is + // only ONE of the two homes live metadata has; see + // {@link foldStoredCollection} for the measured disagreement and for + // why the fold is additive. + const listCollection = (singularType: string, pluralType: string): Promise => + this.foldStoredCollection( + listRegisteredCollection(singularType, pluralType), + singularType, + pluralType, + evt.organizationId ?? null, + ); + // Resolved together rather than in sequence: each is one indexed + // `sys_metadata` read and they do not depend on one another, so the + // store leg costs one round trip of latency for the whole context + // instead of five. + const [objects, permissions, books, datasets, pages] = await Promise.all([ + listCollection('object', 'objects'), + listCollection('permission', 'permissions'), + listCollection('book', 'books'), + // [#7529] The resolution universe validateWidgetBindings needs for a + // dashboard publish — without it every legitimate board reads as + // dangling (see RuntimeStackContext.datasets). + listCollection('dataset', 'datasets'), + // [#13216] The resolution universe validateViewPageRefs needs for a + // `type: 'page'` view publish — without it every legitimate page mount + // reads as dangling (see RuntimeStackContext.pages). Gathered on the + // same terms as the four above: per write, on an `active` publish only. + listCollection('page', 'pages'), + ]); // [#9612] The closure this write is judged against. Resolved from the // package registry — the impure read — and handed to the pure gate as @@ -4735,6 +4764,168 @@ export class ObjectStackProtocolImplementation implements return verdict.advisories; } + /** + * [#15950] Fold the STORED half of a resolution universe onto the registry + * half {@link assertRuntimeAuthoringRules} gathers. + * + * ## Why a second half exists at all + * + * `RuntimeStackContext` declares every one of these collections as the + * **live** declarations — `runtime-gate.ts`'s own words for `datasets` are + * "The live dataset declarations (stack key `datasets`)" — and live + * metadata has TWO homes in this platform, not one: the SchemaRegistry, + * which code packages fill at boot, and `sys_metadata`, which every runtime + * author writes to. {@link getMetaItems} — the read API behind + * `GET /meta/:type` — has always answered from both and merged them. This + * gate answered from the registry alone. + * + * For `object` the two rarely disagree, because + * {@link applyRegistryWriteThrough}'s object branch registers + * unconditionally. For every OTHER type that branch returns early on an + * environment-scoped kernel (and {@link hydrateOverlayIntoRegistry} declines + * an org-scoped row on any kernel), so a `PUT /meta/dataset` that answers + * `200` leaves the registry untouched until the next boot re-hydrates it. + * Measured on the card's shape, in one instant: the row is in the store, + * `GET /meta/dataset` returns it with `_diagnostics.valid: true`, and + * `registry.listItems('dataset')` returns only the five code-package + * datasets — so a dashboard bound to it collected one phantom + * `widget-dataset-unknown` per widget, with a hint enumerating everything + * except the artifact the author had just saved, until the process + * restarted. The 422 and the 200 were two readers disagreeing about the + * same word, and the side that was wrong is this one: the lint contract + * says "live", and the registry alone is not that. + * + * ## The fold is ADDITIVE, deliberately + * + * A stored row contributes a name the registry half does not already carry; + * it never displaces a registry entry. That is not caution for its own + * sake — the registry's copy of an `object` is the RESOLVED schema + * (ADR-0029 D9.2: a base layer with its `extend` contributors folded on), + * while a `sys_metadata` row is the base layer alone, which is exactly why + * {@link getMetaItems} runs {@link foldObjectExtendersFromRegistry} when its + * own merge lets an overlay win. Letting a raw row displace the resolved + * body here would trade this card's phantom for a subtler one — a field + * reference that resolves today reading as dangling — so the universe grows + * and nothing in it is rewritten. The residual is stated rather than + * hidden: where an org overlay REDEFINES a code-package item, the gate + * still judges that item's CONTENT from the registry's version. + * + * ## What the read is scoped to + * + * `state: 'active'` only. A draft must not resolve: #7529's ruling is + * refuse-at-publish precisely so an author can write the widget first and + * the dataset second, and a draft dataset satisfying a board's binding + * would publish a board that cannot render. + * + * Env-wide rows plus, when the write has one, this write's own + * organization — the same two-tier read {@link getMetaItems} performs, and + * the same partition the write itself lands in. No other org's overlays are + * visible here, on any kernel. + * + * ⛔ No disabled-package filter, and that is deliberate rather than + * forgotten: `getMetaItems` applies one, and the comment on it says in as + * many words that the registry primitives keep serving a disabled package's + * items so that "migrations, cross-package references and the runtime + * authoring gate (`protocol.ts` resolution context) still see a complete + * object universe". Filtering here would make the stored half narrower than + * the registry half it is folded onto. + * + * ## Degradation — the invariant, and the half of it that was missing + * + * "Never let context-gathering fail a write" still holds: nothing here + * throws, and a store this host cannot read leaves the caller with the + * registry half it would have had anyway. What is NOT kept is the other + * behaviour of the `catch {}` above — degrading into something that reads + * like a smaller universe with nothing said. A missing table is the one + * benign case (`isMissingTableError`, the declared discriminator: an + * unprovisioned `sys_metadata` genuinely holds no rows, so the registry + * half IS the whole answer); any other failure is reported once, because a + * gather that silently shrinks is how a phantom refusal is manufactured, + * and this method exists because of one. + * + * `warn`, not `error`, per this repo's degradation rule: no write claims to + * have persisted anything it did not. The consequence is a possible wrong + * verdict on the NEXT reference, and the message says so. + */ + private async foldStoredCollection( + registered: unknown[], + singularType: string, + pluralType: string, + organizationId: string | null, + ): Promise { + if (typeof this.engine?.find !== 'function') return registered; + let rows: Record[]; + try { + const scopes: (string | null)[] = organizationId ? [null, organizationId] : [null]; + const read = async (type: string, oid: string | null): Promise[]> => { + const rs = await this.engine.find('sys_metadata', { + where: { type, state: 'active', organization_id: oid }, + }); + return (rs ?? []) as Record[]; + }; + rows = []; + for (const oid of scopes) { + // The same singular/plural retry the registry half and + // `getMetaItems` both perform: rows written through a plural + // URL spelling are stored under it. + let rs = await read(singularType, oid); + if (rs.length === 0) rs = await read(pluralType, oid); + rows.push(...rs); + } + } catch (error) { + if (!isMissingTableError(error, 'sys_metadata')) { + console.warn( + `[Protocol] the runtime authoring gate could not read stored '${singularType}' ` + + `metadata: ${(error as { message?: string } | null)?.message ?? String(error)}. ` + + `This write is NOT blocked, and nothing it saves is at risk — but the gate is ` + + `judging references against the SchemaRegistry alone for this write, so a ` + + `reference to a runtime-authored ${singularType} may be refused as unknown.`, + ); + } + return registered; + } + if (rows.length === 0) return registered; + + const seen = new Set(); + for (const item of registered) { + const name = (item as { name?: unknown } | null | undefined)?.name; + if (typeof name === 'string') seen.add(name); + } + const merged = [...registered]; + for (const row of rows) { + const name = row.name; + if (typeof name !== 'string' || seen.has(name)) continue; + let body: unknown; + try { + const raw = row.metadata; + body = this.convertStoredItem( + singularType, + typeof raw === 'string' ? JSON.parse(raw) : raw, + ); + } catch (error) { + // One unreadable row must not cost the other rows their place + // in the universe — but it is said out loud, because dropping + // it silently is the same manufactured phantom as above, one + // name narrower. + console.warn( + `[Protocol] stored ${singularType}/${name} could not be read into the runtime ` + + `authoring gate's resolution context: ` + + `${(error as { message?: string } | null)?.message ?? String(error)}. ` + + `References to it may be refused as unknown until the row is re-saved.`, + ); + continue; + } + if (!body || typeof body !== 'object') continue; + const packageId = row.package_id; + if (typeof packageId === 'string' && (body as { _packageId?: unknown })._packageId === undefined) { + (body as { _packageId?: unknown })._packageId = packageId; + } + seen.add(name); + merged.push(body); + } + return merged; + } + /** * [#9612] The package closure a write is judged against, or `undefined` * when the gate must keep receiving the whole tenant. @@ -15256,7 +15447,7 @@ export class ObjectStackProtocolImplementation implements // captured and rides the 2xx this write is about to earn. Held in a // local rather than on `this`: the gate is per-write and two concurrent // saves must not read each other's findings. - const runtimeAdvisories = this.assertRuntimeAuthoringRules({ + const runtimeAdvisories = await this.assertRuntimeAuthoringRules({ type: request.type, name: request.name, state: mode === 'draft' ? 'draft' : 'active', @@ -16570,7 +16761,7 @@ export class ObjectStackProtocolImplementation implements // own. Held in a local, never on `this` — the gate is per-write and // two concurrent publishes must not read each other's findings. const runtimeAdvisories: RuntimeAuthoringIssue[] = draftForGate - ? this.assertRuntimeAuthoringRules({ + ? await this.assertRuntimeAuthoringRules({ type: singularType, name: request.name, state: 'active', diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index f52e927863..1d70c220b6 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1166,6 +1166,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts", "verb": "delete",