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
13 changes: 13 additions & 0 deletions .changeset/25079-federated-report-policy-denied.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/objectql': patch
---

fix(objectql): a federated object whose datasource the connect policy denied is reported at info, not in the ERROR that claims its reads hit the wrong table

Clause-②: no (log text and level only: no response, status, outcome, boot verdict or export moves)
Metadata-protocol: none (a boot log line's level and text; nothing a metadata author writes changes)

- **What it was.** At `kernel:ready`, `ObjectQLPlugin` re-binds every federated (`external`) object to its remote table and reports, at ERROR, each one it could not bind. The ERROR says the object stays served while every read against it "resolves to a table named after the OBJECT … no such table, or the wrong table". It said that about an object whose datasource the host's connect policy had denied on purpose. `os migrate apply` on the stock showcase, whose managed-only policy denies every `schemaMode: 'external'` datasource, printed it after a successful apply.
- **What changed.** When the datasource layer records the datasource as refused by policy (availability `blocked`), its unbound objects are reported in one `info` line instead. The line names the objects and their datasource, and says every read against them is refused with `DatasourceUnavailableError` until the policy allows that datasource. That is what the engine already does for such a read. The same line appears on every composition, serving or not, because it follows the recorded verdict alone.
- **Unchanged.** The ERROR, word for word, for every other object it covered: a datasource that was never declared, a connect that FAILED (availability `failed`), a driver without external-object registration, and a registration that threw. On a boot with both, the info line covers the denied objects and the ERROR covers the rest, and the ERROR's counts exclude the denied ones. No binding, connect policy or read result changes.
- **What to do.** Nothing. A log filter or agent that keyed on the ERROR for a policy-denied datasource now finds the info line, which names the same objects and datasource.
215 changes: 215 additions & 0 deletions packages/objectql/src/federated-binding-policy-denied-report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #25079 — `reconcileFederatedBindings`' report for a federated object whose
* datasource the connect POLICY denied.
*
* `os migrate apply` on the stock showcase denies its `schemaMode: 'external'`
* datasource on purpose (the CLI's managed-only connect policy) and then
* printed, at ERROR, that the federated objects "keep their REST routes and
* keep rendering in the UI, while every read … resolves to a table named after
* the OBJECT". For a denied datasource that consequence is false on every
* composition: the datasource layer records the verdict as kind `blocked`
* (`engine.markDatasourceUnavailable`), and every read against such an object
* refuses with `DatasourceUnavailableError`. So the report is judged on that
* recorded verdict alone, never on what the composition serves:
*
* - `blocked` → ONE `info` line, outside the ERROR;
* - undeclared / `failed` / a driver without `registerExternalObject` /
* a registration that threw → the ERROR, unchanged, as before.
*
* The composition is not read by the code under test; the two shapes below
* pin that a migrate-shaped and a serving-shaped context answer alike.
*/

import { describe, it, expect } from 'vitest';
import type { ServiceObject } from '@objectstack/spec/data';
import { ObjectQL } from './engine.js';
import { ObjectQLPlugin } from './plugin.js';

interface Recorded {
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
args: unknown[];
}

function recordingLogger() {
const records: Recorded[] = [];
const push = (level: Recorded['level']) => (message: string, ...args: unknown[]) =>
void records.push({ level, message: String(message), args });
return {
records,
logger: { debug: push('debug'), info: push('info'), warn: push('warn'), error: push('error') },
at(level: Recorded['level']) {
return records.filter((r) => r.level === level);
},
};
}

/** A federated object bound to `datasource` by an explicit `object.datasource`. */
function federated(name: string, datasource: string): ServiceObject {
return {
name,
label: name,
datasource,
fields: { id: { name: 'id', label: 'ID', type: 'text' as const } },
external: { remoteName: `remote_${name}` },
} as ServiceObject;
}

/** A driver registered under `name`; `register` decides what `registerExternalObject` does. */
function namedDriver(name: string, register?: 'ok' | 'throws') {
return {
name,
supports: {},
async syncSchema() { /* not reached for a federated object */ },
async find() { return []; },
...(register
? {
async registerExternalObject() {
if (register === 'throws') throw new Error('remote catalog unreachable');
},
}
: {}),
};
}

/** The two composition shapes: one that serves (an `http.server` is published) and a migrate run's (none is). */
const SHAPES = {
serving: (logger: unknown) => ({
logger,
getService: (n: string) => {
if (n === 'http.server') return {};
throw new Error(`[Kernel] Service '${n}' not found`);
},
}),
migrate: (logger: unknown) => ({
logger,
getService: (n: string) => {
throw new Error(`[Kernel] Service '${n}' not found`);
},
}),
} as const;

type Setup = {
objects: ServiceObject[];
drivers?: unknown[];
unavailable?: Array<{ name: string; kind: 'blocked' | 'failed' }>;
};

async function reconcile(setup: Setup, shape: keyof typeof SHAPES = 'serving') {
const rec = recordingLogger();
const engine = new ObjectQL({ logger: rec.logger } as any);
engine.registerDriver(namedDriver('default', 'ok') as any, true);
for (const d of setup.drivers ?? []) engine.registerDriver(d as any);
for (const obj of setup.objects) engine.registerObject(obj as any);
for (const u of setup.unavailable ?? []) engine.markDatasourceUnavailable(u);
const plugin = new ObjectQLPlugin();
(plugin as any).ql = engine;
await (plugin as any).reconcileFederatedBindings(SHAPES[shape](rec.logger));
return rec;
}

/** The info lines that report a policy denial — identified by the object they name. */
const deniedLines = (rec: ReturnType<typeof recordingLogger>, object: string) =>
rec.at('info').filter((r) => r.message.includes(object));

describe('#25079 a policy-denied (`blocked`) datasource is reported at info, never in the ERROR', () => {
for (const shape of Object.keys(SHAPES) as Array<keyof typeof SHAPES>) {
it(`one info line naming the denial, the datasource and the object; no ERROR (${shape}-shaped composition)`, async () => {
const rec = await reconcile(
{
objects: [federated('fed_customer', 'ext_denied'), federated('fed_order', 'ext_denied')],
unavailable: [{ name: 'ext_denied', kind: 'blocked' }],
},
shape,
);

expect(rec.at('error')).toHaveLength(0);
const lines = deniedLines(rec, 'fed_customer');
expect(lines).toHaveLength(1);
expect(lines[0].message).toContain('fed_order');
expect(lines[0].message).toContain("'ext_denied'");
expect(lines[0].message).toContain('denied');
expect(lines[0].message).toContain('DatasourceUnavailableError');
expect(lines[0].args[0]).toEqual({ denied: 2 });
});
}
});

describe('#25079 controls — every other unbound class keeps the ERROR, on either composition', () => {
const CONTROLS: Array<{ label: string; object: string; setup: Setup; meta: Record<string, number> }> = [
{
label: 'an undeclared datasource (no recorded verdict)',
object: 'fed_orphan',
setup: { objects: [federated('fed_orphan', 'ext_missing')] },
meta: { bound: 0, unbound: 1, unsupported: 0, failed: 0 },
},
{
label: "a FAILED connect (kind 'failed')",
object: 'fed_down',
setup: {
objects: [federated('fed_down', 'ext_down')],
unavailable: [{ name: 'ext_down', kind: 'failed' }],
},
meta: { bound: 0, unbound: 1, unsupported: 0, failed: 0 },
},
{
label: 'a driver without external-object registration',
object: 'fed_plain',
setup: { objects: [federated('fed_plain', 'ext_plain')], drivers: [namedDriver('ext_plain')] },
meta: { bound: 0, unbound: 0, unsupported: 1, failed: 0 },
},
{
label: 'a registration that threw',
object: 'fed_throws',
setup: { objects: [federated('fed_throws', 'ext_throws')], drivers: [namedDriver('ext_throws', 'throws')] },
meta: { bound: 0, unbound: 0, unsupported: 0, failed: 1 },
},
];

for (const c of CONTROLS) {
for (const shape of Object.keys(SHAPES) as Array<keyof typeof SHAPES>) {
it(`${c.label} → the ERROR (${shape}-shaped)`, async () => {
const rec = await reconcile(c.setup, shape);

const errors = rec.at('error');
expect(errors).toHaveLength(1);
expect(errors[0].message).toContain(c.object);
expect(errors[0].message).toContain('NOT bound to their remote table');
expect(errors[0].args[1]).toEqual(c.meta);
expect(deniedLines(rec, c.object)).toHaveLength(0);
});
}
}
});

describe('#25079 a mixed boot — info for the denied, the ERROR for the rest', () => {
it('splits by verdict, and the ERROR counts exclude the denied objects', async () => {
const rec = await reconcile({
objects: [
federated('fed_customer', 'ext_denied'),
federated('fed_orphan', 'ext_missing'),
federated('fed_down', 'ext_down'),
],
unavailable: [
{ name: 'ext_denied', kind: 'blocked' },
{ name: 'ext_down', kind: 'failed' },
],
});

const info = deniedLines(rec, 'fed_customer');
expect(info).toHaveLength(1);
expect(info[0].args[0]).toEqual({ denied: 1 });
expect(info[0].message).not.toContain('fed_orphan');
expect(info[0].message).not.toContain('fed_down');

const errors = rec.at('error');
expect(errors).toHaveLength(1);
expect(errors[0].message).toMatch(/^2 federated \(external\) object\(s\) are NOT bound/);
expect(errors[0].message).toContain('fed_orphan');
expect(errors[0].message).toContain('fed_down');
expect(errors[0].message).not.toContain('fed_customer');
expect(errors[0].args[1]).toEqual({ bound: 0, unbound: 2, unsupported: 0, failed: 0 });
});
});
31 changes: 30 additions & 1 deletion packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1831,6 +1831,10 @@ export class ObjectQLPlugin implements Plugin {
* the outside the deployment looks healthy while declared data is simply
* not reachable — naming the objects, their datasources, the consequence
* and the fix. A boot with nothing to report says nothing.
*
* One class is reported apart, at `info` (#25079): an object whose datasource
* the connect policy denied (`listUnavailableDatasources()` kind `blocked`).
* Its reads refuse loudly, and the denial was deliberate.
*/
private async reconcileFederatedBindings(ctx: PluginContext): Promise<void> {
if (!this.ql) return;
Expand Down Expand Up @@ -1871,15 +1875,25 @@ export class ObjectQLPlugin implements Plugin {

let bound = 0;
const unbound: string[] = [];
const denied: string[] = [];
const unsupported: string[] = [];
const failed: string[] = [];
const datasourceOf = (name: string): string =>
this.ql?.resolveEffectiveDatasource?.(name) ?? '(default)';
// [#25079] The datasource layer's recorded verdict, read back through the
// engine: `blocked` is a connect the host policy REFUSED on purpose
// (`os migrate`'s managed-only policy refuses every `schemaMode:
// 'external'` datasource), as opposed to `failed`. Judged on the verdict
// alone, on every composition.
const blocked = new Set(
this.ql.listUnavailableDatasources().filter((d) => d.kind === 'blocked').map((d) => d.name),
);

for (const obj of federated) {
const driver: any = this.ql.getDriverForObject(obj.name);
if (!driver) {
unbound.push(`${obj.name} -> datasource '${datasourceOf(obj.name)}'`);
const datasource = datasourceOf(obj.name);
(blocked.has(datasource) ? denied : unbound).push(`${obj.name} -> datasource '${datasource}'`);
continue;
}
if (typeof driver.registerExternalObject !== 'function') {
Expand All @@ -1894,6 +1908,21 @@ export class ObjectQLPlugin implements Plugin {
}
}

// [#25079] A policy denial is a decision, not a durability loss: every read
// against such an object refuses with `DatasourceUnavailableError`, so
// nothing looks normal while it is not — functional, hence `info` per the
// AGENTS.md degradation-log-level rule, and named rather than silent
// (#7737). It stays out of the ERROR below, whose consequence ("resolves
// to a table named after the OBJECT") is false for it.
if (denied.length) {
ctx.logger.info(
`${denied.length} federated (external) object(s) left unbound because the connect policy denied their ` +
`datasource: ${denied.join(', ')}. Every read against them is refused (DatasourceUnavailableError) until ` +
`the policy allows that datasource; see its "connect denied by policy" line earlier in this boot.`,
{ denied: denied.length },
);
}

if (unbound.length === 0 && unsupported.length === 0 && failed.length === 0) {
ctx.logger.debug('Federated objects bound to their remote tables', { bound });
return;
Expand Down
Loading