Skip to content

Commit db9c02a

Browse files
fix(objectql): a federated object whose datasource the connect policy denied is reported at info, not in the wrong-table ERROR (#25117)
Fixes #25079 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 changed `ObjectQLPlugin.reconcileFederatedBindings` (`packages/objectql/src/plugin.ts`, the report only) reads the datasource layer's recorded verdict back through the engine (`this.ql.listUnavailableDatasources()`). An unbound federated object whose datasource is recorded as `kind: 'blocked'`, meaning the connect policy denied it on purpose, is now reported in ONE `info` line. The line names the objects and their datasource, and says every read against them is refused with `DatasourceUnavailableError` until the policy allows that datasource. Those objects are no longer counted in the ERROR. Unchanged, at `error`, word for word, on every composition: - an undeclared datasource (no recorded verdict); - a FAILED connect (`kind: 'failed'`); - a driver without `registerExternalObject`; - a registration that threw. On a boot with both, the info line covers the denied objects and the ERROR covers the rest, with its counts excluding the denied ones. No binding, connect policy, read result or export changes. The #9285 registry-read seam is untouched. ## Why On the stock showcase, `os migrate apply --yes` denies `showcase_external` (`schemaMode: 'external'`) on purpose, under the CLI's managed-only policy. The reconciliation then printed an ERROR saying 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 policy-denied datasource that consequence is false on EVERY composition, because the engine refuses those reads with `DatasourceUnavailableError` (`engine.ts`, the `unavailableDatasources` branch of `getDriver`). Under the AGENTS.md degradation-log-level rule, a deliberate denial whose reads refuse loudly is a functional degradation: `info`, and named rather than silent, which keeps #7737's ruling. The scope is the seat's re-declaration 6315166970, option C of the dev's `needs_decision` 6314587078. The composition clause of claim 6314457843 is dropped, because no faithful "serves nothing" signal exists: "no `http.server`" misreads the documented `createHonoApp({ kernel })` embed. The condition is the recorded verdict alone, so no new signal is read and nothing outside `packages/objectql` changes. ## Pins New file `packages/objectql/src/federated-binding-policy-denied-report.test.ts`, 11 cases: - `blocked`: exactly one info line naming the denial, the datasource and both objects, and zero ERROR, on a serving-shaped and a migrate-shaped context alike; - controls, each on both shapes, keep exactly one ERROR with the unchanged counts and no info line: undeclared, `failed`, unsupported driver, registration threw; - mixed boot: info for the denied object only, and an ERROR reading `2 federated …` covering the undeclared and failed ones, excluding the denied one. `packages/runtime/src/federated-boot-binding.test.ts` (`domain:cli`) is NOT moved and stays green: its datasource is undeclared. ## Verification record Base `2e242bb0a9` for the census; built on `ba244f1b4`, then merged `origin/main` twice. The final head is `b9f2cfec1` (merge base `f2b6cf230`). - **Ablation** (`scripts/ablation-replace.mjs`, `(blocked.has(datasource) ? denied : unbound).push(` replaced by `unbound.push(`): anchor 1 → 0, blob `aea19b4003ed` → `45e012d1ed12`. Result: 3 failed | 8 passed (11). The two `blocked` cases and the mixed case went red; all 8 controls stayed green. Restored: blob == HEAD (`aea19b4003ed`) and `git diff HEAD` empty. The test imports `./plugin.js` from source, so no dist leg is involved. - **objectql:** typecheck exit 0 (the `tsconfig.test.json` program lists the new file; `check:test-typecheck` OK, no new debt). Full suite at `55a4c8b9f`, after the merge that brought #25086's objectql change: 459/459 files, 8631/8631 tests. The earlier run at `5cf4a4529` was 459/459, 8621/8621. - **runtime:** full suite 432/432 files, 6794 passed and 19 skipped, at `5cf4a4529` against an `objectql/dist` carrying this change. The diff touches no runtime file. Of the two merges, only #25086 (objectql `engine.ts`, strict read-only writes) moved a package in runtime's closure, and objectql's own suite was rerun after it. - **core `test:repo`:** 6/6 files, 82/82 tests. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 67 lines. Every line ran, each exit code captured before any pipe. At the final head `b9f2cfec1`: 67/67 exit 0, and `--ran` reported `67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED (a DERIVED zero — all 67 recorded an exit code and none of them is 3)`. The same held at `55a4c8b9f`. An earlier pass at `5cf4a4529` had three non-zero lines, none a gate verdict: `check:dual-build-cjs-loads` exit 3 (no full build yet), and `check:query-options-erasure` / `check:type-check-debt` exit 124 (my own 240 s per-command cap). All three exit 0 after the full build. Full build `pnpm exec turbo run build --concurrency=2`: 77/77 at both merged heads. - **Live, stock `examples/app-showcase`, fresh file DB, same tree:** - before (the routing line ablated, objectql rebuilt): `os migrate apply --yes` exit 0, then `INFO datasource 'showcase_external': connect denied by policy (…)`, then `ERROR 2 federated (external) object(s) are NOT bound to their remote table, yet stay registered and served: they keep their REST routes and keep rendering in the UI, …`; - after: exit 0, the same denial line, then `INFO 2 federated (external) object(s) left unbound because the connect policy denied their datasource: showcase_ext_customer -> datasource 'showcase_external', showcase_ext_order -> datasource 'showcase_external'. Every read against them is refused (DatasourceUnavailableError) …`, and 0 ` ERROR ` lines; 70 tables created. The after-run was repeated once objectql's dist was rebuilt from the restored source: same result. ## Acceptance notes - The `Metadata-protocol:` line follows #25114, which landed after the claim; its value is `none`, so the level stays `patch`. - Not built, per the claim: the connection policy and its info line, the binding logic, the CLI's migrate composition, #25031, #24956. --- _Generated by [Claude Code](https://claude.ai/code/session_016cxs5HWHRyhQhA9EaXmnWy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent edcc8ce commit db9c02a

3 files changed

Lines changed: 258 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no (log text and level only: no response, status, outcome, boot verdict or export moves)
8+
Metadata-protocol: none (a boot log line's level and text; nothing a metadata author writes changes)
9+
10+
- **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.
11+
- **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.
12+
- **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.
13+
- **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.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25079 — `reconcileFederatedBindings`' report for a federated object whose
5+
* datasource the connect POLICY denied.
6+
*
7+
* `os migrate apply` on the stock showcase denies its `schemaMode: 'external'`
8+
* datasource on purpose (the CLI's managed-only connect policy) and then
9+
* printed, at ERROR, that the federated objects "keep their REST routes and
10+
* keep rendering in the UI, while every read … resolves to a table named after
11+
* the OBJECT". For a denied datasource that consequence is false on every
12+
* composition: the datasource layer records the verdict as kind `blocked`
13+
* (`engine.markDatasourceUnavailable`), and every read against such an object
14+
* refuses with `DatasourceUnavailableError`. So the report is judged on that
15+
* recorded verdict alone, never on what the composition serves:
16+
*
17+
* - `blocked` → ONE `info` line, outside the ERROR;
18+
* - undeclared / `failed` / a driver without `registerExternalObject` /
19+
* a registration that threw → the ERROR, unchanged, as before.
20+
*
21+
* The composition is not read by the code under test; the two shapes below
22+
* pin that a migrate-shaped and a serving-shaped context answer alike.
23+
*/
24+
25+
import { describe, it, expect } from 'vitest';
26+
import type { ServiceObject } from '@objectstack/spec/data';
27+
import { ObjectQL } from './engine.js';
28+
import { ObjectQLPlugin } from './plugin.js';
29+
30+
interface Recorded {
31+
level: 'debug' | 'info' | 'warn' | 'error';
32+
message: string;
33+
args: unknown[];
34+
}
35+
36+
function recordingLogger() {
37+
const records: Recorded[] = [];
38+
const push = (level: Recorded['level']) => (message: string, ...args: unknown[]) =>
39+
void records.push({ level, message: String(message), args });
40+
return {
41+
records,
42+
logger: { debug: push('debug'), info: push('info'), warn: push('warn'), error: push('error') },
43+
at(level: Recorded['level']) {
44+
return records.filter((r) => r.level === level);
45+
},
46+
};
47+
}
48+
49+
/** A federated object bound to `datasource` by an explicit `object.datasource`. */
50+
function federated(name: string, datasource: string): ServiceObject {
51+
return {
52+
name,
53+
label: name,
54+
datasource,
55+
fields: { id: { name: 'id', label: 'ID', type: 'text' as const } },
56+
external: { remoteName: `remote_${name}` },
57+
} as ServiceObject;
58+
}
59+
60+
/** A driver registered under `name`; `register` decides what `registerExternalObject` does. */
61+
function namedDriver(name: string, register?: 'ok' | 'throws') {
62+
return {
63+
name,
64+
supports: {},
65+
async syncSchema() { /* not reached for a federated object */ },
66+
async find() { return []; },
67+
...(register
68+
? {
69+
async registerExternalObject() {
70+
if (register === 'throws') throw new Error('remote catalog unreachable');
71+
},
72+
}
73+
: {}),
74+
};
75+
}
76+
77+
/** The two composition shapes: one that serves (an `http.server` is published) and a migrate run's (none is). */
78+
const SHAPES = {
79+
serving: (logger: unknown) => ({
80+
logger,
81+
getService: (n: string) => {
82+
if (n === 'http.server') return {};
83+
throw new Error(`[Kernel] Service '${n}' not found`);
84+
},
85+
}),
86+
migrate: (logger: unknown) => ({
87+
logger,
88+
getService: (n: string) => {
89+
throw new Error(`[Kernel] Service '${n}' not found`);
90+
},
91+
}),
92+
} as const;
93+
94+
type Setup = {
95+
objects: ServiceObject[];
96+
drivers?: unknown[];
97+
unavailable?: Array<{ name: string; kind: 'blocked' | 'failed' }>;
98+
};
99+
100+
async function reconcile(setup: Setup, shape: keyof typeof SHAPES = 'serving') {
101+
const rec = recordingLogger();
102+
const engine = new ObjectQL({ logger: rec.logger } as any);
103+
engine.registerDriver(namedDriver('default', 'ok') as any, true);
104+
for (const d of setup.drivers ?? []) engine.registerDriver(d as any);
105+
for (const obj of setup.objects) engine.registerObject(obj as any);
106+
for (const u of setup.unavailable ?? []) engine.markDatasourceUnavailable(u);
107+
const plugin = new ObjectQLPlugin();
108+
(plugin as any).ql = engine;
109+
await (plugin as any).reconcileFederatedBindings(SHAPES[shape](rec.logger));
110+
return rec;
111+
}
112+
113+
/** The info lines that report a policy denial — identified by the object they name. */
114+
const deniedLines = (rec: ReturnType<typeof recordingLogger>, object: string) =>
115+
rec.at('info').filter((r) => r.message.includes(object));
116+
117+
describe('#25079 a policy-denied (`blocked`) datasource is reported at info, never in the ERROR', () => {
118+
for (const shape of Object.keys(SHAPES) as Array<keyof typeof SHAPES>) {
119+
it(`one info line naming the denial, the datasource and the object; no ERROR (${shape}-shaped composition)`, async () => {
120+
const rec = await reconcile(
121+
{
122+
objects: [federated('fed_customer', 'ext_denied'), federated('fed_order', 'ext_denied')],
123+
unavailable: [{ name: 'ext_denied', kind: 'blocked' }],
124+
},
125+
shape,
126+
);
127+
128+
expect(rec.at('error')).toHaveLength(0);
129+
const lines = deniedLines(rec, 'fed_customer');
130+
expect(lines).toHaveLength(1);
131+
expect(lines[0].message).toContain('fed_order');
132+
expect(lines[0].message).toContain("'ext_denied'");
133+
expect(lines[0].message).toContain('denied');
134+
expect(lines[0].message).toContain('DatasourceUnavailableError');
135+
expect(lines[0].args[0]).toEqual({ denied: 2 });
136+
});
137+
}
138+
});
139+
140+
describe('#25079 controls — every other unbound class keeps the ERROR, on either composition', () => {
141+
const CONTROLS: Array<{ label: string; object: string; setup: Setup; meta: Record<string, number> }> = [
142+
{
143+
label: 'an undeclared datasource (no recorded verdict)',
144+
object: 'fed_orphan',
145+
setup: { objects: [federated('fed_orphan', 'ext_missing')] },
146+
meta: { bound: 0, unbound: 1, unsupported: 0, failed: 0 },
147+
},
148+
{
149+
label: "a FAILED connect (kind 'failed')",
150+
object: 'fed_down',
151+
setup: {
152+
objects: [federated('fed_down', 'ext_down')],
153+
unavailable: [{ name: 'ext_down', kind: 'failed' }],
154+
},
155+
meta: { bound: 0, unbound: 1, unsupported: 0, failed: 0 },
156+
},
157+
{
158+
label: 'a driver without external-object registration',
159+
object: 'fed_plain',
160+
setup: { objects: [federated('fed_plain', 'ext_plain')], drivers: [namedDriver('ext_plain')] },
161+
meta: { bound: 0, unbound: 0, unsupported: 1, failed: 0 },
162+
},
163+
{
164+
label: 'a registration that threw',
165+
object: 'fed_throws',
166+
setup: { objects: [federated('fed_throws', 'ext_throws')], drivers: [namedDriver('ext_throws', 'throws')] },
167+
meta: { bound: 0, unbound: 0, unsupported: 0, failed: 1 },
168+
},
169+
];
170+
171+
for (const c of CONTROLS) {
172+
for (const shape of Object.keys(SHAPES) as Array<keyof typeof SHAPES>) {
173+
it(`${c.label} → the ERROR (${shape}-shaped)`, async () => {
174+
const rec = await reconcile(c.setup, shape);
175+
176+
const errors = rec.at('error');
177+
expect(errors).toHaveLength(1);
178+
expect(errors[0].message).toContain(c.object);
179+
expect(errors[0].message).toContain('NOT bound to their remote table');
180+
expect(errors[0].args[1]).toEqual(c.meta);
181+
expect(deniedLines(rec, c.object)).toHaveLength(0);
182+
});
183+
}
184+
}
185+
});
186+
187+
describe('#25079 a mixed boot — info for the denied, the ERROR for the rest', () => {
188+
it('splits by verdict, and the ERROR counts exclude the denied objects', async () => {
189+
const rec = await reconcile({
190+
objects: [
191+
federated('fed_customer', 'ext_denied'),
192+
federated('fed_orphan', 'ext_missing'),
193+
federated('fed_down', 'ext_down'),
194+
],
195+
unavailable: [
196+
{ name: 'ext_denied', kind: 'blocked' },
197+
{ name: 'ext_down', kind: 'failed' },
198+
],
199+
});
200+
201+
const info = deniedLines(rec, 'fed_customer');
202+
expect(info).toHaveLength(1);
203+
expect(info[0].args[0]).toEqual({ denied: 1 });
204+
expect(info[0].message).not.toContain('fed_orphan');
205+
expect(info[0].message).not.toContain('fed_down');
206+
207+
const errors = rec.at('error');
208+
expect(errors).toHaveLength(1);
209+
expect(errors[0].message).toMatch(/^2 federated \(external\) object\(s\) are NOT bound/);
210+
expect(errors[0].message).toContain('fed_orphan');
211+
expect(errors[0].message).toContain('fed_down');
212+
expect(errors[0].message).not.toContain('fed_customer');
213+
expect(errors[0].args[1]).toEqual({ bound: 0, unbound: 2, unsupported: 0, failed: 0 });
214+
});
215+
});

‎packages/objectql/src/plugin.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1831,6 +1831,10 @@ export class ObjectQLPlugin implements Plugin {
18311831
* the outside the deployment looks healthy while declared data is simply
18321832
* not reachable — naming the objects, their datasources, the consequence
18331833
* and the fix. A boot with nothing to report says nothing.
1834+
*
1835+
* One class is reported apart, at `info` (#25079): an object whose datasource
1836+
* the connect policy denied (`listUnavailableDatasources()` kind `blocked`).
1837+
* Its reads refuse loudly, and the denial was deliberate.
18341838
*/
18351839
private async reconcileFederatedBindings(ctx: PluginContext): Promise<void> {
18361840
if (!this.ql) return;
@@ -1871,15 +1875,25 @@ export class ObjectQLPlugin implements Plugin {
18711875

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

18791892
for (const obj of federated) {
18801893
const driver: any = this.ql.getDriverForObject(obj.name);
18811894
if (!driver) {
1882-
unbound.push(`${obj.name} -> datasource '${datasourceOf(obj.name)}'`);
1895+
const datasource = datasourceOf(obj.name);
1896+
(blocked.has(datasource) ? denied : unbound).push(`${obj.name} -> datasource '${datasource}'`);
18831897
continue;
18841898
}
18851899
if (typeof driver.registerExternalObject !== 'function') {
@@ -1894,6 +1908,21 @@ export class ObjectQLPlugin implements Plugin {
18941908
}
18951909
}
18961910

1911+
// [#25079] A policy denial is a decision, not a durability loss: every read
1912+
// against such an object refuses with `DatasourceUnavailableError`, so
1913+
// nothing looks normal while it is not — functional, hence `info` per the
1914+
// AGENTS.md degradation-log-level rule, and named rather than silent
1915+
// (#7737). It stays out of the ERROR below, whose consequence ("resolves
1916+
// to a table named after the OBJECT") is false for it.
1917+
if (denied.length) {
1918+
ctx.logger.info(
1919+
`${denied.length} federated (external) object(s) left unbound because the connect policy denied their ` +
1920+
`datasource: ${denied.join(', ')}. Every read against them is refused (DatasourceUnavailableError) until ` +
1921+
`the policy allows that datasource; see its "connect denied by policy" line earlier in this boot.`,
1922+
{ denied: denied.length },
1923+
);
1924+
}
1925+
18971926
if (unbound.length === 0 && unsupported.length === 0 && failed.length === 0) {
18981927
ctx.logger.debug('Federated objects bound to their remote tables', { bound });
18991928
return;

0 commit comments

Comments
 (0)