v0.42.46.0 fix(engine): federated read scope reaches by-slug reads (#2200) (#2239)

* fix(engine): federated sourceIds[] scope on by-slug secondary reads (#2200)

getTags/getLinks/getBacklinks/getTimeline (both engines) + TimelineOpts now
accept a federated `sourceIds[]` read grant, precedence over scalar sourceId,
filtering `source_id = ANY($::text[])` — mirroring getPage from v0.42.37.0.

- getTags: `page_id = (subquery)` -> `IN (subquery)` + DISTINCT so a slug present
  in >1 granted source unions tags instead of throwing on a multi-row subquery.
- getLinks/getBacklinks: federated branch scopes ALL THREE page endpoints (from,
  to, AND the authoring origin) so a cross-source link can't disclose a foreign
  slug. Scalar/unscoped branches unchanged (trusted internal callers keep the
  cross-source view).
- getTimeline: Postgres 8-branch cartesian tree collapsed to one fragment-composed
  query; PGLite adds the sourceIds branch to its dynamic WHERE.

* fix(ops): route by-slug reads through the federated source scope (#2200)

get_page resolves tags against the concrete page's source; get_tags/get_links/
get_backlinks/get_timeline route through sourceScopeOpts(ctx) (replacing the
copy-pasted scalar `ctx.sourceId ? {sourceId} : {}`). New linkReadScopeOpts
promotes an UNTRUSTED remote scalar scope to sourceIds[] so legacy/pre-federated
tokens also get all-endpoint link scoping; trusted local CLI keeps cross-source.

* test: federated read scope on by-slug reads + engine parity (#2200)

Per-op federated reads, isolation (out-of-grant -> empty), cross-source decoy
guard, far-endpoint + origin leak guards (F1), same-slug union (D3A), empty-array
contract, scalar-remote promotion (D1), getTimeline date-window after the Postgres
fragment refactor (D5A), and engine-parity arms for all four methods.

* chore: bump version and changelog (v0.42.46.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(key-files): document federated by-slug read scope + linkReadScopeOpts (#2200)

The #2200 fix routed get_page tags + get_tags/get_links/get_backlinks/
get_timeline through the federated source scope and added sourceIds[] to the
engine read methods + TimelineOpts. Bring KEY_FILES.md to current state: the
operations.ts entry's sourceScopeOpts read-op list now includes the by-slug
reads and documents the linkReadScopeOpts helper (three-endpoint link scoping +
untrusted-remote scalar promotion); the engine.ts entry notes the by-slug read
methods + TimelineOpts carry the same sourceIds[] federated axis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-16 22:32:38 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5c49225e4b
commit c023a6041d
12 changed files with 569 additions and 91 deletions
+18
View File
@@ -2,6 +2,24 @@
All notable changes to GBrain will be documented in this file.
## [0.42.46.0] - 2026-06-16
**Federated read scope now reaches every by-slug read, not just search and query (gbrain#2200).** A client that mounts several sources (a `federated_read` grant) could find a page through search and query, but the by-slug reads — `get_page`'s tags, plus `get_tags`, `get_links`, `get_backlinks`, and `get_timeline` — didn't honor the same grant. For a page living outside the default source that meant two wrong outcomes: the read came back empty for content the client was authorized to see, or it resolved against the wrong source. This release routes all of those reads through the same source-scope ladder that already governs search/query, so a federated client reads exactly the sources it's granted — no more, no less. Thanks to @mlobo2012 for the report and the proposed fix.
Link reads are scoped on every endpoint. A link connects up to three pages (the source page, the target, and the page that authored the edge); a federated read now constrains all three to the grant, so a link that crosses out of your granted sources doesn't surface a foreign page's slug. Untrusted remote callers carrying a single-source token get the same all-endpoint scoping; trusted local CLI keeps its cross-source view for link reconciliation and validators.
The semantic query cache was already corrected in v0.42.34.0 (cache rows key on the full source set, so a federated result can't be served to a caller with a different scope); this release closes the read-path half of the same theme.
### Fixed
- **By-slug reads honor the federated read grant.** `get_page` resolves a page's tags against that page's own source, and `get_tags` / `get_links` / `get_backlinks` / `get_timeline` route through the federated source scope. A multi-source client reads tags, links, backlinks, and timeline across exactly its granted sources (union), instead of falling back to a single source.
- **Link reads are scoped on all three endpoints** (source, target, and authoring page) under a federated grant, and untrusted remote single-source callers are scoped the same way — so a cross-source link can't disclose a foreign slug. Trusted local reads keep the full cross-source view.
### Changed
- The engine's `getTags` / `getLinks` / `getBacklinks` / `getTimeline` and `TimelineOpts` accept a `sourceIds[]` federated scope (precedence over the scalar source), mirroring `getPage` from v0.42.37.0. Write-side operations are unchanged — a read grant never widens writes.
### To take advantage of v0.42.46.0
`gbrain upgrade`. No configuration needed — federated clients immediately read tags, links, backlinks, and timeline across their full granted source set, and cross-source link reads stop surfacing foreign slugs. Single-source brains are unaffected.
## [0.42.45.0] - 2026-06-13
**The daily sync cron stops wedging on cost, and the embedding-spend estimate finally matches what a sync actually does (gbrain#2139).** On an active brain the inline-embed cost gate priced the *entire* corpus every time the working tree was dirty — which is always, since agents and crons write to it constantly — so a routine daily sync estimated ~158M tokens / ~$8 when the real delta was a few hundred files / ~$0.04, then blocked the cron with a confirmation it could never answer. Embeds silently stalled until someone noticed. The estimate now mirrors execution: it fetches first and prices only the files this run will pull and import, through the same diff machinery the sync itself uses. A brain whose commits are caught up but whose tree is dirty estimates $0, because an attached-HEAD sync imports only the committed diff.
+20
View File
@@ -1,5 +1,25 @@
# TODOS
## gbrain#2200 federated-read follow-ups (filed v0.42.46.0)
- [ ] **P1 — Close the federated-read scope on the remaining same-class by-slug read ops.**
v0.42.46.0 (#2200) routed `get_page` tags + `get_tags` / `get_links` / `get_backlinks` /
`get_timeline` through the federated source scope and taught the engine methods to honor
`sourceIds[]`. The adversarial review (Codex + Claude) flagged sibling read ops in the
SAME class that still use scalar-only `ctx.sourceId ? {sourceId} : {}` and never thread
`ctx.auth.allowedSources`: `get_chunks`, `get_raw_data`, `get_versions`, and `resolve_slugs`
(the standalone op — `resolve_slugs` passes NO scope at all). A remote federated client
(grant set, dispatch-default `ctx.sourceId='default'`) reads these against `default` or
unscoped, not its grant.
- **Why:** same cross-source correctness/isolation class #2200 targets; a federated client
can't read chunks/raw-data/versions for an authorized non-default source, and `resolve_slugs`
can fuzzy-resolve across all sources.
- **How to start:** mirror the #2200 pattern — route each handler through `sourceScopeOpts(ctx)`
(or `linkReadScopeOpts` if a far endpoint exists), add `sourceIds?: string[]` to the engine
methods (`getChunks` / `getRawData` / `getVersions` / `resolveSlugs`) with `source_id = ANY($::text[])`
precedence, and add federated/isolation tests + engine-parity arms.
- **Depends on:** nothing; #2200 established the pattern and the `linkReadScopeOpts` helper.
## Spend-controls wave follow-ups (filed v0.42.45.0, #2139)
Deferred from the #2139 delta-estimator wave. See plan + GSTACK REVIEW REPORT at
+1 -1
View File
@@ -1 +1 @@
0.42.45.0
0.42.46.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -143,5 +143,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.42.45.0"
"version": "0.42.46.0"
}
+20 -4
View File
@@ -1145,13 +1145,23 @@ export interface BrainEngine {
* sources (pre-v0.31.8 behavior; preserved via two-branch query in both
* engines). When set, the from-page filter becomes
* `WHERE f.slug = $1 AND f.source_id = $X`.
*
* #2200: `opts.sourceIds` is a federated read grant (caller's allowedSources).
* It takes precedence over scalar `sourceId` and constrains BOTH endpoints
* (from AND to) to the grant via `source_id = ANY($::text[])` — so an in-grant
* page linking to an out-of-grant page does NOT leak the foreign slug/context
* (carries the `traversePaths` both-endpoint invariant). Remote MCP clients
* always route here (sourceScopeOpts emits an array for any allowedSources
* grant); the scalar branch is internal/CLI and keeps cross-source visibility
* (reconcileLinks + back-link validators depend on it).
*/
getLinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]>;
getLinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]>;
/**
* v0.31.8 (D12 + D16): same `opts.sourceId` semantics as `getLinks`,
* applied to the to-page side of the join.
* applied to the to-page side of the join. #2200: `opts.sourceIds` federated
* grant constrains both endpoints (see `getLinks`).
*/
getBacklinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]>;
getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]>;
/**
* v114 (#1941): distinct link_source provenances with edge counts, for
* `gbrain link-sources`. Source-scoped via `{sourceId?, sourceIds?}` (both
@@ -1330,7 +1340,13 @@ export interface BrainEngine {
*/
addTag(slug: string, tag: string, opts?: { sourceId?: string }): Promise<void>;
removeTag(slug: string, tag: string, opts?: { sourceId?: string }): Promise<void>;
getTags(slug: string, opts?: { sourceId?: string }): Promise<string[]>;
/**
* #2200: getTags ALSO accepts a federated `sourceIds[]` read grant (precedence
* over scalar `sourceId`); it unions tags across the matched same-slug pages
* via `page_id IN (…) … DISTINCT`. The write-side addTag/removeTag deliberately
* stay scalar-only — `allowedSources` is a read grant; writes route to one source.
*/
getTags(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<string[]>;
// Timeline
/**
+44 -10
View File
@@ -425,6 +425,29 @@ export function sourceScopeOpts(ctx: OperationContext): { sourceId?: string; sou
return {};
}
/**
* #2200: source scope for the LINK read ops (get_links / get_backlinks). A link
* row references three pages (from, to, origin); the engine's federated
* (`sourceIds[]`) branch scopes ALL THREE, but its scalar (`sourceId`) branch
* scopes only the near endpoint — by design, because trusted internal callers
* (reconcileLinks, back-link validators, enrich) call the engine directly with a
* scalar scope and need the cross-source view.
*
* An UNTRUSTED remote caller carrying only a scalar scope (a legacy bearer token
* or a pre-`federated_read` OAuth client) would otherwise hit that scalar branch
* and have a foreign far/origin slug disclosed. So for remote callers we promote a
* scalar scope to a single-element `sourceIds:[id]`, routing them through the
* all-endpoint branch. Trusted local CLI (`ctx.remote === false`) keeps the scalar
* cross-source view, and a federated array passes through unchanged.
*/
export function linkReadScopeOpts(ctx: OperationContext): { sourceId?: string; sourceIds?: string[] } {
const scope = sourceScopeOpts(ctx);
if (ctx.remote !== false && scope.sourceId && !scope.sourceIds) {
return { sourceIds: [scope.sourceId] };
}
return scope;
}
/**
* Resolve a per-call requested source scope against the caller's trust + grant.
* FAIL-CLOSED: anything not strictly `ctx.remote === false` is untrusted.
@@ -642,7 +665,12 @@ const get_page: Operation = {
// inside bumpLastRetrievedAt (D2).
bumpLastRetrievedAt(ctx.engine, [page.id]);
const tags = await ctx.engine.getTags(page.slug, sourceOpts);
// #2200: resolve tags against the concrete page's source. `sourceOpts` may
// be { sourceIds:[...] } (federated) with no scalar sourceId, which getTags
// would otherwise fall back to 'default' for — the wrong source for a
// non-default page. We already hold the resolved page, so its source is
// unambiguous.
const tags = await ctx.engine.getTags(page.slug, { sourceId: page.source_id });
// Privacy boundary for the per-token allow-list (v0.28.6 for takes,
// v0.32.2 for facts).
//
@@ -1875,8 +1903,11 @@ const get_tags: Operation = {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
// v0.31.8 (D20): thread ctx.sourceId for read-side ops on multi-source brains.
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
// #2200: route through sourceScopeOpts so a federated read grant
// (ctx.auth.allowedSources) reaches the engine, not just scalar ctx.sourceId.
// Was `ctx.sourceId ? {sourceId} : {}` — a federated client got '{}' →
// engine fell back to 'default' (functionality gap + cross-source leak).
const sourceOpts = sourceScopeOpts(ctx);
return ctx.engine.getTags(p.slug as string, sourceOpts);
},
scope: 'read',
@@ -1972,9 +2003,10 @@ const get_links: Operation = {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
// v0.31.8 (D16): thread ctx.sourceId. When unset, engine falls through
// to cross-source view (back-compat).
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
// #2200: linkReadScopeOpts so a federated grant — and an untrusted remote
// scalar scope (promoted to sourceIds[]) — reaches the engine's all-endpoint
// branch. Trusted local/internal callers keep the scalar cross-source view.
const sourceOpts = linkReadScopeOpts(ctx);
return ctx.engine.getLinks(p.slug as string, sourceOpts);
},
scope: 'read',
@@ -1987,7 +2019,9 @@ const get_backlinks: Operation = {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
// #2200: linkReadScopeOpts — federated grant + untrusted remote scalar
// (promoted to sourceIds[]) reach the engine's all-endpoint branch.
const sourceOpts = linkReadScopeOpts(ctx);
return ctx.engine.getBacklinks(p.slug as string, sourceOpts);
},
scope: 'read',
@@ -2106,9 +2140,9 @@ const get_timeline: Operation = {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
// v0.31.8 (D20): thread ctx.sourceId.
const sourceId = ctx.sourceId;
return ctx.engine.getTimeline(p.slug as string, sourceId ? { sourceId } : undefined);
// #2200: route through sourceScopeOpts so a federated grant reaches the
// engine via TimelineOpts.sourceIds; scalar/unset unchanged.
return ctx.engine.getTimeline(p.slug as string, sourceScopeOpts(ctx));
},
scope: 'read',
cliHints: { name: 'timeline', positional: ['slug'] },
+65 -18
View File
@@ -2583,12 +2583,29 @@ export class PGLiteEngine implements BrainEngine {
}
}
async getLinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]> {
// v0.31.8 (D16): two-branch query. Without opts.sourceId, no source filter
// (preserves pre-v0.31.8 cross-source semantics for back-link validators
// and read-side op handlers that haven't threaded sourceId yet). With
// opts.sourceId, scope to that source — used by reconcileLinks and any
// ctx.sourceId-aware read op (D20).
async getLinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]> {
// #2200: federated grant scopes ALL THREE page endpoints — from, to, AND the
// origin (the authoring page, surfaced as origin_slug). The origin LEFT JOIN
// carries the same ANY($) filter so an out-of-grant origin's slug nulls out.
// Remote MCP clients always land here.
if (opts?.sourceIds && opts.sourceIds.length > 0) {
const { rows } = await this.db.query(
`SELECT f.slug as from_slug, t.slug as to_slug,
l.link_type, l.context, l.link_source,
o.slug as origin_slug, l.origin_field
FROM links l
JOIN pages f ON f.id = l.from_page_id
JOIN pages t ON t.id = l.to_page_id
LEFT JOIN pages o ON o.id = l.origin_page_id AND o.source_id = ANY($2::text[])
WHERE f.slug = $1 AND f.source_id = ANY($2::text[]) AND t.source_id = ANY($2::text[])`,
[slug, opts.sourceIds]
);
return rows as unknown as Link[];
}
// v0.31.8 (D16) + #2200: the federated arm above is the first branch; the two
// below preserve pre-v0.31.8 semantics. Without opts.sourceId, no source filter
// (cross-source view for back-link validators and reconcileLinks). With
// opts.sourceId, scope to that source (D20).
if (opts?.sourceId) {
const { rows } = await this.db.query(
`SELECT f.slug as from_slug, t.slug as to_slug,
@@ -2617,8 +2634,25 @@ export class PGLiteEngine implements BrainEngine {
return rows as unknown as Link[];
}
async getBacklinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]> {
// v0.31.8 (D16): two-branch query. See getLinks() comment.
async getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]> {
// #2200: federated grant scopes all three endpoints (mirrors getLinks) — the
// referrer (from), the queried page (to), AND the origin — so neither a
// foreign referrer nor a foreign origin slug is disclosed to the caller.
if (opts?.sourceIds && opts.sourceIds.length > 0) {
const { rows } = await this.db.query(
`SELECT f.slug as from_slug, t.slug as to_slug,
l.link_type, l.context, l.link_source,
o.slug as origin_slug, l.origin_field
FROM links l
JOIN pages f ON f.id = l.from_page_id
JOIN pages t ON t.id = l.to_page_id
LEFT JOIN pages o ON o.id = l.origin_page_id AND o.source_id = ANY($2::text[])
WHERE t.slug = $1 AND t.source_id = ANY($2::text[]) AND f.source_id = ANY($2::text[])`,
[slug, opts.sourceIds]
);
return rows as unknown as Link[];
}
// v0.31.8 (D16) + #2200: federated arm above is first; two below mirror getLinks.
if (opts?.sourceId) {
const { rows } = await this.db.query(
`SELECT f.slug as from_slug, t.slug as to_slug,
@@ -3231,14 +3265,20 @@ export class PGLiteEngine implements BrainEngine {
);
}
async getTags(slug: string, opts?: { sourceId?: string }): Promise<string[]> {
const sourceId = opts?.sourceId ?? 'default';
// Source-qualify the page-id subquery; slugs are only unique per source.
async getTags(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<string[]> {
// #2200: federated grant (sourceIds[]) wins over scalar. `page_id IN (..)`
// (not `= (..)`) so a slug present in >1 allowed source doesn't blow up;
// DISTINCT unions tags across the matched pages. Scalar/unscoped keeps the
// legacy `?? 'default'` default. Source-qualify; slugs are unique per source.
const scope =
opts?.sourceIds && opts.sourceIds.length > 0
? { sql: 'source_id = ANY($2::text[])', param: opts.sourceIds }
: { sql: 'source_id = $2', param: opts?.sourceId ?? 'default' };
const { rows } = await this.db.query(
`SELECT tag FROM tags
WHERE page_id = (SELECT id FROM pages WHERE slug = $1 AND source_id = $2)
`SELECT DISTINCT tag FROM tags
WHERE page_id IN (SELECT id FROM pages WHERE slug = $1 AND ${scope.sql})
ORDER BY tag`,
[slug, sourceId]
[slug, scope.param]
);
return (rows as { tag: string }[]).map(r => r.tag);
}
@@ -3300,9 +3340,11 @@ export class PGLiteEngine implements BrainEngine {
}
async getTimeline(slug: string, opts?: TimelineOpts): Promise<TimelineEntry[]> {
// v0.31.8 (D16): build WHERE clause dynamically so opts.sourceId composes
// cleanly with the existing after/before filters. Without sourceId, no
// source filter applies (preserves pre-v0.31.8 cross-source semantics).
// v0.31.8 (D16) + #2200: build WHERE clause dynamically so the source scope
// composes cleanly with the after/before filters. Precedence: federated
// sourceIds[] > scalar sourceId > unscoped (cross-source, pre-v0.31.8).
// (Postgres builds the equivalent via sql`` fragment composition — different
// idiom, same result; the engines stay lockstep on behavior, not on builder.)
const limit = opts?.limit || 100;
const where: string[] = ['p.slug = $1'];
const params: unknown[] = [slug];
@@ -3314,7 +3356,12 @@ export class PGLiteEngine implements BrainEngine {
params.push(opts.before);
where.push(`te.date <= $${params.length}::date`);
}
if (opts?.sourceId) {
// #2200: federated grant (sourceIds[]) wins over scalar sourceId. The join
// unions timeline entries across every same-slug page in the grant.
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}`);
}
+74 -55
View File
@@ -2648,11 +2648,31 @@ export class PostgresEngine implements BrainEngine {
}
}
async getLinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]> {
async getLinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]> {
const sql = this.sql;
// v0.31.8 (D16): two-branch query. Without opts.sourceId, no source filter
// (preserves pre-v0.31.8 cross-source semantics). With opts.sourceId,
// scope the from-page lookup. See pglite-engine.ts:getLinks for context.
// #2200: federated grant scopes ALL THREE page endpoints — from, to, AND the
// origin (the page that authored the edge, surfaced as origin_slug). Scoping
// only from+to would still leak an out-of-grant origin's slug; the origin
// LEFT JOIN carries the same ANY($) filter so origin_slug nulls out of grant.
// Remote MCP clients always land here.
if (opts?.sourceIds && opts.sourceIds.length > 0) {
const ids = opts.sourceIds;
const rows = await sql`
SELECT f.slug as from_slug, t.slug as to_slug,
l.link_type, l.context, l.link_source,
o.slug as origin_slug, l.origin_field
FROM links l
JOIN pages f ON f.id = l.from_page_id
JOIN pages t ON t.id = l.to_page_id
LEFT JOIN pages o ON o.id = l.origin_page_id AND o.source_id = ANY(${ids}::text[])
WHERE f.slug = ${slug} AND f.source_id = ANY(${ids}::text[]) AND t.source_id = ANY(${ids}::text[])
`;
return rows as unknown as Link[];
}
// v0.31.8 (D16) + #2200: the federated arm above is the first branch; the
// two below preserve pre-v0.31.8 semantics. Without opts.sourceId, no source
// filter (cross-source view for internal callers). With opts.sourceId, scope
// the from-page lookup. See pglite-engine.ts:getLinks for context.
if (opts?.sourceId) {
const rows = await sql`
SELECT f.slug as from_slug, t.slug as to_slug,
@@ -2679,9 +2699,26 @@ export class PostgresEngine implements BrainEngine {
return rows as unknown as Link[];
}
async getBacklinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]> {
async getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]> {
const sql = this.sql;
// v0.31.8 (D16): two-branch query, mirrors getLinks above.
// #2200: federated grant scopes all three endpoints (mirrors getLinks) — the
// referrer (from), the queried page (to), AND the origin — so neither a
// foreign referrer nor a foreign origin slug is disclosed to the caller.
if (opts?.sourceIds && opts.sourceIds.length > 0) {
const ids = opts.sourceIds;
const rows = await sql`
SELECT f.slug as from_slug, t.slug as to_slug,
l.link_type, l.context, l.link_source,
o.slug as origin_slug, l.origin_field
FROM links l
JOIN pages f ON f.id = l.from_page_id
JOIN pages t ON t.id = l.to_page_id
LEFT JOIN pages o ON o.id = l.origin_page_id AND o.source_id = ANY(${ids}::text[])
WHERE t.slug = ${slug} AND t.source_id = ANY(${ids}::text[]) AND f.source_id = ANY(${ids}::text[])
`;
return rows as unknown as Link[];
}
// v0.31.8 (D16) + #2200: federated arm above is first; two below mirror getLinks.
if (opts?.sourceId) {
const rows = await sql`
SELECT f.slug as from_slug, t.slug as to_slug,
@@ -3302,12 +3339,20 @@ export class PostgresEngine implements BrainEngine {
`;
}
async getTags(slug: string, opts?: { sourceId?: string }): Promise<string[]> {
async getTags(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<string[]> {
const sql = this.sql;
const sourceId = opts?.sourceId ?? 'default';
// #2200: federated grant (sourceIds[]) wins over scalar sourceId. Use
// `page_id IN (subquery)` — NOT `= (subquery)` — because a federated read of
// a slug present in >1 allowed source resolves multiple page-ids, which would
// throw under the scalar-subquery form. DISTINCT unions tags across the
// matched pages. Scalar/unscoped path keeps the legacy `?? 'default'` default.
const scope =
opts?.sourceIds && opts.sourceIds.length > 0
? sql`source_id = ANY(${opts.sourceIds}::text[])`
: sql`source_id = ${opts?.sourceId ?? 'default'}`;
const rows = await sql`
SELECT tag FROM tags
WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug} AND source_id = ${sourceId})
SELECT DISTINCT tag FROM tags
WHERE page_id IN (SELECT id FROM pages WHERE slug = ${slug} AND ${scope})
ORDER BY tag
`;
return rows.map((r) => r.tag as string);
@@ -3373,51 +3418,25 @@ export class PostgresEngine implements BrainEngine {
async getTimeline(slug: string, opts?: TimelineOpts): Promise<TimelineEntry[]> {
const sql = this.sql;
const limit = opts?.limit || 100;
// v0.31.8 (D16): branch on every combination of (after, before, sourceId).
// 8 cases is too many — use an explicit branch on sourceId, then nested
// branches on after/before. Mirrors pglite-engine but stays in postgres.js
// template-literal idiom (which doesn't compose fragment WHERE chains
// cleanly).
const sourceId = opts?.sourceId;
let rows;
if (sourceId) {
if (opts?.after && opts?.before) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
AND te.date >= ${opts.after}::date AND te.date <= ${opts.before}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else if (opts?.after) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
AND te.date >= ${opts.after}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else if (opts?.before) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
AND te.date <= ${opts.before}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
ORDER BY te.date DESC LIMIT ${limit}`;
}
} else if (opts?.after && opts?.before) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND te.date >= ${opts.after}::date AND te.date <= ${opts.before}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else if (opts?.after) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND te.date >= ${opts.after}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else if (opts?.before) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND te.date <= ${opts.before}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug}
ORDER BY te.date DESC LIMIT ${limit}`;
}
// #2200 (D5A): collapse the former 8-branch (sourceId × after × before)
// cartesian tree into ONE query built from composed WHERE fragments — the
// same postgres.js `sql`` idiom getPage/getBacklinks/listLinkSources use.
// Scope precedence: federated sourceIds[] > scalar sourceId > unscoped. The
// federated arm unions entries across every same-slug page in the grant.
// (PGLite builds the equivalent via its dynamic where[]/params[] array —
// different idiom by design, same behavior; lockstep is on result, not builder.)
const sourceCond =
opts?.sourceIds && opts.sourceIds.length > 0
? sql`AND p.source_id = ANY(${opts.sourceIds}::text[])`
: opts?.sourceId
? sql`AND p.source_id = ${opts.sourceId}`
: sql``;
const afterCond = opts?.after ? sql`AND te.date >= ${opts.after}::date` : sql``;
const beforeCond = opts?.before ? sql`AND te.date <= ${opts.before}::date` : sql``;
const rows = await sql`
SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} ${sourceCond} ${afterCond} ${beforeCond}
ORDER BY te.date DESC LIMIT ${limit}`;
return rows as unknown as TimelineEntry[];
}
+7
View File
@@ -1270,6 +1270,13 @@ export interface TimelineOpts {
* (pre-v0.31.8 behavior; preserved by the two-branch query in both engines).
*/
sourceId?: string;
/**
* #2200: federated read grant (caller's allowedSources). Takes precedence over
* scalar `sourceId` when non-empty; scopes the page-id lookup to
* `source_id = ANY($::text[])` so a federated read honors the whole grant
* (union of allowed sources), not just one source. Mirrors `GetPageOpts.sourceIds`.
*/
sourceIds?: string[];
}
// Raw data
+86
View File
@@ -612,3 +612,89 @@ describeBoth('Engine parity — relationalFanout', () => {
expect(shape(pg)).toEqual(shape(pglite));
});
});
// #2200 — federated sourceIds[] on the secondary-fetch reads must behave
// identically on both engines (a drift would mean a federated MCP client sees
// different tags/links/timeline after `gbrain migrate --to supabase`).
async function seedFederated(eng: BrainEngine) {
await eng.executeRaw(`INSERT INTO sources (id, name, local_path) VALUES ('beta', 'beta', '/tmp/beta') ON CONFLICT (id) DO NOTHING`);
await eng.putPage('fed/doc', { type: 'note', title: 'Fed doc', compiled_truth: 'b', timeline: '' }, { sourceId: 'beta' });
await eng.putPage('fed/target', { type: 'note', title: 'Fed target', compiled_truth: 'b', timeline: '' }, { sourceId: 'beta' });
await eng.putPage('fed/doc', { type: 'note', title: 'Default decoy', compiled_truth: 'd', timeline: '' }, { sourceId: 'default' });
await eng.putPage('fed/outside', { type: 'note', title: 'Outside', compiled_truth: 'd', timeline: '' }, { sourceId: 'default' });
await eng.addTag('fed/doc', 'beta-tag', { sourceId: 'beta' });
await eng.addTag('fed/doc', 'default-decoy-tag', { sourceId: 'default' });
await eng.addLink('fed/doc', 'fed/target', 'in', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'beta' });
await eng.addLink('fed/doc', 'fed/outside', 'leak', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'default' });
await eng.addLink('fed/target', 'fed/doc', 'inback', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'beta' });
await eng.addLink('fed/outside', 'fed/doc', 'leakback', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'default', toSourceId: 'beta' });
// F1: in-grant edge authored by an out-of-grant origin — origin_slug must null out.
await eng.addLink('fed/doc', 'fed/target', 'originleak', 'mentions', 'frontmatter', 'fed/outside', 'related', { fromSourceId: 'beta', toSourceId: 'beta', originSourceId: 'default' });
await eng.addTimelineEntry('fed/doc', { date: '2026-02-02', source: 't', summary: 'fed event', detail: 'd' }, { sourceId: 'beta' });
// Second-dated entry so the after/before fragment paths (D5A Postgres refactor) are exercised.
await eng.addTimelineEntry('fed/doc', { date: '2026-08-08', source: 't', summary: 'late event', detail: 'd' }, { sourceId: 'beta' });
}
describeBoth('Engine parity — federated sourceIds[] secondary reads (#2200)', () => {
let pgEngine: BrainEngine;
let pgliteEngine: PGLiteEngine;
const grant = { sourceIds: ['beta'] };
beforeAll(async () => {
pgEngine = await setupDB();
await seedFederated(pgEngine);
pgliteEngine = new PGLiteEngine();
await pgliteEngine.connect({});
await pgliteEngine.initSchema();
await seedFederated(pgliteEngine);
}, 90_000);
afterAll(async () => {
await pgliteEngine.disconnect();
await teardownDB();
}, 30_000);
test('getTags identical under sourceIds[]', async () => {
const pg = (await pgEngine.getTags('fed/doc', grant)).sort();
const pglite = (await pgliteEngine.getTags('fed/doc', grant)).sort();
expect(pg).toEqual(pglite);
expect(pg).toEqual(['beta-tag']); // default decoy excluded
});
test('getLinks identical under sourceIds[] (all three endpoints scoped)', async () => {
const pg = (await pgEngine.getLinks('fed/doc', grant)).map(l => l.to_slug).sort();
const pglite = (await pgliteEngine.getLinks('fed/doc', grant)).map(l => l.to_slug).sort();
expect(pg).toEqual(pglite);
expect([...new Set(pg)]).toEqual(['fed/target']); // far-endpoint 'fed/outside' excluded
// F1: origin_slug nulled identically on both engines when origin is out-of-grant.
const pgOrigins = (await pgEngine.getLinks('fed/doc', grant)).map(l => l.origin_slug ?? null);
const pgliteOrigins = (await pgliteEngine.getLinks('fed/doc', grant)).map(l => l.origin_slug ?? null);
expect(pgOrigins.sort()).toEqual(pgliteOrigins.sort());
expect(pgOrigins).not.toContain('fed/outside');
});
test('getBacklinks identical under sourceIds[] (both endpoints scoped)', async () => {
const pg = (await pgEngine.getBacklinks('fed/doc', grant)).map(l => l.from_slug).sort();
const pglite = (await pgliteEngine.getBacklinks('fed/doc', grant)).map(l => l.from_slug).sort();
expect(pg).toEqual(pglite);
expect(pg).toEqual(['fed/target']);
});
test('getTimeline identical under sourceIds[]', async () => {
const pg = (await pgEngine.getTimeline('fed/doc', grant)).map(e => e.summary).sort();
const pglite = (await pgliteEngine.getTimeline('fed/doc', grant)).map(e => e.summary).sort();
expect(pg).toEqual(pglite);
expect(pg).toEqual(['fed event', 'late event']);
});
// Pins the D5A Postgres fragment refactor: after/before/both window paths must
// match PGLite under a federated grant (the 8-branch→composed-WHERE rewrite).
test('getTimeline date-window fragments identical across engines (D5A regression guard)', async () => {
for (const win of [{ after: '2026-05-01' }, { before: '2026-05-01' }, { after: '2026-01-01', before: '2026-12-31' }]) {
const opts = { ...grant, ...win };
const pg = (await pgEngine.getTimeline('fed/doc', opts)).map(e => e.summary).sort();
const pglite = (await pgliteEngine.getTimeline('fed/doc', opts)).map(e => e.summary).sort();
expect(pg).toEqual(pglite);
}
});
});
+231
View File
@@ -8,6 +8,18 @@
* any page by slug. The fuzzy path was already scoped (#1436); this closes the
* exact path by (a) routing it through sourceScopeOpts and (b) teaching
* engine.getPage to honor a `sourceIds[]` array (both engines).
*
* #2200 — the SAME class on the secondary-fetch read ops. get_page resolves the
* page under the grant but fetched tags against 'default' (wrong source for a
* non-default page); get_tags / get_links / get_backlinks / get_timeline didn't
* route the federated grant to the engine at all (functionality gap + a
* cross-source fallback/foreign-endpoint leak). These tests cover:
* - get_page tags resolved against the concrete page's source
* - the 4 standalone ops honoring a federated grant
* - isolation (out-of-grant slug → empty, never the 'default' page's data)
* - the foreign-endpoint link leak (D4A: both endpoints scoped)
* - same-slug-across-sources union (D3A)
* - engine getTags/getLinks/getBacklinks/getTimeline sourceIds[] precedence
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
@@ -16,6 +28,10 @@ import { operations, OperationError, type OperationContext } from '../src/core/o
let engine: PGLiteEngine;
const get_page = operations.find(o => o.name === 'get_page')!;
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')!;
function ctxOf(overrides: Partial<OperationContext> = {}): OperationContext {
return {
@@ -50,8 +66,56 @@ beforeEach(async () => {
await engine.putPage('shared/alpha-doc', {
type: 'note', title: 'Alpha doc', compiled_truth: 'alpha content', frontmatter: {},
}, { sourceId: 'alpha' });
// --- #2200 secondary-fetch fixtures ---
// beta page's own tags.
await engine.addTag('secret/beta-doc', 'beta-confidential', { sourceId: 'beta' });
await engine.addTag('secret/beta-doc', 'beta-tag', { sourceId: 'beta' });
// A same-slug page in 'default' with DIFFERENT tags — the cross-source bleed
// guard. A federated read scoped to [alpha,beta] must NEVER surface these.
await engine.putPage('secret/beta-doc', {
type: 'note', title: 'Default decoy', compiled_truth: 'default content', frontmatter: {},
}, { sourceId: 'default' });
await engine.addTag('secret/beta-doc', 'default-secret-tag', { 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', {
type: 'note', title: 'Beta target', compiled_truth: 'beta target', frontmatter: {},
}, { sourceId: 'beta' });
await engine.putPage('default/only-doc', {
type: 'note', title: 'Default only', compiled_truth: 'default only', frontmatter: {},
}, { sourceId: 'default' });
// In-grant outgoing link beta→beta (must show for [alpha,beta]).
await engine.addLink('secret/beta-doc', 'secret/beta-target', 'in-grant ctx', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'beta' });
// Far-endpoint-leak outgoing link beta→default (must NOT show for [alpha,beta] — D4A).
await engine.addLink('secret/beta-doc', 'default/only-doc', 'LEAK ctx', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'default' });
// In-grant backlink beta→beta (referrer beta-target → secret/beta-doc).
await engine.addLink('secret/beta-target', 'secret/beta-doc', 'in-grant back', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'beta', toSourceId: 'beta' });
// Far-endpoint-leak backlink: referrer in 'default' → must NOT show for [alpha,beta].
await engine.addLink('default/only-doc', 'secret/beta-doc', 'LEAK back', 'cites', 'markdown', undefined, undefined, { fromSourceId: 'default', toSourceId: 'beta' });
// Origin-leak guard (F1): both endpoints in-grant (beta→beta) but the AUTHORING
// (origin) page is out-of-grant ('default'). origin_slug must NOT leak that slug.
await engine.addLink('secret/beta-doc', 'secret/beta-target', 'origin-leak ctx', 'mentions', 'frontmatter', 'default/only-doc', 'related', { fromSourceId: 'beta', toSourceId: 'beta', originSourceId: 'default' });
// Timeline entry on the beta page.
await engine.addTimelineEntry('secret/beta-doc', {
date: '2026-01-01', source: 'test', summary: 'beta event', detail: 'beta detail',
}, { sourceId: 'beta' });
// D3A union: same slug in BOTH alpha and beta with distinct tags.
await engine.putPage('shared/dup', {
type: 'note', title: 'Dup alpha', compiled_truth: 'a', frontmatter: {},
}, { sourceId: 'alpha' });
await engine.addTag('shared/dup', 'alpha-only', { sourceId: 'alpha' });
await engine.putPage('shared/dup', {
type: 'note', title: 'Dup beta', compiled_truth: 'b', frontmatter: {},
}, { sourceId: 'beta' });
await engine.addTag('shared/dup', 'beta-only', { sourceId: 'beta' });
});
function remoteCtx(allowedSources: string[]): OperationContext {
// Federated remote client: no scalar ctx.sourceId, grant via allowedSources.
return ctxOf({ remote: true, sourceId: undefined, auth: { token: 't', clientId: 'c', scopes: [], allowedSources } as any });
}
describe('engine.getPage honors sourceIds[] (federated grant)', () => {
test('sourceIds[] matching the page returns it', async () => {
const page = await engine.getPage('secret/beta-doc', { sourceIds: ['alpha', 'beta'] });
@@ -89,3 +153,170 @@ describe('get_page handler closes the cross-source exact-read leak', () => {
expect(page.title).toBe('Alpha doc');
});
});
// ---------------------------------------------------------------------------
// #2200 — secondary-fetch read ops honor the federated grant
// ---------------------------------------------------------------------------
describe('#2200 get_page resolves tags against the concrete page source', () => {
test('federated [alpha,beta] read of the beta page returns BETA tags, not default decoy', async () => {
const page: any = await get_page.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' });
expect(page.title).toBe('Beta secret');
expect(page.tags.sort()).toEqual(['beta-confidential', 'beta-tag']);
expect(page.tags).not.toContain('default-secret-tag');
});
});
describe('#2200 get_tags honors the federated grant', () => {
test('[alpha,beta] returns the beta page tags', async () => {
const tags = await get_tags.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' });
expect((tags as string[]).sort()).toEqual(['beta-confidential', 'beta-tag']);
});
test('[alpha] only → empty, never the default decoy tags (isolation)', async () => {
const tags = await get_tags.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' });
expect(tags).toEqual([]);
});
test('D3A same-slug-across-sources → union of tags', async () => {
const tags = await get_tags.handler(remoteCtx(['alpha', 'beta']), { slug: 'shared/dup' });
expect((tags as string[]).sort()).toEqual(['alpha-only', 'beta-only']);
});
});
describe('#2200 get_links honors the grant and scopes BOTH endpoints (D4A)', () => {
test('[alpha,beta] returns the in-grant beta→beta link', async () => {
const links = (await get_links.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
expect(links.map(l => l.to_slug)).toContain('secret/beta-target');
});
test('[alpha,beta] does NOT leak the beta→default far-endpoint link', async () => {
const links = (await get_links.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
expect(links.map(l => l.to_slug)).not.toContain('default/only-doc');
expect(links.map(l => l.context)).not.toContain('LEAK ctx');
});
test('[alpha] only → no beta links (isolation)', async () => {
const links = (await get_links.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' })) as any[];
expect(links).toEqual([]);
});
test('F1: in-grant link authored by an out-of-grant origin does NOT leak origin_slug', async () => {
const links = (await get_links.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
const originLeakLink = links.find(l => l.link_type === 'mentions' && l.to_slug === 'secret/beta-target');
expect(originLeakLink).toBeDefined();
// origin page 'default/only-doc' is out of the [alpha,beta] grant → origin_slug nulled.
expect(originLeakLink.origin_slug ?? null).toBeNull();
expect(links.map(l => l.origin_slug)).not.toContain('default/only-doc');
});
test('D1: UNTRUSTED remote with a scalar source scope is promoted to all-endpoint scoping (no far-endpoint leak)', async () => {
// legacy/pre-federated token: remote, scalar ctx.sourceId='beta', NO allowedSources.
const ctx = ctxOf({ remote: true, sourceId: 'beta', auth: undefined });
const links = (await get_links.handler(ctx, { slug: 'secret/beta-doc' })) as any[];
expect(links.map(l => l.to_slug)).toContain('secret/beta-target');
expect(links.map(l => l.to_slug)).not.toContain('default/only-doc'); // far endpoint out of scope
expect(links.map(l => l.origin_slug)).not.toContain('default/only-doc'); // origin too
});
test('D1: TRUSTED local CLI (remote=false) with a scalar scope keeps the cross-source view', async () => {
// reconcileLinks / validators depend on this — local CLI sees cross-source links.
const ctx = ctxOf({ remote: false, sourceId: 'beta', auth: undefined });
const links = (await get_links.handler(ctx, { slug: 'secret/beta-doc' })) as any[];
expect(links.map(l => l.to_slug)).toContain('default/only-doc'); // cross-source visible for trusted local
});
});
describe('#2200 get_backlinks honors the grant and scopes BOTH endpoints (D4A)', () => {
test('[alpha,beta] returns the in-grant beta→beta backlink', async () => {
const back = (await get_backlinks.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
expect(back.map(l => l.from_slug)).toContain('secret/beta-target');
});
test('[alpha,beta] does NOT leak the default→beta far-referrer backlink', async () => {
const back = (await get_backlinks.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
expect(back.map(l => l.from_slug)).not.toContain('default/only-doc');
expect(back.map(l => l.context)).not.toContain('LEAK back');
});
test('[alpha] only → no beta backlinks (isolation)', async () => {
const back = (await get_backlinks.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' })) as any[];
expect(back).toEqual([]);
});
});
describe('#2200 get_timeline honors the federated grant', () => {
test('[alpha,beta] returns the beta timeline entry', async () => {
const tl = (await get_timeline.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' })) as any[];
expect(tl.map(e => e.summary)).toContain('beta event');
});
test('[alpha] only → empty (isolation)', async () => {
const tl = (await get_timeline.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' })) as any[];
expect(tl).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())
.toEqual(['beta-confidential', 'beta-tag']);
expect(await engine.getTags('secret/beta-doc', { sourceIds: ['alpha'] })).toEqual([]);
expect((await engine.getTags('shared/dup', { sourceIds: ['alpha', 'beta'] })).sort())
.toEqual(['alpha-only', 'beta-only']);
});
test('getTags: sourceIds[] takes precedence over scalar sourceId', async () => {
// scalar says default (decoy), array says beta — array wins.
const tags = await engine.getTags('secret/beta-doc', { sourceId: 'default', sourceIds: ['beta'] });
expect(tags.sort()).toEqual(['beta-confidential', 'beta-tag']);
});
test('engine contract: empty sourceIds[] is NOT a federated scope — falls through to scalar (length>0 guard)', async () => {
// sourceScopeOpts never emits [] (it treats an empty grant as no-scope), but
// the engine methods are public: the `sourceIds && length > 0` guard must NOT
// treat [] as "match nothing" (ANY('{}')) NOR widen scope. It falls to scalar,
// here defaulting to 'default'. Pins the guard so a future `>= 0` regression fails.
// getTags scalar fallback defaults to 'default' → the decoy tag.
expect(await engine.getTags('secret/beta-doc', { sourceIds: [] })).toEqual(['default-secret-tag']);
// getTimeline's scalar branch with no sourceId is UNSCOPED (cross-source,
// pre-v0.31.8 semantics) — so [] yields the cross-source view, here the beta
// entry. The point: [] is treated as "no federated scope", never as ANY('{}').
const tl = await engine.getTimeline('secret/beta-doc', { sourceIds: [] });
expect(tl.map(e => e.summary)).toEqual(['beta event']);
});
test('getLinks: sourceIds[] scopes both endpoints (no far-endpoint leak); precedence over scalar', async () => {
const links = await engine.getLinks('secret/beta-doc', { sourceIds: ['alpha', 'beta'] });
expect(links.map(l => l.to_slug)).toContain('secret/beta-target');
expect(links.map(l => l.to_slug)).not.toContain('default/only-doc');
expect(await engine.getLinks('secret/beta-doc', { sourceIds: ['alpha'] })).toEqual([]);
// array beats scalar: scalar 'default' would surface the leak link; array ['beta'] must not.
const prec = await engine.getLinks('secret/beta-doc', { sourceId: 'default', sourceIds: ['beta'] });
expect([...new Set(prec.map(l => l.to_slug))]).toEqual(['secret/beta-target']); // only in-grant targets (multiple link_types collapse)
expect(prec.map(l => l.to_slug)).not.toContain('default/only-doc');
});
test('getBacklinks: sourceIds[] scopes both endpoints; precedence over scalar', async () => {
const back = await engine.getBacklinks('secret/beta-doc', { sourceIds: ['alpha', 'beta'] });
expect(back.map(l => l.from_slug)).toContain('secret/beta-target');
expect(back.map(l => l.from_slug)).not.toContain('default/only-doc');
const prec = await engine.getBacklinks('secret/beta-doc', { sourceId: 'default', sourceIds: ['beta'] });
expect(prec.map(l => l.from_slug)).toEqual(['secret/beta-target']);
});
test('getTimeline: sourceIds[] matching → returns; excluding → empty; precedence over scalar', async () => {
const hit = await engine.getTimeline('secret/beta-doc', { sourceIds: ['alpha', 'beta'] });
expect(hit.map(e => e.summary)).toContain('beta event');
expect(await engine.getTimeline('secret/beta-doc', { sourceIds: ['alpha'] })).toEqual([]);
// array beats scalar: scalar 'default' page has no timeline entry; array ['beta'] returns the beta event.
const prec = await engine.getTimeline('secret/beta-doc', { sourceId: 'default', sourceIds: ['beta'] });
expect(prec.map(e => e.summary)).toContain('beta event');
});
test('getTimeline: date-window filters still correct after the fragment refactor (D5A regression guard)', async () => {
await engine.addTimelineEntry('secret/beta-doc', { date: '2026-06-01', source: 'test', summary: 'june event', detail: 'd' }, { sourceId: 'beta' });
const windowed = await engine.getTimeline('secret/beta-doc', { sourceIds: ['beta'], after: '2026-03-01', before: '2026-12-31' });
expect(windowed.map(e => e.summary)).toEqual(['june event']);
});
});