From b23df3929f3d4e21776dbff87c2c770100c35c5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:51:32 +0000 Subject: [PATCH 1/6] fix(metadata-protocol)!: the meta door refuses an edit of a code-defined datasource, and removes only a stored row under one isArtifactBacked now sees a datasource an installed code package declares, through a second non-standalone-artifact resolver read from the package records' declared datasources. The existing package door and the repository's write intent then refuse a PUT with NOT_OVERRIDABLE / 403 and the datasource row of the packaged-base regime table: the admin door's verdict (code-defined, cannot be edited at runtime, read-only) and its remedy (edit the *.datasource.ts source). A DELETE that would remove nothing is refused with the same verdict; a DELETE of a stored row under a code-defined name stays possible as repair, in the protocol's delete door and the repository's delete gate. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../src/packaged-base-regime.ts | 164 ++++++++++++----- packages/metadata-protocol/src/protocol.ts | 173 +++++++++++++++++- .../src/sys-metadata-repository.ts | 18 +- 3 files changed, 304 insertions(+), 51 deletions(-) diff --git a/packages/metadata-protocol/src/packaged-base-regime.ts b/packages/metadata-protocol/src/packaged-base-regime.ts index 7f06cc1d9f9..3b234c9fddf 100644 --- a/packages/metadata-protocol/src/packaged-base-regime.ts +++ b/packages/metadata-protocol/src/packaged-base-regime.ts @@ -63,17 +63,44 @@ * `skill` / `position` — no sanctioned path is built for them, and a row with no * route does not compile ({@link PackagedBaseRegimeCRoutes}). * + * ## The one origin-gated row + * + * [#21899] ADR-0126 §3 places `datasource` OUTSIDE the three regimes, and says + * how: "origin-gated: code-defined read-only, runtime-created free". A + * code-defined datasource has no runtime route at all — no overlay, no clone, + * no switch: `DatasourceSchema.origin` (`@objectstack/spec`) declares it + * "authored as `*.datasource.ts`, GitOps-owned, read-only in the UI", ADR-0062 + * ratifies it read-only, and the datasource-admin service refuses to edit or + * remove one ("… is code-defined and cannot be edited at runtime."). So its row + * carries no routes; it names the source that owns the datasource, and its + * sentence states the admin door's verdict in the admin door's words, then that + * remedy. The two doors keep their own codes (`NOT_OVERRIDABLE` / 403 here, + * `DATASOURCE_ADMIN_ERROR` / 400 there); the verdict and the remedy agree. + * + * The row is also what {@link isOriginGatedType} answers from, for the one + * removal both the protocol's delete door and the repository's delete gate + * allow on such a type: deleting a STORED row under a code-defined name. The + * runtime registers a code-defined datasource in memory only and never + * persists it, so a stored row under its name is never a layer of it — it is + * residue a runtime write left — and removing it restores the code definition. + * * ⛔ No sentence built here names the `OS_METADATA_WRITABLE` hatch. The hatch * still opens these locks exactly as before, so which writes are refused does - * not move — only what the refusal prescribes. ⛔ Nor does any prescribe editing - * the source and redeploying: the administrator of an installed package cannot - * do that. + * not move — only what the refusal prescribes. ⛔ Nor does a Regime C sentence + * prescribe editing the source and redeploying: the administrator of an + * installed package cannot do that, and a Regime C type has a runtime route + * instead. The origin-gated row has none, so the source is the only remedy + * there is to name. */ import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; -/** An ADR-0126 customization regime a packaged-base refusal speaks for — only the ones it needs today. */ -export type PackagedBaseRegime = 'C'; +/** + * An ADR-0126 customization regime a packaged-base refusal speaks for — only the + * ones it needs today: Regime C, and the origin-gated posture §3 records for a + * type outside the regimes. + */ +export type PackagedBaseRegime = 'C' | 'origin-gated'; /** * The sanctioned routes a Regime C type's row supplies to its refusal: how to @@ -86,11 +113,21 @@ export type PackagedBaseRegimeCRoutes = | { readonly clone: string; readonly switchOff?: string } | { readonly clone?: string; readonly switchOff: string }; -/** One type's row: its regime, and the routes that regime names for it. */ -export interface PackagedBaseRegimeRow { - readonly regime: PackagedBaseRegime; - readonly routes: PackagedBaseRegimeCRoutes; -} +/** + * One type's row: its regime, and what that regime names for it — a Regime C + * type's sanctioned routes, or an origin-gated type's owning source. + */ +export type PackagedBaseRegimeRow = + | { readonly regime: 'C'; readonly routes: PackagedBaseRegimeCRoutes } + | { + readonly regime: 'origin-gated'; + /** The type's noun, opening the sentence the way the type's own admin door opens it. */ + readonly noun: string; + /** The source pattern a code-defined item of the type is authored in. */ + readonly source: string; + /** The decision record the sentence cites. */ + readonly docs: string; + }; /** The table. Keyed by the canonical (singular) metadata type. */ export const PACKAGED_BASE_REGIME: Readonly> = { @@ -117,6 +154,12 @@ export const PACKAGED_BASE_REGIME: Readonly string>> = { - C: (routes) => { - // [verb opening the sentence, verb after "or", the rest of the path] - const paths: Array = [ - ...(routes.clone - ? [['Clone', 'clone', ` it under a new name to customize it (${routes.clone})`] as const] - : []), - ...(routes.switchOff ? [['Switch', 'switch', ` it off (${routes.switchOff})`] as const] : []), - ]; - const prescription = paths - .map(([opening, following, rest], i) => (i === 0 ? opening : following) + rest) - .join(', or '); - return `${prescription}. See docs/adr/0126-packaged-metadata-customization-model.md.`; - }, -}; +function regimeCPrescription(routes: PackagedBaseRegimeCRoutes): string { + // [verb opening the sentence, verb after "or", the rest of the path] + const paths: Array = [ + ...(routes.clone + ? [['Clone', 'clone', ` it under a new name to customize it (${routes.clone})`] as const] + : []), + ...(routes.switchOff ? [['Switch', 'switch', ` it off (${routes.switchOff})`] as const] : []), + ]; + const prescription = paths + .map(([opening, following, rest], i) => (i === 0 ? opening : following) + rest) + .join(', or '); + return `${prescription}. See docs/adr/0126-packaged-metadata-customization-model.md.`; +} + +/** + * The PRESCRIPTION half of a regime's refusal, built from the row alone: a + * Regime C row's sanctioned paths ({@link regimeCPrescription}), or an + * origin-gated row's owning source — the only remedy such a type has — and the + * row's citation. + */ +function rowPrescription(row: PackagedBaseRegimeRow): string { + switch (row.regime) { + case 'C': + return regimeCPrescription(row.routes); + case 'origin-gated': + return `Edit the ${row.source} source that declares it and redeploy. See ${row.docs}.`; + } +} /** - * The regime prescription for `type` — the row's sanctioned paths and the - * ADR-0126 citation, one sentence pair, no opener — or `undefined` when the type - * declares no regime and the emitter keeps its own remedy. + * The regime prescription for `type` — the row's remedy and its citation, one + * sentence pair, no opener — or `undefined` when the type declares no regime and + * the emitter keeps its own remedy. */ export function packagedBaseRegimePrescription(type: string): string | undefined { const row = packagedBaseRegimeRow(type); - return row ? PRESCRIPTION_BY_REGIME[row.regime](row.routes) : undefined; + return row ? rowPrescription(row) : undefined; +} + +/** + * [#21899] Is `type` origin-gated (ADR-0126 §3: code-defined read-only, + * runtime-created free)? The one removal such a type allows on a code-defined + * name is deleting a STORED row under it: the runtime never persists a + * code-defined item of the type, so the row is residue a runtime write left, + * never a layer of the item, and removing it restores the code definition. Read + * by the protocol's delete door and by the repository's delete gate — one row, + * so the two cannot disagree about which types this is. + */ +export function isOriginGatedType(type: string): boolean { + return packagedBaseRegimeRow(type)?.regime === 'origin-gated'; } /** @@ -167,20 +235,30 @@ export function packagedBaseRegimePrescription(type: string): string | undefined * `undefined` when the type declares no regime and the emitter keeps its own * sentence. Read on the canonical type, and spoken with it. * + * The lock is the regime's: a Regime C item "is provided by a code package, and + * its packaged base is locked"; an origin-gated item "is code-defined and cannot + * be edited (removed) at runtime: it is read-only" — the datasource-admin + * service's own verdict on the same item, so the two doors onto one code-defined + * datasource state one verdict and one remedy. + * * Kept under the REST door's 500-character client-message bound * (`truncateClientMessage`, `packages/rest/src/error-response.ts`), past which * the tail is truncated. Characters before the item's name, save / removal: - * `flow` 411 / 404, `action` 365 / 358, `permission` 317 / 310 — so a name of up - * to 88 characters arrives whole for every row (pinned). A `flow`'s sentence is - * byte-identical to the one the row table replaced (pinned literally). + * `flow` 411 / 404, `action` 365 / 358, `permission` 317 / 310, `datasource` + * 192 / 193 — so a name of up to 88 characters arrives whole for every row + * (pinned). A `flow`'s sentence is byte-identical to the one the row table + * replaced (pinned literally). */ export function packagedBaseRegimeSentence( type: string, name: string, operation: 'save' | 'delete', ): string | undefined { const singular = PLURAL_TO_SINGULAR[type] ?? type; - const prescription = packagedBaseRegimePrescription(singular); - if (prescription === undefined) return undefined; - return `Metadata item '${singular}/${name}' is provided by a code package, and its packaged base is locked ` - + (operation === 'delete' ? `against removal. ` : `against in-place edits. `) - + prescription; + const row = packagedBaseRegimeRow(singular); + if (row === undefined) return undefined; + const lock = row.regime === 'origin-gated' + ? `${row.noun} '${name}' is code-defined and cannot be ` + + (operation === 'delete' ? 'removed' : 'edited') + ' at runtime: it is read-only. ' + : `Metadata item '${singular}/${name}' is provided by a code package, and its packaged base is locked ` + + (operation === 'delete' ? `against removal. ` : `against in-place edits. `); + return lock + rowPrescription(row); } diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0b35ebb2763..1fbf87c7009 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -44,7 +44,7 @@ import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; import { ensureMetadataOverlayIndexes } from './migrations/overlay-index.js'; import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js'; import { SysMetadataRepository, type SysMetadataEngine } from './sys-metadata-repository.js'; -import { packagedBaseRegimeSentence } from './packaged-base-regime.js'; +import { isOriginGatedType, packagedBaseRegimeSentence } from './packaged-base-regime.js'; import { resolveArtifactLockLayer, resolveItemLock, @@ -15988,8 +15988,9 @@ export class ObjectStackProtocolImplementation implements * * [#7743] …and it must answer that question about the artifact as SHIPPED, * not about how the registry happens to key it. See - * {@link isNestedArtifactField} for the one declared type whose artifacts - * are not standalone registry items at all. + * {@link isNestedArtifactField} and [#21899] {@link isDeclaredCodeDatasource} + * for the two declared types whose artifacts are not standalone registry + * items at all. */ private isArtifactBacked(type: string, name: string): boolean { // `lookupArtifactItem` only returns items whose `_packageId` marks a @@ -15997,7 +15998,7 @@ export class ObjectStackProtocolImplementation implements // excluded), and — via `SchemaRegistry.getArtifactItem` — is immune // to plain-key shadows hydrated from overlay rows. if (this.lookupArtifactItem(type, name) !== undefined) return true; - return this.isNestedArtifactField(type, name); + return this.isNestedArtifactField(type, name) || this.isDeclaredCodeDatasource(type, name); } /** @@ -16041,6 +16042,19 @@ export class ObjectStackProtocolImplementation implements * artifact disagree, so widening this to a class would be widening it past * what was measured. * + * [#21899] The census missed one type, and it is a SECOND non-standalone + * shape rather than a second nesting: `datasource`. A code-defined + * datasource is registered neither standalone nor nested in the + * SchemaRegistry — the runtime registers it in the MetadataService, in + * memory only — so `getArtifactItem('datasource', 'showcase_external')` + * missed on the showcase's own `*.datasource.ts` (measured on a booted + * showcase) and `PUT /api/v1/meta/datasource/showcase_external` answered + * 200 on the `runtime-only` intent. Its resolver is + * {@link isDeclaredCodeDatasource}, beside this one and in this shape: + * a containment boolean, read from what the installed code packages + * ship. Two resolvers, each for the one type it measured — still not a + * nesting rule. + * * ## Shape decisions * * - **Containment, not a synthetic envelope.** This returns a boolean @@ -16076,6 +16090,78 @@ export class ObjectStackProtocolImplementation implements return Object.prototype.hasOwnProperty.call(fields, name.slice(sep + 1)); } + /** + * [#21899] Is `(datasource, name)` a datasource an installed code package + * declares — a code-defined datasource? + * + * ## Why this predicate needs a resolver of its own + * + * The second non-standalone shape in {@link isNestedArtifactField}'s + * census. A code-defined datasource never reaches the SchemaRegistry as an + * item: `AppPlugin` registers it in the MetadataService, in memory only, + * stamped `origin: 'code'` (ADR-0062), so the artifact-only lookup misses + * every one. That miss was the whole defect: {@link isArtifactBacked} + * answered false, the save took the `runtime-only` intent, and + * `allowRuntimeCreate: true` let `PUT /api/v1/meta/datasource/:name` + * persist an edit of a datasource the published contract + * (`DatasourceSchema.origin`: "code — authored as `*.datasource.ts`, + * GitOps-owned, read-only in the UI") and the datasource-admin service both + * call read-only. Seen here, the package door and the repository's write + * intent refuse it the way they refuse every code-shipped item of a type + * with no overlay channel. + * + * ## What it reads + * + * - **The installed package records** the engine registry holds + * (`getAllPackages()`), each record's manifest `datasources`. + * `registerApp` installs that record from the same manifest it indexes + * the datasource definitions from, and `AppPlugin` registers what the + * package bodies declare — so this is the set the runtime treats as + * code-defined, read from where the package ships it. + * - **In its one canonical form**: the array + * `ObjectStackDefinitionSchema.datasources` declares. The name-keyed map + * is authorable, but `defineStack` normalizes it to the array before any + * artifact is built (`MAP_SUPPORTED_FIELDS`), and the showcase's + * installed record carries the array (measured). No map fallback: Prime + * Directive #12 keeps one contract. + * - **Every installed package, a disabled one included**: the registry + * never filters package records, and a disabled package still ships the + * datasource the runtime registered for it — the artifact-only lookup + * answers for a disabled package's items the same way. + * - ⛔ **Never the MetadataService slot's `origin`**: a stored row the + * datasource-admin plugin restores at boot overwrites that slot, `origin` + * and all, so the slot answers what was stored last, not what a package + * ships. ⛔ **Never a request body's `origin`**: the caller sets it. + * + * ## What it does not see + * + * - **The host's `default` datasource.** No package declares it: + * `DefaultDatasourcePlugin` registers it from the host's own definition, + * in memory, as `origin: 'code'`, and the datasource-admin service + * refuses to edit or remove it as code-defined. The host's code + * datasource set is not readable from this package — the MetadataService + * slot is the unsound source above, the connection service retains no + * origin, and the engine's datasource definitions mix both origins — so + * the `/meta` door still answers for `default` as it did. A named gap, + * not a reading this predicate makes. + * - **A name no package declares** answers false and keeps the + * `runtime-only` intent: a runtime datasource stays creatable, editable + * and removable through this door. + */ + private isDeclaredCodeDatasource(type: string, name: string): boolean { + if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'datasource') return false; + const registry = (this.engine as any)?.registry; + if (typeof registry?.getAllPackages !== 'function') return false; + for (const record of registry.getAllPackages() as unknown[]) { + const declared = (record as { manifest?: { datasources?: unknown } } | null | undefined) + ?.manifest?.datasources; + if (Array.isArray(declared) && declared.some((ds) => (ds as { name?: unknown } | null)?.name === name)) { + return true; + } + } + return false; + } + /** * [#20679, ADR-0126 §2] The `/meta` door's LOCKED-BASE verdict on an item * that already exists, as a VALUE — for a second door that writes or @@ -16666,6 +16752,54 @@ export class ObjectStackProtocolImplementation implements } } + /** + * [#21899, ADR-0126 §3] The removal verdict on an item of an ORIGIN-GATED + * type ({@link isOriginGatedType} — `datasource`: code-defined read-only, + * runtime-created free) when the item is code-defined: the package door's + * own refusal, {@link packagedBaseRefusal} for `delete`, held for + * {@link deleteMetaItem} to answer at its row probe. `null` for an item of + * any other type, for a runtime item of this one (nothing ships the name), + * and with `OS_METADATA_WRITABLE` open on the type — each keeps the verdict + * it had. + * + * ## The one removal it lifts, and the one it keeps + * + * `deleteMetaItem` throws it when NO stored row exists under the name: the + * delete would remove nothing, and a code-defined datasource is not + * removable at runtime — the datasource-admin service's verdict, now the + * `/meta` door's too, in the same `NOT_OVERRIDABLE` / 403 its save gets. + * When a stored row DOES exist, the delete goes ahead and removes it. + * + * That second half is not a new rule. It is the one {@link saveMetaItem}'s + * #5086 record states and #6960 extended, applied to the save refusal this + * card adds: "removing a code-only row that predates this refusal is + * repair, and must stay possible", because the removal restores the + * code-declared state — the narrowing direction, which cannot widen + * anything. Rows that predate it exist (#21899 measured them: a `/meta` + * save of a code-defined datasource persisted one, and at boot it shadows + * the code definition in both doors), and before this refusal the `/meta` + * door was the one door that could clear them; refusing every delete would + * leave them to database surgery. + * + * #6960's own carve-out ({@link mergesOverlayAtRead}) does not reach it, + * and is not widened to: it is keyed on `supportsOverlay`, which + * `datasource` does not declare — and `object`, which shares those flags, + * keeps refusing both verbs (ADR-0029 D9.6). What sets an origin-gated type + * apart is that its code-defined items are never persisted at all, so a + * stored row under one is never a layer of it, only residue. The + * repository's delete gate mirrors the lift for the same type + * ({@link SysMetadataRepository.assertDeleteAllowed}), because it is + * topology-independent and is the gate a host-config kernel asks. + * + * The read envelope keeps answering `deletable: false` (the item is not + * removable) and `resettable: true` (its stored row is, which is what this + * repair is), through the {@link packagedBaseRefusal} it already asks. + */ + private originGatedRemovalRefusal(type: string, name: string): Error | null { + if (!isOriginGatedType(type)) return null; + return this.packagedBaseRefusal({ type, name, operation: 'delete' }); + } + // ─────────────────────────────────────────────────────────────────── // ADR-0010 — metadata protection (Phase 1: L3 item-level lock) // ─────────────────────────────────────────────────────────────────── @@ -25415,6 +25549,12 @@ export class ObjectStackProtocolImplementation implements // spelling while the repository deletes under the singular — so a // DELETE could remove the row and leave the shadow it was meant to lift. request = canonicalizeMetaRequestType(request); + // [#21899] The removal verdict on an ORIGIN-GATED code-defined item + // (ADR-0126 §3: `datasource` — code-defined read-only, runtime-created + // free), held back until the row probe below. See + // {@link originGatedRemovalRefusal}: the item itself is never removed + // here, but a stored row under its name is, as repair. + const originGatedRefusal = this.originGatedRemovalRefusal(request.type, request.name); // Two-tier authorization for delete (mirrors saveMetaItem). // • Artifact-backed item → delete becomes a tombstone overlay, // requires `allowOrgOverride`… @@ -25473,7 +25613,11 @@ export class ObjectStackProtocolImplementation implements // {@link refusePackagedBaseRemoval}, so a second removal door onto // the same packaged artifact asks this one through // {@link packagedBaseRefusal} instead of carrying a copy. - this.refusePackagedBaseRemoval(request); + // + // [#21899] An origin-gated code-defined item's verdict is that same + // refusal, answered at the row probe instead of here: thrown when + // no stored row exists, and lifted for the repair that deletes one. + if (originGatedRefusal === null) this.refusePackagedBaseRemoval(request); if (!artifactBacked && !overlayAllowed && !runtimeCreateAllowed) { const err = new Error( `Metadata type '${request.type}' does not allow runtime creation or deletion.` @@ -25491,8 +25635,13 @@ export class ObjectStackProtocolImplementation implements // on a host-config kernel a packaged base it refuses keeps the answer // that kernel already gave it — the repository's delete gate when an // overlay row exists, a no-op that leaves the artifact standing when - // none does (see {@link packagedBaseRefusal}). - if (this.packagedBaseRefusal({ type: request.type, name: request.name, operation: 'delete' }) === null) { + // none does (see {@link packagedBaseRefusal}). [#21899] The + // origin-gated repair is a removal the doors allow, so the `_lock` + // gate asks about it as it asks about every other allowed removal. + if ( + originGatedRefusal !== null + || this.packagedBaseRefusal({ type: request.type, name: request.name, operation: 'delete' }) === null + ) { const lockErr = await this.assertLockAllowsDelete({ type: request.type, name: request.name, @@ -25543,6 +25692,12 @@ export class ObjectStackProtocolImplementation implements // Probe first — "no overlay exists" is a success/no-op, not // a conflict. The repo would otherwise throw ConflictError. const current = await repo.get(ref, { state: targetState }); + // [#21899] An origin-gated code-defined item with NO stored row: + // the delete would remove nothing, and the item itself is not + // removable at runtime — refused with the package door's own + // verdict, the one its save gets. Rethrown as it is by the + // `catch` below. + if (!current && originGatedRefusal !== null) throw originGatedRefusal; if (!current) { // Self-heal: even with no overlay row, a stale runtime // shadow may linger in the registry (e.g. pollution from @@ -25688,6 +25843,10 @@ export class ObjectStackProtocolImplementation implements : `Deleted ${singularTypeForRepo} '${request.name}' — it no longer exists. [seq=${result.seq}]`, }; } catch (err: any) { + // [#21899] The door's own verdict, not a store failure: it + // leaves with its envelope and sentence, never re-wrapped as an + // overlay delete that failed. + if (err === originGatedRefusal) throw err; if (err instanceof ConflictError) { // [#21207] Keyed values or none in the text and attributes. const conflict = await this.metadataConflictRefusal( diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 013ffb59ed3..9e44a73d525 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -83,7 +83,7 @@ import type { IObjectQLEngine } from '@objectstack/core'; // lifecycle gate read, so a third read-only signal added there reaches this // door too (that shared-rule argument is the module's whole reason to exist). import { isWritablePackage } from './package-writability.js'; -import { packagedBaseRegimePrescription, packagedBaseRegimeSentence } from './packaged-base-regime.js'; +import { isOriginGatedType, packagedBaseRegimePrescription, packagedBaseRegimeSentence } from './packaged-base-regime.js'; /** * Canonicalise a driver-materialised timestamp into the ISO-8601 string the @@ -1891,6 +1891,21 @@ export class SysMetadataRepository implements MetadataRepository { * this name", which the `allowRuntimeCreate` tier already governs — so * the carve-out is scoped to the `override-artifact` intent, which is * precisely the artifact-backed case the ruling names. + * + * ## [#21899] The origin-gated type, the same lift for a different reason + * + * An ORIGIN-GATED type (`isOriginGatedType`, `./packaged-base-regime.ts` — + * `datasource`: ADR-0126 §3, code-defined read-only, runtime-created free) + * does not declare `supportsOverlay`, so the carve-out above never reached + * it. It is lifted here on its own ground: the runtime never persists a + * code-defined item of the type, so a stored row under its name is never a + * layer of it — only residue a runtime write left — and removing it restores + * the code definition, the narrowing direction #6960's ruling names. This + * gate is reached only with a row (`delete` refuses a missing one with a + * conflict); the protocol's delete door answers the row-less removal with + * its own refusal before asking here (`originGatedRemovalRefusal`). + * `object`, with the same registry flags, is not origin-gated and keeps the + * refusal. */ private assertDeleteAllowed( ref: { type: string; name: string }, @@ -1900,6 +1915,7 @@ export class SysMetadataRepository implements MetadataRepository { if (intent !== 'runtime-only') { const singular = PLURAL_TO_SINGULAR[type] ?? type; if (OVERLAY_CAPABLE_TYPES.has(singular) || OVERLAY_CAPABLE_TYPES.has(type)) return; + if (isOriginGatedType(singular)) return; } this.assertAllowed(ref, 'delete', intent); } From 778200ebac25f5a33e6a66e6bcf87ea49b50c012 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:53:43 +0000 Subject: [PATCH 2/6] test(metadata-protocol): pin the meta door's answer on a code-defined datasource, both kernel shapes Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...tocol.code-defined-datasource-door.test.ts | 380 ++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts diff --git a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts new file mode 100644 index 00000000000..136c3f18bd6 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts @@ -0,0 +1,380 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21899] The `/meta` door answers a CODE-DEFINED datasource the way the + * datasource-admin door does: read-only. Edits are refused; a stored row under + * the name is removable as repair, and nothing else is. + * + * ## The defect + * + * A code-defined datasource (`*.datasource.ts`, `origin: 'code'`) is + * registered by the runtime in the MetadataService, in memory only — never as + * a SchemaRegistry item — so `isArtifactBacked` answered false for every one. + * The save took the `runtime-only` intent, `datasource` declares + * `allowRuntimeCreate: true`, and `PUT /api/v1/meta/datasource/showcase_external` + * answered 200 and persisted an edit of a datasource the published contract + * (`DatasourceSchema.origin`: "code — … read-only in the UI") and the admin + * service both call read-only. Its `DELETE` answered 200 too. + * + * ## The ruling this file pins (triage, Q1-A and Q2-B) + * + * - Q1-A: `isArtifactBacked` sees the datasources the installed packages + * declare (a second non-standalone-artifact resolver, beside #7743's + * `field` one), so the EXISTING package door and the repository's write + * intent refuse the save — `NOT_OVERRIDABLE` / 403, this door's own code, + * with a sentence naming the admin door's verdict and remedy. + * - Q2-B: a `DELETE` that would remove nothing is refused with that verdict; + * a `DELETE` of a stored row under the name stays possible, as repair. + * - A runtime datasource (a name no package declares) saves and deletes as + * before. + * + * Every case runs on both kernel shapes: the package door answers on an + * environment kernel, and `SysMetadataRepository` — the same refusal one layer + * down — on a host-config one (the showcase's shape). + * + * Harness: the real write path over a stub engine, the shape of + * `protocol.legacy-overlay-delete.test.ts`, with the engine registry carrying + * the installed package records the resolver reads. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions, so the fake engine +// below cannot accept a call ObjectQL refuses. From `@objectstack/metadata-core`, +// not `@objectstack/objectql`, which depends on this package. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +/** The showcase's code-defined datasource and the package that declares it. */ +const CODE_DS = 'showcase_external'; +const PACKAGE_ID = 'com.example.showcase'; +/** A name no package declares: a runtime datasource. */ +const RUNTIME_DS = 'dogfood_rt_21899'; + +/** The sentences the two refusals carry, spelled out — never read back from the table under test. */ +const SAVE_SENTENCE = `Datasource '${CODE_DS}' is code-defined and cannot be edited at runtime: it is read-only. ` + + 'Edit the *.datasource.ts source that declares it and redeploy. See docs/adr/0062-external-datasource-runtime.md.'; +const DELETE_SENTENCE = `Datasource '${CODE_DS}' is code-defined and cannot be removed at runtime: it is read-only. ` + + 'Edit the *.datasource.ts source that declares it and redeploy. See docs/adr/0062-external-datasource-runtime.md.'; + +/** A schema-valid datasource body, so a refusal is the door's and never a 422. */ +const body = (name: string, label: string) => ({ name, label, driver: 'sqlite', config: { filename: `${name}.db` } }); + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum: string; +} + +/** A row a `/meta` save persisted before this refusal existed — the residue Q2-B keeps removable. */ +const shadowRow = (name: string): Row => ({ + id: `row_datasource_${name}`, + type: 'datasource', + name, + organization_id: null, + package_id: null, + state: 'active', + metadata: JSON.stringify({ ...body(name, 'Meta Renamed before the fix'), origin: 'code' }), + checksum: 'sha256_pre_fix_shadow', +}); + +const matchesWhere = (row: Record, where: Record) => + Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v === null || v === undefined) return row[k] === null || row[k] === undefined; + return row[k] === v; + }); + +/** + * One kernel. `packages` are the installed package records the engine registry + * holds — what makes a datasource code-defined; `seed` is a stored row a + * previous process persisted. + */ +function makeSession(opts: { + environmentId?: string; + packages?: Array<{ manifest: Record }>; + seed?: Row[]; +} = {}) { + const rows = new Map(); + for (const r of opts.seed ?? []) rows.set(r.id, r); + const historyRows: Array> = []; + + const engine: any = { + async findOne(table: string, o: { where: Record }) { + assertEngineFindOnePredicate(table, o); + if (table === 'sys_metadata_history') { + return historyRows.find((h) => matchesWhere(h, o.where)) ?? null; + } + if (table !== 'sys_metadata') return null; + for (const row of rows.values()) if (matchesWhere(row as any, o.where)) return row; + return null; + }, + async find(table: string) { + if (table === 'sys_metadata_history') return historyRows; + if (table !== 'sys_metadata') return []; + return Array.from(rows.values()); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_history') { + historyRows.push({ ...data }); + return { id: String(data.id ?? `h_${historyRows.length}`) }; + } + if (table !== 'sys_metadata') return { id: 'side_effect_skip' }; + const row = { id: `r_${rows.size + 1}`, ...(data as any) } as Row; + rows.set(row.id, row); + return { id: row.id }; + }, + async update(table: string, data: Record, o?: Record) { + assertEngineUpdateDispatch(data, o); + if (table !== 'sys_metadata') return { id: null }; + const id = (o as any)?.where?.id; + const row = rows.get(id); + if (row) rows.set(id, { ...row, ...(data as any) }); + return { id: id ?? null }; + }, + async delete(table: string, o?: Record) { + // [#4550] The producer's own delete-verb dispatch contract. + assertEngineDeleteDispatch(o); + if (table !== 'sys_metadata') return { deleted: 0 }; + const id = (o as any)?.where?.id; + const existed = rows.delete(id); + return { deleted: existed ? 1 : 0 }; + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + listItems: () => [], + getItem: () => undefined, + // A code-defined datasource is never a SchemaRegistry item — the + // artifact-only lookup misses it, exactly as on a booted showcase. + getArtifactItem: () => undefined, + getAllPackages: () => opts.packages ?? [], + removeRuntimeShadow: () => false, + removeOverlayEntry: () => {}, + }, + }; + + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), opts.environmentId) as any; + return { protocol, rows, historyRows }; +} + +/** The showcase's installed package record, in the canonical shape `registerApp` installs it. */ +const SHOWCASE_PACKAGE = { + manifest: { + id: PACKAGE_ID, + namespace: 'showcase', + datasources: [{ name: CODE_DS, label: 'External Analytics (SQLite)', driver: 'sqlite', config: { filename: 'x.db' } }], + }, +}; + +const KERNELS: Array<{ label: string; environmentId?: string }> = [ + { label: 'host-config kernel (no environmentId)' }, + { label: 'environment kernel (environmentId set)', environmentId: 'env_test' }, +]; + +const refusalOf = (run: Promise) => run.then(() => null, (e: any) => e); + +const resetEnvHatch = () => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}; + +describe('[#21899] the resolver — a datasource an installed package declares is artifact-backed', () => { + it('sees the declared datasource, and nothing a package does not declare', () => { + const { protocol } = makeSession({ packages: [SHOWCASE_PACKAGE] }); + expect(protocol.isArtifactBacked('datasource', CODE_DS)).toBe(true); + // The plural spelling folds before the read (#4432). + expect(protocol.isArtifactBacked('datasources', CODE_DS)).toBe(true); + expect(protocol.isArtifactBacked('datasource', RUNTIME_DS)).toBe(false); + // The host's `default` is declared by no package: the named gap, pinned + // as what this resolver answers so a change to it is seen. + expect(protocol.isArtifactBacked('datasource', 'default')).toBe(false); + }); + + it('reads only the `datasource` type — another type of the same name is not made artifact-backed', () => { + const { protocol } = makeSession({ packages: [SHOWCASE_PACKAGE] }); + expect(protocol.isArtifactBacked('object', CODE_DS)).toBe(false); + expect(protocol.isArtifactBacked('external_catalog', CODE_DS)).toBe(false); + }); + + it('a registry with no package records answers false (nothing is guessed)', () => { + expect(makeSession().protocol.isArtifactBacked('datasource', CODE_DS)).toBe(false); + }); +}); + +for (const { label, environmentId } of KERNELS) { + describe(`[#21899] the /meta door on a code-defined datasource — ${label}`, () => { + beforeEach(resetEnvHatch); + afterEach(resetEnvHatch); + + const session = (seed?: Row[]) => makeSession({ + ...(environmentId ? { environmentId } : {}), + packages: [SHOWCASE_PACKAGE], + ...(seed ? { seed } : {}), + }); + + it('PUT is refused NOT_OVERRIDABLE / 403 with the admin door\'s verdict and remedy, and stores nothing', async () => { + const { protocol, rows, historyRows } = session(); + + const err = await refusalOf(protocol.saveMetaItem({ type: 'datasource', name: CODE_DS, item: body(CODE_DS, 'Meta Renamed 21899') })); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(err.message).toBe(SAVE_SENTENCE); + expect(rows.size).toBe(0); + expect(historyRows).toEqual([]); + }); + + it('PUT through the plural spelling is refused the same way', async () => { + const { protocol, rows } = session(); + const err = await refusalOf(protocol.saveMetaItem({ type: 'datasources', name: CODE_DS, item: body(CODE_DS, 'x') })); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(rows.size).toBe(0); + }); + + it('PUT is refused whatever `origin` the body asserts (the caller sets it; the resolver never reads it)', async () => { + const { protocol, rows } = session(); + const err = await refusalOf(protocol.saveMetaItem({ + type: 'datasource', name: CODE_DS, item: { ...body(CODE_DS, 'x'), origin: 'runtime' }, + })); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(rows.size).toBe(0); + }); + + it('DELETE with no stored row is refused with the same verdict, naming the removal', async () => { + const { protocol, rows, historyRows } = session(); + + const err = await refusalOf(protocol.deleteMetaItem({ type: 'datasource', name: CODE_DS })); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(err.message).toBe(DELETE_SENTENCE); + expect(rows.size).toBe(0); + expect(historyRows).toEqual([]); + }); + + it('DELETE of a pre-existing stored row answers 200 and removes it, with its tombstone (repair)', async () => { + const { protocol, rows, historyRows } = session([shadowRow(CODE_DS)]); + + const res = await protocol.deleteMetaItem({ type: 'datasource', name: CODE_DS }); + + expect(res).toMatchObject({ success: true, reset: true }); + expect(Array.from(rows.values()).filter((r) => r.name === CODE_DS)).toEqual([]); + expect(historyRows.some((h) => h.name === CODE_DS && h.operation_type === 'delete')).toBe(true); + + // …and the removal is not repeatable: with the row gone, the next + // DELETE would remove nothing and is refused. + const again = await refusalOf(protocol.deleteMetaItem({ type: 'datasource', name: CODE_DS })); + expect({ code: again?.code, status: again?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + }); + + it('the read envelope agrees: not editable, not deletable, resettable', () => { + const { protocol } = session(); + expect(protocol.packagedBaseRefusal({ type: 'datasource', name: CODE_DS, operation: 'save' })?.message).toBe(SAVE_SENTENCE); + expect(protocol.packagedBaseRefusal({ type: 'datasource', name: CODE_DS, operation: 'delete' })?.message).toBe(DELETE_SENTENCE); + const state = protocol.servedLockState('datasource', CODE_DS, {}, true, { lock: 'none' }); + expect({ editable: state.editable, deletable: state.deletable, resettable: state.resettable }) + .toEqual({ editable: false, deletable: false, resettable: true }); + }); + + it('control: a runtime datasource still saves and deletes through this door', async () => { + const { protocol, rows } = session(); + + const saved = await protocol.saveMetaItem({ type: 'datasource', name: RUNTIME_DS, item: body(RUNTIME_DS, 'Runtime') }); + expect(saved).toMatchObject({ success: true }); + expect(Array.from(rows.values()).filter((r) => r.name === RUNTIME_DS)).toHaveLength(1); + + const updated = await protocol.saveMetaItem({ type: 'datasource', name: RUNTIME_DS, item: body(RUNTIME_DS, 'Runtime 2') }); + expect(updated).toMatchObject({ success: true }); + + const removed = await protocol.deleteMetaItem({ type: 'datasource', name: RUNTIME_DS }); + expect(removed).toMatchObject({ success: true, reset: true }); + expect(Array.from(rows.values()).filter((r) => r.name === RUNTIME_DS)).toEqual([]); + + // A DELETE that removes nothing on a RUNTIME name keeps its no-op answer. + const none = await protocol.deleteMetaItem({ type: 'datasource', name: RUNTIME_DS }); + expect(none).toMatchObject({ success: true, reset: false }); + }); + + it('control: a runtime datasource whose body asserts `origin: code` is still a runtime datasource', async () => { + const { protocol, rows } = session(); + const saved = await protocol.saveMetaItem({ + type: 'datasource', name: RUNTIME_DS, item: { ...body(RUNTIME_DS, 'Runtime'), origin: 'code' }, + }); + expect(saved).toMatchObject({ success: true }); + expect(rows.size).toBe(1); + }); + + it('[GUARD] the operator hatch opens the lock exactly as before (not this card\'s to move)', async () => { + process.env.OS_METADATA_WRITABLE = 'datasource'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + const { protocol, rows } = session(); + const saved = await protocol.saveMetaItem({ type: 'datasource', name: CODE_DS, item: body(CODE_DS, 'Hatch') }); + expect(saved).toMatchObject({ success: true }); + expect(rows.size).toBe(1); + }); + }); +} + +describe('[#21899] the repository delete gate lifts the origin-gated type only', () => { + beforeEach(resetEnvHatch); + afterEach(resetEnvHatch); + + const repoOver = (row: Row) => { + const rows = new Map([[row.id, row]]); + const engine: any = { + async findOne(table: string, o: { where: Record }) { + assertEngineFindOnePredicate(table, o); + if (table !== 'sys_metadata') return null; + for (const r of rows.values()) if (matchesWhere(r as any, o.where)) return r; + return null; + }, + async find() { return []; }, + async insert() { return { id: 'h' }; }, + async update(_t: string, data: Record, o?: Record) { + assertEngineUpdateDispatch(data, o); + return { id: null }; + }, + async delete(table: string, o?: Record) { + assertEngineDeleteDispatch(o); + if (table !== 'sys_metadata') return { deleted: 0 }; + return { deleted: rows.delete((o as any)?.where?.id) ? 1 : 0 }; + }, + }; + return { rows, repo: new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'env' }) }; + }; + + it('removes a stored row under a code-defined datasource on the override-artifact intent', async () => { + const { rows, repo } = repoOver(shadowRow(CODE_DS)); + await repo.delete( + { org: 'env', type: 'datasource', name: CODE_DS }, + { parentVersion: 'sha256_pre_fix_shadow', actor: null, intent: 'override-artifact' }, + ); + expect(rows.size).toBe(0); + }); + + it('[GUARD] `object`, with the same registry flags, keeps its refusal', async () => { + const { rows, repo } = repoOver({ ...shadowRow('showcase_task'), type: 'object' }); + const err = await refusalOf(repo.delete( + { org: 'env', type: 'object', name: 'showcase_task' }, + { parentVersion: 'sha256_pre_fix_shadow', actor: null, intent: 'override-artifact' }, + )); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(rows.size).toBe(1); + }); + + it('a save of a code-defined datasource is refused at the repository with the same sentence', async () => { + const { repo } = repoOver(shadowRow('unrelated')); + const err = await refusalOf(repo.put( + { org: 'env', type: 'datasource', name: CODE_DS }, + body(CODE_DS, 'x'), + { parentVersion: null, actor: null, intent: 'override-artifact' }, + )); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(err.message).toBe(SAVE_SENTENCE); + }); +}); From 1a5aec82f1c429d6037f844e88107706fcb966bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:03:12 +0000 Subject: [PATCH 3/6] fix(metadata-protocol): the read envelope reports the origin-gated repair, and the refusal sweeps leave that tier to its own pins servedLockState's deletable reads whether the read found a stored row for an origin-gated code-defined item, so the read agrees with the door in both states: refused with no row, admitted (repair) with one. The two delete refusal sweeps derived from the registry flags exclude the origin-gated type, and the read-versus-door table measures that type's host-config removal at the protocol's delete door, where it is answered. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...tocol.code-defined-datasource-door.test.ts | 31 ++++++++++++++----- .../protocol.delete-rewrap-envelope.test.ts | 9 +++++- .../protocol.legacy-overlay-delete.test.ts | 9 ++++++ ...rotocol.read-lock-flags-write-door.test.ts | 20 +++++++++++- packages/metadata-protocol/src/protocol.ts | 28 ++++++++++++++--- 5 files changed, 82 insertions(+), 15 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts index 136c3f18bd6..8a728aed722 100644 --- a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts +++ b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts @@ -148,7 +148,13 @@ function makeSession(opts: { registerItem: () => {}, registerObject: () => {}, listItems: () => [], - getItem: () => undefined, + // The served code definition, as the runtime's in-memory + // registration serves it on a read. + getItem: (type: string, name: string) => + (type === 'datasource' && name === CODE_DS && (opts.packages ?? []).length > 0 + ? { ...body(CODE_DS, 'External Analytics (SQLite)'), origin: 'code' } + : undefined), + applyNavContributions: (app: unknown) => app, // A code-defined datasource is never a SchemaRegistry item — the // artifact-only lookup misses it, exactly as on a booted showcase. getArtifactItem: () => undefined, @@ -271,13 +277,22 @@ for (const { label, environmentId } of KERNELS) { expect({ code: again?.code, status: again?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); }); - it('the read envelope agrees: not editable, not deletable, resettable', () => { - const { protocol } = session(); - expect(protocol.packagedBaseRefusal({ type: 'datasource', name: CODE_DS, operation: 'save' })?.message).toBe(SAVE_SENTENCE); - expect(protocol.packagedBaseRefusal({ type: 'datasource', name: CODE_DS, operation: 'delete' })?.message).toBe(DELETE_SENTENCE); - const state = protocol.servedLockState('datasource', CODE_DS, {}, true, { lock: 'none' }); - expect({ editable: state.editable, deletable: state.deletable, resettable: state.resettable }) - .toEqual({ editable: false, deletable: false, resettable: true }); + it('the read envelope agrees with the doors: never editable, deletable only while a stored row exists', async () => { + const flags = (r: any) => ({ lock: r.lock, editable: r.editable, deletable: r.deletable, resettable: r.resettable }); + const read = async (protocol: any) => ({ + byName: flags(await protocol.getMetaItem({ type: 'datasource', name: CODE_DS })), + layered: flags(await protocol.getMetaItemLayered({ type: 'datasource', name: CODE_DS })), + }); + + // No stored row: PUT and DELETE are both refused (above). + const none = await read(session().protocol); + expect(none.byName).toEqual({ lock: 'full', editable: false, deletable: false, resettable: true }); + expect(none.layered).toEqual(none.byName); + + // A pre-existing stored row: PUT is refused, DELETE removes it (above). + const residue = await read(session([shadowRow(CODE_DS)]).protocol); + expect(residue.byName).toEqual({ lock: 'no-overlay', editable: false, deletable: true, resettable: true }); + expect(residue.layered).toEqual(residue.byName); }); it('control: a runtime datasource still saves and deletes through this door', async () => { diff --git a/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts b/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts index 9dbe56d4b77..895f38694a8 100644 --- a/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts @@ -80,7 +80,7 @@ import { ErrorCode } from '@objectstack/spec/api'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; -import { packagedBaseRegimeRow } from './packaged-base-regime.js'; +import { isOriginGatedType, packagedBaseRegimeRow } from './packaged-base-regime.js'; /** * The tier whose artifact-backed delete is refused BY THE REPOSITORY on a @@ -92,9 +92,16 @@ import { packagedBaseRegimeRow } from './packaged-base-regime.js'; * and routes the delete into the repository in the first place. * * `object` is the specimen ADR-0029 D9 pins; the rest ride the same flags. + * + * [#21899] Minus the ORIGIN-GATED type (`datasource`): the repository lifts + * its delete gate for a stored row under a code-defined name (repair), so it + * refuses no such delete. The removal refusal for a code-defined datasource is + * the protocol's, answered when no row exists, and is pinned in + * `protocol.code-defined-datasource-door.test.ts`. */ const REPO_REFUSED_TYPES: readonly string[] = DEFAULT_METADATA_TYPE_REGISTRY .filter((e) => !e.supportsOverlay && !e.allowOrgOverride && e.allowRuntimeCreate) + .filter((e) => !isOriginGatedType(e.type)) .map((e) => e.type) .sort(); diff --git a/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts b/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts index 940d62b57bb..340e42b0d1c 100644 --- a/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts +++ b/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts @@ -86,6 +86,7 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFin import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; +import { isOriginGatedType } from './packaged-base-regime.js'; /** * THE TIER THAT MOVES, derived from the registry and never listed by hand @@ -119,9 +120,17 @@ const OVERLAY_INCAPABLE_TYPES: readonly string[] = DEFAULT_METADATA_TYPE_REGISTR * the boundary sweep below turned it red on the control-plane leg with a * SUCCESSFUL delete, which is pre-existing, documented, and outside this * card. `protocol.code-only-types.test.ts` owns that tier. + * + * [#21899] …and minus the ORIGIN-GATED type (`datasource`, ADR-0126 §3: + * code-defined read-only, runtime-created free). It shares these flags with + * `object`, but the runtime never persists a code-defined datasource, so a + * stored row under its name is residue whose removal is repair — its own tier, + * pinned (refused with no row, admitted with one) in + * `protocol.code-defined-datasource-door.test.ts`. */ const OVERLAY_INCAPABLE_RUNTIME_CREATABLE_TYPES: readonly string[] = DEFAULT_METADATA_TYPE_REGISTRY .filter((e) => !e.supportsOverlay && !e.allowOrgOverride && e.allowRuntimeCreate) + .filter((e) => !isOriginGatedType(e.type)) .map((e) => e.type) .sort(); diff --git a/packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts b/packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts index 2728a35e1f5..94ec93af99a 100644 --- a/packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts +++ b/packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts @@ -48,6 +48,7 @@ import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; +import { isOriginGatedType } from './packaged-base-regime.js'; const PACKAGE_ID = 'com.example.pkg'; const ENV_ID = 'env_1'; @@ -216,6 +217,21 @@ async function hostConfigDoor(type: string, name: string, operation: 'save' | 'd return 'admitted'; } +/** + * [#21899] The host-config kernel's REMOVAL door for a packaged item of an + * origin-gated type (`datasource`): the protocol's own delete. The repository + * gate that {@link hostConfigDoor} measures is reached only with a stored row + * (whose removal is repair, and admitted); with none, the protocol answers the + * removal at its row probe, before the repository is asked — on this kernel as + * on an environment one. Refused ⇔ that answer is a lock refusal. + */ +async function hostConfigOriginGatedRemoval(type: string, name: string, rows: StoredRow[]): Promise { + const outcome = await settle(harness(undefined, rows).deleteMetaItem({ type, name })); + if (isLockRefusal(outcome)) return 'refused'; + expect(outcome, `${type}/${name} delete: neither refused nor answered`).toBeNull(); + return 'admitted'; +} + async function readFlags(protocol: ObjectStackProtocolImplementation, type: string, name: string): Promise<{ layered: Flags; byName: Flags }> { const pick = (r: any): Flags => ({ lock: r.lock, editable: r.editable, deletable: r.deletable }); return { @@ -251,7 +267,9 @@ async function measure(kernel: Kernel, arm: Arm, type: string): Promise { del = await environmentDoor(harness(ENV_ID, rows), type, name, 'delete'); } else { save = await hostConfigDoor(type, name, 'save', arm === 'packaged'); - del = await hostConfigDoor(type, name, 'delete', arm === 'packaged'); + del = arm === 'packaged' && isOriginGatedType(type) + ? await hostConfigOriginGatedRemoval(type, name, rows) + : await hostConfigDoor(type, name, 'delete', arm === 'packaged'); } return { kernel, arm, type, name, read: layered, byName, save, del }; } diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 1fbf87c7009..8e2d759618b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -9995,6 +9995,9 @@ export class ObjectStackProtocolImplementation implements // served-row resolution — see {@link findServedOverlayRow}. It // decides the CONTENT only; the lock is selected below from this // read's address ([#21761]). + // [#21899] Whether a stored row was found — the fact the origin-gated + // repair's `deletable` reads ({@link servedLockState}). + let storedRowServed = false; try { const record = (await this.findServedOverlayRow({ type: request.type, @@ -10003,6 +10006,7 @@ export class ObjectStackProtocolImplementation implements state: readState, ...(request.packageId ? { packageId: request.packageId } : {}), }))?.row; + storedRowServed = record !== undefined && record !== null; // [#20946] The stored-row half — see `shippedFlowActiveRead` above. if (record && !shippedFlowActiveRead) { item = this.convertStoredItem( @@ -10275,7 +10279,9 @@ export class ObjectStackProtocolImplementation implements // [#21738] The lock is the one item-lock resolution's (above), over the // layers this read resolved — never the served document's `_lock`. const artifactBacked = this.isArtifactBacked(request.type, request.name); - const lockState = this.servedLockState(request.type, request.name, decorated, artifactBacked, itemLock); + const lockState = this.servedLockState( + request.type, request.name, decorated, artifactBacked, itemLock, storedRowServed, + ); return { type: request.type, name: request.name, @@ -10743,6 +10749,7 @@ export class ObjectStackProtocolImplementation implements // doors answer — the same derivation `getMetaItem` publishes. const lockState = this.servedLockState( request.type, request.name, code ?? overlay ?? {}, artifactBacked, itemLock, + overlay !== null && overlay !== undefined, ); // [#8154] The per-type credential redaction, on the ONE read exit @@ -16298,6 +16305,15 @@ export class ObjectStackProtocolImplementation implements * ⛔ Not a policy. Which writes are refused is decided at the doors; this * method only reports their answer, so a door that moves moves this read * with it. + * + * [#21899] One removal verdict depends on the store, not the registry: an + * origin-gated code-defined item ({@link originGatedRemovalRefusal}) is + * refused removal unless a stored row exists, whose delete is repair. So + * `storedRowServed` — whether the read found a stored row for the item — + * is the one store fact this derivation takes, and only that verdict reads + * it: `deletable` is true while there is such a row to remove, false once + * there is none. Every other removal verdict ignores it. The diagnostics + * tile passes none (it counts `lock !== 'none'`, which both answers are). */ private servedLockState( type: string, @@ -16305,12 +16321,14 @@ export class ObjectStackProtocolImplementation implements document: unknown, artifactBacked: boolean, itemLock: ItemLock, + storedRowServed = false, ): ReturnType { const { provenance, packageId, packageVersion } = extractProtection(document); const editable = evaluateLockForWrite(itemLock.lock) === null && this.packagedBaseRefusal({ type, name, operation: 'save' }) === null; + const removalRefusal = this.packagedBaseRefusal({ type, name, operation: 'delete' }); const deletable = evaluateLockForDelete(itemLock.lock) === null - && this.packagedBaseRefusal({ type, name, operation: 'delete' }) === null; + && (removalRefusal === null || (storedRowServed && isOriginGatedType(type))); const lock = MetadataLockSchema.options.find((state) => (evaluateLockForWrite(state) === null) === editable && (evaluateLockForDelete(state) === null) === deletable); @@ -16791,9 +16809,9 @@ export class ObjectStackProtocolImplementation implements * ({@link SysMetadataRepository.assertDeleteAllowed}), because it is * topology-independent and is the gate a host-config kernel asks. * - * The read envelope keeps answering `deletable: false` (the item is not - * removable) and `resettable: true` (its stored row is, which is what this - * repair is), through the {@link packagedBaseRefusal} it already asks. + * The read envelope reports the same verdict ({@link servedLockState}): + * `editable: false` always, and `deletable` true exactly while the read + * found a stored row for the delete to remove. */ private originGatedRemovalRefusal(type: string, name: string): Error | null { if (!isOriginGatedType(type)) return null; From 598ebc30eceb2fc947cdd098709ce026c85002e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:08:27 +0000 Subject: [PATCH 4/6] test(dogfood): the meta door answers showcase_external as read-only, and a pre-fix stored row is removable across restarts Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...tocol.code-defined-datasource-door.test.ts | 42 ++- .../meta-door-code-datasource.dogfood.test.ts | 257 ++++++++++++++++++ 2 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts diff --git a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts index 8a728aed722..deb9cd5be08 100644 --- a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts +++ b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts @@ -50,11 +50,22 @@ const PACKAGE_ID = 'com.example.showcase'; /** A name no package declares: a runtime datasource. */ const RUNTIME_DS = 'dogfood_rt_21899'; -/** The sentences the two refusals carry, spelled out — never read back from the table under test. */ -const SAVE_SENTENCE = `Datasource '${CODE_DS}' is code-defined and cannot be edited at runtime: it is read-only. ` - + 'Edit the *.datasource.ts source that declares it and redeploy. See docs/adr/0062-external-datasource-runtime.md.'; -const DELETE_SENTENCE = `Datasource '${CODE_DS}' is code-defined and cannot be removed at runtime: it is read-only. ` - + 'Edit the *.datasource.ts source that declares it and redeploy. See docs/adr/0062-external-datasource-runtime.md.'; +/** + * What the two refusals say: the admin door's verdict as the first sentence, in + * the admin door's words, then the remedy — the wording triage ruled the + * refusal must carry, so it is asserted beside the code and status, never + * instead of them. Spelled out, never read back from the table under test. + */ +const SAVE_VERDICT = `Datasource '${CODE_DS}' is code-defined and cannot be edited at runtime: it is read-only.`; +const DELETE_VERDICT = `Datasource '${CODE_DS}' is code-defined and cannot be removed at runtime: it is read-only.`; +const REMEDY = 'Edit the *.datasource.ts source that declares it and redeploy.'; +/** The verdict first, the remedy after it, and no operator hatch prescribed. */ +const expectVerdict = (message: unknown, verdict: string) => { + const text = String(message); + expect(text.startsWith(`${verdict} `), text).toBe(true); + expect(text).toContain(REMEDY); + expect(text).not.toContain('OS_METADATA_WRITABLE'); +}; /** A schema-valid datasource body, so a refusal is the door's and never a 422. */ const body = (name: string, label: string) => ({ name, label, driver: 'sqlite', config: { filename: `${name}.db` } }); @@ -211,6 +222,21 @@ describe('[#21899] the resolver — a datasource an installed package declares i it('a registry with no package records answers false (nothing is guessed)', () => { expect(makeSession().protocol.isArtifactBacked('datasource', CODE_DS)).toBe(false); }); + + it('both refusals arrive whole through the REST door\'s 500-character bound for an 88-character name', () => { + // `truncateClientMessage` (packages/rest/src/error-response.ts) keeps a + // message only while it is SHORTER than 500 characters. + const longName = `ds_${'x'.repeat(85)}`; + expect(longName).toHaveLength(88); + const { protocol } = makeSession({ + packages: [{ manifest: { id: PACKAGE_ID, datasources: [{ name: longName, driver: 'sqlite', config: {} }] } }], + }); + for (const operation of ['save', 'delete'] as const) { + const message = String(protocol.packagedBaseRefusal({ type: 'datasource', name: longName, operation })?.message); + expect(message.length, operation).toBeLessThan(500); + expect(message.endsWith('See docs/adr/0062-external-datasource-runtime.md.'), operation).toBe(true); + } + }); }); for (const { label, environmentId } of KERNELS) { @@ -230,7 +256,7 @@ for (const { label, environmentId } of KERNELS) { const err = await refusalOf(protocol.saveMetaItem({ type: 'datasource', name: CODE_DS, item: body(CODE_DS, 'Meta Renamed 21899') })); expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(err.message).toBe(SAVE_SENTENCE); + expectVerdict(err.message, SAVE_VERDICT); expect(rows.size).toBe(0); expect(historyRows).toEqual([]); }); @@ -257,7 +283,7 @@ for (const { label, environmentId } of KERNELS) { const err = await refusalOf(protocol.deleteMetaItem({ type: 'datasource', name: CODE_DS })); expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(err.message).toBe(DELETE_SENTENCE); + expectVerdict(err.message, DELETE_VERDICT); expect(rows.size).toBe(0); expect(historyRows).toEqual([]); }); @@ -390,6 +416,6 @@ describe('[#21899] the repository delete gate lifts the origin-gated type only', { parentVersion: null, actor: null, intent: 'override-artifact' }, )); expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(err.message).toBe(SAVE_SENTENCE); + expectVerdict(err.message, SAVE_VERDICT); }); }); diff --git a/packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts b/packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts new file mode 100644 index 00000000000..55e7ea9ceb4 --- /dev/null +++ b/packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts @@ -0,0 +1,257 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21899] The metadata door answers a CODE-DEFINED datasource the way the +// datasource-admin door does — read-only — over the real showcase +// composition, across restarts. +// +// ## What was broken +// +// The showcase declares `showcase_external` in `*.datasource.ts`, so it is +// code-defined (`DatasourceSchema.origin`: "code — authored as +// `*.datasource.ts`, GitOps-owned, read-only in the UI"). The admin door +// refused to edit or remove it. The metadata door did not: +// `PUT /api/v1/meta/datasource/showcase_external` answered 200 and persisted a +// row, `GET /meta` then served the edit, and `DELETE` answered 200 too. The +// runtime registers a code-defined datasource in memory only, never as a +// registry item, so the door's artifact check missed it and the save took the +// runtime-create tier. +// +// ## What each case pins (triage's four pins, Q1-A and Q2-B) +// +// - `PUT` on a code-defined datasource is refused with the door's own +// `NOT_OVERRIDABLE` / 403 and the admin door's verdict and remedy; the +// admin door keeps its own `DATASOURCE_ADMIN_ERROR` / 400 — the two codes +// differ by ruling, the verdict agrees; +// - a `DELETE` with no stored row is refused the same way; +// - a `DELETE` of a stored row left from before this refusal answers 200, and +// after a restart both doors serve the code definition again; +// - a runtime datasource still saves and deletes, each through the door that +// created it. +// +// The pre-existing row is seeded the way the metadata door wrote it before +// this refusal existed: through the door's own repository on the runtime-only +// intent, the intent that save took for every code-defined datasource. No +// public door writes one any more, which is the point of the fix. +// +// The verify harness composes the datasource-admin service but not its REST +// routes, so this file mounts `registerDatasourceAdminRoutes` the way +// `packages/cli/src/commands/serve.ts` does. The working directory is a +// temporary one because the showcase's external datasource and its fixture +// both name a cwd-relative SQLite file. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** The showcase's code-defined datasource and the package that declares it. */ +const DATASOURCE = 'showcase_external'; +const PACKAGE_ID = 'com.example.showcase'; +const CODE_LABEL = 'External Analytics (SQLite)'; +/** The label a pre-fix metadata-door save left in the stored row. */ +const SHADOW_LABEL = 'Shadow 21899'; +/** Runtime datasources: one created through each door. */ +const ADMIN_RUNTIME = 'dogfood_admin_rt_21899'; +const META_RUNTIME = 'dogfood_meta_rt_21899'; + +/** The verdict both doors state, in the admin door's words. */ +const EDIT_VERDICT = `Datasource '${DATASOURCE}' is code-defined and cannot be edited at runtime`; +const REMOVE_VERDICT = `Datasource '${DATASOURCE}' is code-defined and cannot be removed at runtime`; +/** The remedy the metadata door names. */ +const REMEDY = 'Edit the *.datasource.ts source that declares it and redeploy.'; + +const SYS = { isSystem: true, positions: [], permissions: [] }; + +/** The REST error envelopes the two doors answer with. */ +interface Answer { + status: number; + json: { + success?: boolean; + reset?: boolean; + error?: string | { code?: string; message?: string }; + code?: string; + item?: Record; + data?: { datasource?: Record; datasources?: Array> }; + }; +} + +/** `{ code, message }` from either door's refusal envelope. */ +const refusal = (a: Answer) => { + const { error, code } = a.json; + return typeof error === 'object' && error !== null + ? { status: a.status, code: error.code, message: String(error.message ?? '') } + : { status: a.status, code, message: String(error ?? '') }; +}; + +describe('[#21899] the metadata door answers a code-defined datasource as read-only (showcase)', () => { + let stack: VerifyStack; + let token: string; + let prevCwd: string; + let dir: string; + + const routes = () => ({ + name: 'dogfood.datasource-admin-routes', + version: '1.0.0', + optionalDependencies: ['com.objectstack.server.hono'], + init: async (ctx: { getService?: (name: string) => unknown }) => { + const httpServer = ctx.getService?.('http.server') ?? ctx.getService?.('http-server'); + registerDatasourceAdminRoutes(httpServer as never, ctx as never, '/api/v1'); + }, + }); + const boot = async () => { + stack = await bootStack(showcaseStack, { databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] }); + token = await stack.signIn(); + }; + const restart = async () => { + await stack.stop(); + await boot(); + }; + const call = async (method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + return { status: res.status, json: (await res.json().catch(() => ({}))) as Answer['json'] }; + }; + /** The served body, minus the read decorations, as a client edits it. */ + const servedBody = async (): Promise> => { + const read = await call('GET', `/meta/datasource/${DATASOURCE}`); + expect(read.status, JSON.stringify(read.json)).toBe(200); + return Object.fromEntries(Object.entries(read.json.item ?? {}).filter(([k]) => !k.startsWith('_'))); + }; + const storedRows = async (name: string) => { + const ql = (await stack.kernel.getServiceAsync('objectql')) as { + find(object: string, query: unknown): Promise>>; + }; + return ql.find('sys_metadata', { where: { type: 'datasource', name }, context: SYS }); + }; + const adminEntry = async () => { + const listed = await call('GET', '/datasources'); + expect(listed.status, JSON.stringify(listed.json)).toBe(200); + return listed.json.data?.datasources?.find((d) => d.name === DATASOURCE); + }; + + beforeAll(async () => { + prevCwd = process.cwd(); + dir = mkdtempSync(join(tmpdir(), 'dogfood-21899-')); + process.chdir(dir); + // Provision the remote tables, exactly as `os dev` does at boot. + await onEnable({ logger: { info() {}, warn() {} } } as never); + await boot(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('premise: both doors serve the code definition, and no row is stored for it', async () => { + const read = await call('GET', `/meta/datasource/${DATASOURCE}`); + expect(read.status, JSON.stringify(read.json)).toBe(200); + expect(read.json.item).toMatchObject({ origin: 'code', label: CODE_LABEL, _packageId: PACKAGE_ID }); + expect(await adminEntry()).toMatchObject({ origin: 'code', label: CODE_LABEL }); + expect(await storedRows(DATASOURCE)).toEqual([]); + }); + + it('PUT is refused NOT_OVERRIDABLE / 403 with the admin door\'s verdict and remedy, and stores nothing', async () => { + const put = await call('PUT', `/meta/datasource/${DATASOURCE}`, { ...(await servedBody()), label: 'Meta Renamed 21899' }); + + const answer = refusal(put); + expect({ status: answer.status, code: answer.code }).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(answer.message.startsWith(`${EDIT_VERDICT}: it is read-only.`), answer.message).toBe(true); + expect(answer.message).toContain(REMEDY); + + expect(await storedRows(DATASOURCE)).toEqual([]); + expect((await servedBody()).label).toBe(CODE_LABEL); + + // The admin door keeps its own code — the two differ by ruling — and + // states the same verdict. + const admin = refusal(await call('PATCH', `/datasources/${DATASOURCE}`, { label: 'Admin Renamed 21899' })); + expect({ status: admin.status, code: admin.code }).toEqual({ status: 400, code: 'DATASOURCE_ADMIN_ERROR' }); + expect(admin.message.startsWith(EDIT_VERDICT), admin.message).toBe(true); + }); + + it('DELETE with no stored row is refused the same way, naming the removal', async () => { + const del = refusal(await call('DELETE', `/meta/datasource/${DATASOURCE}`)); + expect({ status: del.status, code: del.code }).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(del.message.startsWith(`${REMOVE_VERDICT}: it is read-only.`), del.message).toBe(true); + expect(del.message).toContain(REMEDY); + + const admin = refusal(await call('DELETE', `/datasources/${DATASOURCE}`)); + expect({ status: admin.status, code: admin.code }).toEqual({ status: 400, code: 'DATASOURCE_ADMIN_ERROR' }); + expect(admin.message.startsWith(REMOVE_VERDICT), admin.message).toBe(true); + + expect(await adminEntry()).toMatchObject({ origin: 'code', label: CODE_LABEL }); + }); + + it('control: a runtime datasource still saves and deletes through the door that created it', async () => { + // The admin door. + const created = await call('POST', '/datasources', { + name: ADMIN_RUNTIME, label: 'Admin Runtime 21899', driver: 'sqlite', config: { filename: join(dir, 'admin-rt.db') }, + }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + expect(created.json.data?.datasource).toMatchObject({ name: ADMIN_RUNTIME, origin: 'runtime' }); + const patched = await call('PATCH', `/datasources/${ADMIN_RUNTIME}`, { label: 'Admin Runtime 21899 (edited)' }); + expect(patched.status, JSON.stringify(patched.json)).toBe(200); + const removed = await call('DELETE', `/datasources/${ADMIN_RUNTIME}`); + expect(removed.status, JSON.stringify(removed.json)).toBe(204); + + // The metadata door. + const runtimeBody = (label: string) => ({ + name: META_RUNTIME, label, driver: 'sqlite', config: { filename: join(dir, 'meta-rt.db') }, + }); + const saved = await call('PUT', `/meta/datasource/${META_RUNTIME}`, runtimeBody('Meta Runtime 21899')); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + const updated = await call('PUT', `/meta/datasource/${META_RUNTIME}`, runtimeBody('Meta Runtime 21899 (edited)')); + expect(updated.status, JSON.stringify(updated.json)).toBe(200); + expect(await storedRows(META_RUNTIME)).toHaveLength(1); + const deleted = await call('DELETE', `/meta/datasource/${META_RUNTIME}`); + expect(deleted.status, JSON.stringify(deleted.json)).toBe(200); + expect(deleted.json).toMatchObject({ success: true, reset: true }); + expect(await storedRows(META_RUNTIME)).toEqual([]); + }); + + it('a stored row left from before the refusal: PUT is still refused, DELETE removes it (200), a second DELETE is refused', async () => { + // The row a pre-fix `PUT /meta/datasource/showcase_external` persisted, + // written the way that save wrote it. + const protocol = stack.kernel.getService('protocol') as { + getOverlayRepo(organizationId: string | null): { + put(ref: unknown, body: unknown, opts: unknown): Promise; + }; + }; + await protocol.getOverlayRepo(null).put( + { org: 'env', type: 'datasource', name: DATASOURCE }, + { ...(await servedBody()), label: SHADOW_LABEL }, + { parentVersion: null, actor: null, intent: 'runtime-only', source: 'dogfood.pre-fix-save' }, + ); + expect(await storedRows(DATASOURCE)).toHaveLength(1); + + // Across a restart, so the row is the one a deployment upgraded onto this + // fix carries. + await restart(); + expect(await storedRows(DATASOURCE)).toHaveLength(1); + expect((await servedBody()).label).toBe(SHADOW_LABEL); + + const put = refusal(await call('PUT', `/meta/datasource/${DATASOURCE}`, { ...(await servedBody()), label: 'Meta Renamed 21899' })); + expect({ status: put.status, code: put.code }).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + + const repaired = await call('DELETE', `/meta/datasource/${DATASOURCE}`); + expect(repaired.status, JSON.stringify(repaired.json)).toBe(200); + expect(repaired.json).toMatchObject({ success: true, reset: true }); + expect(await storedRows(DATASOURCE)).toEqual([]); + + const again = refusal(await call('DELETE', `/meta/datasource/${DATASOURCE}`)); + expect({ status: again.status, code: again.code }).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + }, 180_000); + + it('after the repair and a restart, both doors serve the code definition again', async () => { + await restart(); + + const read = await call('GET', `/meta/datasource/${DATASOURCE}`); + expect(read.status, JSON.stringify(read.json)).toBe(200); + expect(read.json.item).toMatchObject({ origin: 'code', label: CODE_LABEL, _packageId: PACKAGE_ID }); + expect(await adminEntry()).toMatchObject({ origin: 'code', label: CODE_LABEL }); + expect(await storedRows(DATASOURCE)).toEqual([]); + }, 180_000); +}); From 8413b4622d4d26dd776474c2058fe76bc45f1a82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:09:16 +0000 Subject: [PATCH 5/6] chore(changeset): metadata-protocol minor, the meta door's code-defined datasource refusal (narrowing) Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...899-meta-door-code-datasource-read-only.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .changeset/21899-meta-door-code-datasource-read-only.md diff --git a/.changeset/21899-meta-door-code-datasource-read-only.md b/.changeset/21899-meta-door-code-datasource-read-only.md new file mode 100644 index 00000000000..e05a99e92c9 --- /dev/null +++ b/.changeset/21899-meta-door-code-datasource-read-only.md @@ -0,0 +1,27 @@ +--- +"@objectstack/metadata-protocol": minor +--- + +fix(metadata-protocol)!: the metadata door refuses an edit of a code-defined datasource, and removes only a stored row left under one (#21899) + +Clause-②: no (narrowing) + +A datasource an installed package declares in `*.datasource.ts` is code-defined: `DatasourceSchema.origin` publishes it as "GitOps-owned, read-only in the UI", and the datasource-admin door already refused to edit or remove one. The metadata door did not. The runtime registers a code-defined datasource in memory only, never as a registry item, so the door's artifact check missed it and the write took the runtime-create tier: `PUT /api/v1/meta/datasource/:name` answered 200, persisted a row, and the metadata read then served that row in place of the code definition. + +The door's artifact check now reads the datasources the installed packages declare, so the package door that refuses every other code-shipped item of a type with no overlay channel refuses this one too. + +**BREAKING — what moves for consumers.** + +- `PUT /api/v1/meta/datasource/:name` on a code-defined datasource answered 200 and now answers `403 NOT_OVERRIDABLE`: "Datasource ':name' is code-defined and cannot be edited at runtime: it is read-only. Edit the *.datasource.ts source that declares it and redeploy." +- `DELETE /api/v1/meta/datasource/:name` on a code-defined datasource with no stored row answered 200 ("nothing to delete") and now answers the same `403 NOT_OVERRIDABLE`, saying "cannot be removed at runtime". +- The admin door keeps its own `400 DATASOURCE_ADMIN_ERROR`. The two doors' codes differ; the verdict and the remedy are the same. +- The metadata read envelope reports the same answers: `editable: false`, and `deletable` true only while a stored row exists under the name. + +**Remedy.** + +- To change a code-defined datasource, edit the `*.datasource.ts` source that declares it and redeploy. +- A row an earlier `PUT` stored under a code-defined datasource's name is still removable, and removing it is the repair: `DELETE /api/v1/meta/datasource/:name` answers 200 and deletes it, once per name. After the next restart both doors serve the code definition again. Until that restart the datasource-admin service keeps the stored copy it restored at boot (tracked in #21922). + +**Unchanged.** A runtime datasource, one no package declares, saves and deletes through the metadata door as before. `OS_METADATA_WRITABLE=datasource` opens the lock exactly as it did. The host's `default` datasource is declared by no package, so the metadata door still accepts edits to it as before; the admin door refuses them. + + From dd81fb50d5ad7bf85b6d9c65db325866a7890def Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:19:41 +0000 Subject: [PATCH 6/6] chore(engine-double-contract): record the code-defined datasource door test's pinned doubles Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index bba7511cf05..1fc09854566 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -431,6 +431,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts", + "verb": "delete", + "pinned": 2 + }, + { + "file": "packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts", + "verb": "findOne", + "pinned": 2 + }, + { + "file": "packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts", + "verb": "update", + "pinned": 2 + }, { "file": "packages/metadata-protocol/src/protocol.code-only-types.test.ts", "verb": "delete",