From 499c5b6c0feacceb4be29698663bb4149e1b4662 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:50:57 +0000 Subject: [PATCH 1/3] wip(objectql): report a policy-denied federated datasource at info Claude-Session: https://claude.ai/code/session_016cxs5HWHRyhQhA9EaXmnWy Co-authored-by: Claude --- ...rated-binding-policy-denied-report.test.ts | 215 ++++++++++++++++++ packages/objectql/src/plugin.ts | 31 ++- 2 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 packages/objectql/src/federated-binding-policy-denied-report.test.ts diff --git a/packages/objectql/src/federated-binding-policy-denied-report.test.ts b/packages/objectql/src/federated-binding-policy-denied-report.test.ts new file mode 100644 index 00000000000..66afb119099 --- /dev/null +++ b/packages/objectql/src/federated-binding-policy-denied-report.test.ts @@ -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, 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) { + 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 }> = [ + { + 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) { + 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 }); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 2efc507c283..aea19b4003e 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -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 { if (!this.ql) return; @@ -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') { @@ -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; From 5cf4a4529531d3f3d26bb9ad8a4ae5105b4d5bd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 11:55:19 +0000 Subject: [PATCH 2/3] chore(changeset): objectql patch for the policy-denied federated report Claude-Session: https://claude.ai/code/session_016cxs5HWHRyhQhA9EaXmnWy Co-authored-by: Claude --- .changeset/25079-federated-report-policy-denied.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/25079-federated-report-policy-denied.md diff --git a/.changeset/25079-federated-report-policy-denied.md b/.changeset/25079-federated-report-policy-denied.md new file mode 100644 index 00000000000..ce21cc252cf --- /dev/null +++ b/.changeset/25079-federated-report-policy-denied.md @@ -0,0 +1,12 @@ +--- +'@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) + +- **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. From b9f2cfec12f2bb83a85549c313557c95bade8887 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 13:23:29 +0000 Subject: [PATCH 3/3] chore(changeset): declare Metadata-protocol for the objectql patch Claude-Session: https://claude.ai/code/session_016cxs5HWHRyhQhA9EaXmnWy Co-authored-by: Claude --- .changeset/25079-federated-report-policy-denied.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/25079-federated-report-policy-denied.md b/.changeset/25079-federated-report-policy-denied.md index ce21cc252cf..1b910bdc781 100644 --- a/.changeset/25079-federated-report-policy-denied.md +++ b/.changeset/25079-federated-report-policy-denied.md @@ -5,6 +5,7 @@ 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.