diff --git a/CHANGELOG.md b/CHANGELOG.md index 662838cf5..99e592209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TODOS.md b/TODOS.md index ed6f5507c..d96aa793b 100644 --- a/TODOS.md +++ b/TODOS.md @@ -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 diff --git a/VERSION b/VERSION index 360bb9599..a09261a41 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.42.45.0 +0.42.46.0 diff --git a/docs/architecture/KEY_FILES.md b/docs/architecture/KEY_FILES.md index ced7b8326..fa3cfe3a4 100644 --- a/docs/architecture/KEY_FILES.md +++ b/docs/architecture/KEY_FILES.md @@ -8,8 +8,8 @@ lives in `CHANGELOG.md` + `git log` / `git blame`, NOT here. Do not append per-release `**vX.Y.Z:**` narration — CI enforces this (`scripts/check-key-files-current-state.sh`). -- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Exports upload validators `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (glob matcher: `/*` matches recursive children; bare `` matches exact only). `OperationContext.remote` is a REQUIRED field flagging untrusted callers; `OperationContext.allowedSlugPrefixes` is the trusted-workspace allow-list set by the dream cycle; `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents//...` namespace check applies. Auto-link skipped only when `remote=true && !trustedWorkspace`. Every `Operation` carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`; `sync_brain`, `file_upload`, `file_list`, `file_url` are `admin + localOnly` (rejected over HTTP). Four trust-boundary call sites (`put_page` allowlist, file_upload trust-narrowing, submit_job protected-name guard, auto-link skip) use FAIL-CLOSED semantics: `ctx.remote === false` for trusted-only sites, `ctx.remote !== false` for "untrust unless explicit-false" — anything not strictly `false` is treated as remote (closes the HTTP MCP shell-job RCE where a read+write OAuth token could submit `shell` jobs). `sourceScopeOpts(ctx)` encodes the source-scoped read precedence ladder — federated array (`ctx.auth.allowedSources`) wins over scalar (`ctx.sourceId`/`ctx.auth.sourceId`) over nothing; every read-side op handler routes through it so a source-bound OAuth client can't see neighboring sources via `search`/`query`/`list_pages`/`get_page`/`find_experts`/query's image path. `put_page`'s inline disk write-through is the shared `writePageThrough` helper (`src/core/write-through.ts`), ATOMIC via temp-sibling + rename so a crash or concurrent `gbrain sync` can't read a half-written `.md`; same helper backs `gbrain brainstorm/lsd --save`. Link provenance surface (#1941): `add_link` (`gbrain link`/`link-add`) + `remove_link` (`gbrain unlink`/`link-rm`) expose `link_source`/`link_type`; `add_link` rejects the reconciliation-managed built-ins via `MANAGED_LINK_SOURCES` (`markdown`/`frontmatter`/`mentions`/`wikilink-resolved`) and defaults omitted provenance to `'manual'` (the engine's own default stays `'markdown'` for internal callers); `list_link_sources` (`gbrain link-sources`, read) lists provenances via `sourceScopeOpts`. CLI aliases register through `cliHints.aliases` (collision-guarded in `src/cli.ts`). -- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput`/`TimelineBatchInput` for the bulk-insert API (`addLinksBatch`/`addTimelineEntriesBatch`). `readonly kind: 'postgres' | 'pglite'` discriminator lets `src/core/migrate.ts` and others branch without `instanceof` + dynamic imports. Methods: `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[],$2::text[],$3::real[])` composite-keyed on `(slug, source_id)`), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` has `sort?: 'updated_desc'|'updated_asc'|'created_desc'|'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines. `listAllPageRefs(): Promise>` ordered by `(source_id, slug)` — cheap cross-source enumeration replacing the `getAllSlugs()→getPage(slug)` N+1 (which silently defaulted to `source_id='default'`); parity across postgres-engine.ts + pglite-engine.ts; Pinned by `test/e2e/multi-source-bug-class.test.ts`. `SearchOpts`+`PageFilters` add `sourceIds?: string[]` (federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when set, preserve scalar `sourceId` fast path when unset); `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId`/`opts.sourceIds`. `traverseGraph` opts has `frontierCap?: number` (per-iteration recursive-CTE cap, approx per-BFS-layer); return type `Promise` for MCP wire stability; export `TraverseGraphOpts`; Postgres uses parenthesized `LIMIT N ORDER BY (slug, id)` inside the recursive term, PGLite mirrors with positional params; Pinned by `test/regressions/v0_36_frontier_cap.test.ts`. Phantom-redirect methods: `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (content_hash refresh required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug`+`source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence; parity at `test/phantom-redirect-engine-parity.test.ts`. `getAdjacencyBoosts(pageIds): Promise>` powers the per-query graph-signals stage — one SQL query returning inbound-link counts among top-K plus a cross-source count (links from differing `source_id`); `COALESCE(p.source_id,'default')` null safety, `HAVING >= 1`, cross-source CASE-WHEN excludes the target's own source; parity SQL across both engines; `SearchResult` gains optional `base_score`, `backlink_boost`, `salience_boost`, `recency_boost`, `exact_match_boost`, `graph_adjacency_boost`, `graph_cross_source_boost`, `session_demote_factor`, `reranker_delta` + internal staging fields; Pinned by `test/e2e/graph-signals-engine.test.ts`. Two REQUIRED methods: `deletePages(slugs, {sourceId}): Promise` (single-batch primitive returning slugs actually deleted) and `resolveSlugsByPaths(paths, {sourceId}): Promise>` (batch path→slug lookup); `sourceId` REQUIRED on both at the type level (asymmetric with single-row `deletePage` which keeps optional/'default'); both short-circuit on empty input and throw when `> DELETE_BATCH_SIZE`. Embedding-signature stale-detection quartet: `countStaleChunks(opts?)` gains optional `signature?: string` widening the stale predicate from `embedding IS NULL` to ALSO include chunks whose JOINed page `embedding_signature IS NOT NULL AND <> $signature` (NULL signature is GRANDFATHERED, never counted; omit `signature` for the legacy NULL-only count); `sumStaleChunkChars(opts?: {sourceId?, signature?}): Promise` = `SUM(LENGTH(chunk_text))` over stale chunks (same predicate + embed_skip filter + optional sourceId scope), used by `gbrain sync --all` cost preview via `estimateCostFromChars`; `setPageEmbeddingSignature(slug, {sourceId?, signature})` stamps `pages.embedding_signature` after a page's chunks (re)embed, idempotent no-op when page absent; `invalidateStaleSignatureEmbeddings({signature, sourceId?}): Promise` NULLs `embedding`+`embedded_at` on every chunk whose page signature is set AND differs, returning the count, called BEFORE `listStaleChunks` so signature-drift pages flow through the NULL-embedding keyset cursor unchanged (NULL never invalidated). Widens `findOrphanPages(opts?: {sourceId?, sourceIds?})` (candidate-side scoping only; inbound links counted from any source). Pinned by `test/sum-stale-chunk-chars.test.ts`, `test/embedding-signature-stale.test.ts`, `test/e2e/engine-parity.test.ts`. Free-text alias layer: `resolveAliases(aliasNorms, opts?): Promise>>` (READ; maps each normalized alias to declaring `(slug, source_id)` pairs, source-scoped) and `setPageAliases(slug, sourceId, aliasNorms)` (WRITE; replaces the full alias set, delete-then-insert, empty clears, idempotent on the unique triple), called by the `importFromContent` ingest projection and the `reindex --aliases` backfill; parity across both engines, Pinned by `test/search/page-aliases-engine.test.ts`. `searchVector` in both engines injects the shared `buildBestPerPagePoolCte` per-page max-pool so a page surfaces on its strongest chunk. `executeRawDirect(sql, params?, opts?)` is the lock-hot-path sibling of `executeRaw`: same single-statement contract, but routes to the direct session-mode pool when dual-pool is active (Postgres/Supabase port 5432) so a long-held lock heartbeat survives the transaction pooler's per-transaction connection recycling; PGLite delegates straight to `executeRaw` (no pooler). Both engines implement it; the Minion lock path (`claim`/`renewLock`) is the consumer. `reconnect(ctx?: {error?})` is a REQUIRED lifecycle method on BOTH engines: it recovers a dropped connection using the config captured at the last `connect()`, so callers (autopilot health probe, `batchRetry`) never `disconnect()` + bare `connect()` (which loses the config and throws `database_url undefined` forever, and opens a null-connection window). PostgresEngine rebuilds its pool with a `_reconnecting` reentrancy guard and emits pool-recovery audit; PGLite is single-writer in-process so it just restores the saved data dir for parity. +- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Exports upload validators `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (glob matcher: `/*` matches recursive children; bare `` matches exact only). `OperationContext.remote` is a REQUIRED field flagging untrusted callers; `OperationContext.allowedSlugPrefixes` is the trusted-workspace allow-list set by the dream cycle; `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents//...` namespace check applies. Auto-link skipped only when `remote=true && !trustedWorkspace`. Every `Operation` carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`; `sync_brain`, `file_upload`, `file_list`, `file_url` are `admin + localOnly` (rejected over HTTP). Four trust-boundary call sites (`put_page` allowlist, file_upload trust-narrowing, submit_job protected-name guard, auto-link skip) use FAIL-CLOSED semantics: `ctx.remote === false` for trusted-only sites, `ctx.remote !== false` for "untrust unless explicit-false" — anything not strictly `false` is treated as remote (closes the HTTP MCP shell-job RCE where a read+write OAuth token could submit `shell` jobs). `sourceScopeOpts(ctx)` encodes the source-scoped read precedence ladder — federated array (`ctx.auth.allowedSources`) wins over scalar (`ctx.sourceId`/`ctx.auth.sourceId`) over nothing; every read-side op handler routes through it so a source-bound OAuth client can't see neighboring sources via `search`/`query`/`list_pages`/`get_page`/`find_experts`/query's image path, plus the by-slug reads `get_tags`/`get_links`/`get_backlinks`/`get_timeline` (and `get_page`'s tag fetch, which resolves against the concrete page's own `source_id`). `linkReadScopeOpts(ctx)` is the link-read sibling for `get_links`/`get_backlinks`: a link row references three pages (from/to/origin), and the engine's federated (`sourceIds[]`) branch scopes ALL THREE while its scalar (`sourceId`) branch scopes only the near endpoint (by design — trusted internal callers like `reconcileLinks` and back-link validators call the engine with a scalar scope and need the cross-source view). For an UNTRUSTED remote caller (`ctx.remote !== false`) carrying only a scalar scope it promotes that scope to a single-element `sourceIds:[id]`, routing them through the all-endpoint branch so a far/origin slug from outside the grant isn't disclosed; a federated array passes through unchanged and trusted local CLI (`ctx.remote === false`) keeps the scalar cross-source view. `put_page`'s inline disk write-through is the shared `writePageThrough` helper (`src/core/write-through.ts`), ATOMIC via temp-sibling + rename so a crash or concurrent `gbrain sync` can't read a half-written `.md`; same helper backs `gbrain brainstorm/lsd --save`. Link provenance surface (#1941): `add_link` (`gbrain link`/`link-add`) + `remove_link` (`gbrain unlink`/`link-rm`) expose `link_source`/`link_type`; `add_link` rejects the reconciliation-managed built-ins via `MANAGED_LINK_SOURCES` (`markdown`/`frontmatter`/`mentions`/`wikilink-resolved`) and defaults omitted provenance to `'manual'` (the engine's own default stays `'markdown'` for internal callers); `list_link_sources` (`gbrain link-sources`, read) lists provenances via `sourceScopeOpts`. CLI aliases register through `cliHints.aliases` (collision-guarded in `src/cli.ts`). +- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput`/`TimelineBatchInput` for the bulk-insert API (`addLinksBatch`/`addTimelineEntriesBatch`). `readonly kind: 'postgres' | 'pglite'` discriminator lets `src/core/migrate.ts` and others branch without `instanceof` + dynamic imports. Methods: `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[],$2::text[],$3::real[])` composite-keyed on `(slug, source_id)`), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` has `sort?: 'updated_desc'|'updated_asc'|'created_desc'|'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines. `listAllPageRefs(): Promise>` ordered by `(source_id, slug)` — cheap cross-source enumeration replacing the `getAllSlugs()→getPage(slug)` N+1 (which silently defaulted to `source_id='default'`); parity across postgres-engine.ts + pglite-engine.ts; Pinned by `test/e2e/multi-source-bug-class.test.ts`. `SearchOpts`+`PageFilters` add `sourceIds?: string[]` (federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when set, preserve scalar `sourceId` fast path when unset); `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId`/`opts.sourceIds`. The by-slug read methods carry the same federated axis: `getTags`/`getLinks`/`getBacklinks` opts and `TimelineOpts` (consumed by `getTimeline`) accept `sourceIds?: string[]` taking precedence over the scalar `sourceId` (`source_id = ANY($::text[])` scoping the slug→page-id lookup); the link reads (`getLinks`/`getBacklinks`) scope ALL THREE endpoints (from/to/origin) on the federated branch while the scalar branch scopes only the near endpoint for trusted internal cross-source callers. `traverseGraph` opts has `frontierCap?: number` (per-iteration recursive-CTE cap, approx per-BFS-layer); return type `Promise` for MCP wire stability; export `TraverseGraphOpts`; Postgres uses parenthesized `LIMIT N ORDER BY (slug, id)` inside the recursive term, PGLite mirrors with positional params; Pinned by `test/regressions/v0_36_frontier_cap.test.ts`. Phantom-redirect methods: `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (content_hash refresh required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug`+`source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence; parity at `test/phantom-redirect-engine-parity.test.ts`. `getAdjacencyBoosts(pageIds): Promise>` powers the per-query graph-signals stage — one SQL query returning inbound-link counts among top-K plus a cross-source count (links from differing `source_id`); `COALESCE(p.source_id,'default')` null safety, `HAVING >= 1`, cross-source CASE-WHEN excludes the target's own source; parity SQL across both engines; `SearchResult` gains optional `base_score`, `backlink_boost`, `salience_boost`, `recency_boost`, `exact_match_boost`, `graph_adjacency_boost`, `graph_cross_source_boost`, `session_demote_factor`, `reranker_delta` + internal staging fields; Pinned by `test/e2e/graph-signals-engine.test.ts`. Two REQUIRED methods: `deletePages(slugs, {sourceId}): Promise` (single-batch primitive returning slugs actually deleted) and `resolveSlugsByPaths(paths, {sourceId}): Promise>` (batch path→slug lookup); `sourceId` REQUIRED on both at the type level (asymmetric with single-row `deletePage` which keeps optional/'default'); both short-circuit on empty input and throw when `> DELETE_BATCH_SIZE`. Embedding-signature stale-detection quartet: `countStaleChunks(opts?)` gains optional `signature?: string` widening the stale predicate from `embedding IS NULL` to ALSO include chunks whose JOINed page `embedding_signature IS NOT NULL AND <> $signature` (NULL signature is GRANDFATHERED, never counted; omit `signature` for the legacy NULL-only count); `sumStaleChunkChars(opts?: {sourceId?, signature?}): Promise` = `SUM(LENGTH(chunk_text))` over stale chunks (same predicate + embed_skip filter + optional sourceId scope), used by `gbrain sync --all` cost preview via `estimateCostFromChars`; `setPageEmbeddingSignature(slug, {sourceId?, signature})` stamps `pages.embedding_signature` after a page's chunks (re)embed, idempotent no-op when page absent; `invalidateStaleSignatureEmbeddings({signature, sourceId?}): Promise` NULLs `embedding`+`embedded_at` on every chunk whose page signature is set AND differs, returning the count, called BEFORE `listStaleChunks` so signature-drift pages flow through the NULL-embedding keyset cursor unchanged (NULL never invalidated). Widens `findOrphanPages(opts?: {sourceId?, sourceIds?})` (candidate-side scoping only; inbound links counted from any source). Pinned by `test/sum-stale-chunk-chars.test.ts`, `test/embedding-signature-stale.test.ts`, `test/e2e/engine-parity.test.ts`. Free-text alias layer: `resolveAliases(aliasNorms, opts?): Promise>>` (READ; maps each normalized alias to declaring `(slug, source_id)` pairs, source-scoped) and `setPageAliases(slug, sourceId, aliasNorms)` (WRITE; replaces the full alias set, delete-then-insert, empty clears, idempotent on the unique triple), called by the `importFromContent` ingest projection and the `reindex --aliases` backfill; parity across both engines, Pinned by `test/search/page-aliases-engine.test.ts`. `searchVector` in both engines injects the shared `buildBestPerPagePoolCte` per-page max-pool so a page surfaces on its strongest chunk. `executeRawDirect(sql, params?, opts?)` is the lock-hot-path sibling of `executeRaw`: same single-statement contract, but routes to the direct session-mode pool when dual-pool is active (Postgres/Supabase port 5432) so a long-held lock heartbeat survives the transaction pooler's per-transaction connection recycling; PGLite delegates straight to `executeRaw` (no pooler). Both engines implement it; the Minion lock path (`claim`/`renewLock`) is the consumer. `reconnect(ctx?: {error?})` is a REQUIRED lifecycle method on BOTH engines: it recovers a dropped connection using the config captured at the last `connect()`, so callers (autopilot health probe, `batchRetry`) never `disconnect()` + bare `connect()` (which loses the config and throws `database_url undefined` forever, and opens a null-connection window). PostgresEngine rebuilds its pool with a `_reconnecting` reentrancy guard and emits pool-recovery audit; PGLite is single-writer in-process so it just restores the saved data dir for parity. - `src/core/engine-constants.ts` — single source of truth for engine batch-sizing constants. Exports `DELETE_BATCH_SIZE = 500` consumed by both engines' `deletePages` + `resolveSlugsByPaths` and by the sync delete + rename loops. Lives outside `engine.ts` (the interface module) to avoid circular-import worry — bounded per-statement work for predictable lock hold time + write amplification. - `src/core/background-work.ts` (#1762/#1745/#1775) — process background-work registry: the single owner of "drain every fire-and-forget DB-write sink before the CLI disconnects." `registerBackgroundWorkDrainer({name, order, drain(timeoutMs), abort?})` + `drainAllBackgroundWorkForCliExit({timeoutMs})` over a `Map` (idempotent registration by name; `__registerDrainerForTest` returns an unregister handle). Drains in explicit `(order, name)` order — facts FIRST (order 0) so its abort-path DB `logIngest` runs against the freshest live engine — and AWAITS `abort()` only when `drain()` reports `unfinished>0`. Best-effort per drainer: one sink's failure never blocks the others or the disconnect. FIVE sinks register at module import: `facts/queue.ts` (order 0; `abort`=`shutdown()` cancels a hung facts:absorb Haiku via internalAbort), `last-retrieved.ts` (order 1), `search/hybrid.ts` (order 2; `awaitPendingSearchCacheWrites` bounded via `Promise.race`), `eval-capture.ts` (order 3; `captureEvalCandidate` self-tracks its promise via `awaitPendingEvalCaptures`), `context/volunteer-events.ts` (order 4, #2095 — batched volunteer-event INSERTs, drained like the rest). Every cli.ts teardown site reaches it through `finishCliTeardown` (`src/core/cli-force-exit.ts`), which drains the registry before `engine.disconnect()` — closing the PGLite busy-loop where `db.close()` raced an in-flight job and pinned the single-writer lock (#1762). Exports `backgroundWorkSinkCount()` so the teardown helper computes its backstop deadline from the registered sink count. CLI-EXIT-ONLY: the facts `shutdown()` abort is permanent process state, never call in a long-lived `gbrain serve`. Companion changes: `src/core/ai/gateway.ts` `withDefaultTimeout(caller, ms)` bounds every outbound AI call (chat 300s, embed+multimodal 60s; env `GBRAIN_AI_{CHAT,EMBED,MULTIMODAL}_TIMEOUT_MS`; composed with caller signals via `AbortSignal.any`) and the teardown backstop honors an errored op's exit code so a hung disconnect can't mask failure as success (see `cli-force-exit.ts`); `src/core/postgres-engine.ts` `reconnect()` module-mode branch re-establishes via idempotent `db.connect()` + `connectionManager.setReadPool` refresh instead of `db.disconnect()` (no null window for concurrent ops; fail-loud on real connect failure — #1745); `src/core/search/hybrid.ts` `embedQueryBounded` + a shared `QueryEmbedDeadline` (6s, floored 2s per embed via `MIN_QUERY_EMBED_BUDGET_MS`; env `GBRAIN_QUERY_EMBED_TIMEOUT_MS`) bounds the cache-lookup AND inner query embeds so a stalled provider falls back to keyword instead of stalling the whole op (#1775). Incorporates + hardens PR #1763 (@ElliotDrel). Pinned by `test/core/background-work.test.ts`, `test/search/query-embed-deadline.test.ts`, `test/eval-capture-drain.test.ts`, `test/e2e/postgres-reconnect-singleton.test.ts`, `test/e2e/pglite-cli-exit.serial.test.ts`, `test/fix-wave-structural.test.ts`. - `src/core/search/graph-signals.ts` — per-query graph-signals helper. `applyGraphSignals(results, engine, opts)` runs as the 4th post-fusion stage (after backlink/salience/recency). Three boosts: `ADJACENCY_BOOST=1.05` (page linked from 2+ OTHER top-K results — local hub for THIS query), `CROSS_SOURCE_BOOST=1.10` (page linked from 2+ DIFFERENT sources — corroborated across team brains, dormant in single-source brains), `SESSION_DEMOTE=0.95` (3+ results from same chat session — keep the highest-scoring at full score, demote the rest). All three inherit the floor-ratio gate preventing weak pages from being boosted past strong ones via popularity. `computeScoreDistribution(results)` emits min/p25/p50/p75/p95/max + `reorder_band_width`. `sessionPrefix(slug)` extracts the chat-session anchor (`chat/2026-05-15-...`). Pure `pairedBootstrapPValue(deltas, resamples, rng)` exported for eval gates. Test seam via `adjacencyFn` DI. Fail-open: any error logs via `logGraphSignalsFailure` (JSONL audit via `audit-writer`) and returns the input array unchanged. Pinned by `test/search/graph-signals.test.ts` (incl. the IRON-RULE floor-gate regression). diff --git a/package.json b/package.json index 9ba4444fd..e4f39a94d 100644 --- a/package.json +++ b/package.json @@ -143,5 +143,5 @@ "bun": ">=1.3.10" }, "license": "MIT", - "version": "0.42.45.0" + "version": "0.42.46.0" } diff --git a/src/core/engine.ts b/src/core/engine.ts index 075870269..660972d1c 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -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; + getLinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise; /** * 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; + getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise; /** * 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; removeTag(slug: string, tag: string, opts?: { sourceId?: string }): Promise; - getTags(slug: string, opts?: { sourceId?: string }): Promise; + /** + * #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; // Timeline /** diff --git a/src/core/operations.ts b/src/core/operations.ts index eb6adc70f..c5c156b99 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -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'] }, diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index 6fdb57d07..dc6d62063 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -2583,12 +2583,29 @@ export class PGLiteEngine implements BrainEngine { } } - async getLinks(slug: string, opts?: { sourceId?: string }): Promise { - // 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 { + // #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 { - // v0.31.8 (D16): two-branch query. See getLinks() comment. + async getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise { + // #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 { - 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 { + // #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 { - // 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}`); } diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index ff781eaa3..d09af80bf 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -2648,11 +2648,31 @@ export class PostgresEngine implements BrainEngine { } } - async getLinks(slug: string, opts?: { sourceId?: string }): Promise { + async getLinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise { 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 { + async getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise { 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 { + async getTags(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise { 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 { 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[]; } diff --git a/src/core/types.ts b/src/core/types.ts index 300e6880b..c7964b7ef 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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 diff --git a/test/e2e/engine-parity.test.ts b/test/e2e/engine-parity.test.ts index d03c765c2..7b27424d4 100644 --- a/test/e2e/engine-parity.test.ts +++ b/test/e2e/engine-parity.test.ts @@ -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); + } + }); +}); diff --git a/test/get-page-federated-scope.test.ts b/test/get-page-federated-scope.test.ts index a21bc89c0..1c637cba4 100644 --- a/test/get-page-federated-scope.test.ts +++ b/test/get-page-federated-scope.test.ts @@ -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 { 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']); + }); +});