From b8a1d86c411a2e60dad0b6fb2cf485ea11bb5940 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:15:28 +0000 Subject: [PATCH 1/5] wip: ADR-0130 D1+D3 install gate co-ownership + object-name uniqueness --- packages/objectql/src/artifact-packages.ts | 30 ++- packages/objectql/src/engine.ts | 13 +- packages/objectql/src/plugin.ts | 20 +- packages/objectql/src/registry.ts | 228 ++++++++++++++++++++- 4 files changed, 270 insertions(+), 21 deletions(-) diff --git a/packages/objectql/src/artifact-packages.ts b/packages/objectql/src/artifact-packages.ts index 36f57969c8..cddd565692 100644 --- a/packages/objectql/src/artifact-packages.ts +++ b/packages/objectql/src/artifact-packages.ts @@ -117,6 +117,25 @@ function refuse(code: string, message: string): ArtifactPackageError { return err; } +/** + * The id one artifact package is keyed by. + * + * `||`, not `??`, on purpose: `ObjectQL.registerApp` keys the installed package + * on `manifest.id || manifest.name`, so an empty-string `id` falls back to + * `name` there. Every seam that has to name a package — this module's ordering + * map, and the install gate's co-ownership set (ADR-0130 D1) — reads the id + * through THIS function, so none of them can order or admit a package under a + * key the registry never stores it by. + * + * @returns The package id, or `undefined` when the manifest carries neither a + * usable `id` nor a usable `name`. + */ +export function artifactPackageId(manifest: unknown): string | undefined { + const id = (manifest as { id?: unknown; name?: unknown } | null | undefined)?.id + || (manifest as { name?: unknown } | null | undefined)?.name; + return typeof id === 'string' && id !== '' ? id : undefined; +} + /** The ordering-relevant projection of one artifact package. */ interface ArtifactPackageNode extends OrderablePlugin { /** The caller's ORIGINAL manifest body — never a parsed clone. */ @@ -186,14 +205,9 @@ export function resolveArtifactPackageOrder(artifact: unknown): unknown[] { // header: the schema is a gate here, and a parsed clone carries defaults // and drops undeclared keys the singular-`manifest` branch keeps. const manifest = (entry as { manifest?: unknown }).manifest; - // `||`, not `??`, on purpose: `ObjectQL.registerApp` keys the installed - // package on `manifest.id || manifest.name`, so an empty-string `id` falls - // back to `name` there — this seam must agree on what the id IS, or the - // sorter would order a package under a key the registry never stores it by. - const id = (manifest as { id?: unknown; name?: unknown }).id - || (manifest as { name?: unknown }).name; - - if (typeof id !== 'string' || id === '') { + const id = artifactPackageId(manifest); + + if (id === undefined) { throw refuse( 'INVALID_ARTIFACT_PACKAGE_ENTRY', `Release artifact \`packages[${index}]\` carries a manifest with no usable ` diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 07c5edbe5a..90247d5b88 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -169,7 +169,7 @@ import { SECRET_MASK, } from './secret-fields.js'; import { pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared'; -import { SchemaRegistry, computeFQN } from './registry.js'; +import { SchemaRegistry, computeFQN, type ArtifactInstallScope } from './registry.js'; import { expandSearchToFilter } from './search-filter.js'; import { isSearchCompanionRequested, stripSearchCompanion } from './search-companion.js'; import { ExpressionEngine } from '@objectstack/formula'; @@ -4768,7 +4768,7 @@ export class ObjectQL implements IObjectQLEngine { * Key: Package ≠ App. The manifest is the package. The apps[] array inside * the manifest contains UI navigation definitions (AppSchema). */ - registerApp(manifest: any) { + registerApp(manifest: any, scope?: ArtifactInstallScope) { const id = manifest.id || manifest.name; const namespace = manifest.namespace as string | undefined; this.invalidateSummaryIndex(); // new objects may add/change summary fields @@ -4791,8 +4791,13 @@ export class ObjectQL implements IObjectQLEngine { } } - // 1. Register the Package (manifest + lifecycle state) - this._registry.installPackage(manifest); + // 1. Register the Package (manifest + lifecycle state). + // [ADR-0130 D1/D3] `scope` is the artifact's own package list, passed by + // the load path that read the artifact. It is what lets the install gate + // tell a CO-OWNER (another package from this same artifact) from a + // stranger — the question D1 corrected the gate to ask. Absent for every + // single-package caller, where the gate behaves exactly as before (D7). + this._registry.installPackage(manifest, undefined, scope); this.logger.debug('Installed Package', { id: manifest.id, name: manifest.name, namespace }); // 2. Register owned objects diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 35a752dcaf..72614be0b2 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -4,7 +4,7 @@ import { ObjectQL } from './engine.js'; import { assembleMetadataProtocol } from '@objectstack/metadata-protocol'; import type { MetadataAuthoringChannel } from '@objectstack/metadata-protocol'; import { Plugin, PluginContext } from '@objectstack/core'; -import { resolveArtifactPackageOrder } from './artifact-packages.js'; +import { resolveArtifactPackageOrder, artifactPackageId } from './artifact-packages.js'; import { applyConversionsToStoredItem } from '@objectstack/spec'; import { StorageNameMapping } from '@objectstack/spec/system'; import { LifecycleService } from './lifecycle/lifecycle-service.js'; @@ -433,8 +433,24 @@ export class ObjectQLPlugin implements Plugin { return Promise.resolve(); } + // [ADR-0130 D1] The artifact's own package list, handed to every + // install in it. This is the whole of how the install gate learns + // "same artifact": co-ownership is proven by joint delivery, and this + // load path is the only place that holds the delivery. ⛔ No owner field + // on the manifest (D8) and nothing persisted — the claim cannot outlive, + // or drift from, the artifact that IS the claim. + // + // A single-`manifest` artifact yields a one-element list whose only + // member is the installing package itself, which the gate excludes + // anyway: that path stays bit-identical (D7). + const scope = { + packageIds: ordered + .map((m) => artifactPackageId(m)) + .filter((id): id is string => id !== undefined), + }; + for (const manifest of ordered) { - ql.registerApp(manifest); + ql.registerApp(manifest, scope); ctx.logger.debug('Manifest registered via manifest service', { id: manifest.id || manifest.name }); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 8ca8d2903f..3dc402332c 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -37,6 +37,11 @@ import { provisionSearchCompanion, SEARCH_COMPANION_FIELD } from './search-compa import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema, checkFieldCompleteness } from '@objectstack/spec/kernel'; import { AppSchema } from '@objectstack/spec/ui'; import { applyProtection } from '@objectstack/spec/shared'; +// [ADR-0130 D1] The ONE derivation of the key a package is installed under +// (`id || name`). The install gate's co-ownership set must name packages by the +// same string the artifact loader ordered them by, or a co-owner would be +// admitted — or refused — under a key nothing else in the path uses. +import { artifactPackageId } from './artifact-packages.js'; /** * Reserved namespaces that do not get FQN prefix applied. @@ -1153,6 +1158,119 @@ export class NamespaceConflictError extends Error { } } +/** + * [ADR-0130 D1] What the install gate is told about the artifact now installing. + * + * `installPackage` has no notion of which release artifact a manifest arrived + * in, and ADR-0130 D8 deliberately adds **no** owner/publisher field to the + * manifest to give it one. So the knowledge is threaded as an install-time + * SCOPE: the load path that reads an artifact (`manifest.register()` → + * `resolveArtifactPackageOrder` → `registerApp`) already holds the artifact's + * package list, and passes it down. + * + * Two consequences worth stating, because both are load-bearing: + * + * - **It is per-install, not stored.** Nothing persists a co-ownership claim, + * so nothing can drift from the artifact that is the claim (D8's objection to + * a manifest field, applied to the runtime). + * - **Absent scope means today's behaviour, structurally.** Every caller that + * installs ONE package — `protocol.installPackage`, `POST /packages`, a bare + * `registerApp` — passes no scope, so the co-owner set is empty and both + * halves of the ADR-0130 D1/D3 pair are no-ops. A single-`manifest` artifact + * passes a one-element scope, whose only member is the package itself, which + * the gate excludes anyway (D7). + */ +export interface ArtifactInstallScope { + /** + * The ids of every package delivered by the artifact currently installing — + * read through {@link artifactPackageId}, so they are the same strings the + * registry keys installed packages by. + */ + readonly packageIds: readonly string[]; +} + +/** + * The object names one manifest CLAIMS OWNERSHIP of at install time — the names + * `registerApp` is about to hand `registerObject` with `ownership: 'own'`. + * + * Both authored shapes are read, because `registerApp` reads both: an array of + * object definitions, or a name-keyed map (whose KEY is the name — `registerApp` + * overwrites `objDef.name` with it). + * + * ⛔ Glob strings are skipped, not treated as names. `ManifestSchema.objects` is + * `z.array(z.string())` — file patterns — and an unassembled manifest that still + * carries them registers no object here; a pattern is not an object name, and + * refusing on one would refuse a package that claims nothing. + * + * ⛔ `objectExtensions` are not read: an extension contributes fields to an + * object it does not own (`ownership: 'extend'`), which is the co-ownership + * ADR-0130 exists to permit, not the collision it refuses. + */ +function declaredOwnedObjectNames(manifest: ObjectStackManifest): string[] { + const objects = (manifest as { objects?: unknown }).objects; + if (!objects || typeof objects !== 'object') return []; + if (Array.isArray(objects)) { + return objects + .map((o) => (o && typeof o === 'object' ? (o as { name?: unknown }).name : undefined)) + .filter((n): n is string => typeof n === 'string' && n !== ''); + } + return Object.keys(objects as Record).filter((n) => n !== ''); +} + +/** + * [ADR-0130 D3] Raised when two packages delivered by ONE release artifact both + * claim the same object name. + * + * This refusal is the other half of the D1 gate relaxation, and D3 specifies the + * pair as a machine constraint rather than an instruction because this is the + * only part of ADR-0130 that can reach customer data. Today's namespace + * exclusivity is silently carrying a second guarantee: ADR-0048 §3.2 grounds the + * namespace gate on "two packages with namespace `crm` both try to create + * `crm_account` and the second fails at the DB", so "no two packages share a + * namespace" has been proxying for **"no two packages define the same object + * name."** Relax the first without adding the second and two co-owning packages + * defining `crm_account` produce either a duplicate `CREATE TABLE` or — + * driver-dependent — one package silently overwriting the other's table + * definition. + * + * So the check is install-time and ahead of every mutation `installPackage` + * makes, which puts it ahead of all DDL: the refusal lands before the second + * package is recorded at all, rather than half-applying an install and blowing + * up at table creation. + * + * Carries the ADR-0112 envelope (`code` + `status`) — the shape this + * repository's rejection tests assert against, never a bare throw. + */ +export class ArtifactObjectNameConflictError extends Error { + readonly code = 'DUPLICATE_ARTIFACT_OBJECT_NAME'; + readonly status = 422; + /** The object name both packages claim. */ + readonly objectName: string; + /** The co-owning package that already owns the name. */ + readonly existingPackageId: string; + /** The package whose install this refusal stopped. */ + readonly incomingPackageId: string; + + constructor(objectName: string, existingPackageId: string, incomingPackageId: string) { + super( + `Object name conflict inside one release artifact: object "${objectName}" is ` + + `already owned by package "${existingPackageId}", so package ` + + `"${incomingPackageId}" — delivered by the same artifact — cannot define it ` + + `too. Two packages defining one object name map to one physical table: the ` + + `install would either fail at the DB with a duplicate CREATE TABLE or, ` + + `driver-dependent, let one definition silently overwrite the other. Rename ` + + `the object in "${incomingPackageId}", or have it \`extend\` ` + + `"${existingPackageId}"'s object instead of owning a second one. Sharing a ` + + `namespace across packages in one artifact is allowed (ADR-0130 D1); ` + + `sharing an object NAME is not (ADR-0130 D3).`, + ); + this.name = 'ArtifactObjectNameConflictError'; + this.objectName = objectName; + this.existingPackageId = existingPackageId; + this.incomingPackageId = incomingPackageId; + } +} + // [#10062] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here. // They now live in `@objectstack/metadata-core` // (`code-artifact-provenance.ts`), imported at the top of this file and @@ -3572,16 +3690,45 @@ export class SchemaRegistry { // Package Management // ========================================== - installPackage(manifest: ObjectStackManifest, settings?: Record): InstalledPackage { + installPackage( + manifest: ObjectStackManifest, + settings?: Record, + scope?: ArtifactInstallScope, + ): InstalledPackage { + // [ADR-0130 D1] The co-owners of this package — the OTHER packages the same + // release artifact is delivering. Computed once and read by both halves of + // the install-time pair below, so the relaxation and the refusal cannot + // disagree about who counts as a co-owner. + // + // Everything inside one artifact is built, versioned, downloaded and + // installed as one atomic act by one publisher, and D1 makes that joint + // delivery the ownership proof itself — nothing else asserts it, and D8 + // deliberately adds no manifest field that could drift from it. + // + // The self-exclusion is by the SAME key the registry installs under + // ({@link artifactPackageId}), not by `manifest.id` alone: a manifest + // carrying only `name` is installed under that name, and a co-owner set + // that still contained it would let the package "co-own" with itself. + const selfId = artifactPackageId(manifest); + const coOwners = new Set((scope?.packageIds ?? []).filter((id) => id !== selfId)); + // ADR-0048 Phase 1 — install-time namespace gate. Refuse a package whose - // namespace is already owned by a *different* installed package; this is - // the constraint the object/table layer enforces implicitly (a duplicate - // `CREATE TABLE _` fails at the DB), made explicit and early. - // Same-package reinstall/reload is excluded (owner === manifest.id), and - // shareable platform namespaces (base/system/sys) are exempt. + // namespace is already owned by an installed package that is not a co-owner + // of it; this is the constraint the object/table layer enforces implicitly + // (a duplicate `CREATE TABLE _` fails at the DB), made explicit and + // early. Same-package reinstall/reload is excluded (owner === manifest.id), + // shareable platform namespaces (base/system/sys) are exempt, and — since + // ADR-0130 D1 — so is a co-owner delivered by the same artifact. + // + // ⛔ This relaxation may not exist without the object-name check below it. + // Namespace exclusivity has been silently proxying for "no two packages + // define the same object name" (ADR-0048 §3.2's own grounding), so dropping + // the proxy alone is the one path in ADR-0130 that can damage customer data. + // ADR-0130 D3 states the pair as a machine constraint for that reason, and + // `registry-artifact-co-ownership.test.ts` asserts it as ONE proposition. if (manifest.namespace && !isShareableNamespace(manifest.namespace)) { const conflictOwner = this.getNamespaceOwners(manifest.namespace).find( - (owner) => owner !== manifest.id, + (owner) => owner !== manifest.id && !coOwners.has(owner), ); if (conflictOwner) { if (this.collisionPolicy === 'warn') { @@ -3597,6 +3744,13 @@ export class SchemaRegistry { } } + // [ADR-0130 D3] The other half of the pair: per-object-name uniqueness + // across the co-owners of one artifact, install-time and ahead of any DDL. + // Placed ahead of EVERY mutation this method makes, so a refused package + // leaves no record, no namespace ownership and no half-applied install + // behind — the same disposition the namespace gate above has. + this.refuseCoOwnedObjectNameCollision(manifest, selfId, coOwners); + const now = new Date().toISOString(); const disabled = this.initialDisabledPackageIds.has(manifest.id); const pkg: InstalledPackage = { @@ -3628,6 +3782,66 @@ export class SchemaRegistry { return pkg; } + /** + * [ADR-0130 D3] Refuse an install in which a CO-OWNER of the incoming package + * — a package delivered by the same release artifact — already owns an object + * name the incoming package claims. + * + * ## Why the refusal is scoped to co-owners + * + * It is exactly the surface D1 opened. Before D1 the namespace gate refused + * two packages sharing a namespace outright, and that refusal was doing this + * job by proxy; a name claimed by a package from a DIFFERENT artifact is still + * that gate's business and still refused there. So this check adds no refusal + * the platform did not already make in some form — per #14122 §6.2 it can only + * reject a configuration that would have failed at the DB anyway, earlier and + * more legibly — while the co-owner case, which D1 has just admitted, is the + * one that would otherwise reach the DB unchecked. + * + * ## One resolution, shared with the read path + * + * The name is resolved through {@link computeFQN} — the same call + * `registerObject` makes on the very definitions this manifest is about to + * register — rather than by a private spelling. A gate that resolved names its + * own way could refuse an entry `registerObject` would have accepted under a + * different key, or wave through the one it would have refused. + * + * ## The one carve-out + * + * A tenant-authored sitting owner is skipped, because `registerObject`'s + * late-install path (ADR-0029 D9 §6.1) does not refuse that case either: a + * code package that ships a name a `sys_metadata` row already holds TAKES + * ownership and re-classifies the tenant contribution as its overlay. Refusing + * here would break an install the very next call would have completed. + * + * @throws {ArtifactObjectNameConflictError} ADR-0112 envelope, naming both + * packages and the object. + */ + private refuseCoOwnedObjectNameCollision( + manifest: ObjectStackManifest, + selfId: string | undefined, + coOwners: ReadonlySet, + ): void { + // No artifact scope, or an artifact carrying one package: nothing this + // method can refuse. Stated as an early return rather than left to fall out + // of the loop, because "the single-package path is untouched" (D7) is a + // property of this code, not a coincidence of its inputs. + if (coOwners.size === 0) return; + + for (const shortName of declaredOwnedObjectNames(manifest)) { + const fqn = computeFQN(manifest.namespace, shortName); + const owner = this.getObjectOwner(fqn); + const ownerId = owner?.packageId; + if (owner === undefined || ownerId === undefined || ownerId === selfId) continue; + // Not a co-owner: a cross-artifact claim, which the namespace gate above + // owns and refuses. ⛔ Not widened here — that would be a new refusal for + // configurations D1 never admitted, outside this card and outside D3. + if (!coOwners.has(ownerId)) continue; + if (isTenantAuthored(owner.definition)) continue; + throw new ArtifactObjectNameConflictError(fqn, ownerId, selfId ?? manifest.id); + } + } + uninstallPackage(id: string): boolean { const pkg = this.getPackage(id); if (!pkg) { From d4ecf126d8368b92c0eb5e698562233cefb47b48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:22:26 +0000 Subject: [PATCH 2/5] test: pairing gate for ADR-0130 D1+D3 --- .../registry-artifact-co-ownership.test.ts | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 packages/objectql/src/registry-artifact-co-ownership.test.ts diff --git a/packages/objectql/src/registry-artifact-co-ownership.test.ts b/packages/objectql/src/registry-artifact-co-ownership.test.ts new file mode 100644 index 0000000000..5d95f65b61 --- /dev/null +++ b/packages/objectql/src/registry-artifact-co-ownership.test.ts @@ -0,0 +1,341 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0130 D1 + D3 — the install gate's co-ownership criterion, and the + * object-name uniqueness check that may not ship without it. + * + * ## Why one file, and why the first test carries both halves + * + * D3 specifies this pair as a MACHINE constraint rather than an instruction, + * because it is the only part of ADR-0130 that can reach customer data. Today's + * namespace exclusivity is silently carrying a second guarantee — ADR-0048 §3.2 + * grounds it on "two packages with namespace `crm` both try to create + * `crm_account` and the second fails at the DB" — so "no two packages share a + * namespace" has been proxying for "no two packages define the same object + * name". Relax the first without adding the second and two co-owning packages + * defining `crm_account` produce a duplicate `CREATE TABLE`, or — driver + * dependent — one package silently overwriting the other's table definition. + * + * So the first test is deliberately ONE `it` asserting ONE proposition: *given + * the gate admits two co-owning packages, an artifact whose co-owners define the + * same object name is refused at install.* Delete the refusal and its second + * half goes red; revert the relaxation and its first half goes red. ⛔ Do not + * "tidy" it into two independent tests — separable tests are exactly what would + * let the relaxation ship alone, which is the outcome D3 exists to prevent. + * + * That test drives the REAL load path (`manifest.register()` on a booted + * kernel), not `installPackage` with a hand-made scope, because the scope + * threading is part of the change: an implementation that relaxes the gate but + * never tells it which artifact a package arrived in would pass a hand-fed test + * and fail on a real artifact. + * + * Rejection assertions carry the ADR-0112 envelope — `code` AND `status` — + * never a bare "it throws": a bare throw assertion stays green against a driver + * or a fixture that throws a plain `Error` for an unrelated reason, which is + * precisely the failure this suite exists to catch. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from './plugin.js'; +import { SchemaRegistry, NamespaceConflictError, ArtifactObjectNameConflictError } from './registry.js'; +import type { ObjectQL } from './engine.js'; + +type ManifestService = { register(m: unknown): void | Promise }; + +const engineOf = (kernel: ObjectKernel): ObjectQL => kernel.getService('objectql'); + +/** The error shape every rejection assertion below reads. */ +type Envelope = Error & { code?: string; status?: number }; + +/** + * `manifest.register()` may reject or throw synchronously — a refusal raised by + * `installPackage` propagates out of the non-async `register` before the promise + * it would otherwise return exists. Catching both is the honest spelling; an + * `await expect(...).rejects` assertion would MISS the synchronous throw and + * report it as a test error instead of a refusal. + */ +const registerAndCatch = async (svc: ManifestService, artifact: unknown): Promise => { + try { + await svc.register(artifact); + return undefined; + } catch (e) { + return e as Envelope; + } +}; + +/** The package that owns `crm_account`, under namespace `crm`. */ +const crmCore = () => ({ + id: 'com.acme.crm', + name: 'acme_crm', + version: '1.0.0', + type: 'app', + namespace: 'crm', + objects: [ + { name: 'crm_account', label: 'Account', fields: { name: { name: 'name', label: 'Name', type: 'text' } } }, + ], +}); + +/** + * A co-owning package: SAME namespace `crm`, different object. This is exactly + * the shape today's gate refuses and D1 admits — two packages, one artifact, one + * namespace. + */ +const crmBilling = () => ({ + id: 'com.acme.crm.billing', + name: 'acme_crm_billing', + version: '1.0.0', + type: 'module', + namespace: 'crm', + objects: [ + { name: 'crm_invoice', label: 'Invoice', fields: { total: { name: 'total', label: 'Total', type: 'number' } } }, + ], +}); + +/** The same co-owner, but claiming the object name `com.acme.crm` already owns. */ +const crmBillingColliding = () => ({ + ...crmBilling(), + objects: [ + { name: 'crm_account', label: 'Account (billing)', fields: { balance: { name: 'balance', label: 'Balance', type: 'number' } } }, + ], +}); + +const artifactOf = (...manifests: unknown[]) => ({ packages: manifests.map((manifest) => ({ manifest })) }); + +const bootKernel = async () => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + return kernel; +}; + +describe('ADR-0130 D1 + D3 — the gate relaxation and the object-name check are ONE change', () => { + const kernels: ObjectKernel[] = []; + + const freshKernel = async () => { + const k = await bootKernel(); + kernels.push(k); + return k; + }; + + afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } + }); + + it('THE PAIRING GATE — given the gate admits same-artifact co-owners, an artifact whose co-owners define the same object name is REFUSED at install', async () => { + // ── Half 1 (D1): the relaxation. Two packages, one artifact, one namespace. + // Today's gate refuses this outright; joint delivery in one artifact IS the + // co-ownership declaration, so it must now install. + const admitting = await freshKernel(); + const admitted = await registerAndCatch( + admitting.getService('manifest') as ManifestService, + artifactOf(crmCore(), crmBilling()), + ); + expect(admitted).toBeUndefined(); + + const ql = engineOf(admitting); + expect(ql.registry.getPackage('com.acme.crm')).toBeDefined(); + expect(ql.registry.getPackage('com.acme.crm.billing')).toBeDefined(); + // Both are owners of the namespace — co-ownership, not a transfer. + expect(ql.registry.getNamespaceOwners('crm').sort()) + .toEqual(['com.acme.crm', 'com.acme.crm.billing']); + // …and each owns its own object. + expect(ql.registry.getObjectOwner('crm_account')?.packageId).toBe('com.acme.crm'); + expect(ql.registry.getObjectOwner('crm_invoice')?.packageId).toBe('com.acme.crm.billing'); + + // ── Half 2 (D3): the refusal the relaxation may not ship without. The SAME + // co-ownership, with both packages defining `crm_account`. + const refusing = await freshKernel(); + const refused = await registerAndCatch( + refusing.getService('manifest') as ManifestService, + artifactOf(crmCore(), crmBillingColliding()), + ); + + expect(refused).toBeDefined(); + // ADR-0112 envelope — never a bare `toThrow()`. + expect(refused?.code).toBe('DUPLICATE_ARTIFACT_OBJECT_NAME'); + expect(refused?.status).toBe(422); + // D3: the error names BOTH packages and the object. + expect(refused?.message).toContain('com.acme.crm'); + expect(refused?.message).toContain('com.acme.crm.billing'); + expect(refused?.message).toContain('crm_account'); + expect(refused).toBeInstanceOf(ArtifactObjectNameConflictError); + }); + + it('refuses whichever co-owner claims the name second, in either declared order', async () => { + // The refusal is a property of the artifact, not of the array slot. With no + // dependency edges the loader preserves declared order, so this artifact + // installs the colliding package FIRST and the refusal names the roles the + // other way round. An implementation that only looked at "the last one in" + // would still be wrong for one of these two. + const kernel = await freshKernel(); + const refused = await registerAndCatch( + kernel.getService('manifest') as ManifestService, + artifactOf(crmBillingColliding(), crmCore()), + ); + expect(refused?.code).toBe('DUPLICATE_ARTIFACT_OBJECT_NAME'); + expect(refused?.status).toBe(422); + const err = refused as ArtifactObjectNameConflictError; + expect(err.objectName).toBe('crm_account'); + expect(err.existingPackageId).toBe('com.acme.crm.billing'); + expect(err.incomingPackageId).toBe('com.acme.crm'); + }); + + it('still refuses two packages from DIFFERENT artifacts that share a namespace', async () => { + // The relaxation is co-ownership, not permission. Two single-package + // artifacts registered separately are two deliveries by (as far as the + // runtime can observe) two publishers, and the ADR-0048 gate stands. + const kernel = await freshKernel(); + const svc = kernel.getService('manifest') as ManifestService; + + expect(await registerAndCatch(svc, crmCore())).toBeUndefined(); + const refused = await registerAndCatch(svc, crmBilling()); + + expect(refused).toBeInstanceOf(NamespaceConflictError); + const err = refused as NamespaceConflictError; + expect(err.namespace).toBe('crm'); + expect(err.existingPackageId).toBe('com.acme.crm'); + expect(err.incomingPackageId).toBe('com.acme.crm.billing'); + // Nothing half-applied: the refused package is not recorded. + expect(engineOf(kernel).registry.getPackage('com.acme.crm.billing')).toBeUndefined(); + }); + + it('leaves NOTHING behind when it refuses — no package record, no namespace claim, the sitting definition untouched', async () => { + // D3 item 3: the check is install-time, ahead of any DDL. Its observable + // consequence at this layer is that the refused package never reaches the + // registry at all — the refusal lands before every mutation `installPackage` + // makes, so there is no half-applied install to unwind and no second + // definition of `crm_account` for a driver to reconcile. + const kernel = await freshKernel(); + const refused = await registerAndCatch( + kernel.getService('manifest') as ManifestService, + artifactOf(crmCore(), crmBillingColliding()), + ); + expect(refused?.code).toBe('DUPLICATE_ARTIFACT_OBJECT_NAME'); + + const registry = engineOf(kernel).registry; + expect(registry.getPackage('com.acme.crm.billing')).toBeUndefined(); + expect(registry.getNamespaceOwners('crm')).toEqual(['com.acme.crm']); + // The sitting owner's definition is exactly its own — not merged with, and + // not replaced by, the refused package's body. + expect(registry.getObjectOwner('crm_account')?.packageId).toBe('com.acme.crm'); + const account = registry.resolveObject('crm_account') as { label?: string; fields?: Record } | undefined; + expect(account?.label).toBe('Account'); + expect(account?.fields?.balance).toBeUndefined(); + }); +}); + +describe('ADR-0130 D1 — the relaxation does not widen the same-id reinstall exemption', () => { + /** + * The negative half, and the reason this suite exists in the shape it does. + * + * Today's gate admits `owner === manifest.id` — a package reinstalling or + * reloading itself. Widening the namespace predicate to "co-owner within one + * artifact" must NOT widen that exemption too: being delivered alongside a + * package must never become the right to overwrite it. Co-ownership shares a + * NAMESPACE; it does not share an identity, a package record, or an object. + */ + let registry: SchemaRegistry; + + const scopeOf = (...ids: string[]) => ({ packageIds: ids }); + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + registry.logLevel = 'silent'; + }); + + /** + * Install a package the way `registerApp` does — the package record, then its + * owned objects through the registry's own primitive with the same arguments + * `registerApp` passes. + */ + const install = (manifest: ReturnType, scope?: { packageIds: string[] }) => { + registry.installPackage(manifest as never, undefined, scope); + for (const obj of manifest.objects ?? []) { + registry.registerObject(obj as never, manifest.id, manifest.namespace, 'own'); + } + }; + + it('a co-owner does not take over the package record, the namespace, or the object it shares a namespace with', () => { + const scope = scopeOf('com.acme.crm', 'com.acme.crm.billing'); + install(crmCore(), scope); + install(crmBilling(), scope); + + // The sitting package's record is still ITS manifest, not the co-owner's. + expect(registry.getPackage('com.acme.crm')?.manifest?.name).toBe('acme_crm'); + expect(registry.getPackage('com.acme.crm.billing')?.manifest?.name).toBe('acme_crm_billing'); + // The namespace gained an owner; it did not change hands. + expect(registry.getNamespaceOwners('crm').sort()) + .toEqual(['com.acme.crm', 'com.acme.crm.billing']); + // The object stays with the package that declared it. + expect(registry.getObjectOwner('crm_account')?.packageId).toBe('com.acme.crm'); + }); + + it('a DIFFERENT id in the same artifact gains no right to redefine an installed co-owner\'s object', () => { + const scope = scopeOf('com.acme.crm', 'com.acme.crm.billing'); + install(crmCore(), scope); + + let caught: Envelope | undefined; + try { + registry.installPackage(crmBillingColliding() as never, undefined, scope); + } catch (e) { caught = e as Envelope; } + + expect(caught?.code).toBe('DUPLICATE_ARTIFACT_OBJECT_NAME'); + expect(caught?.status).toBe(422); + expect(registry.getPackage('com.acme.crm.billing')).toBeUndefined(); + }); + + it('still admits a package reinstalling ITSELF, with or without an artifact scope', () => { + // The exemption the relaxation must leave exactly as wide as it was: same + // id, so same package, so a reload — including a reload arriving from a + // different artifact, which is what a version upgrade is. + install(crmCore(), scopeOf('com.acme.crm')); + expect(() => registry.installPackage(crmCore() as never, undefined, scopeOf('com.acme.crm'))).not.toThrow(); + expect(() => registry.installPackage(crmCore() as never)).not.toThrow(); + expect(() => registry.installPackage(crmCore() as never, undefined, scopeOf('com.acme.crm', 'com.acme.crm.billing'))) + .not.toThrow(); + }); + + it('does not refuse an object name claimed from OUTSIDE the artifact — that is the namespace gate\'s question', () => { + // Scope discipline, pinned: D3's refusal covers what D1 admitted, and + // nothing else. A stranger claiming a name it does not co-own is refused by + // the ADR-0048 gate (same namespace) or by `registerObject`'s own ownership + // rule (different namespace) — both unchanged by this card, and neither one + // this refusal's to pre-empt. + install(crmCore(), scopeOf('com.acme.crm')); + const stranger = { ...crmBillingColliding(), id: 'com.other.suite', namespace: 'other' }; + + let caught: Envelope | undefined; + try { + registry.installPackage(stranger as never, undefined, scopeOf('com.other.suite')); + } catch (e) { caught = e as Envelope; } + + expect(caught).toBeUndefined(); + expect(registry.getPackage('com.other.suite')).toBeDefined(); + }); + + it('is NOT downgraded by OS_METADATA_COLLISION=warn', () => { + // `collisionPolicy: 'warn'` is ADR-0048's escape hatch for a deliberate + // NAMESPACE migration. It was never a licence to let two definitions of one + // object name through: that is the outcome ADR-0130 D3 calls the only one in + // this design that can damage customer data, and the DB would refuse it + // anyway — later, and less legibly. So the object-name refusal is hard. + const warnReg = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'warn' }); + warnReg.logLevel = 'silent'; + const scope = scopeOf('com.acme.crm', 'com.acme.crm.billing'); + const base = crmCore(); + warnReg.installPackage(base as never, undefined, scope); + for (const obj of base.objects) warnReg.registerObject(obj as never, base.id, base.namespace, 'own'); + + let caught: Envelope | undefined; + try { + warnReg.installPackage(crmBillingColliding() as never, undefined, scope); + } catch (e) { caught = e as Envelope; } + + expect(caught?.code).toBe('DUPLICATE_ARTIFACT_OBJECT_NAME'); + expect(caught?.status).toBe(422); + }); +}); From 249a781faa80f1509e59006b28ce4c19f3f1ce47 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:24:04 +0000 Subject: [PATCH 3/5] chore: error-code vocabulary row + changeset for ADR-0130 D1+D3 --- .../adr-0130-install-gate-co-ownership.md | 47 +++++++++++++++++++ .../src/dispatcher-error-vocabulary.ts | 28 +++++++++++ 2 files changed, 75 insertions(+) create mode 100644 .changeset/adr-0130-install-gate-co-ownership.md diff --git a/.changeset/adr-0130-install-gate-co-ownership.md b/.changeset/adr-0130-install-gate-co-ownership.md new file mode 100644 index 0000000000..e309f75120 --- /dev/null +++ b/.changeset/adr-0130-install-gate-co-ownership.md @@ -0,0 +1,47 @@ +--- +"@objectstack/objectql": minor +"@objectstack/runtime": patch +--- + +feat(objectql): admit same-artifact co-owners at the namespace gate, and refuse two of them defining one object name (#14163) + +ADR-0130 **D1 + D3**, landed as the single change D3 specifies as a machine +constraint. Neither half may ship alone, in either order. + +- **D1 — the gate's question is corrected.** `SchemaRegistry.installPackage`'s + ADR-0048 namespace gate asked *"is this the same package id?"*; it now asks + *"are these co-owners within one artifact?"*. Everything inside one release + artifact is built, versioned, downloaded and installed as one atomic act by + one publisher, and ADR-0130 D1 makes that joint delivery the ownership proof. + `RESERVED_NAMESPACES` / `isShareableNamespace` are unchanged, and two packages + from **different** artifacts sharing a namespace are still refused with the + existing `NamespaceConflictError`. +- **D3 — the guarantee the gate was silently carrying is now checked directly.** + Namespace exclusivity has been proxying for *"no two packages define the same + object name"* (ADR-0048 §3.2 grounds it on exactly that). So `installPackage` + now refuses, **at install time and ahead of any DDL**, a package whose object + name is already owned by a co-owner from the same artifact — + `ArtifactObjectNameConflictError`, an ADR-0112 envelope (`code: + 'DUPLICATE_ARTIFACT_OBJECT_NAME'`, `status: 422`) naming both packages and the + object. Without it, two co-owning packages defining `crm_account` would reach + the DB as a duplicate `CREATE TABLE` or — driver-dependent — one silently + overwriting the other's table definition. + +**How the gate learns "same artifact".** An optional third argument on +`installPackage` (`ArtifactInstallScope`, the artifact's own package-id list), +threaded from the ADR-0130 D4/D5 load path through `ObjectQL.registerApp`. ⛔ No +owner/publisher field on the manifest — ADR-0130 D8 defers that deliberately, +and nothing is persisted, so a co-ownership claim cannot outlive or drift from +the artifact that IS the claim. + +**Not a compatibility break** (#14122 §6.2): the new refusal can only reject a +configuration that would have failed at the DB anyway, and it rejects it earlier +and more legibly. Every caller that installs one package — +`protocol.installPackage`, `POST /packages`, a bare `registerApp` — passes no +scope, so both halves are structurally no-ops there; a single-`manifest` +artifact passes a one-element scope whose only member is the installing package, +which the gate excludes anyway (D7's bit-identity pin covers it). + +`@objectstack/runtime` carries the classification row for the new error code in +the dispatcher error-code vocabulary (verdict `boot-refusal`: the refusal cannot +be raised by either HTTP install site, which pass no artifact scope). diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 29c9809840..ed9ebf08ed 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -716,6 +716,34 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ 'repo\'s rejection tests assert on, not evidence of a door. If an install door ever answers with ' + 'this code itself, the verdict becomes pending-registration and it belongs in the ledger batch.' }, + { + code: 'DUPLICATE_ARTIFACT_OBJECT_NAME', + file: 'packages/objectql/src/registry.ts', + shape: 'classfield', + door: 'none', + verdict: 'boot-refusal', + why: + 'ADR-0130 D3 — the refusal for two packages delivered by ONE release artifact both claiming ' + + 'the same object name, raised by `SchemaRegistry.installPackage` ahead of every mutation it ' + + 'makes (and therefore ahead of all DDL). Its reachability is narrower than the method it lives ' + + 'in: it can only fire when `installPackage` is handed an artifact install scope naming a SECOND ' + + 'package, and measured on this tree exactly one caller builds one — the `manifest` service\'s ' + + '`register()` in `packages/objectql/src/plugin.ts`, the ADR-0130 D4/D5 load path. The two ' + + 'HTTP-facing install sites pass no scope at all and so cannot raise it: ' + + '`packages/metadata-protocol/src/protocol.ts` `installPackage(manifest, request.settings)` and ' + + '`packages/runtime/src/domains/packages.ts` `installPackage(manifest, body.settings)`. Through ' + + 'the load path the reading is the one the three sibling ADR-0130 codes above already record, ' + + 're-measured here: boot-time `manifest.register()` callers (`packages/runtime/src/app-plugin.ts`, ' + + 'the platform app plugins) register inside plugin init, where a throw aborts boot before any ' + + 'HTTP boundary exists; the rehydrate loop in ' + + '`packages/cloud-connection/src/marketplace-install-local-plugin.ts` catches per entry and logs; ' + + 'and the import route in that same file catches and answers with its OWN registered ' + + '`PLUGIN_REGISTER_FAILED` at 422, interpolating this refusal\'s MESSAGE into that envelope. So ' + + 'the code reaches a reader only inside a message string, never as `error.code`. Its ' + + '`status: 422` is the ADR-0112 envelope shape this repo\'s rejection tests assert on, not ' + + 'evidence of a door. If an install door ever answers with this code itself, the verdict becomes ' + + 'pending-registration and it belongs in the ledger batch.' + }, // ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ── // // The 29 rows below are the whole verdict cost of widening `codehelper` to From 12a9b4b9dcca2d8eb2246b891979cea68bdd6e14 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:33:04 +0000 Subject: [PATCH 4/5] chore: census re-anchor + test type fix --- content/docs/permissions/system-context.mdx | 22 +++++++++---------- .../registry-artifact-co-ownership.test.ts | 8 ++++++- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 7fe8c239f8..c9a444db41 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,18 +109,18 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10914` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11076` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9772` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10919` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11081` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9777` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9809`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5730` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9814`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5735` | | 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3599`, `:3609`, `:3636` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6428` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11662` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11591` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6433` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11667` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11596` | ### 3. Sharing (`plugin-sharing`) @@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| | 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14011` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 63 | `objectql/src/engine.ts:14016` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9755`–`9772` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9760`–`9777` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` | @@ -235,7 +235,7 @@ should recognise it instead of re-deriving it. rule-materialised grant that the next reconcile silently restores. 5. **`applySystemFields` does not read this flag.** It is named as if it did. - `packages/objectql/src/registry.ts:459` is **schema-side column + `packages/objectql/src/registry.ts:464` is **schema-side column provisioning** — which columns an object carries — and consumes `ExecutionContext.isSystem` zero times. The write-time ownership behaviour people attribute to it is row 2, in `plugin-security`. diff --git a/packages/objectql/src/registry-artifact-co-ownership.test.ts b/packages/objectql/src/registry-artifact-co-ownership.test.ts index 5d95f65b61..4e989f08f1 100644 --- a/packages/objectql/src/registry-artifact-co-ownership.test.ts +++ b/packages/objectql/src/registry-artifact-co-ownership.test.ts @@ -252,7 +252,13 @@ describe('ADR-0130 D1 — the relaxation does not widen the same-id reinstall ex * owned objects through the registry's own primitive with the same arguments * `registerApp` passes. */ - const install = (manifest: ReturnType, scope?: { packageIds: string[] }) => { + const install = ( + // Structural, not `ReturnType`: the fixtures differ in their + // field shapes, and a parameter typed to one of them would reject the other + // for a reason that has nothing to do with what this suite asserts. + manifest: { id: string; namespace: string; objects?: Array<{ name: string }> }, + scope?: { packageIds: string[] }, + ) => { registry.installPackage(manifest as never, undefined, scope); for (const obj of manifest.objects ?? []) { registry.registerObject(obj as never, manifest.id, manifest.namespace, 'own'); From bf980cc498f73075b7ac4ddf848e9f628a4680e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:58:12 +0000 Subject: [PATCH 5/5] merge: origin/main into ADR-0130 D1+D3 branch, census regenerated from the merged tree Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --- content/docs/permissions/system-context.mdx | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index c9a444db41..04ae27f0cc 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,18 +109,18 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10919` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11081` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9777` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10986` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11154` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9782` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9814`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5735` | -| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3599`, `:3609`, `:3636` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9819`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5740` | +| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3604`, `:3614`, `:3641` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6433` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11667` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11596` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6438` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11747` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11676` | ### 3. Sharing (`plugin-sharing`) @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14016` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 62 | `objectql/src/engine.ts:3411` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 63 | `objectql/src/engine.ts:14096` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9760`–`9777` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9765`–`9782` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |