fix: scope residual federated reads (#3550)

Co-Authored-By: dialthewolff <jordan@jcwolff.com>
This commit is contained in:
Garry Tan
2026-08-01 10:06:17 +08:00
committed by Sina Matian
co-authored by dialthewolff
parent 7522b90db9
commit 273bd0e2be
5 changed files with 90 additions and 33 deletions
+3 -3
View File
@@ -995,7 +995,7 @@ export interface BrainEngine {
* same-slug source (importCodeFile uses this for incremental embedding
* reuse, which would then attach the wrong source's embeddings).
*/
getChunks(slug: string, opts?: { sourceId?: string }): Promise<Chunk[]>;
getChunks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Chunk[]>;
/**
* Count chunks across the brain where embedding IS NULL.
* Pre-flight short-circuit for `embed --stale` so a 100%-embedded brain
@@ -1499,7 +1499,7 @@ export interface BrainEngine {
* it, multi-source brains return raw_data rows from every same-slug page
* (preserved via two-branch query for back-compat).
*/
getRawData(slug: string, source?: string, opts?: { sourceId?: string }): Promise<RawData[]>;
getRawData(slug: string, source?: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<RawData[]>;
// Files (v0.27.1: binary asset metadata + storage_path. Image bytes never
// enter the DB; storage_path references a path inside the brain repo or an
@@ -1925,7 +1925,7 @@ export interface BrainEngine {
* When omitted, returns versions for every same-slug page across sources
* (pre-v0.31.8 behavior; preserved via two-branch query).
*/
getVersions(slug: string, opts?: { sourceId?: string }): Promise<PageVersion[]>;
getVersions(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<PageVersion[]>;
/**
* v0.31.8 (D12): `opts.sourceId` source-scopes both the version lookup
* and the page revert. Without it, multi-source brains can revert the
+3 -9
View File
@@ -2708,9 +2708,7 @@ const get_versions: Operation = {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
// v0.31.8 (D20): thread ctx.sourceId.
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
const versions = await ctx.engine.getVersions(p.slug as string, sourceOpts);
const versions = await ctx.engine.getVersions(p.slug as string, sourceScopeOpts(ctx));
// Same takes-allow-list privacy boundary as get_page. Snapshots persist
// historical compiled_truth verbatim, including the takes fence, so
// a remote token bypassing get_page via /history would re-introduce
@@ -2807,9 +2805,7 @@ const get_raw_data: Operation = {
source: { type: 'string', description: 'Filter by source' },
},
handler: async (ctx, p) => {
// v0.31.8 (D20 + D21): thread ctx.sourceId.
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
return ctx.engine.getRawData(p.slug as string, p.source as string | undefined, sourceOpts);
return ctx.engine.getRawData(p.slug as string, p.source as string | undefined, sourceScopeOpts(ctx));
},
scope: 'read',
};
@@ -2839,9 +2835,7 @@ const get_chunks: Operation = {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
// v0.31.8 (D20): thread ctx.sourceId.
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
return ctx.engine.getChunks(p.slug as string, sourceOpts);
return ctx.engine.getChunks(p.slug as string, sourceScopeOpts(ctx));
},
scope: 'read',
};
+21 -9
View File
@@ -2472,14 +2472,15 @@ export class PGLiteEngine implements BrainEngine {
);
}
async getChunks(slug: string, opts?: { sourceId?: string }): Promise<Chunk[]> {
const sourceId = opts?.sourceId ?? 'default';
async getChunks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Chunk[]> {
const sourceIds = opts?.sourceIds && opts.sourceIds.length > 0 ? opts.sourceIds : undefined;
const source = sourceIds ?? opts?.sourceId ?? 'default';
const { rows } = await this.db.query(
`SELECT cc.* FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
WHERE p.slug = $1 AND p.source_id = $2
WHERE p.slug = $1 AND ${sourceIds ? 'p.source_id = ANY($2::text[])' : 'p.source_id = $2'}
ORDER BY cc.chunk_index`,
[slug, sourceId]
[slug, source]
);
return (rows as Record<string, unknown>[]).map(r => rowToChunk(r));
}
@@ -4047,7 +4048,7 @@ export class PGLiteEngine implements BrainEngine {
async getRawData(
slug: string,
source?: string,
opts?: { sourceId?: string },
opts?: { sourceId?: string; sourceIds?: string[] },
): Promise<RawData[]> {
// v0.31.8 (D21): build WHERE clause dynamically. Without opts.sourceId,
// no source filter (preserves pre-v0.31.8 cross-source read).
@@ -4057,7 +4058,10 @@ export class PGLiteEngine implements BrainEngine {
params.push(source);
where.push(`rd.source = $${params.length}`);
}
if (opts?.sourceId) {
if (opts?.sourceIds && opts.sourceIds.length > 0) {
params.push(opts.sourceIds);
where.push(`p.source_id = ANY($${params.length}::text[])`);
} else if (opts?.sourceId) {
params.push(opts.sourceId);
where.push(`p.source_id = $${params.length}`);
}
@@ -5288,9 +5292,17 @@ export class PGLiteEngine implements BrainEngine {
return rows[0] as unknown as PageVersion;
}
async getVersions(slug: string, opts?: { sourceId?: string }): Promise<PageVersion[]> {
// v0.31.8 (D16): two-branch. Without opts.sourceId, joins return versions
// for every same-slug page (preserves pre-v0.31.8 cross-source view).
async getVersions(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<PageVersion[]> {
if (opts?.sourceIds && opts.sourceIds.length > 0) {
const { rows } = await this.db.query(
`SELECT pv.* FROM page_versions pv
JOIN pages p ON p.id = pv.page_id
WHERE p.slug = $1 AND p.source_id = ANY($2::text[])
ORDER BY pv.snapshot_at DESC`,
[slug, opts.sourceIds]
);
return rows as unknown as PageVersion[];
}
if (opts?.sourceId) {
const { rows } = await this.db.query(
`SELECT pv.* FROM page_versions pv
+30 -12
View File
@@ -2605,14 +2605,18 @@ export class PostgresEngine implements BrainEngine {
);
}
async getChunks(slug: string, opts?: { sourceId?: string }): Promise<Chunk[]> {
const sourceId = opts?.sourceId ?? 'default';
async getChunks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Chunk[]> {
const sourceIds = opts?.sourceIds && opts.sourceIds.length > 0 ? opts.sourceIds : undefined;
const scalarSourceId = opts?.sourceId ?? 'default';
// RLS scope binding (opt-in via GBRAIN_RLS_SCOPE_BINDING).
return await this.withScopedReadTransaction(undefined, sourceId, async (tx) => {
return await this.withScopedReadTransaction(sourceIds, sourceIds ? undefined : scalarSourceId, async (tx) => {
const scope = sourceIds
? tx`p.source_id = ANY(${sourceIds}::text[])`
: tx`p.source_id = ${scalarSourceId}`;
const rows = await tx`
SELECT cc.* FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
WHERE p.slug = ${slug} AND ${scope}
ORDER BY cc.chunk_index
`;
return rows.map((r: Record<string, unknown>) => rowToChunk(r));
@@ -4188,15 +4192,21 @@ export class PostgresEngine implements BrainEngine {
async getRawData(
slug: string,
source?: string,
opts?: { sourceId?: string },
opts?: { sourceId?: string; sourceIds?: string[] },
): Promise<RawData[]> {
const sql = this.sql;
// v0.31.8 (D21): four-branch shape on (source provided, sourceId provided).
// Postgres.js template-literal style doesn't compose fragments cleanly so
// we enumerate.
const sourceId = opts?.sourceId;
const sourceIds = opts?.sourceIds && opts.sourceIds.length > 0 ? opts.sourceIds : undefined;
const sourceId = sourceIds ? undefined : opts?.sourceId;
let rows;
if (source && sourceId) {
if (source && sourceIds) {
rows = await sql`SELECT rd.source, rd.data, rd.fetched_at FROM raw_data rd
JOIN pages p ON p.id = rd.page_id
WHERE p.slug = ${slug} AND rd.source = ${source} AND p.source_id = ANY(${sourceIds}::text[])`;
} else if (sourceIds) {
rows = await sql`SELECT rd.source, rd.data, rd.fetched_at FROM raw_data rd
JOIN pages p ON p.id = rd.page_id
WHERE p.slug = ${slug} AND p.source_id = ANY(${sourceIds}::text[])`;
} else if (source && sourceId) {
rows = await sql`SELECT rd.source, rd.data, rd.fetched_at FROM raw_data rd
JOIN pages p ON p.id = rd.page_id
WHERE p.slug = ${slug} AND rd.source = ${source} AND p.source_id = ${sourceId}`;
@@ -5384,9 +5394,17 @@ export class PostgresEngine implements BrainEngine {
return rows[0] as unknown as PageVersion;
}
async getVersions(slug: string, opts?: { sourceId?: string }): Promise<PageVersion[]> {
async getVersions(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<PageVersion[]> {
const sql = this.sql;
// v0.31.8 (D16): two-branch.
if (opts?.sourceIds && opts.sourceIds.length > 0) {
const rows = await sql`
SELECT pv.* FROM page_versions pv
JOIN pages p ON p.id = pv.page_id
WHERE p.slug = ${slug} AND p.source_id = ANY(${opts.sourceIds}::text[])
ORDER BY pv.snapshot_at DESC
`;
return rows as unknown as PageVersion[];
}
if (opts?.sourceId) {
const rows = await sql`
SELECT pv.* FROM page_versions pv
+33
View File
@@ -32,6 +32,9 @@ const get_tags = operations.find(o => o.name === 'get_tags')!;
const get_links = operations.find(o => o.name === 'get_links')!;
const get_backlinks = operations.find(o => o.name === 'get_backlinks')!;
const get_timeline = operations.find(o => o.name === 'get_timeline')!;
const get_chunks = operations.find(o => o.name === 'get_chunks')!;
const get_raw_data = operations.find(o => o.name === 'get_raw_data')!;
const get_versions = operations.find(o => o.name === 'get_versions')!;
function ctxOf(overrides: Partial<OperationContext> = {}): OperationContext {
return {
@@ -77,6 +80,16 @@ beforeEach(async () => {
type: 'note', title: 'Default decoy', compiled_truth: 'default content', frontmatter: {},
}, { sourceId: 'default' });
await engine.addTag('secret/beta-doc', 'default-secret-tag', { sourceId: 'default' });
await engine.upsertChunks('secret/beta-doc', [{
chunk_index: 0, chunk_text: 'beta chunk', chunk_source: 'compiled_truth', token_count: 2,
}], { sourceId: 'beta' });
await engine.upsertChunks('secret/beta-doc', [{
chunk_index: 0, chunk_text: 'default chunk', chunk_source: 'compiled_truth', token_count: 2,
}], { sourceId: 'default' });
await engine.putRawData('secret/beta-doc', 'crm', { owner: 'beta' }, { sourceId: 'beta' });
await engine.putRawData('secret/beta-doc', 'crm', { owner: 'default' }, { sourceId: 'default' });
await engine.createVersion('secret/beta-doc', { sourceId: 'beta' });
await engine.createVersion('secret/beta-doc', { sourceId: 'default' });
// Link endpoints. NOTE (Codex #7): addLink defaults BOTH endpoints to 'default'
// unless given {fromSourceId,toSourceId} — pass them or the beta edges won't seed.
await engine.putPage('secret/beta-target', {
@@ -277,6 +290,26 @@ describe('#2200 get_timeline honors the federated grant', () => {
});
});
describe('#2200 residual by-slug reads honor the federated grant', () => {
test('get_chunks returns only in-grant chunks', async () => {
const hit = await get_chunks.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' }) as any[];
expect(hit.map(c => c.chunk_text)).toEqual(['beta chunk']);
expect(await get_chunks.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' })).toEqual([]);
});
test('get_raw_data returns only in-grant rows', async () => {
const hit = await get_raw_data.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc', source: 'crm' }) as any[];
expect(hit.map(r => r.data.owner)).toEqual(['beta']);
expect(await get_raw_data.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc', source: 'crm' })).toEqual([]);
});
test('get_versions returns only in-grant snapshots', async () => {
const hit = await get_versions.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' }) as any[];
expect(hit.map(v => v.compiled_truth)).toEqual(['beta-only content']);
expect(await get_versions.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' })).toEqual([]);
});
});
describe('#2200 engine secondary-fetch methods honor sourceIds[]', () => {
test('getTags: sourceIds[] matching → returns; excluding → empty; union on collision', async () => {
expect((await engine.getTags('secret/beta-doc', { sourceIds: ['alpha', 'beta'] })).sort())