diff --git a/.changeset/28039-install-local-declared-datasources.md b/.changeset/28039-install-local-declared-datasources.md
new file mode 100644
index 00000000000..2465cbd38e1
--- /dev/null
+++ b/.changeset/28039-install-local-declared-datasources.md
@@ -0,0 +1,29 @@
+---
+'@objectstack/runtime': patch
+'@objectstack/cloud-connection': patch
+---
+
+fix(cloud-connection)!: an install-local install connects and records the datasources the package declares, as a boot of its artifact does
+
+Clause-②: yes (narrowing)
+Metadata-protocol: none
+
+
+
+**BREAKING** accept-set narrowing at `POST /api/v1/marketplace/install-local`, alongside the widening below (a new `@objectstack/runtime` export and a new field on the install answer).
+
+- **Before.** A package declaring a datasource whose name the environment already held (the host's `default`, a datasource created in Setup, another package's) installed `200`, and its declaration was dropped: measured on `main` with a package declaring `showcase_external`, the install answered `200` with `hotLoaded: true`.
+- **Now.** The same install is refused `409 RESOURCE_CONFLICT`, with `details.datasource` naming the held name, before anything is written. Accepting it now would serve the package's objects from the holder's pool (the connect is idempotent on the name) and, by recording the name as code, make the holder's own datasource read-only.
+- **Unchanged.** A package that declares no datasource, or only names nothing else holds, installs as before; a reinstall of the same package is not a collision with itself.
+- **Who sees it.** Only an install-local caller (`os package install`, an AI agent, a script) installing a package whose datasource name collides with one the environment already has.
+
+**The remedy.** Give the package's datasource a name the environment does not already hold, and install again.
+
+**What else changes.**
+
+A package installed through `POST /api/v1/marketplace/install-local` had its objects registered and nothing of its datasources: an object bound to the package's own datasource answered `404 OBJECT_NOT_FOUND` on every read and write, and the datasource was not served as code-defined. Now the install, and every restart's rehydrate of the ledger, take the path a boot of the package's artifact takes:
+
+- **One path.** `@objectstack/runtime` exports `connectPackageDatasources`, which `AppPlugin.start` now calls for the booted artifact and install-local calls for an installed package. Each declared datasource is registered in the metadata registry as `origin: 'code'`, stamped with the package (`_packageId`, `_packageVersion`), its name is recorded in the host's code-datasource set, and it is connected through the `datasource-connection` service's `connectDeclared` (the ADR-0062 D2 gate and the host's connect policy apply as on a boot). The boot's own behaviour is unchanged.
+- **The install answer** carries `datasources`: each declared datasource's connect verdict (`connected`, or the connection service's own non-`ok` status). Omitted for a package that declares none.
+- **A credential that cannot be resolved** is that datasource's named non-`ok` verdict, never a driver gap: the package is installed, Setup → Datasources reports the datasource as `error` with its reason, and a read of an object bound to it answers `503 ERR_DATASOURCE_UNAVAILABLE` naming it. Where a code datasource's credential comes from is not changed here.
+- **Reinstall and uninstall.** A reinstall replaces the previous version's datasources; the uninstall closes their pools, removes their registrations and their names, reported as the `runtime.package-datasources` entry in `cleanups`.
diff --git a/content/docs/data-modeling/external-datasources.mdx b/content/docs/data-modeling/external-datasources.mdx
index 64c20325b87..3e10becb5b3 100644
--- a/content/docs/data-modeling/external-datasources.mdx
+++ b/content/docs/data-modeling/external-datasources.mdx
@@ -186,6 +186,17 @@ connect, so queries against its objects keep failing until the server is
restarted. Do not set it in production.
+
+**A package installed into a running runtime** (`os package install`, which calls
+`POST /api/v1/marketplace/install-local`) gets the same treatment at install time
+and at every restart: its declared datasources are registered as `origin: code`,
+read-only, and auto-connected by the same gates. There is no boot left to refuse,
+so a datasource that cannot be connected is installed with its failed `status`
+instead, and reads of its bound objects answer `503 ERR_DATASOURCE_UNAVAILABLE`.
+The install answer lists each datasource's verdict under `datasources`. A package
+declaring a datasource name the runtime already holds is refused `409`.
+
+
### Seeing the state of a datasource
Every connect attempt's verdict is retained, so a datasource that is down no
diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts
index b270567b195..3dbce3c9108 100644
--- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts
+++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts
@@ -632,6 +632,19 @@ function manifestIdOf(p: any): string | undefined {
return p?.manifest?.id ?? p?.id ?? p?.manifest?.name ?? undefined;
}
+/**
+ * [#28039] The datasources a package body declares, as `AppPlugin` reads a
+ * stack's: a list, or a map keyed by name.
+ */
+function declaredDatasources(body: unknown): any[] {
+ const defs = isPlainObject(body) ? body.datasources : undefined;
+ return Array.isArray(defs)
+ ? defs
+ : isPlainObject(defs)
+ ? Object.entries(defs).map(([name, def]) => ({ name, ...(def as any) }))
+ : [];
+}
+
/**
* The seed datasets a cached manifest bundles: its `data[]` entries that name
* an object and carry records. One reading for the two doors that match seed
@@ -1009,6 +1022,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
const ql: any = ctx.getService('objectql');
if (ql && typeof ql.syncSchemas === 'function') await ql.syncSchemas();
} catch { /* non-fatal */ }
+ // [#28039] Connect the package's declared datasources — the
+ // boot artifact's were connected in `AppPlugin.start`, and this
+ // package arrives after it.
+ await this.connectDeclaredDatasources(ctx, converted, entry.manifestId, entry.version);
// [#21321] Bind the package's script-action bodies and body
// hooks — `register` above makes them declared, not runnable.
await this.bindArtifactHandlers(ctx, converted, entry.manifestId);
@@ -1788,6 +1805,27 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
}, 409);
}
+ // 2a. [#28039] The package's datasources are registered as code and
+ // connected in step 4b′, so a name another holder already owns —
+ // the host's `default`, a datasource created in Setup or another
+ // package's — is refused here, before any write: connecting would
+ // hand the package's objects that holder's pool (the connect is
+ // idempotent on the name), and recording the name as code would
+ // make the holder's own datasource read-only to the admin door.
+ const heldDatasource = await this.findHeldDatasourceName(ctx, manifest, manifestId);
+ if (heldDatasource) {
+ return c.json({
+ success: false,
+ error: {
+ code: 'RESOURCE_CONFLICT',
+ message: `Package '${manifestId}' cannot be installed: it declares the datasource '${heldDatasource.name}', `
+ + `and ${heldDatasource.holder} already holds that name in this system. Rename the package's datasource, `
+ + 'or remove the one holding the name first.',
+ details: { datasource: heldDatasource.name },
+ },
+ }, 409);
+ }
+
// 2b. [ADR-0120 D5e] `isolated`-posture gate on installation-wide
// uniques. Runs BEFORE hot-register and before anything is written
// to the ledger: a stopped install must leave the runtime exactly as
@@ -1967,6 +2005,16 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
ctx.logger?.warn?.(`[MarketplaceInstallLocal] syncSchemas failed for ${manifestId}: ${err?.message ?? err}`);
}
+ // 4b′. [#28039] Register, record and connect the package's declared
+ // datasources through the runtime's ONE path — the call
+ // `AppPlugin.start` makes for a boot artifact — after the schema
+ // sync, where the boot connects them too. Only for a package the
+ // running kernel holds: on the lenient path nothing of it is
+ // registered until the restart's rehydrate, which connects them.
+ const datasources = hotLoadError === undefined
+ ? await this.connectDeclaredDatasources(ctx, converted, manifestId, version)
+ : undefined;
+
// 4c. [#21321] Bind the package's script-action bodies and body hooks
// through the runtime's ONE binder — the call `AppPlugin.start`
// makes for a boot artifact. A reinstall replaces the previous
@@ -2026,6 +2074,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
// [#27237] Omitted, not emptied, for a package whose connectors
// name no credential, so that answer's key set is unchanged.
...(credentialRefs !== undefined ? { credentialRefs } : {}),
+ // [#28039] Each declared datasource's connect verdict — `connected`,
+ // or the connection service's named non-`ok` status (a credential
+ // it cannot resolve is `failed-credentials`). Omitted for a package
+ // that declares none, so that answer's key set is unchanged.
+ ...(datasources !== undefined ? { datasources } : {}),
...(unsetRefs.length > 0 ? { credentialNote: installedCredentialNote(unsetRefs, { door }) } : {}),
upgradedFrom: conflict === 'marketplace' ? 'previous-marketplace-version' : null,
translationsLoaded: seededSummary.translationsLoaded,
@@ -3477,6 +3530,111 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
return typeof ql?.convertPackageBodyForward === 'function' ? ql.convertPackageBodyForward(manifest) : manifest;
};
+ /**
+ * [#28039] Register, record and connect the datasources `manifest`
+ * declares, through `connectPackageDatasources` — the ONE path, which
+ * `AppPlugin.start` takes for the artifact the host boots: each registered
+ * in the metadata registry as `origin: 'code'` stamped with this package,
+ * its name recorded in the host's code-datasource set, and connected
+ * through the `datasource-connection` service (the D2 gate and the host's
+ * connect policy apply as on a boot). A reinstall replaces the previous
+ * version's datasources; the uninstall withdraws them through the
+ * `runtime.package-datasources` uninstall cleanup.
+ *
+ * Before this, the door registered a package's objects and nothing of its
+ * datasources: an object bound to one had no driver, and the admin door
+ * did not serve the datasource as code-defined.
+ *
+ * ⛔ Where a declared credential comes from is not decided here (#27687):
+ * a credential the connection service cannot resolve is its
+ * `failed-credentials` verdict, which the datasource answers and a read of
+ * a bound object names (`ERR_DATASOURCE_UNAVAILABLE`).
+ *
+ * Answers each declared datasource's verdict, or `undefined` when the
+ * package declares none (or the runtime predates the path). Never throws:
+ * the package is installed either way.
+ */
+ private connectDeclaredDatasources = async (
+ ctx: PluginContext,
+ manifest: unknown,
+ manifestId: string,
+ version: string | undefined,
+ ): Promise | undefined> => {
+ const declared = declaredDatasources(manifest);
+ let connect: typeof import('@objectstack/runtime')['connectPackageDatasources'] | undefined;
+ try {
+ const mod: any = await import('@objectstack/runtime');
+ if (typeof mod?.connectPackageDatasources === 'function') connect = mod.connectPackageDatasources;
+ } catch { /* reported below */ }
+ if (!connect) {
+ if (declared.length > 0) {
+ ctx.logger?.warn?.(
+ `[MarketplaceInstallLocal] this runtime has no connectPackageDatasources — the datasources of ${manifestId} are NOT connected: `
+ + 'the objects bound to them have no driver. Upgrade @objectstack/runtime alongside @objectstack/cloud-connection.',
+ );
+ }
+ return undefined;
+ }
+ const objects = isPlainObject(manifest) && Array.isArray(manifest.objects) ? manifest.objects : [];
+ try {
+ // Called with an empty list too: a reinstall whose new version
+ // declares none withdraws what the previous version registered.
+ const verdicts = await connect(ctx, {
+ appId: manifestId,
+ source: 'MarketplaceInstallLocal',
+ datasources: declared,
+ owned: declared.map((datasource) => ({
+ datasource,
+ owner: { packageId: manifestId, ...(typeof version === 'string' ? { packageVersion: version } : {}) },
+ })),
+ objects,
+ installed: true,
+ });
+ return declared.length > 0 ? verdicts : undefined;
+ } catch (err: any) {
+ ctx.logger?.error?.(
+ `[MarketplaceInstallLocal] the datasources of ${manifestId} could not be registered or connected: ${err?.message ?? err}`,
+ );
+ return declared.length > 0 ? [] : undefined;
+ }
+ };
+
+ /**
+ * [#28039] The first datasource `manifest` declares whose name another
+ * holder already owns, with that holder: the host's `default`, or a
+ * datasource in the metadata service not stamped with this package (one
+ * created in Setup, the boot artifact's, another package's). A datasource
+ * this package's earlier install registered is its own, so a reinstall
+ * passes. `undefined` when nothing collides or nothing can be read.
+ */
+ private findHeldDatasourceName = async (
+ ctx: PluginContext,
+ manifest: unknown,
+ manifestId: string,
+ ): Promise<{ name: string; holder: string } | undefined> => {
+ const names = declaredDatasources(manifest)
+ .map((ds) => ds?.name)
+ .filter((name): name is string => typeof name === 'string' && name.length > 0);
+ if (names.length === 0) return undefined;
+ if (names.includes('default')) return { name: 'default', holder: "the host's primary datasource" };
+ let metadata: { get?: (type: string, name: string) => Promise } | undefined;
+ try { metadata = ctx.getService('metadata'); } catch { return undefined; }
+ for (const name of names) {
+ let item: unknown;
+ try { item = await metadata?.get?.('datasource', name); } catch { continue; }
+ if (!isPlainObject(item)) continue;
+ const owner = item._packageId;
+ if (owner === manifestId) continue;
+ return {
+ name,
+ holder: typeof owner === 'string' && owner.length > 0
+ ? `the package '${owner}'`
+ : item.origin === 'runtime' ? 'a datasource created in Setup' : 'a datasource',
+ };
+ }
+ return undefined;
+ };
+
/**
* [#21321] Bind an installed package's executable handlers — its
* `type: 'script'` action bodies and its body hooks — through
diff --git a/packages/qa/dogfood/test/install-local-declared-datasources.dogfood.test.ts b/packages/qa/dogfood/test/install-local-declared-datasources.dogfood.test.ts
new file mode 100644
index 00000000000..84e26adba7b
--- /dev/null
+++ b/packages/qa/dogfood/test/install-local-declared-datasources.dogfood.test.ts
@@ -0,0 +1,296 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// GOLDEN REGRESSION — an install-local install connects the datasources the
+// package declares and records them as code-defined, the way a boot of the
+// package's artifact does (#28039).
+//
+// ## What was measured before the fix
+//
+// On `origin/main` `549203d0`, this file's install (a package declaring a
+// managed sqlite datasource `qads_store` and an external one `qads_vault`
+// whose `credentialsRef` names no secret, each with an object bound to it)
+// answered `200` with `hotLoaded: true`, and then:
+//
+// - `POST /data/qads_note` and `GET /data/qads_note` answered `404
+// OBJECT_NOT_FOUND` ("Object 'qads_note' is not registered"), and so did
+// `GET /data/qads_secret_row`: the install registered the objects and
+// nothing of their datasources, so no driver served them;
+// - the connection service held no verdict for either datasource, and the
+// host's code-datasource set held neither name.
+//
+// The boot path (`AppPlugin.start`) registers each declared datasource as
+// code, records its name in the set and connects it through the connection
+// service's `connectDeclared`; the install door did none of the three.
+// Triage's ruling: the install connects and records through the same one
+// helper, and where the credential cannot be resolved the datasource answers
+// a named non-`ok` status, never a silent driver gap. ⛔ Where a code
+// datasource's credential comes from is #27687's, and not decided here.
+//
+// ## What this file pins
+//
+// 1. the install answers each declared datasource's verdict: `qads_store`
+// connected, `qads_vault` a named failure whose reason names the
+// credential; both are registered in the metadata registry as code,
+// stamped with the package, and recorded in the code-datasource set;
+// 2. a write and a read of the object bound to `qads_store` reach its
+// driver;
+// 3. a read of the object bound to `qads_vault` answers `503
+// ERR_DATASOURCE_UNAVAILABLE` naming the datasource — not a 404;
+// 4. the uninstall withdraws all of it, through its
+// `runtime.package-datasources` cleanup: no verdict, no metadata
+// registration, no name in the set;
+// 5. a package declaring a datasource name another holder owns (the
+// boot's `showcase_external`, the host's `default`) is refused `409
+// RESOURCE_CONFLICT` before any write;
+// 6. on a restart, the rehydrate connects the ledger's package again, as
+// code, and its bound object reads (`200`). The store is `:memory:`, so
+// the pin is that the read reaches a driver, not that a row survives.
+//
+// Boots fixture stacks of its own (the install-local plugin mounted), so it
+// stays out of `SHARED_SHOWCASE`.
+
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { type VerifyStack } from '@objectstack/verify';
+import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection';
+import { bootShowcase } from './showcase-boot.js';
+
+const ROUTE = '/marketplace/install-local';
+const PACKAGE_ID = 'com.example.qads';
+const STORE = 'qads_store';
+const VAULT = 'qads_vault';
+const NOTE = 'qads_note';
+const SECRET_ROW = 'qads_secret_row';
+const UNSET_REF = 'sys_secret:00000000-0000-0000-0000-000000000000';
+
+/** A package declaring two datasources, each with an object bound to it. */
+const INSTALL_BODY = {
+ manifest: {
+ id: PACKAGE_ID,
+ namespace: 'qads',
+ name: 'QA declared datasources',
+ version: '1.0.0',
+ type: 'app',
+ datasources: [
+ { name: STORE, label: 'QA store', driver: 'sqlite', config: { filename: ':memory:' } },
+ {
+ name: VAULT,
+ label: 'QA vault',
+ driver: 'postgres',
+ schemaMode: 'external',
+ config: { host: '127.0.0.1', port: 1, database: 'nowhere', user: 'nobody' },
+ external: { credentialsRef: UNSET_REF, validation: { onMismatch: 'warn', checkOnBoot: false } },
+ },
+ ],
+ objects: [
+ { name: NOTE, label: 'QA note', datasource: STORE, fields: { title: { type: 'text', label: 'Title' } } },
+ { name: SECRET_ROW, label: 'QA secret row', datasource: VAULT, external: {}, fields: { title: { type: 'text', label: 'Title' } } },
+ ],
+ },
+};
+
+/** A package whose one datasource takes a name `held` by someone else. */
+function collidingBody(id: string, held: string) {
+ return {
+ manifest: {
+ id,
+ namespace: id.split('.').pop(),
+ name: `QA collision ${held}`,
+ version: '1.0.0',
+ type: 'app',
+ datasources: [{ name: held, label: 'Collides', driver: 'sqlite', config: { filename: ':memory:' } }],
+ },
+ };
+}
+
+type Answer = { status: number; body: any };
+async function json(res: Response): Promise {
+ return { status: res.status, body: await res.json().catch(() => null) };
+}
+
+interface ConnectionView {
+ getConnectionState(name: string): { status: string; reason?: string } | undefined;
+}
+
+const codeNamesOf = (stack: VerifyStack): string[] =>
+ (stack.kernel.getService('code-datasource-names') as { list(): string[] }).list();
+
+describe('dogfood: an install-local install connects and records the package\'s declared datasources', () => {
+ let stack: VerifyStack;
+ let storageDir: string;
+ let connection: ConnectionView;
+ let metadata: { get(type: string, name: string): Promise };
+
+ let install: Answer;
+ const states: Record = {};
+ const registered: Record = {};
+ let codeNames: string[];
+ let listed: Record;
+ let write: Answer;
+ let read: Answer;
+ let readVault: Answer;
+ let uninstall: Answer;
+ const statesAfter: Record = {};
+ const registeredAfter: Record = {};
+ let codeNamesAfter: string[];
+ const collisions: Record = {};
+
+ beforeAll(async () => {
+ storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-ds-'));
+ stack = await bootShowcase({
+ extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })],
+ });
+ connection = stack.kernel.getService('datasource-connection') as ConnectionView;
+ metadata = stack.kernel.getService('metadata') as typeof metadata;
+ const token = await stack.signIn();
+
+ // ── collisions, before anything of this package's is installed ─────────
+ collisions.showcase_external = await json(await stack.apiAs(token, 'POST', ROUTE, collidingBody('com.example.qadsc', 'showcase_external')));
+ collisions.default = await json(await stack.apiAs(token, 'POST', ROUTE, collidingBody('com.example.qadsd', 'default')));
+
+ // ── install ────────────────────────────────────────────────────────────
+ install = await json(await stack.apiAs(token, 'POST', ROUTE, INSTALL_BODY));
+ for (const name of [STORE, VAULT]) {
+ states[name] = connection.getConnectionState(name);
+ registered[name] = await metadata.get('datasource', name);
+ }
+ codeNames = codeNamesOf(stack);
+ const admin = stack.kernel.getService('datasource-admin') as { listDatasources(): Promise> };
+ listed = Object.fromEntries((await admin.listDatasources()).map((d) => [d.name, d]));
+ write = await json(await stack.apiAs(token, 'POST', `/data/${NOTE}`, { title: 'hello' }));
+ read = await json(await stack.apiAs(token, 'GET', `/data/${NOTE}`));
+ readVault = await json(await stack.apiAs(token, 'GET', `/data/${SECRET_ROW}`));
+
+ // ── uninstall ──────────────────────────────────────────────────────────
+ uninstall = await json(await stack.apiAs(token, 'DELETE', `${ROUTE}/${PACKAGE_ID}`));
+ for (const name of [STORE, VAULT]) {
+ statesAfter[name] = connection.getConnectionState(name);
+ registeredAfter[name] = await metadata.get('datasource', name);
+ }
+ codeNamesAfter = codeNamesOf(stack);
+ }, 300_000);
+
+ afterAll(async () => {
+ await stack?.stop?.();
+ if (storageDir) rmSync(storageDir, { recursive: true, force: true });
+ });
+
+ it('the install answers each declared datasource\'s verdict', () => {
+ expect(install.status, JSON.stringify(install.body)).toBe(200);
+ expect(install.body?.data?.hotLoaded).toBe(true);
+ const verdicts = Object.fromEntries(
+ (install.body?.data?.datasources ?? []).map((v: { name: string; status: string }) => [v.name, v.status]),
+ );
+ expect(verdicts[STORE]).toBe('connected');
+ // The connection service's own name for a datasource it could not connect.
+ expect(['failed-credentials', 'failed-degraded']).toContain(verdicts[VAULT]);
+ });
+
+ it('both are registered as code, stamped with the package, and recorded in the code-datasource set', () => {
+ for (const name of [STORE, VAULT]) {
+ expect(registered[name]?.origin, name).toBe('code');
+ expect(registered[name]?._packageId, name).toBe(PACKAGE_ID);
+ expect(registered[name]?._packageVersion, name).toBe('1.0.0');
+ }
+ expect(codeNames).toEqual(expect.arrayContaining([STORE, VAULT]));
+ });
+
+ it('the admin list serves both as code-defined: the connected one ok, the other error naming the credential', () => {
+ expect(listed[STORE]).toMatchObject({ origin: 'code', status: 'ok' });
+ expect(listed[VAULT]).toMatchObject({ origin: 'code', status: 'error' });
+ expect(listed[VAULT]?.statusReason).toContain(UNSET_REF);
+ });
+
+ it('a write and a read of the object bound to the connected datasource reach its driver', () => {
+ expect(write.status, JSON.stringify(write.body)).toBe(201);
+ expect(read.status, JSON.stringify(read.body)).toBe(200);
+ expect((read.body?.records ?? []).map((r: { title?: string }) => r.title)).toEqual(['hello']);
+ });
+
+ it('the unresolved credential is a named non-ok verdict, and a bound read answers 503 naming the datasource', () => {
+ expect(states[STORE]?.status).toBe('connected');
+ expect(['failed-credentials', 'failed-degraded']).toContain(states[VAULT]?.status);
+ expect(states[VAULT]?.reason).toContain(UNSET_REF);
+ expect(readVault.status, JSON.stringify(readVault.body)).toBe(503);
+ expect(readVault.body?.code).toBe('ERR_DATASOURCE_UNAVAILABLE');
+ expect(readVault.body?.datasource).toBe(VAULT);
+ });
+
+ it('the uninstall withdraws the verdicts, the registrations and the names, as its own cleanup', () => {
+ expect(uninstall.status, JSON.stringify(uninstall.body)).toBe(200);
+ const cleanups: Array<{ name: string; success: boolean; removed: number }> = uninstall.body?.data?.cleanups ?? [];
+ expect(cleanups.filter((o) => o.success !== true)).toEqual([]);
+ expect(cleanups.find((o) => o.name === 'runtime.package-datasources')).toEqual({
+ name: 'runtime.package-datasources',
+ success: true,
+ removed: 2,
+ });
+ expect(statesAfter).toEqual({ [STORE]: undefined, [VAULT]: undefined });
+ expect(registeredAfter).toEqual({ [STORE]: undefined, [VAULT]: undefined });
+ expect(codeNamesAfter).not.toContain(STORE);
+ expect(codeNamesAfter).not.toContain(VAULT);
+ // CONTROL: the boot's own datasource is untouched.
+ expect(codeNamesAfter).toContain('showcase_external');
+ });
+
+ it('a package declaring a datasource name another holder owns is refused 409 before any write', () => {
+ for (const held of ['showcase_external', 'default']) {
+ const answer = collisions[held];
+ expect(answer.status, `${held}: ${JSON.stringify(answer.body)}`).toBe(409);
+ expect(answer.body?.error?.code).toBe('RESOURCE_CONFLICT');
+ expect(answer.body?.error?.details).toEqual({ datasource: held });
+ }
+ // Nothing of the refused packages reached the set or the connection service.
+ expect(codeNames.filter((n) => n !== STORE && n !== VAULT).sort()).toEqual(['default', 'showcase_external']);
+ });
+});
+
+describe('dogfood: the install-local rehydrate connects a ledgered package\'s declared datasources', () => {
+ let storageDir: string;
+ let installAnswer: Answer;
+ let rehydrated: { state?: { status: string }; registered?: any; codeNames: string[]; read: Answer };
+
+ beforeAll(async () => {
+ storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-ds-rehydrate-'));
+ const first = await bootShowcase({
+ extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })],
+ });
+ try {
+ installAnswer = await json(await first.apiAs(await first.signIn(), 'POST', ROUTE, INSTALL_BODY));
+ } finally {
+ await first.stop();
+ }
+
+ const second = await bootShowcase({
+ extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })],
+ });
+ try {
+ const connection = second.kernel.getService('datasource-connection') as ConnectionView;
+ const metadata = second.kernel.getService('metadata') as { get(type: string, name: string): Promise };
+ rehydrated = {
+ state: connection.getConnectionState(STORE),
+ registered: await metadata.get('datasource', STORE),
+ codeNames: codeNamesOf(second),
+ read: await json(await second.apiAs(await second.signIn(), 'GET', `/data/${NOTE}`)),
+ };
+ } finally {
+ await second.stop();
+ }
+ }, 300_000);
+
+ afterAll(() => {
+ if (storageDir) rmSync(storageDir, { recursive: true, force: true });
+ });
+
+ it('after a restart the package\'s datasource is connected, registered as code and recorded, and its object reads', () => {
+ expect(installAnswer.status, JSON.stringify(installAnswer.body)).toBe(200);
+ expect(rehydrated.state?.status).toBe('connected');
+ expect(rehydrated.registered?.origin).toBe('code');
+ expect(rehydrated.registered?._packageId).toBe(PACKAGE_ID);
+ expect(rehydrated.codeNames).toEqual(expect.arrayContaining([STORE, VAULT]));
+ expect(rehydrated.read.status, JSON.stringify(rehydrated.read.body)).toBe(200);
+ });
+});
diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts
index 252a35b1833..94326730178 100644
--- a/packages/runtime/src/app-plugin.ts
+++ b/packages/runtime/src/app-plugin.ts
@@ -25,6 +25,7 @@ import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/secu
import { SeedLoaderService } from './seed-loader.js';
import { recordSeedOutcome } from './seed-summary.js';
import { contributeCodeDatasourceNames } from './code-datasource-names.js';
+import { connectPackageDatasources } from './declared-datasources.js';
import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js';
import { declareSeedSource, recordOrganizationReplay, type SeedLandingReport } from './seed-settlement.js';
import { loadDisabledPackageIds } from './package-state-store.js';
@@ -1314,51 +1315,13 @@ export class AppPlugin implements Plugin {
);
}
}
- try {
- const dsDefs = this.convertedCollection(ctx, 'datasources');
- const dsList = Array.isArray(dsDefs)
- ? dsDefs
- : dsDefs && typeof dsDefs === 'object'
- ? Object.entries(dsDefs).map(([name, def]) => ({ name, ...(def as any) }))
- : [];
- if (dsList.length > 0) {
- const metadata = ctx.getService('metadata') as
- | { registerInMemory?: (t: string, n: string, d: unknown) => void }
- | undefined;
- if (typeof metadata?.registerInMemory === 'function') {
- // [#21889] Each one stamped with the package that declares
- // it (ADR-0010 `_packageId` / `_packageVersion`), through the
- // one stamping helper both other load paths use. See
- // `codeDefinedDatasourceOwners` for who that package is.
- for (const { datasource: ds, owner } of this.codeDefinedDatasourceOwners(ctx)) {
- if (!ds?.name) continue;
- metadata.registerInMemory(
- 'datasource',
- ds.name,
- applyProtection({ ...ds, origin: 'code' }, owner),
- );
- }
- ctx.logger.info('Registered code-defined datasources in metadata registry', {
- appId,
- count: dsList.length,
- });
- }
- }
- } catch (err) {
- ctx.logger.warn('[AppPlugin] failed to register code-defined datasources', {
- error: (err as Error)?.message ?? String(err),
- });
- }
-
- // [#22989] The stack's declared `apis`, by the same route — see
- // `registerDeclaredApiEndpoints`.
- this.registerDeclaredApiEndpoints(ctx, appId);
-
- // Auto-connect declared datasources (ADR-0062 D1/D2/D5). The metadata
- // registration above only makes a datasource *visible*; to make its
- // federated objects *queryable* with zero app boilerplate, build + open
- // + register a live driver via the shared `'datasource-connection'`
- // service (when present — wired by the datasource-admin plugin). The
+ // [#28039] The registration and the auto-connect (ADR-0062 D1/D2/D5) go
+ // through `connectPackageDatasources` — the ONE path, which install-local
+ // takes for a package installed into the running kernel. The metadata
+ // registration only makes a datasource *visible*; to make its federated
+ // objects *queryable* with zero app boilerplate, the shared
+ // `'datasource-connection'` service (when present — wired by the
+ // datasource-admin plugin) builds, opens and registers a live driver. The
// service applies the D2 gate (connect only when `external`, an object
// explicitly binds via `object.datasource`, a `datasourceMapping` rule
// routes objects to it, or `autoConnect:true`) and the host connect
@@ -1367,8 +1330,16 @@ export class AppPlugin implements Plugin {
//
// Runs in `start()` (before the `kernel:ready` external-validation gate)
// so the kernel's init-all-then-start-all ordering guarantees the
- // connection service was already registered during init.
- try {
+ // connection service was already registered during init. The names
+ // joined the code-datasource set in `init()`.
+ //
+ // A fail-fast connect error propagates to brick boot as intended
+ // (ADR-0062 D5): the datasource has no fallback path — `external` +
+ // onMismatch:'fail', or objects that bind to it explicitly and would
+ // otherwise fail every query with "Datasource 'x' is not registered"
+ // (#3758). The helper logs it and re-throws so the kernel surfaces the
+ // real cause.
+ {
const dsDefs = this.convertedCollection(ctx, 'datasources');
const dsList: any[] = Array.isArray(dsDefs)
? dsDefs
@@ -1376,59 +1347,25 @@ export class AppPlugin implements Plugin {
? Object.entries(dsDefs).map(([name, def]) => ({ name, ...(def as any) }))
: [];
if (dsList.length > 0) {
- // `ctx.getService` throws when a service is absent, so resolve
- // defensively — a runtime without the datasource-admin plugin
- // simply has no connection service, and declared datasources
- // stay metadata-only (the legacy `onEnable` escape hatch still
- // works). This must NOT fall into the fail-fast catch below.
- let connection:
- | {
- connectDeclared?: (input: {
- datasources: any[];
- objects?: Array<{ name?: string; datasource?: string }>;
- mappedObjects?: Record;
- }) => Promise>;
- }
- | undefined;
- try {
- connection = ctx.getService('datasource-connection');
- } catch {
- connection = undefined;
- }
- if (typeof connection?.connectDeclared === 'function') {
- const declaredObjects = this.convertedCollection(ctx, 'objects');
- const objects = Array.isArray(declaredObjects) ? declaredObjects : [];
- const results = await connection.connectDeclared({
- datasources: dsList,
- objects,
- mappedObjects: this.resolveMappedObjects(ql, objects),
- });
- const connected = results.filter((r) => r.status === 'connected');
- if (connected.length > 0) {
- ctx.logger.info('Auto-connected declared datasources', {
- appId,
- connected: connected.map((r) => r.name),
- });
- }
- } else {
- ctx.logger.debug('No datasource-connection service — declared datasources stay metadata-only', { appId });
- }
+ const declaredObjects = this.convertedCollection(ctx, 'objects');
+ const objects = Array.isArray(declaredObjects) ? declaredObjects : [];
+ // [#21889] Each one stamped with the package that declares it
+ // (ADR-0010 `_packageId` / `_packageVersion`). See
+ // `codeDefinedDatasourceOwners` for who that package is.
+ await connectPackageDatasources(ctx, {
+ appId,
+ datasources: dsList,
+ owned: () => this.codeDefinedDatasourceOwners(ctx),
+ objects,
+ mappedObjects: this.resolveMappedObjects(ql, objects),
+ });
}
- } catch (err) {
- // A fail-fast connect error propagates to brick boot as intended
- // (ADR-0062 D5): the datasource has no fallback path — `external` +
- // onMismatch:'fail', or objects that bind to it explicitly and would
- // otherwise fail every query with "Datasource 'x' is not registered"
- // (#3758). Other errors are already degraded inside the connection
- // service. Re-throw so the kernel surfaces the real cause.
- // (Single-string message: the context logger types
- // `error(message, error?)`, not a meta object.)
- ctx.logger.error(
- `[AppPlugin] declared-datasource auto-connect failed for app '${appId}': ${(err as Error)?.message ?? String(err)}`,
- );
- throw err;
}
+ // [#22989] The stack's declared `apis`, by the same route the datasources
+ // take — see `registerDeclaredApiEndpoints`.
+ this.registerDeclaredApiEndpoints(ctx, appId);
+
// [ADR-0130 D4] The residual of a multi-package stack — top-level
// items no package body declares — through the metadata door's one
// rule. BEFORE the security block below on purpose: that block is the
diff --git a/packages/runtime/src/code-datasource-names.ts b/packages/runtime/src/code-datasource-names.ts
index 8eb568aaaa0..3f15799dfc9 100644
--- a/packages/runtime/src/code-datasource-names.ts
+++ b/packages/runtime/src/code-datasource-names.ts
@@ -16,6 +16,13 @@
* resolves at its first read ({@link CodeDatasourceNames} says why);
* - `DefaultDatasourcePlugin` — the host's own `default`.
*
+ * …and one that arrives after boot: a package installed into the running
+ * kernel (install-local's install and its `kernel:ready` rehydrate), whose
+ * datasources `connectPackageDatasources` (`declared-datasources.ts`, #28039)
+ * registers as code and contributes here at that moment. The boot restore has
+ * run by then, so the phase argument below does not reach it; its uninstall
+ * withdraws the names again ({@link withdrawCodeDatasourceNames}).
+ *
* `init()` is Phase 1, and Phase 1 completes before ANY `start()` runs
* (`ObjectKernel.bootstrap()`). The datasource-admin plugin restores stored
* rows in its `start()`, so it reads a complete set in every composition,
@@ -49,8 +56,9 @@
*
* ## What it does not hold
*
- * A package installed after boot (an HTTP install, or the Phase 2 rehydrate
- * from `sys_packages`). Nothing registers such a package's datasources in the
+ * A package installed after boot through any door other than install-local
+ * (the protocol's package install, or the Phase 2 rehydrate from
+ * `sys_packages`). Nothing registers such a package's datasources in the
* MetadataService as code, so the admin door does not treat them as code
* either; the `/meta` door's resolver reads the installed package records
* live, beside this set.
@@ -100,6 +108,12 @@ export class CodeDatasourceNames {
return this.names.has(name);
}
+ /** Take names out of the set — an installed package's uninstall (#28039). */
+ delete(names: Iterable): void {
+ this.settle();
+ for (const name of names) this.names.delete(name);
+ }
+
/** Every name in the set, sorted — for diagnostics and pins. */
list(): string[] {
this.settle();
@@ -130,7 +144,8 @@ interface ServiceRegistryContext {
/**
* Add a contribution to the host's code-datasource set, registering the set on
* the kernel the first time any producer contributes. Called from a producer's
- * `init()` only — see the module header for why the phase matters.
+ * `init()` — see the module header for why the phase matters — or, for a
+ * package installed into the running kernel, by `connectPackageDatasources`.
*
* `getService` throws while the service is unregistered; that miss is the
* one case that creates the set. A name occupied by something that is not this
@@ -154,3 +169,18 @@ export function contributeCodeDatasourceNames(
}
(set as CodeDatasourceNames).add(contribution);
}
+
+/**
+ * Take an uninstalled package's datasource names out of the host's set
+ * (#28039). A kernel with no set, or one holding something that is not this
+ * set, has nothing of this package's to withdraw.
+ */
+export function withdrawCodeDatasourceNames(ctx: ServiceRegistryContext, names: Iterable): void {
+ let set: unknown;
+ try {
+ set = ctx.getService(CODE_DATASOURCE_NAMES_SERVICE);
+ } catch {
+ return;
+ }
+ if (set instanceof CodeDatasourceNames) set.delete(names);
+}
diff --git a/packages/runtime/src/declared-datasources.test.ts b/packages/runtime/src/declared-datasources.test.ts
new file mode 100644
index 00000000000..c4cc5283638
--- /dev/null
+++ b/packages/runtime/src/declared-datasources.test.ts
@@ -0,0 +1,180 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// [#28039] `connectPackageDatasources` — the ONE path that registers, records
+// and connects the datasources a package declares, for the boot artifact
+// (`AppPlugin.start`) and for a package installed into a running kernel
+// (install-local, `installed: true`).
+//
+// The two callers differ in exactly three ways, each pinned here:
+// - a boot leaves the names to `init()`'s contribution; an install records
+// them in the code-datasource set itself;
+// - a fail-fast connect verdict refuses a boot (it throws), and is reported
+// by an install as the retained verdict;
+// - an install's reinstall withdraws what the previous call registered, and
+// its uninstall cleanup (`runtime.package-datasources`) withdraws the rest.
+// The end-to-end half (a real install door, driver and read) is the dogfood
+// file `install-local-declared-datasources.dogfood.test.ts`.
+
+import { describe, it, expect, vi } from 'vitest';
+import { connectPackageDatasources } from './declared-datasources.js';
+import { CODE_DATASOURCE_NAMES_SERVICE, CodeDatasourceNames } from './code-datasource-names.js';
+
+type Verdict = { name: string; status: string; reason?: string };
+
+/** A kernel's service registry with a metadata service, a connection service and a protocol. */
+function kernel(opts: { failFast?: string; withConnection?: boolean } = {}) {
+ const services = new Map();
+ const items = new Map();
+ const states = new Map();
+ const cleanups = new Map Promise<{ success: boolean; removed: number; error?: string }>>();
+ const connectCalls: Array<{ datasources: any[]; objects?: unknown }> = [];
+ const disconnected: string[] = [];
+
+ services.set('metadata', {
+ registerInMemory: (type: string, name: string, data: unknown) => items.set(`${type}/${name}`, data),
+ get: async (type: string, name: string) => items.get(`${type}/${name}`),
+ unregister: async (type: string, name: string) => { items.delete(`${type}/${name}`); },
+ });
+ if (opts.withConnection !== false) {
+ services.set('datasource-connection', {
+ connectDeclared: async (input: { datasources: any[]; objects?: unknown }) => {
+ connectCalls.push(input);
+ const results: Verdict[] = [];
+ for (const ds of input.datasources) {
+ const v: Verdict = ds.name === opts.failFast
+ ? { name: ds.name, status: 'failed-credentials', reason: `credential '${ds.external?.credentialsRef}' could not be resolved` }
+ : { name: ds.name, status: 'connected' };
+ states.set(ds.name, v);
+ results.push(v);
+ }
+ if (opts.failFast && states.has(opts.failFast)) throw new Error(`datasource '${opts.failFast}': connect failed`);
+ return results;
+ },
+ getConnectionState: (name: string) => states.get(name),
+ disconnect: async (name: string) => { disconnected.push(name); states.delete(name); },
+ });
+ }
+ services.set('protocol', {
+ registerUninstallCleanup: (name: string, cleanup: any) => cleanups.set(name, cleanup),
+ });
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
+ const ctx = {
+ getService(name: string): T {
+ if (!services.has(name)) throw new Error(`[Kernel] Service '${name}' not found`);
+ return services.get(name) as T;
+ },
+ registerService(name: string, service: unknown) {
+ if (services.has(name)) throw new Error(`[Kernel] Service '${name}' already registered`);
+ services.set(name, service);
+ },
+ logger,
+ };
+ const codeNames = () => (services.get(CODE_DATASOURCE_NAMES_SERVICE) as CodeDatasourceNames | undefined)?.list() ?? [];
+ return { ctx: ctx as never, items, states, cleanups, connectCalls, disconnected, logger, codeNames };
+}
+
+const STORE = { name: 'pkg_store', driver: 'sqlite', config: { filename: ':memory:' } };
+const VAULT = { name: 'pkg_vault', driver: 'postgres', schemaMode: 'external', external: { credentialsRef: 'sys_secret:nope' } };
+const OBJECTS = [{ name: 'pkg_note', datasource: 'pkg_store' }, { name: 'pkg_secret', datasource: 'pkg_vault' }];
+
+describe('connectPackageDatasources — an installed package', () => {
+ it('registers each datasource as code stamped with the package, records the names, and connects them', async () => {
+ const k = kernel();
+ const verdicts = await connectPackageDatasources(k.ctx, {
+ appId: 'com.example.pkg',
+ source: 'MarketplaceInstallLocal',
+ datasources: [STORE],
+ owned: [{ datasource: STORE, owner: { packageId: 'com.example.pkg', packageVersion: '1.0.0' } }],
+ objects: OBJECTS,
+ installed: true,
+ });
+ expect(verdicts).toEqual([{ name: 'pkg_store', status: 'connected' }]);
+ expect(k.items.get('datasource/pkg_store')).toMatchObject({
+ name: 'pkg_store',
+ origin: 'code',
+ _packageId: 'com.example.pkg',
+ _packageVersion: '1.0.0',
+ });
+ expect(k.codeNames()).toEqual(['pkg_store']);
+ expect(k.connectCalls).toEqual([{ datasources: [STORE], objects: OBJECTS, mappedObjects: {} }]);
+ expect([...k.cleanups.keys()]).toEqual(['runtime.package-datasources']);
+ });
+
+ it('a fail-fast verdict is reported, not thrown: each attempted datasource answers its retained verdict', async () => {
+ const k = kernel({ failFast: 'pkg_vault' });
+ const verdicts = await connectPackageDatasources(k.ctx, {
+ appId: 'com.example.pkg',
+ datasources: [STORE, VAULT],
+ objects: OBJECTS,
+ installed: true,
+ });
+ expect(verdicts).toEqual([
+ { name: 'pkg_store', status: 'connected' },
+ { name: 'pkg_vault', status: 'failed-credentials' },
+ ]);
+ expect(k.logger.error).toHaveBeenCalledTimes(1);
+ // Recorded and registered all the same: the datasource answers its verdict.
+ expect(k.codeNames()).toEqual(['pkg_store', 'pkg_vault']);
+ expect(k.items.get('datasource/pkg_vault')?.origin).toBe('code');
+ });
+
+ it('a reinstall replaces: what the previous version registered and the new one drops is withdrawn', async () => {
+ const k = kernel();
+ await connectPackageDatasources(k.ctx, { appId: 'com.example.pkg', datasources: [STORE, VAULT], installed: true });
+ await connectPackageDatasources(k.ctx, { appId: 'com.example.pkg', datasources: [STORE], installed: true });
+ expect(k.disconnected).toEqual(['pkg_store', 'pkg_vault']);
+ expect(k.codeNames()).toEqual(['pkg_store']);
+ expect(k.items.has('datasource/pkg_vault')).toBe(false);
+ expect(k.states.get('pkg_store')?.status).toBe('connected');
+ });
+
+ it('the uninstall cleanup withdraws the package\'s datasources, and only its own', async () => {
+ const k = kernel();
+ await connectPackageDatasources(k.ctx, { appId: 'com.example.pkg', datasources: [STORE], installed: true });
+ await connectPackageDatasources(k.ctx, { appId: 'com.example.other', datasources: [VAULT], installed: true });
+ const cleanup = k.cleanups.get('runtime.package-datasources')!;
+
+ expect(await cleanup({ packageId: 'com.example.pkg' })).toEqual({ success: true, removed: 1 });
+ expect(k.disconnected).toEqual(['pkg_store']);
+ expect(k.items.has('datasource/pkg_store')).toBe(false);
+ expect(k.codeNames()).toEqual(['pkg_vault']);
+
+ // A package that registered nothing here — a boot artifact's, another door's — is a no-op.
+ expect(await cleanup({ packageId: 'com.example.unknown' })).toEqual({ success: true, removed: 0 });
+ expect(k.items.has('datasource/pkg_vault')).toBe(true);
+ });
+
+ it('a metadata registration stamped with another package is not removed by this one\'s withdrawal', async () => {
+ const k = kernel();
+ await connectPackageDatasources(k.ctx, { appId: 'com.example.pkg', datasources: [STORE], installed: true });
+ k.items.set('datasource/pkg_store', { name: 'pkg_store', origin: 'code', _packageId: 'com.example.later' });
+ await k.cleanups.get('runtime.package-datasources')!({ packageId: 'com.example.pkg' });
+ expect(k.items.get('datasource/pkg_store')?._packageId).toBe('com.example.later');
+ });
+});
+
+describe('connectPackageDatasources — the boot artifact', () => {
+ it('a fail-fast verdict refuses the boot, as it always did', async () => {
+ const k = kernel({ failFast: 'pkg_vault' });
+ await expect(
+ connectPackageDatasources(k.ctx, { appId: 'com.example.app', datasources: [STORE, VAULT], objects: OBJECTS }),
+ ).rejects.toThrow("datasource 'pkg_vault': connect failed");
+ expect(k.logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("[AppPlugin] declared-datasource auto-connect failed for app 'com.example.app'"),
+ );
+ });
+
+ it('records no name and registers no uninstall cleanup: the boot owns them', async () => {
+ const k = kernel();
+ await connectPackageDatasources(k.ctx, { appId: 'com.example.app', datasources: [STORE] });
+ expect(k.codeNames()).toEqual([]);
+ expect(k.cleanups.size).toBe(0);
+ expect(k.items.get('datasource/pkg_store')?.origin).toBe('code');
+ });
+
+ it('with no connection service the datasources stay metadata-only, and nothing throws', async () => {
+ const k = kernel({ withConnection: false });
+ expect(await connectPackageDatasources(k.ctx, { appId: 'com.example.app', datasources: [STORE] })).toEqual([]);
+ expect(k.items.get('datasource/pkg_store')?.origin).toBe('code');
+ });
+});
diff --git a/packages/runtime/src/declared-datasources.ts b/packages/runtime/src/declared-datasources.ts
new file mode 100644
index 00000000000..17b4535642f
--- /dev/null
+++ b/packages/runtime/src/declared-datasources.ts
@@ -0,0 +1,343 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#28039] The ONE path that makes the datasources a package declares live
+ * (ADR-0015 Addendum, ADR-0062 D1/D2/D5):
+ *
+ * 1. registered in the metadata registry IN MEMORY as `origin: 'code'`,
+ * stamped with the package that declares each one (ADR-0010
+ * `_packageId` / `_packageVersion`), so the admin list returns them and
+ * the admin service enforces them as read-only;
+ * 2. recorded in the host's code-datasource set
+ * ({@link CODE_DATASOURCE_NAMES_SERVICE}), so the admin door and the `/meta`
+ * door treat them as code-defined;
+ * 3. auto-connected through the `datasource-connection` service's
+ * `connectDeclared`, which applies the D2 gate and the host's connect
+ * policy and records each verdict (`getConnectionState`).
+ *
+ * Two callers, one path:
+ *
+ * - `AppPlugin.start()`, for the artifact the host boots. Its names joined
+ * the set in `init()` (Phase 1, before the datasource-admin plugin's
+ * restore — `code-datasource-names.ts` says why the phase matters), so step
+ * 2 is already done; a fail-fast connect verdict refuses the boot.
+ * - the install-local plugin (`@objectstack/cloud-connection`), for a package
+ * installed into a RUNNING kernel — on install and on every rehydrate
+ * ({@link PackageDatasourceOptions.installed}). Before this, that door
+ * registered the package's objects and nothing of its datasources, so an
+ * object bound to one had no driver and the name was not code-defined.
+ * Here the names join the set now, a fail-fast verdict is reported rather
+ * than thrown (there is no boot left to refuse: the datasource answers its
+ * retained non-`ok` verdict, and a read of a bound object answers
+ * `ERR_DATASOURCE_UNAVAILABLE` naming it), and the package's uninstall
+ * undoes all three steps through the `runtime.package-datasources`
+ * uninstall cleanup, as `runtime.package-jobs` does for its jobs.
+ *
+ * ⛔ Where a declared credential comes from is not decided here (#27687): the
+ * connection service resolves `external.credentialsRef` the one way it does
+ * for every datasource, and a credential it cannot resolve is its
+ * `failed-credentials` verdict.
+ */
+
+import type { PluginContext } from '@objectstack/core';
+import { applyProtection } from '@objectstack/spec/shared';
+import {
+ CODE_DATASOURCE_NAMES_SERVICE,
+ contributeCodeDatasourceNames,
+ withdrawCodeDatasourceNames,
+} from './code-datasource-names.js';
+
+/** The package a declared datasource is stamped with (ADR-0010). */
+export interface DeclaredDatasourceOwner {
+ packageId?: string;
+ packageVersion?: string;
+}
+
+/** What {@link connectPackageDatasources} is given. */
+export interface PackageDatasourceOptions {
+ /** The app / package id, for the log lines and the uninstall record. */
+ appId: string;
+ /** The log tag — `AppPlugin`, `MarketplaceInstallLocal`. Defaults to `AppPlugin`. */
+ source?: string;
+ /** The list `connectDeclared` receives. */
+ datasources: readonly any[];
+ /**
+ * Each declared datasource with the package that declares it — what
+ * registers in the metadata registry. Defaults to {@link datasources}, each
+ * stamped with `appId`. A function is resolved inside the registration's
+ * lenient catch: `AppPlugin`'s ownership resolution reads the artifact's
+ * package bodies, and a refusal there leaves the datasources unlisted, never
+ * the boot refused.
+ */
+ owned?: OwnedDatasources | (() => OwnedDatasources);
+ /** The package's objects: the D2 gate's explicit bindings, and the schemas synced on connect. */
+ objects?: ReadonlyArray<{ name?: string; datasource?: string }>;
+ /** Datasource name → the objects a `datasourceMapping` rule routes to it (D2 (d)). */
+ mappedObjects?: Readonly>;
+ /**
+ * The package arrived in a running kernel (install, rehydrate). The names
+ * join the code-datasource set now, the datasources the previous call for
+ * this `appId` registered are withdrawn first (a reinstall replaces, never
+ * accumulates), a fail-fast connect verdict is reported instead of thrown,
+ * and the uninstall cleanup is registered.
+ */
+ installed?: boolean;
+}
+
+/** Each declared datasource with the package that declares it. */
+export type OwnedDatasources = ReadonlyArray<{ datasource: any; owner: DeclaredDatasourceOwner }>;
+
+/** One declared datasource's connect verdict, as the connection service retained it. */
+export interface PackageDatasourceVerdict {
+ name: string;
+ status: string;
+}
+
+/** The `datasource-connection` surface this path drives. */
+interface ConnectionServiceLike {
+ connectDeclared?: (input: {
+ datasources: any[];
+ objects?: ReadonlyArray<{ name?: string; datasource?: string }>;
+ mappedObjects?: Readonly>;
+ }) => Promise>;
+ getConnectionState?: (name: string) => { name: string; status: string } | undefined;
+ disconnect?: (name: string) => Promise;
+}
+
+/** The uninstall cleanup's name on the protocol's registry. */
+const PACKAGE_DATASOURCES_UNINSTALL_CLEANUP = 'runtime.package-datasources';
+
+/**
+ * Per kernel (keyed by its code-datasource set), the datasource names an
+ * installed package's call registered — what its reinstall and its uninstall
+ * withdraw. A boot artifact's are never recorded: the boot owns them.
+ */
+const INSTALLED_BY_APP = new WeakMap