Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/28698-stored-connector-fault-soft-at-boot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/service-automation": patch
---

fix(service-automation): a stored connector with a configuration fault never stops the platform starting

A provider-bound connector saved in the environment (through the metadata door or Studio, environment-wide or in an `authored` package) that later stops being valid, for example because the plugin offering its provider was removed, stopped the next start of the platform. The same fault met while the platform runs only skipped that connector.

Clause-②: no — no accepted value and no public surface changes: the same stored definitions are refused as before; a start now carries on past them instead of stopping.

Metadata-protocol: none

- **Before.** At start, any configuration fault of a stored connector (a provider no installed plugin offers, a host credential resolver that throws, a name a plugin connector already holds, a configuration its provider refuses) stopped the start. Only a refused package-relative ref was skipped.
- **Now.** Every configuration fault of a stored connector takes the path it takes while the platform runs: it is logged with the same record, that connector is not served, and the host's own connectors beside it serve. A connector the host's own stack declares, and an installed package's, still stop the start on a configuration fault, as before.
- **Not covered here.** A stored connector that takes a name a plugin connector registers later in the start is a separate card.
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// GOLDEN REGRESSION — a stored connector entry whose package-relative ref is
// refused (#28594) never stops the platform from booting.
// refused (#28594), or that carries any other configuration fault (#28698),
// never stops the platform from booting.
//
// ## Why a restart
//
Expand All @@ -20,11 +21,14 @@
// metadata door, an environment-wide `openapi` entry naming a
// package-relative file, the same entry in an `authored` package, an
// environment-wide stdio `mcp` entry, and a control carrying its document
// inline. The second boot:
// inline. [#28698] It also saves an environment-wide entry naming a provider
// no installed plugin offers (accepted at save, skipped after boot: the shape
// a row takes when the plugin that offered its provider is removed). The
// second boot:
//
// 1. starts (no throw);
// 2. does not list either refused entry, and its boot record names each
// entry and its ref;
// 2. does not list any refused entry, and its boot record names each entry
// and its ref (or, for the unknown provider, the provider);
// 3. lists the stored inline control `ready` with its operation (the rows
// were hydrated, so the refusal above was read at boot, not skipped);
// 4. lists the showcase's own file-ref and stdio entries `ready` (control).
Expand All @@ -47,6 +51,8 @@ const AUTHORED_FILE = 'srr_authored_file';
/** An `authored` package created through the package door. */
const AUTHORED_PACKAGE_ID = 'com.dogfood.storedrestart';
const INLINE = 'srr_inline';
/** [#28698] A stored entry whose provider no installed plugin offers. */
const UNKNOWN_PROVIDER = 'srr_unknown_provider';
const HOST_FILE_ENTRY = 'showcase_status_openapi';
const HOST_STDIO_ENTRY = 'showcase_mcp_tools';

Expand Down Expand Up @@ -144,6 +150,10 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe
saves[AUTHORED_FILE] = await json(await first.apiAs(
token, 'PUT', `/meta/connector/${AUTHORED_FILE}?package=${AUTHORED_PACKAGE_ID}`, openapi(AUTHORED_FILE, FILE_REF),
));
saves[UNKNOWN_PROVIDER] = await json(await first.apiAs(token, 'PUT', `/meta/connector/${UNKNOWN_PROVIDER}`, {
...openapi(UNKNOWN_PROVIDER, DOCUMENT),
provider: 'no_such_provider',
}));
saves[INLINE] = await json(await first.apiAs(token, 'PUT', `/meta/connector/${INLINE}`, openapi(INLINE, DOCUMENT)));
before = await settle(first, token, [INLINE]);
} finally {
Expand Down Expand Up @@ -175,6 +185,7 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe
}
expect(entryOf(before, INLINE)?.state, shown(before, INLINE)).toBe('ready');
expect(entryOf(before, ENV_FILE), shown(before, ENV_FILE)).toBeUndefined();
expect(entryOf(before, UNKNOWN_PROVIDER), shown(before, UNKNOWN_PROVIDER)).toBeUndefined();
});

it('the second boot over the same database starts', () => {
Expand All @@ -192,6 +203,13 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe
expect(record).toContain(ref);
});

it('[#28698] a stored entry naming a provider no plugin offers is not listed after the restart, and the boot record names it', () => {
expect(entryOf(after, UNKNOWN_PROVIDER), shown(after, UNKNOWN_PROVIDER)).toBeUndefined();
const record = bootLines.find((l) => l.includes(`connector instance '${UNKNOWN_PROVIDER}' declares provider 'no_such_provider'`));
expect(record, `no boot record names '${UNKNOWN_PROVIDER}'`).toBeDefined();
expect(record).toContain('no provider factory is registered');
});

it('controls: the stored inline entry, and the showcase\'s own file-ref and stdio entries, list ready after the restart', () => {
expect(entryOf(after, INLINE)?.state, shown(after, INLINE)).toBe('ready');
expect(entryOf(after, INLINE).actions.map((a: any) => a.key)).toEqual(['GetThings']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1037,16 +1037,15 @@ describe('#28594 — a definition stored in this environment is refused its pack
await kernel.shutdown();
});

it('control: a configuration fault other than a refused ref (an unknown provider) still fails the boot', async () => {
// The soft path is the stored entry's refused ref alone; a provider the
// runtime does not offer keeps the fail-loud contract.
it('control: the host\'s own entry with a configuration fault (an unknown provider) still fails the boot', async () => {
// The soft path is a stored entry's alone (#28698): the host's
// registered declaration, faulty as filed, keeps the fail-loud contract.
const { factory } = makeRefProvider();
const faulty = { ...hostEntry, provider: 'no_such_provider' };
const err = await rejectionOf(
boot([{ ...hostEntry, provider: 'no_such_provider' }], {
providerFactory: factory, packageRoot: fixtureRoot, packages, registered,
}),
boot([{ ...faulty }], { providerFactory: factory, packageRoot: fixtureRoot, packages, registered: [faulty] }),
);
expect(err.message).toContain('host_pkg');
expect(err.message).toContain(`connector instance 'host_pkg' declares provider 'no_such_provider'`);
});

it('control: a stored entry carrying its document inline materializes — neither member is reached', async () => {
Expand Down Expand Up @@ -1085,6 +1084,139 @@ describe('#28594 — a definition stored in this environment is refused its pack
});
});

// ── #28698 — every configuration fault of a STORED entry is soft at boot ────
//
// #28594 made a stored entry's refused ref soft at boot. Every other
// configuration fault the boot reconcile meets on a stored entry takes the same
// path (#28698): logged with the record a reload writes, the entry skipped,
// the host's entries beside it served. The host's own entry with the same fault
// keeps failing the boot.

describe('#28698 — a stored entry\'s configuration fault never fails the boot; the host\'s still does', () => {
const HOST = 'com.acme.host';
const packages = { [HOST]: { source: 'code' } };
const ownedBy = <T extends object>(item: T, packageId: string) => ({ ...item, _packageId: packageId });
const bearer = { type: 'bearer', credentialRef: 'OS_TEST_28698_VAULT_REF' } as ConnectorInstanceAuth;

/** A factory that materializes every entry, except one whose providerConfig asks it to refuse. */
function makeProvider() {
const calls: string[] = [];
const factory: ConnectorProviderFactory = (ctx) => {
calls.push(ctx.name);
if (ctx.providerConfig.refuse === true) throw new Error(`providerConfig of '${ctx.name}' is not valid: refuse is set`);
return {
def: { name: ctx.name, label: ctx.label, type: 'api', authentication: { type: 'none' }, actions: [{ key: 'ping', label: 'Ping' }] } as unknown as Connector,
handlers: { ping: async () => ({ ok: true }) },
};
};
return { factory, calls };
}
const throwingResolver: CredentialResolver = () => { throw new Error('the vault could not be reached'); };

/** The host's entry served beside every stored fault: registered, so it is the host's. */
const hostServed = ownedBy(providerConnector('host_served'), HOST);

/**
* One fault, spelled for an entry `name`: what the entry carries, the boot
* options it needs, and the record text that names it.
*/
const FAULTS: Array<{
fault: string;
entry: (name: string) => Record<string, unknown>;
opts?: Partial<BootOptions>;
livePlugin?: boolean;
record: (name: string) => string;
}> = [
{
fault: 'a provider no plugin offers',
entry: (name) => providerConnector(name, { provider: 'no_such_provider' }),
record: (name) => `connector instance '${name}' declares provider 'no_such_provider', but no provider factory is registered`,
},
{
fault: 'an invalid retryConfig',
entry: (name) => ({ ...providerConnector(name), retryConfig: { maxAttempts: -3 } }),
record: (name) => `connector instance '${name}' (provider 'fake'): retryConfig is not a valid connector retry policy`,
},
{
fault: 'a credential resolver that throws',
entry: (name) => providerConnector(name, { auth: bearer }),
opts: { credentialResolver: throwingResolver },
record: (name) => `connector instance '${name}' (provider 'fake'): auth resolution failed`,
},
{
fault: 'a name a plugin connector holds',
entry: (name) => providerConnector(name),
livePlugin: true,
record: (name) => `declarative provider-bound instance '${name}' collides with a plugin-registered connector`,
},
{
fault: 'a providerConfig its provider refuses',
entry: (name) => providerConnector(name, { providerConfig: { refuse: true } }),
record: (name) => `failed to materialize connector instance '${name}' via provider 'fake'`,
},
];

it.each(FAULTS.map((f) => [f.fault, f] as const))('a stored entry with %s: the boot starts, the entry is skipped with its record, the host\'s entry serves', async (_fault, f) => {
const { factory } = makeProvider();
const logged: Array<{ msg: string; meta?: unknown }> = [];
const name = 'stored_faulty';
const kernel = await boot([{ ...hostServed }, f.entry(name)], {
providerFactory: factory, packages, registered: [hostServed], logged,
...(f.livePlugin ? { registerLivePlugin: [name] } : {}),
...f.opts,
});
const engine = automationOf(kernel);
expect(engine.getRegisteredConnectors()).toContain('host_served');
// Skipped: nothing declarative serves the name (a plugin connector may).
expect(engine.getConnectorOrigin(name)).not.toBe('declarative');
const record = logged.find((l) => l.msg.includes(f.record(name)));
expect(record, `no record names '${name}': ${JSON.stringify(logged.map((l) => l.msg))}`).toBeDefined();
await kernel.shutdown();
});

it.each(FAULTS.map((f) => [f.fault, f] as const))('control: the host\'s own entry with %s still fails the boot', async (_fault, f) => {
const { factory } = makeProvider();
const name = 'host_faulty';
const faulty = ownedBy(f.entry(name), HOST);
const err = await rejectionOf(boot([{ ...faulty }], {
providerFactory: factory, packages, registered: [faulty],
...(f.livePlugin ? { registerLivePlugin: [name] } : {}),
...f.opts,
}));
expect(err.message).toContain(f.record(name));
});

it('a stored entry duplicating the host\'s name is skipped with its record; the host\'s entry serves', async () => {
const { factory, calls } = makeProvider();
const logged: Array<{ msg: string; meta?: unknown }> = [];
const kernel = await boot(
[{ ...hostServed }, providerConnector('host_served', { providerConfig: { other: true } })],
{ providerFactory: factory, packages, registered: [hostServed], logged },
);
expect(automationOf(kernel).getRegisteredConnectors()).toContain('host_served');
expect(calls).toEqual(['host_served']);
expect(logged.some((l) => l.msg.includes(`duplicate declarative connector instance name 'host_served'`))).toBe(true);
await kernel.shutdown();
});

it('control: two host entries of one name still fail the boot', async () => {
const { factory } = makeProvider();
const err = await rejectionOf(boot([{ ...hostServed }, { ...hostServed }], {
providerFactory: factory, packages, registered: [hostServed],
}));
expect(err.message).toContain(`duplicate declarative connector instance name 'host_served'`);
});

it('control: an INSTALLED package\'s entry with a configuration fault still fails the boot (#28534 unchanged)', async () => {
const { factory } = makeProvider();
const installed = ownedBy(providerConnector('installed_faulty', { provider: 'no_such_provider' }), 'com.acme.installed');
const err = await rejectionOf(boot([installed], {
providerFactory: factory, packages: { ...packages, 'com.acme.installed': { source: 'marketplace' } }, registered: [installed],
}));
expect(err.message).toContain(`connector instance 'installed_faulty' declares provider 'no_such_provider'`);
});
});

// ── #3017 — upstream unavailable: degrade + retry, never a dead boot ─────────
//
// A provider factory that throws the CONNECTOR_UPSTREAM_UNAVAILABLE marker
Expand Down
Loading
Loading