Commit Graph
8 Commits
Author SHA1 Message Date
03cd52631b fix(thin-client): map --source scope onto source_id for remote-routed ops (#3086)
* fix(thin-client): map --source/GBRAIN_SOURCE/.gbrain-source onto source_id for routed ops (#2098)

The thin-client route short-circuits before makeContext, so the 6-tier
source resolution never ran and `gbrain query --source X` against a
remote brain sent the unknown `source` key verbatim — the server op
ignored it and searched unscoped.

applyThinClientSourceScope now runs the engine-free tiers (flag → env →
dotfile; DB-backed tiers need an engine, and the server's grant scoping
covers the rest) and sets the op's source_id wire param. Ops declaring
their own `source` param are untouched; an explicit --source on an op
with no source_id wire param errors loudly instead of silently dropping;
explicit --source-id/--all-sources on the wire win over ambient tiers.

Fixes #2098

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(thin-client): use withEnv() instead of direct process.env mutation (test-isolation R1)

CI verify failed on check:test-isolation — thin-client-source-scope.test.ts
mutated process.env.GBRAIN_SOURCE via beforeEach/afterEach. Wrapped each
test body in withEnv() from test/helpers/with-env.ts instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(thin-client): keep ambient scope out of get_skill's non-scope source_id param

get_skill's source_id is a mode switch (host catalog vs brain-resident-pack
lookup), not a read-scope filter. Ambient GBRAIN_SOURCE / .gbrain-source
injection would silently reroute 'gbrain skill <name>' on thin clients to
getResidentSkillDetail. Exclude it via NON_SCOPE_SOURCE_ID_OPS; explicit
--source-id still passes through, explicit --source errors with a hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-23 16:45:36 -07:00
8160236ade fix(search): honor sources.config.federated in unqualified local CLI search/query (#2561) (#3141)
A source registered with `gbrain sources add --federated` was invisible to
an unqualified `gbrain search`/`gbrain query`: the local CLI always emitted
a scalar {sourceId} scope, and nothing on the read path ever consulted
sources.config.federated — contradicting docs/guides/multi-source-brains.md
('Source participates in unqualified gbrain search results').

Fix, at the trusted-local boundary only:
- src/cli.ts makeContext resolves the source WITH its tier and, when the
  tier is non-explicit (local_path / brain_default / sole_non_default /
  seed_default), computes ctx.localFederatedSourceIds = [resolved source,
  ...other config.federated=true sources] (archived excluded).
- New federatedSearchScope (operations.ts) delegates to
  resolveRequestedScope, then widens an unqualified trusted-local scalar
  scope to that set. Used by the search + query handlers only.
- Expansion NEVER applies when ctx.remote !== false (fail-closed source
  isolation), when a per-call source_id/__all__ is passed, when an OAuth
  grant (allowedSources) is present, or when --source/GBRAIN_SOURCE/dotfile
  named the source explicitly.

Deliberately NOT inside sourceScopeOpts: code-intel ops reject multi-source
scopes (resolveCodeIntelScope) and non-search reads keep their scalar
behavior. Cache contamination is already handled — cacheScopeKey folds
sourceIds sets into the query-cache key.

Fixes #2561

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:57:42 -07:00
dde1132a29 v0.42.55.0 fix(security): dotfile/skills/slug confinement + DCR consent default + schema-lint migration (#418 #419 #245 #1353 #1647 #171 #1385) (#2399)
* fix(security): confine routing dotfiles, skills dir, slugs, and transcription exec

Shared src/core/path-confine.ts consolidates the realpath-containment idiom
(moved from sources-ops.ts) and adds isTrustedDotfile + isWriteTargetContained.

- .gbrain-source (source-resolver) and .gbrain-mount (brain-resolver) walk-up
  dotfiles are now lstat trust-gated: a symlink, foreign-owned, or world-writable
  file is refused on multi-user hosts (#418), fail-closed on stat error.
- resolveWorkspaceSkillsDir + every skills-dir tier (env, cwd_walk_up, repo_root,
  cwd_skills, install_path) route through realpath containment so a symlinked
  workspace/skills can't escape the declared workspace (#419).
- resolveSourceId/resolveBrainId realpath both sides of the registered local_path
  / mount prefix match so a symlinked cwd can't misattribute source/brain.
- validateSlug rejects NUL/control, bidi/RTL overrides, backslashes, and
  URL-encoded path separators at the shared putPage/updateSlug chokepoint;
  write-through confirms the file path stays within the source tree.
- transcribeLargeFile uses execFileSync arg-arrays + fs.rmSync (no shell), so a
  path with shell metacharacters is never parsed by a shell (#245).

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

* fix(security): default dynamic-registration clients to authorization_code

Self-registered DCR clients (the unauthenticated network registration path)
previously defaulted to the client_credentials grant, which bypasses the
/authorize consent screen. They now default to authorization_code; an explicit
client_credentials request is rejected with invalid_client_metadata unless the
operator opts in with the new --enable-dcr-insecure flag. A loud stderr WARNING
prints at startup whenever DCR is enabled (#1353). Manual CLI/admin client
registration is unchanged (operator-trusted).

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

* fix(security): schema-lint hardening migration (search_path + view security_invoker)

Migration v120 brings existing brains to the same posture as fresh installs:

- ALTER VIEW page_links SET (security_invoker = on) on Postgres so the view
  honors the caller's RLS instead of the owner's (the view-through-RLS bypass).
- ALTER FUNCTION ... SET search_path on the gbrain-owned trigger/event functions
  (both engines, IF EXISTS so engine-only functions are skipped; body untouched,
  so the load-bearing auto_enable_rls event trigger is unchanged). Closes #171.
- Broaden the BYPASSRLS preflight in the historical RLS migration gates to honor
  superuser and inherited-role BYPASSRLS, so a superuser-connected fresh install
  no longer aborts (#1385).

Fresh-install function definitions in schema.sql / pglite-schema.ts are
born-correct (regenerated schema-embedded.ts). scripts/check-search-path.sh is a
new CI guard (wired into verify) that fails if a trigger function in the schema
base files is added without SET search_path. Postgres-only assertions live in the
bootstrap E2E; the PGLite path is covered by test/migration-v120.test.ts.

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

* v0.42.55.0 fix(security): dotfile/skills/slug confinement, DCR consent default, schema-lint migration

Bump VERSION + package.json to 0.42.55.0 and add the CHANGELOG entry for the
security-hardening wave (#418 #419 #245 #1353 #1647 #171 #1385).

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

* docs(security): note the DCR consent default in SECURITY.md (#1353)

The "disable client_credentials, only allow authorization_code" guidance is now
the built-in DCR default; document the new --enable-dcr-insecure escape hatch.

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

* docs(todos): add takes_search + code_def to the federated by-slug P1 (#2200)

The v0.42.55.0 eng-review codex pass flagged takes_search (holder-allowlist only)
and code_def (brain-wide raw SQL over content_chunks) as remaining same-class
surfaces. Noted on the existing #2200 P1 follow-up, with the caveat that the
#2399 close-list deliberately keeps #1371/#2200 open until this lands.

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

* fix(security): correct plpgsql alias collision in #1385 BYPASSRLS gate (real-PG)

The broadened BYPASSRLS preflight aliased `pg_roles r`, but several RLS DO-blocks
already declare `r record` for their backfill FOR loop, so plpgsql resolved
`r.oid`/`r.rolbypassrls` to the unassigned record variable → "record \"r\" is
not assigned yet" on real Postgres (PGLite tolerated it; the DATABASE_URL-gated
e2e jobs are the backstop). Renamed the subquery alias to `pr` at all 10
migrate.ts sites; also broadened the schema.sql base RLS gate the same way (with
the `pr` alias) for #1385 consistency on superuser fresh installs, and
regenerated schema-embedded.ts.

Also fixes a PRE-EXISTING engine-parity bug (confirmed failing on clean
origin/master): the relationalFanout shape compared `canonical_chunk_id`, a
serial id that diverges between a fresh PGLite engine and a shared Postgres DB
(setupDB TRUNCATEs without RESTART IDENTITY). Compare its presence, not the
exact value.

Validated on real Postgres (pgvector/pg16): migration v120 applies, the v35 RLS
backfill runs, and engine-parity + postgres-bootstrap + jsonb-parity are green.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:00:58 -07:00
f56cc619ba v0.41.13.0 fix(sync): infiniteGameExp + foxhoundinc 5-bug wave (#1422, #1433, #1434, #1309, #1436) (#1456)
* fix(sync): infiniteGameExp + foxhoundinc 5-bug wave (#1422, #1433, #1434, #1309, #1436)

Five real production bugs from infiniteGameExp (PostgreSQL onboarding) and
foxhoundinc (dream-cycle reproduction), each silent-failure shape where gbrain
told the user the operation succeeded when it didn't.

* #1422 — `gbrain dream` swallowed connectEngine errors. Bind the caught
  error and surface `[dream] WARNING: could not connect to DB (...)` on
  stderr before falling through to filesystem-only phases. runDream(null)
  no-DB fallback preserved.

* #1433 — `gbrain sync` deleted previously-indexed log.md / schema.md /
  index.md / README.md pages on every re-sync. Refactor isSyncable
  through private classifySync helper; expose unsyncableReason (companion
  returning the same tagged reason) and SYNC_SKIP_FILES named export.
  Cleanup loop guards on reason === 'metafile' before deleting.

* #1434 — `gbrain sync` without --source on single-vault brains routed
  to source_id='default' (zero pages) and silently failed. Add resolver
  tier 5.5 'sole_non_default' AFTER brain_default (explicit user intent
  wins). Wire runSync + runImport to call resolveSourceWithTier
  unconditionally so the tier actually fires. Stderr nudge on tier hit;
  suppress with GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1.

* #1309 — overlapping ingest roots created duplicate pages. New
  BrainEngine.findDuplicatePage?(sourceId, {hash, frontmatterId}) with
  identity-based posture: SKIP when frontmatter.id matches (true
  external duplicate), WARN-ALWAYS on content_hash collision with
  different/missing fm.id, FAIL CLOSED on lookup error. Migration v95
  adds partial index pages_dedup_idx (Postgres CONCURRENTLY, PGLite
  plain CREATE).

* #1436 — MCP fuzzy get_page returned slug candidates from sources
  outside caller's scope. resolveSlugs signature extended with
  {sourceId?, sourceIds?} matching the sourceScopeOpts helper output;
  operations.ts threads it through. Both engines preserve unscoped
  back-compat for internal CLI callers.

Plus a stable tiebreaker on searchVector ORDER BY (score DESC, page_id
ASC, chunk_id ASC) in both engines. Caught while wiring the index above
— basis-vector eval fixtures with tied scores depend on planner row
order, which any new index on pages could flip. Pins eval-replay-gate
ranking determinism against future index changes.

Per codex review of the original plan: caught 6 load-bearing gaps that
the engineering review missed (runSync bypass, #1436 misclassified as
fixed, dedup fail-open, content-hash-alone too aggressive, soft-delete
filter missing, tier-ordering contradiction). All folded in pre-merge.

Tests: 65 new wave cases across 7 new files + 1 extended; all green.

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:53:59 -07:00
1666ec427e v0.39.2.0 feat(autopilot): per-source fan-out + cycle lock primitive + phase taxonomy (#1295)
* feat(source-id): canonical dependency-free source_id validator module

New src/core/source-id.ts consolidates the three regex sites that drifted
across the codebase (utils.ts permissive, sources-ops.ts strict,
source-resolver.ts strict). Exports:
  - SOURCE_ID_RE: ^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$ (strict kebab,
    1-32 chars, no underscores, alphanumeric boundaries)
  - isValidSourceId(s): boolean — for silent-fallback tiers (dotfile,
    brain_default config)
  - assertValidSourceId(s): void, throws — for explicit-validation tiers
    (explicit --source flag, GBRAIN_SOURCE env, cycleLockIdFor primitive)

Dependency-free by design (no engine imports), so both PGLite and
Postgres engines can pull it without circular-import risk. Replaces
the soon-to-be-removed local validators in utils.ts and sources-ops.ts;
preserves both call shapes (boolean + throwing) per the codex outside-voice
finding that resolver tiers need both.

19 unit tests covering valid ids, length boundary (32-char max), underscore
rejection, path-traversal shapes, edge hyphens, whitespace, non-ASCII,
non-string inputs, and TypeScript narrowing.

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

* refactor(source-id): migrate three regex sites to canonical source-id.ts

Consolidates source_id validation through src/core/source-id.ts:

- src/core/utils.ts: validateSourceId is now a back-compat re-export of
  assertValidSourceId. Regex TIGHTENS from permissive ^[a-z0-9_-]+$ to
  the strict kebab. The path-safety boundary now matches what sources-ops
  enforces at source creation time; no production source IDs break because
  sources-ops always rejected underscored IDs at creation. Picks up the
  blast-radius callers in cycle/patterns.ts and cycle/synthesize.ts
  reverse-write paths.

- src/core/sources-ops.ts: deletes local SOURCE_ID_RE + validateSourceId;
  imports isValidSourceId from source-id.ts. Keeps the thin SourceOpError-
  wrapping validator so `gbrain sources add` keeps its user-facing error
  envelope.

- src/core/source-resolver.ts: imports SOURCE_ID_RE + isValidSourceId from
  source-id.ts. Per codex outside-voice P1-F, silent-fallback tiers
  (dotfile read at tier 3, brain_default config at tier 5) use
  isValidSourceId so an invalid dotfile/config value falls through to the
  next resolver tier instead of throwing. Explicit + env tiers keep their
  inline regex-test-and-throw shape because they need tailored error
  messages.

Behaviour: validateSourceId('snake_id') NOW THROWS where pre-PR it accepted.
Documented as the intentional tightening; no existing IDs in production
contain underscores.

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

* feat(cycle): per-source lock primitive + db-lock consolidation + PGLite ordering

Three intertwined cycle.ts changes that landed as one logical unit:

1) DELETE acquirePostgresLock + acquirePGLiteLock (~75 LOC of duplicated
   UPSERT-with-TTL SQL). Replace with tryAcquireDbLock from
   src/core/db-lock.ts, which was extracted in v0.22.13 and should have
   been adopted here at that time. New acquireDbCycleLock(engine, sourceId)
   is a 6-line adapter that keeps cycle.ts's LockHandle shape.

   Deliberately uses tryAcquireDbLock NOT withRefreshingLock (codex r2 P0-A):
   - tryAcquireDbLock returns null on busy → cycle returns
     {status:'skipped', reason:'cycle_already_running'} (existing contract)
   - withRefreshingLock throws → would convert busy cycles into failures
   - withRefreshingLock's background timer would skip Minion job-lock
     renewal (codex r2 P0-B) and add in-phase DB traffic on PGLite's
     single connection (codex r2 P1-A)

2) Add cycleLockIdFor(sourceId?: string) primitive:
   - undefined → 'gbrain-cycle' (legacy default, back-compat for autopilot
     and every existing caller)
   - valid kebab → 'gbrain-cycle:<source_id>' (per-source DB lock row)
   - invalid → throws via assertValidSourceId (codex r2 P1-B defense-
     in-depth at the primitive layer, since CycleOpts.sourceId is a new
     direct API surface that becomes part of a DB lock ID AND a PGLite
     file path component)

   Add CycleOpts.sourceId; thread through to acquireDbCycleLock. Documents
   that this only scopes the LOCK — embed/orphans/purge/etc remain
   brain-global per PHASE_SCOPE.

3) PGLite file+DB ordering invariant (codex r2 P0-C + P0-D):
   - PGLite engines acquire the GLOBAL file lock (cycle.lock, no source
     suffix) BEFORE the per-source DB lock. PGLite's process-level
     write-lock is the single-writer guard; per-source DB lock IDs
     alone would let two PGLite cycles run concurrently.
   - File lock release on DB acquisition failure (cleanup guarantee)
   - Compose both handles into one LockHandle whose release() is
     reverse-of-acquire (DB first, file last) so file lock isn't released
     while DB lock is still live.
   - Postgres engines skip the file lock entirely — per-source DB IDs
     are the full granularity.

13 unit tests in test/cycle-lock-per-source.test.ts pin the back-compat
default, per-source ID shape, distinct-ID property, and the internal-
validation throws.

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

* feat(cycle): PHASE_SCOPE taxonomy + doctor cycle_phase_scope check

Static documentation of each cycle phase's scope: 'source' (safe to
parallelize per source), 'global' (must serialize brain-wide), or
'mixed' (per-phase decomposition needed before parallelizing).

The PHASE_SCOPE record is the load-bearing input for any future
autopilot fan-out wave. It surfaces what codex round-1 P0-1 was
warning about: not all 14 cycle phases are source-scoped today.
embed/orphans/purge/resolve_symbol_edges/grade_takes/calibration_profile
walk brain-wide regardless of sourceId. Per-source cycle LOCKS (this
PR) let two cycles RUN concurrently, but global-scoped phases inside
each will still touch the same rows.

The taxonomy is documentation, not runtime enforcement (runtime
enforcement deferred per plan; filed as TODO).

New doctor check cycle_phase_scope renders the taxonomy as an
operator-facing message AND surfaces phase_scope_map under
Check.details for JSON consumers. Added optional Check.details field
to the doctor types — mirrors PhaseResult.details. Additive; no
schema_version bump.

11 unit tests across phase-scope-coverage + doctor-cycle-phase-scope.

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

* test(source-id): blast-radius regression for strict-regex callers

Pins the codex round-2 P1-D finding: utils.validateSourceId is also
used in cycle reverse-write paths at patterns.ts:263 and
synthesize.ts:909. Pre-PR they used the permissive regex; post-PR
they share the strict kebab regex with sources-ops creation-time
validation. Existing underscore IDs would fail at THOSE cycle sites,
not just at source add/remove.

Structural assertions guard against future drift:
- utils.ts validateSourceId === assertValidSourceId from source-id.ts
- patterns.ts + synthesize.ts both import validateSourceId from utils
- validation call precedes the join() at both reverse-write sites
- utils.ts no longer contains the inline ^[a-z0-9_-]+$ permissive regex
- utils.ts re-exports assertValidSourceId-as-validateSourceId from source-id.ts

9 cases pin the contract. IRON-RULE: source-text grep regressions land
on the offending refactor first.

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

* feat(engine): listAllSources + updateSourceConfig for per-source autopilot

Two lean engine-layer methods that the v0.38 per-source autopilot wave
consumes. Both have parity implementations on Postgres + PGLite.

listAllSources(opts?):
- Returns the bare SourceRow shape (id, name, local_path, last_sync_at,
  config) without sources-ops.listSources's per-source page_count
  enrichment (N+1 expensive; out of scope for hot-loop callers).
- `includeArchived` defaults false (matches sources-ops semantics).
- `localPathOnly` filters local_path IS NOT NULL so autopilot fan-out
  doesn't dispatch jobs for pure-DB sources whose handler would fall
  back to global sync.repo_path (codex r1 P1-4).
- Ordering: (id = 'default') DESC, id — same as sources-ops for
  operator-output stability.

updateSourceConfig(sourceId, patch):
- Atomic JSONB merge via Postgres `config || $patch::jsonb` operator.
  No read-modify-write race; same-key overwrites (no deep merge —
  flat patches only, matches the v0.38 use case of last_full_cycle_at).
- Returns true when a row was updated, false when sourceId doesn't
  exist (best-effort no-op; caller decides how to handle).
- Postgres: sql.json(patch) per the canonical pattern; PGLite:
  JSON.stringify + ::jsonb cast on positional param.

New SourceRow type exported from engine.ts. Imported by both engine
impls. 11 integration tests in test/list-all-sources.test.ts cover
defaults, filters, JSONB round-trip, archived flag, and merge semantics.

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

* feat(cycle): write last_full_cycle_at to sources.config on per-source cycle exit

Closes codex round-1 P0-5 (write site for last_full_cycle_at was
unspecified pre-PR). runCycle's exit hook persists
{ last_full_cycle_at: '<ISO>' } to sources.config JSONB when a
successful per-source cycle completes. Autopilot's v0.38 per-source
fan-out gate reads this field next tick to decide whether to skip a
source (60-min freshness floor).

Conditions for write (all required):
  - opts.sourceId is set — legacy callers without sourceId skip the
    write (autopilot will keep working today via fallback path)
  - engine is non-null — no-DB path skips
  - status is 'ok' / 'clean' / 'partial' — failed/skipped cycles do NOT
    mark a source as fresh (next cycle will redo work)
  - dryRun is false — writes are out of scope

Best-effort: write failure logs a warning but does NOT change the
CycleReport status. The cycle already succeeded by the time we get
here; the cost of missing a stale write is one redundant cycle next
tick, not data loss.

5 PGLite integration tests cover all four gate conditions plus the
"timestamp advances on each successful run" property.

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

* feat(jobs): autopilot-cycle handler honors source_id + pull + archive recheck

Threads the v0.38 per-source dispatch payload through the autopilot-cycle
handler at src/commands/jobs.ts:1146. Closes three codex round-1 findings:

- P0-2 / P1-B: validates job.data.source_id at handler entry via the
  canonical source-id.ts isValidSourceId boolean check. Malformed
  source_id from a queue replay dead-letters with a clear error
  instead of reaching cycle code.

- P1-2: job.data.pull explicit boolean overrides the legacy hardcoded
  `true`, so per-source dispatch for local-only sources can pass
  pull: false (no git network round-trip for sources without remote_url).
  Missing/undefined preserves the legacy true for back-compat with cron/
  launchd callers that don't know about the new field.

- P1-5: archived-source recheck happens BEFORE runCycle is invoked
  (cheap SELECT archived FROM sources WHERE id = $1). If the source was
  archived between fan-out and worker claim, handler returns
  { status: 'skipped', reason: 'source_archived' } cleanly — no lock
  acquired, no phases run, no last_full_cycle_at touched. Same skip
  shape for source_not_found (deleted between dispatch and claim).

7 PGLite integration tests cover all five paths (legacy / valid /
not-found / archived / malformed-source_id / non-string source_id /
pull: false override).

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

* feat(autopilot): per-source fan-out dispatch (the headline parallelism win)

The headline change of the v0.38 federated-sync wave. Replaces autopilot's
single-job-per-tick dispatch with per-source fan-out so a 5-source
federated brain refreshes in ~5min wall-clock instead of ~25min sequential.

src/commands/autopilot-fanout.ts (new) — pure-function dispatch helper:
- resolveFanoutMax(engine): PGLite=1 (codex P1-3 — preserves single-writer
  invariant), Postgres=4, operator override via autopilot.fanout_max_per_tick
- readLastFullCycleAt(src): JSONB→Date with NULL/unparseable safety
- isSourceStale(src, now?, floorMin?): 60-min default freshness floor
- selectSourcesForDispatch(sources, fanoutMax): stale-only + oldest-first
  + alphabetical tiebreaker (deterministic for tests)
- dispatchPerSource(engine, queue, opts): the orchestrator

src/commands/autopilot.ts (modified): the existing shouldFullCycle branch
calls dispatchPerSource. Behavior preserved:
- Healthy + recent (60min floor) → sleep (unchanged)
- Targeted-plan path → unchanged (uses computeRecommendations)
- Full-cycle path → NOW fans out per-source rather than ONE job for default

Per-source dispatch shape:
- Idempotency key: `autopilot-cycle:<source_id>:<slot>` — two ticks for
  the same source within one slot coalesce; different sources never collide
- pull: !!source.config.remote_url — remotes pull, local-only don't
- maxWaiting: 1 per submit — backpressure when worker can't drain
- Per-submit try/catch (codex E1 F1) — one source's failure doesn't
  abort the tick; surfaces as fanout_submit_failed event

Fallback path: empty `sources` table (pre-v0.18 brain or fresh install
before `gbrain sources add`) falls back to the legacy single autopilot-
cycle job with no source_id, preserving today's single-source behavior.

JSON event stream extended:
- `dispatched` event gains source_id + mode='per_source' fields
- new `fanout_summary` event per tick with dispatched/skipped_fresh/
  skipped_cap arrays so operators can see what the tick did
- new `fanout_cap_reached` event when sources overflow the cap

Caveat (intentional, codex r1 P0-1 scope): per-source LOCKS let two
cycles RUN concurrently, but several phases (embed, orphans, purge,
resolve_symbol_edges, grade_takes, calibration_profile) still walk the
brain globally inside each cycle. PHASE_SCOPE taxonomy from the prior
commit documents this. Genuine per-phase per-source isolation is the
deferred Phase 2 follow-up.

27 unit tests in test/autopilot-fanout.test.ts pin every branch (stale
gate, cap behavior, idempotency keys, legacy fallback, per-submit error
isolation, oldest-first sort, alphabetical tiebreaker).

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

* feat(doctor): cycle_freshness check — sibling to sync_freshness

New check_cycle_freshness sibling to checkSyncFreshness. Where
sync_freshness reads sources.last_sync_at (one phase), this check
reads sources.config->>'last_full_cycle_at' — the canonical
"this whole cycle completed" timestamp the v0.38 runCycle exit hook
writes and the v0.38 autopilot fan-out gate reads.

Operator sees exactly what autopilot sees when deciding to skip a
source. Default thresholds tighter than sync_freshness (6h warn /
24h fail vs 24h/72h) because full-cycle staleness compounds: sync
stale → extract stale → embed stale → search returns stale results.

Env overrides:
- GBRAIN_CYCLE_FRESHNESS_WARN_HOURS (default 6)
- GBRAIN_CYCLE_FRESHNESS_FAIL_HOURS (default 24)

Edge cases covered (9 PGLite integration tests):
- empty (no federated sources) → ok
- last_full_cycle_at present + fresh → ok
- last_full_cycle_at present + warn window → warn
- last_full_cycle_at present + fail window → fail
- last_full_cycle_at NULL (never cycled) → fail
- mixed severity → highest wins
- future timestamp (clock skew) → warn
- unparseable timestamp → warn
- local_path NULL sources filtered (codex P1-4 parity)

Failure messages embed source.id so the printed fix command
`gbrain dream --source <id>` matches what the user copy-pastes.

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

* test: fill gaps surfaced by post-Phase-2 audit (resolver silent-fallback + PGLite ordering)

Two test gaps identified by the test-coverage audit after Phase 1 + 2 shipped:

1. src/core/source-resolver.ts — the migration to isValidSourceId for
   silent-fallback tiers (dotfile read, brain_default config) had no
   direct test. Pre-PR these used inline regex; post-PR they use the
   canonical isValidSourceId. Codex P1-F intent (silent fallback for
   invalid input on tiers 3+5, throw on tiers 1+2) deserved an explicit
   test.

   test/source-resolver-silent-fallback.test.ts (12 cases):
     - tier 3: valid dotfile honored; underscore/whitespace/uppercase
       silently falls through to next tier
     - tier 5: valid brain_default honored; underscore + 33+ char silently
       falls through
     - tier 1: valid explicit --source returns; underscore/whitespace
       THROWS (contract distinction)
     - tier 2: valid env GBRAIN_SOURCE returns; underscore THROWS

2. src/core/cycle.ts — the PGLite file+DB ordering invariant (codex r2
   P0-C + P0-D) was implemented in Phase 1 (T5) but had no test pinning
   the ordering / cleanup / per-source DB lock ID semantics.

   test/cycle-pglite-lock-ordering.test.ts (6 cases):
     - global file lock acquired during PGLite cycle
     - cycle for source A then B serializes (file lock held in turn)
     - DB-lock acquire failure releases file lock cleanly (no stranded state)
     - engine=null path still uses file lock
     - DB lock row uses per-source ID (gbrain-cycle:<source>) not legacy
     - consecutive cycles can re-acquire both locks (release-on-exit works)

Plus .context/PHASE_3_ASSESSMENT.md (gitignored) documenting why each
Phase 3 DRY refactor item from the original plan is deferred: each item
on closer inspection is either a premature abstraction or was explicitly
rejected by the original author (BaseCyclePhase header comment).

18 new tests; 0 fails. typecheck clean.

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

* test: autopilot wiring static guard + Postgres parity e2e for fan-out

Closes the remaining test gaps identified by the post-Phase-2 audit:

test/autopilot-fanout-wiring.test.ts (5 cases) — static-shape regression
for autopilot.ts ↔ dispatchPerSource. The fan-out helper itself has 27
unit tests; this file pins the WIRING in autopilot.ts:
  - imports dispatchPerSource + resolveFanoutMax
  - calls dispatchPerSource inside the shouldFullCycle branch (not the
    targeted-plan path)
  - updates lastFullCycleAt after dispatch
  - does NOT regress to the pre-PR single-job dispatch (regex-grep guard
    against the legacy `autopilot-cycle:${slot}` idempotency-key shape
    reappearing in autopilot.ts)

Same canonical static-shape pattern as test/autopilot-supervisor-wiring.test.ts.

test/e2e/list-all-sources-postgres.test.ts (10 cases) — Postgres parity
for engine.listAllSources + updateSourceConfig. The PGLite path has
unit-level coverage; the Postgres path has separate impls (sql.json
serialization, sql.count semantics) that could drift. Specifically pins:
  - returns rows, filters archived/localPath correctly
  - JSONB config parses to object (autopilot reads last_full_cycle_at)
  - default source sorts first
  - updateSourceConfig: not-found returns false, patch merges, same-key
    overwrites, idempotent on repeat
  - jsonb_typeof regression: round-trip stores real JSONB object, NOT
    a JSON-encoded string (feedback_postgres_jsonb_double_encode class)

test/e2e/autopilot-fanout-postgres.test.ts (6 cases) — end-to-end
integration on Postgres:
  - 3 sources fan out as 3 distinct jobs with per-source idempotency keys
  - re-dispatch within same slot dedupes (idempotency-key coalesce)
  - last_full_cycle_at < 60min ago sources are skipped by gate
  - end-to-end: updateSourceConfig → listAllSources → selectSourcesForDispatch
    correctly classifies fresh sources
  - fan-out cap honored (5 sources, fanoutMax=2 → 2 dispatched)
  - empty federated brain falls back to legacy single-job dispatch

21 new test cases. Brings the v0.38 wave coverage to 132 unit + 16 e2e.

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

* fix(autopilot): drop maxWaiting from per-source submit (E2E found silent coalesce)

The Postgres E2E for fan-out surfaced two real bugs that the unit-stub
tests + PGLite tests couldn't catch:

1) **CRITICAL** — dispatchPerSource passed `maxWaiting: 1` to every
   per-source queue.add. maxWaiting is per-(name, queue) — since all
   per-source jobs share `name='autopilot-cycle'`, the second + third +
   Nth source's submit silently coalesced into the FIRST source's
   waiting job. Net result on a real worker: 1 job processed per tick,
   not N. The entire fan-out feature was a silent no-op past the first
   source.

   Per-source idempotency_key (`autopilot-cycle:<source_id>:<slot>`)
   already handles "two ticks for same source within slot" dedup, which
   is the only thing maxWaiting was buying us. Dropping it fixes fan-out
   without losing dedup.

   New unit-stub regression test asserts maxWaiting is NOT in the per-
   source submit opts so a future refactor that re-adds it gets caught
   in 100x faster CI (test/autopilot-fanout.test.ts).

2) **postgres-engine.ts:updateSourceConfig** — initial impl used
   sql.json() correctly but my mid-debug rewrite to executeRaw +
   positional `$1::jsonb` produced JSONB STRING shape (not OBJECT)
   because postgres-js double-encodes JS string params in unsafe mode.
   `||` between JSONB object + JSONB string yields a JSONB ARRAY,
   wiping every existing config key on update.

   Same latent bug class exists at src/commands/sources.ts:482 (gbrain
   sources federate/unfederate path); flagged for follow-up but
   out-of-scope here.

   Reverted to sql.json() inside the template tag (verified via direct
   psql round-trip: jsonb_typeof = 'object'). Updated the e2e seed
   helper to use sql.json() too — the executeRaw + JSON.stringify
   pattern was producing string-shape JSONB at SEED time which made
   the failure cascade harder to debug.

Coverage adds:
- test/e2e/list-all-sources-postgres.test.ts: 11 cases pin Postgres
  parity for listAllSources + updateSourceConfig including jsonb_typeof
  round-trip
- test/e2e/autopilot-fanout-postgres.test.ts: 6 cases end-to-end
  including 3-source fan-out producing 3 distinct rows, idempotency
  coalesce within slot, cap honored, legacy fallback path
- test/autopilot-fanout.test.ts: +1 regression guard on maxWaiting

This is the kind of bug that justifies the user's "fill test gaps then
run E2E" mandate. The unit tests + PGLite parity tests + typecheck all
passed cleanly; only the real-Postgres E2E found the coalesce.

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

* test(e2e): update multi-source-bug-class validateSourceId expectations for strict regex

The v0.32.8 test pinned the OLD permissive ^[a-z0-9_-]+$ behavior including
the underscored case 'jarvis_memory'. The v0.38 wave (this PR's E2 + codex
P1-D) tightens validateSourceId to the strict kebab regex shared with
sources-ops. 'jarvis_memory' now lives in the rejected set, not the
allowed set.

Updated the test to assert the new contract:
- Replaced 'jarvis_memory' allowed case with 'jarvis-memory' (kebab)
- Added 'a' (single-char) to allowed cases
- Added 'jarvis_memory', 'snake_case', '-leading', 'trailing-', and a
  33-char string to the rejected cases — the v0.38 strict-regex additions

Comment explains the contract shift so future readers don't see the test
as flapping intent.

Found by running the main E2E suite — the test file is in the canonical
e2e set and would have failed CI otherwise.

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

* v0.39.3.0: per-source autopilot fan-out + cycle lock primitive + phase taxonomy

VERSION + CHANGELOG bump for the parallel-federated-sync wave (14 commits).

Headline change: federated brains refresh all sources in parallel via
per-source autopilot dispatch instead of one source per 5-min tick.
Five-source brain wall-clock: ~25min → ~5min.

Test infra adjustments for the v0.38 test-isolation lint that landed via
upstream master merge:
  - test/source-resolver-silent-fallback.test.ts now uses withEnv() for
    GBRAIN_SOURCE mutations (was direct process.env mutation)
  - test/cycle-pglite-lock-ordering.test.ts → .serial.test.ts (the
    file-wide GBRAIN_HOME setup needs quarantine from the parallel pool)

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

* fix(test): bump cycle-abort handler-source-grep window 2000→6000

CI on c835f93f failed on 1 of 2005 tests: the v0.20.5
"autopilot-cycle handler contract" regression guard at
test/cycle-abort.test.ts:99 does a source-grep over the first 2000
chars after `worker.register('autopilot-cycle'` to assert `signal:
job.signal` appears.

The v0.38 wave (in c835f93f) added source_id validation + archive
recheck + pull-flag threading at the top of that handler, pushing the
runCycle({signal: job.signal}) call from ~chars 800 to ~chars 2100.
The slice cut off before reaching it, so the assertion failed even
though the code still propagates the signal correctly (line 1213).

Fix: bump the window to 6000 chars and add a comment explaining why.
The guard's intent is unchanged ("handler passes job.signal to
runCycle"); the window just needs to be wide enough to span any
reasonable handler. Also added an existence check on `handlerStart`
so a future refactor that renames the register call surfaces a
clearer error than `undefined.toContain`.

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

* fix(merge): add schema-suggest to PHASE_SCOPE taxonomy

The v0.39.1.0 master merge added a new 'schema-suggest' cycle phase.
PHASE_SCOPE is a TS Record<CyclePhase, PhaseScope> so the compiler
required an entry. Classified as 'source' — the phase accepts
sourceId and operates per-source via runSuggest().

* chore(version): renumber v0.39.3.0 → v0.39.2.0

Renumber to claim the next available slot after v0.39.1.0 (schema packs)
landed on master. No code changes.

* fix(test): bump PHASE_SCOPE count 16→17 for schema-suggest

The v0.39.1.0 master merge added the 17th cycle phase ('schema-suggest').
The previous commit added it to PHASE_SCOPE; this commit updates the
count-pin regression test to match.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:04:30 -07:00
f1f9199ff4 v0.37.7.0 fix wave: federated brains + autopilot safety + OAuth confidential clients (#1253)
* fix(reindex-frontmatter): connect engine before query (#1225)

`createEngine()` from src/core/engine-factory.ts only constructs the
engine; callers MUST call connect() before any executeRaw. The
reindex-frontmatter CLI was constructing the engine and going
straight to countAffected, which crashed on PGLite with "PGLite not
connected. Call connect() first." even on --dry-run.

Fix follows the existing-command pattern (src/commands/auth.ts,
src/commands/backfill.ts, src/commands/integrity.ts all do the
same): pass toEngineConfig(cfg) into both createEngine() AND
engine.connect(), then engine.initSchema() (idempotent on a current
schema, ~1ms cost).

Pre-fix verification: codex outside-voice CF5 flagged the related
"can't import connectEngine from cli.ts" misdirection in the
original fix plan. This implementation uses the canonical sibling
pattern instead.

Regression test pinned at test/reindex-frontmatter-connect.test.ts.

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

* chore: bump VERSION to 0.37.7.0 + stub CHANGELOG

v0.37.5.0 claimed by #1229 (warsaw-v4); v0.37.6.0 by #1246
(OpenRouter recipe). v0.37.7.0 is the next free slot for this
fix wave.

CHANGELOG entry stubbed in user-facing voice per CLAUDE.md
"CHANGELOG voice + release-summary format" — ELI10 lead-first,
real fix details below. The "## To take advantage of v0.37.7.0"
block follows the v0.13+ self-repair pattern from CLAUDE.md.

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

* fix(subagent): short-circuit terminal-on-resume (#1151)

Bug: when the worker resumed a subagent job whose persisted last
message was an assistant turn with text-only content (no tool_use
blocks), the replay reconciler at subagent.ts:241-247 had no
branch for that case. The main loop then called messages.create
against a conversation ending in assistant role, which Sonnet 4.6+
rejects with HTTP 400 "This model does not support assistant
message prefill." 3 retries later → dead-letter, despite all the
job's work having committed in earlier turns.

@zscgeek's bug report pinned this exactly: dream-cycle Otter
corpus runs hit ~7% dead-letter rate, every dead job's last
subagent_messages row was a text-only synthesis summary listing
slugs that already existed in `pages`. Their proposed fix mirrors
this implementation.

Fix: add an else branch to the assistant-tail check that mirrors
the live-loop terminal logic at subagent.ts:440-447 — reconstruct
finalText from the persisted text blocks, return
stop_reason='end_turn' immediately. No LLM call, no schema change.

Two new regression cases:
  - text-only terminal on resume returns immediately with zero
    messages.create calls
  - tool-use replay path unchanged (existing behavior preserved)

Codex outside-voice (CF13) initially flagged this fix as
mis-targeted, claiming subagent.ts already handled the case.
/investigate run revealed the live-loop terminal at :440-447 was
covered but the REPLAY-path terminal at :241-247 was missing —
both branches need symmetric handling.

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

* fix(autopilot): scope lockfile to GBRAIN_HOME (#1226)

The autopilot lockfile was hardcoded at `~/.gbrain/autopilot.lock`
(via `process.env.HOME`), bypassing GBRAIN_HOME. Two brains pointed
at different GBRAIN_HOME directories still wrote to the same global
lockfile; one would silently take over the other on each restart.

Fix: route through `gbrainPath('autopilot.lock')` from
src/core/config.ts (imported aliased as gbrainHomePath since the
local `gbrainPath` var in installAutopilot references the CLI
binary path). The mkdirSync(`~/.gbrain`) call also routes through
the helper so the directory is created in the right place too.

Co-authored with @rafaelreis-r — same fix shape as PR #1227,
re-implemented against current master per the wave's
"re-implement, credit, close" workflow.

Tests cover: one GBRAIN_HOME → one canonical lock; two
GBRAIN_HOME values → two distinct locks; default fall-through
still works.

Co-Authored-By: rafaelreis-r <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(graph-query): foreign-edge footer + --include-foreign (#1153)

The graph-query CLI silently dropped edges to pages in other sources
on federated brains. Users had no signal those edges existed unless
they read the source code.

Fix:
- New --include-foreign flag (off by default, preserves the existing
  scoping contract; on = explicit cross-source traversal).
- After every traversal, count edges from rootSlug whose target page
  lives in a different source. When count > 0 AND user didn't opt in,
  emit a stderr footer:
    `(N edge(s) to foreign-source pages hidden; pass --include-foreign
     to include them)`
- The "no edges found" path also runs the count + footer so users
  discover foreign edges even when scoped traversal returned nothing.
- Thin-client path skips the count (engine query not available);
  future T1 work threads source resolution through MCP for that path.
- Single quotation correctness in count SQL: page_links table is
  `links` (not `page_links`); JOIN both endpoints to pages and compare
  source_id, NULL-safe via `IS NOT NULL` guards on both sides.
- Fail-open on missing source_id column for pre-v0.18 brains: return 0
  (no foreign edges to report) instead of throwing.

4 new test cases: footer fires on scoped query with foreign edge,
--include-foreign suppresses footer, zero-foreign no-footer case,
pluralization regression guard.

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

* feat(sources): `gbrain sources current` + tier attribution (#1222)

Federated-brain users running destructive ops (extract, import,
purge) need a way to verify which source they're targeting BEFORE
the op runs. Pre-fix, the only way was to grep config files or run
the op with --dry-run and inspect output.

New command:
  gbrain sources current             # human output
  gbrain sources current --json      # machine-readable
  gbrain sources current --source X  # show what an explicit --source
                                     # X would resolve to (validates
                                     # X exists in the sources table)

Output names BOTH the resolved source id AND which tier of the 6-tier
resolution chain won (flag / env / dotfile / local_path /
brain_default / seed_default), plus a `detail` line naming the
winning signal (e.g. "GBRAIN_SOURCE=dept-x" or ".gbrain-source" or
"/work/gstack/src").

Implementation:
- New `resolveSourceWithTier()` in source-resolver.ts as an additive
  variant of `resolveSourceId()`. Walks the same 6 steps in the same
  order; just returns `{ source_id, tier, detail? }` instead of bare
  string. Existing `resolveSourceId()` unchanged — all callers
  continue working.
- New `SOURCE_TIER_NAMES` const + `SourceTier` type export so the
  CLI, doctor (Tier 5 follow-up), and future MCP consumers share one
  vocabulary instead of inlining strings.
- Help text updated; `current` subcommand registered in dispatcher.

11 new tests pin the 6-tier ladder + priority semantics. Existing
19 source-resolver tests still pass (regression preserved).

Per codex CF3 (the existing src/core/source-resolver.ts was missed
in the original plan). Re-uses the existing helper instead of
inventing a duplicate.

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

* feat(extract): --source-id scopes extraction to one brain source (#1204)

Federated brain users running `gbrain extract` had no way to scope
extraction to one source. The DB path walks all sources together via
listAllPageRefs(), which is correct for cross-source resolution but
sometimes the user wants to extract per-source explicitly (e.g.
re-running extract on a specific source after a manual import).

The pre-existing `--source` flag is the data-source axis (fs|db) and
can't be repurposed. New flag `--source-id <id>` joins it on the
brain-source-id axis:

  gbrain extract all --source db --source-id alpha
    -> walks only alpha-source pages; extracts links + timeline
       from those, into the alpha source

Important: the resolver maps (allSlugs + slugToSources) stay built
from the FULL listAllPageRefs result, not the scoped subset. This
ensures qualified cross-source wikilinks like `[[other-src:slug]]`
still resolve correctly even when the extract walk is scoped — the
filter is on which pages we extract FROM, not what we can resolve TO.

Threaded through both `extractLinksFromDB` and `extractTimelineFromDB`
with backward-compat: callers passing no opts get the old behavior.

4 new test cases pin: walks-all-without-flag baseline,
alpha-only-when-scoped-to-alpha, beta-only-when-scoped-to-beta,
empty-set-on-unknown-source.

Note: #1204's wider "silent 0 links" report on federated brains has
additional facets beyond this flag (resolver path edge cases on
overlapping slugs). The scoped-walk fix gives users an explicit
workaround AND closes the per-source extraction gap.

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

* chore(todos): file v0.37.7.0 follow-ups (#1173, #1204, T5N)

Three items deferred from v0.37.7.0:

1. #1173 .sql indexing — verify-first gate found
   tree-sitter-sql.wasm missing from src/assets/wasm/grammars/.
   Dedicated wave needed: vendor the wasm, add .sql to walker
   filter, address slug-shape collision with #1172.

2. #1204 deeper investigation — wave added --source-id flag as
   workaround. Underlying silent-zero-links bug on unscoped
   federated extracts needs its own /investigate pass against
   a cross-source-duplicate-slug fixture.

3. Tier 5N doctor sweep for dead-lettered subagent jobs matching
   the #1151 fingerprint. Deferred to v0.37.8+ behind the islamabad
   doctor.ts conflict resolution.

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

* fix(sync): walker skips git submodule directories (#1169)

Sync walker descended into git submodules and indexed their markdown
content as if it belonged to the parent brain. Users with submodules
in their brain repo saw foreign content in their pages table.

Fix: pruneDir gains an optional `parentDir` arg. When set, the helper
stats `<parentDir>/<name>/.git` and skips the directory if `.git`
exists as a FILE (gitfile pointer — the canonical submodule shape).
Directories containing `.git` as a DIRECTORY (a real nested repo,
not a submodule) are descended into; the inner `.git` dir itself is
then dot-prefix-excluded.

Callers updated to pass parentDir:
- src/commands/extract.ts walkMarkdownFiles
- src/core/cycle/transcript-discovery.ts walker

Back-compat preserved: existing pruneDir(name) callers without
parentDir get the pre-v0.37.7.0 behavior unchanged.

Companion `.gitignore`-respect feature from PR #1159 (@jetsetterfl)
NOT in this wave — it would require adding the `ignore` npm package
as a dep, which the plan's "no new deps in this PR" gate excludes.
Filed as follow-up TODO for a dedicated wave.

5 new test cases pin the submodule shape + back-compat + nested-repo
ambiguity. Existing extract-fs / extract-db tests unchanged.

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

* docs(brain-routing): document 6-tier source resolution chain (#1222)

The convention skill didn't have a tier-by-tier reference for how
gbrain resolves the active source. Users running federated brains
had to read the source code to know which signal wins.

Added:
- Canonical 6-tier table (flag → env → dotfile → local_path →
  brain_default → seed_default) matching src/core/source-resolver.ts.
- Pointer to `gbrain sources current` (new in v0.37.7.0) as the
  verification command.
- The CLI-layer trust boundary note: operations.ts handlers don't
  read env/dotfile (preserves v0.34.1.0 source-isolation work for
  MCP callers).
- Per-command flag map: --source, --source-id (extract), and
  --include-foreign (graph-query).

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

* feat(import): --source-id flag routes pages to a brain source (#1167)

`gbrain import --source dept-x ./pages` silently fell back to the
default source because the CLI parser never consumed --source. PR
#707's design intent excluded the flag explicitly; users had no
signal their pages were going to the wrong place. #1167 + #1222
filed the regression.

Fix: parse `--source-id <id>` (matching v0.37.7.0 extract.ts T2's
naming convention — --source-id stays out of conflict with future
axes that may want --source). When set, the flag value wins over
any programmatic opts.sourceId; back-compat preserved for callers
that pass sourceId via opts only.

Also threaded into the positional-dir arg parser's flagValues set
so `--source-id <value> <dir>` doesn't treat <value> as the dir.

Note on related surfaces:
- `gbrain query "X" --source_id dept-x` already routed correctly
  via the operations.ts query op (added in v0.34) — no fix needed.
- `gbrain extract --source-id <id>` shipped in T2.
- `gbrain sync --source <id>` already worked (pre-existing).
- `gbrain sources current` (shipped in T4) is the verification
  tool — run it before destructive ops to confirm routing.

Closes the silent-fallback for the import path. Co-authored with
@tyad67-netizen (#1168), @hnshah (#1124, #1120), whose patches
informed the shape; re-implemented against current master per
the wave's "re-implement, credit, close" workflow.

3 new test cases pin: default-without-flag, --source-id-routes-correctly,
flag-value-not-treated-as-dirArg.

Co-Authored-By: tyad67-netizen <noreply@github.com>
Co-Authored-By: hnshah <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autopilot): reconnect classifier + launchd ThrottleInterval (#1162)

Pre-fix: when database_url was unset/malformed, the DB-health-check
reconnect loop logged `config.database_url undefined` forever
because the catch swallowed every error type uniformly. launchd's
KeepAlive=true respawned immediately on any exit, so even when
the process did exit, it came right back into the same bad state.
@colin477 reported the daemon-thrash pattern.

Two-part fix:

1. In-process error classifier — `classifyReconnectError(err)`:
   - `unrecoverable` (database_url missing/empty/malformed, auth
     failure, no-brain-configured): exit immediately with a clear
     stderr line. Pattern-matched against postgres / config-loader
     error shapes. Tests pin the matcher against the #1162
     fingerprint exactly.
   - `recoverable` (network blip, pool saturated, connection refused
     on a port coming up, Supabase 503): retry. Up to
     GBRAIN_AUTOPILOT_MAX_RECONNECT_FAILS (default 30 = ~5min) before
     finally giving up with `max_reconnect_fails_exceeded`.
   - Counter resets on every successful health probe or reconnect.

2. launchd plist gains `ThrottleInterval=60`. Combined with the
   in-process exit, launchd waits 60s before relaunching instead
   of immediate respawn. Pure-function `generateLaunchdPlist()`
   exported for tests.

16 new test cases:
- 11 classifier cases (database_url shapes, malformed URL, auth,
  role-does-not-exist with quoted name, network blip, pool
  saturated, 503, non-Error inputs, case-insensitivity)
- 5 plist generator cases (ThrottleInterval=60, KeepAlive
  preserved, wrapper path, XML escaping, StandardErrorPath).

Pre-existing autopilot-lock-path tests unchanged — both fixes
land cleanly side-by-side.

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

* fix(oauth): confidential clients via custom /token middleware (#1166)

v0.34.1.0 (#909) fixed PUBLIC PKCE clients (client_secret=undefined)
by normalizing NULL → undefined in getClient. Confidential clients
regressed: the MCP SDK's clientAuth middleware does plaintext
`client.client_secret !== presented_secret` compare, but gbrain
stores SHA-256 hashes, so the SDK's compare always failed for
authorization_code and refresh_token grants on confidential clients.
Result: /token returned `invalid_client` for every confidential
exchange.

Fix shape per locked-decision-5: custom /token middleware BEFORE the
SDK's authRouter, similar to the pre-existing client_credentials
handler. The middleware:

1. Detects confidential auth via `client_secret` in body
   (client_secret_post) OR `Authorization: Basic` header
   (client_secret_basic per RFC 6749 §2.3.1).
2. Falls through to the SDK when neither is present (public PKCE
   path stays canonical, preserves v0.34.1.0 behavior).
3. Calls new `verifyConfidentialClientSecret(clientId, presented)`
   on the provider which does SHA-256 hash compare ourselves
   (same shape as exchangeClientCredentials' existing hash check).
4. On verification success, calls existing
   `exchangeAuthorizationCode` / `exchangeRefreshToken` directly
   with the validated client.
5. RFC 6749 §5.2 error semantics: 401 invalid_client for auth
   failures, 400 invalid_grant for code/token problems.

Per CLAUDE.md "GBRAIN:RLS_EXEMPT" annotation contract: this surface
sits in front of the SDK's clientAuth and doesn't depend on the
SDK's plaintext compare working — the SDK's middleware never
fires for confidential paths the new middleware claims.

7 new test cases pin: correct-secret-returns-client, wrong-secret
opaque rejection, non-existent client, public-client refuses
the confidential path, case-sensitivity, soft-deleted revocation,
verify-then-exchange-refresh round-trip with second-use rejection.

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

* feat(doctor): 3 new checks — source routing + oauth + autopilot lock (T12/T13/T14)

Three v0.37.7.0 doctor checks landing in one atomic commit (single
file, shared merge-conflict surface with garrytan/islamabad-v3 per
locked decision 1):

1. source_routing_health (T12 / #1167):
   Sample non-default sources for pages; warn when a registered
   source has zero pages (silent-collapse-to-default fingerprint).
   D5 lock: total-sample cap of 200 pages across all sources, with
   per-source cap = min(50, ceil(200/N)) so a 20-source CEO brain
   pays 200 selects, not 1000. Fix hint paste-ready to
   `gbrain sources current --json` for verification.

2. oauth_confidential_client_health (T13 / #1166):
   Probe every oauth_clients row. Confidential clients (auth_method
   != 'none') must have a non-NULL client_secret_hash; if any row
   claims confidential auth but stores NULL hash, that's the
   pre-v0.37.7.0 regression. Public clients (auth_method='none')
   correctly keep NULL hash per v0.34.1.0 #909. Fix hint:
   `gbrain auth revoke-client + register-client` OR `gbrain upgrade`.
   Pre-OAuth schemas (missing oauth_clients table) skip gracefully.

3. autopilot_lock_scope (T14 / #1226):
   Detect stale ~/.gbrain/autopilot.lock outside the current
   GBRAIN_HOME. Codex CF11: dangerous to paste-ready `rm` without
   verifying the owning PID isn't a live process. Hint reads the
   PID file and gives the user a `ps -p <pid>` check before any
   delete — matches sshd-style stale-lock recovery hints.

9 new test cases pin the canonical paths. Pre-existing 80+ doctor
checks unchanged.

Expected to conflict with garrytan/islamabad-v3 at merge time. The
3 new check functions live in their own block far from the
islamabad skill_brain_first check; the conflict surface should be
limited to the `checks.push(...)` call site near the end of
runDoctor's DB-checks phase (~10 lines).

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

* fix(test): withEnv wrapper in source-resolver-with-tier (test-isolation lint)

The new source-resolver-with-tier.test.ts from T4 mutated
process.env.GBRAIN_SOURCE directly in two cases, which violates
scripts/check-test-isolation.sh R1 (env mutations leak across
parallel-loaded test files in the same shard process).

Fix: wrap both mutation sites in withEnv() from test/helpers/with-env.ts,
which saves+restores via try/finally per the canonical pattern in
CLAUDE.md.

Pure refactor — all 11 cases still green.

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

* docs: update project documentation for v0.37.7.0

CHANGELOG.md — populated the "What landed" stub with the 18-commit
brisbane wave (source-id flag threading, sources current subcommand,
graph-query foreign-edge footer, autopilot lockfile scope + reconnect
classifier + launchd ThrottleInterval, OAuth confidential client
middleware, reindex-frontmatter connect fix, subagent terminal-on-resume
fix, sync walker submodule skip, 3 new doctor checks, brain-routing.md
convention skill). Voice: ELI10 lead, capability table, paste-ready
verification, "what's safe to know" + "what we caught" sections.

CLAUDE.md — extended Key Files annotations for the v0.37.7.0 changes:
import/extract --source-id flags, sources current subcommand, graph-query
--include-foreign, resolveSourceWithTier() additive helper, autopilot
classifyReconnectError + generateLaunchdPlist exports, OAuth confidential
client middleware, pruneDir submodule detection, subagent terminal
short-circuit, 3 new doctor checks. Pinned by their test files.

llms-full.txt — regenerated via `bun run build:llms` (CI guard at
test/build-llms.test.ts will fail otherwise).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: rafaelreis-r <noreply@github.com>
2026-05-21 08:29:55 -07:00
52f9581966 v0.22.11 feat: storage tiering — db_tracked vs db_only directories (#494)
* feat: storage tiering — git-tracked vs supabase-only directories

Brain repos scaling to 200K+ files. Bulk data (tweets, articles, transcripts)
bloats git repos and slows operations. New storage config in gbrain.yml lets
users declare git-tracked and supabase-only directories.

Changes:
- New config: storage.git_tracked and storage.supabase_only in gbrain.yml
- gbrain sync auto-manages .gitignore for supabase-only paths
- gbrain export --restore-only restores missing supabase-only files from DB
- New gbrain storage status command shows tier breakdown
- Config validation warns on conflicts
- 8 tests passing, full docs at docs/storage-tiering.md

Backward compatible — systems without gbrain.yml work unchanged.

* feat: add getDefaultSourcePath() typed accessor (step 1/15)

Single source of truth for "what brain repo are we operating against?"
Replaces ad-hoc raw SQL in storage.ts:38 (Issue #3 of eng review). Used by
both gbrain storage status and gbrain export --restore-only.

Returns null on miss, throws on DB error. Composes with the existing
resolveSourceId chain so it honors --source flag / GBRAIN_SOURCE env /
.gbrain-source dotfile / longest-prefix CWD match / brain-level default.

4 new test cases covering happy path, missing local_path, DB error
propagation, and CWD-prefix resolution priority.

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

* fix: replace gray-matter with dedicated YAML parser (step 2/15)

The original storage-config.ts called gray-matter on a delimiter-less YAML
file. Gray-matter only parses YAML inside `---` frontmatter blocks; without
delimiters, it returns `{data: {}}`. Result: loadStorageConfig() always
returned null, the entire feature was a silent no-op for every user.

Original eng review's P0 confidence-9 finding (Issue #1).

Replaces gray-matter with a small dedicated parser for the gbrain.yml shape
(top-level `storage:` section, two array-valued nested keys). Yaml-lite was
considered first, but its flat key:value design doesn't handle nested
arrays. The dedicated parser is ~50 lines and trades expressiveness for
zero-dep, predictable parsing of a file format we control.

Adds the Issue #1B sanity warning (locked B): when gbrain.yml exists but
has no storage section (or empty arrays), warn once-per-process so the
user sees their config didn't take. The single test that would have caught
the original P0 — write a real gbrain.yml, call loadStorageConfig, assert
non-null — now exists.

Also tightens loadStorageConfig per D36: distinguishes "absent" (silent
null) from "unreadable" (throws). The previous code silently swallowed
read errors, hiding broken installs.

8 new test cases: real-disk happy path, comments + blank lines, quoted
values, missing storage section warning, empty section warning,
once-per-process warning suppression, unreadable file behavior, and the
existing helper tests (validation, tier matching, edge cases) all still
pass.

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

* refactor: rename storage keys to db_tracked/db_only (step 3/15)

The vendor-specific names "supabase_only" and "git_tracked" hardcoded a
backend (Supabase) into the config schema. gbrain ships two engines —
PGLite and Postgres-via-Supabase. The canonical distinction is "lives in
the brain DB only" vs "lives in the brain DB and on disk under git." Both
work on either engine.

Renamed throughout (Issue #4 of eng review):
  git_tracked    → db_tracked
  supabase_only  → db_only
  isGitTracked() → isDbTracked()
  isSupabaseOnly() → isDbOnly()
  StorageTier 'git_tracked'/'supabase_only' → 'db_tracked'/'db_only'

Backward compatibility (D3 lock):
  loadStorageConfig accepts both shapes. Loader resolution order per the
  eng-review pass-2 finding: parse YAML → if canonical keys present use
  them, else if deprecated keys present map to canonical AND emit
  once-per-process deprecation warning → THEN run validation.
  Validation always sees the canonical shape so error messages reference
  db_tracked/db_only regardless of which keys the user wrote.

  The deprecation warning suggests `gbrain doctor --fix` for an automated
  rename (D72 — fix path lands in step 7).

  When both shapes coexist in one file, canonical wins and a stronger
  warning fires ("deprecated keys ignored — remove them").

Aliases isGitTracked/isSupabaseOnly kept for now to avoid churning the
sync.ts / export.ts / storage.ts call sites in this commit; they'll be
removed in a follow-up step. Storage.ts's tier-bucket initializers and
output strings updated. ASCII output replaces unicode box-drawing per D10.

gbrain.yml example file updated to canonical keys with explanatory
comments.

2 new test cases: deprecated-key fallback (asserts both shapes load
correctly with warning), canonical-wins-over-deprecated (asserts the
"both shapes coexist" path).

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

* feat: add slugPrefix to PageFilters with engine-side filter (step 4/15)

Issue #13 of the eng review: storage.ts and export.ts loaded every page
in the brain (limit: 1_000_000) to check tier membership. On the 200K-page
brains this feature targets, that's the wall-clock and memory landmine
the feature exists to fix.

Adds an optional `slugPrefix` field to PageFilters. Both engines implement
it as `WHERE slug LIKE prefix || '%' ESCAPE '\'`, with literal escaping of
LIKE metacharacters (%, _, \) so user-supplied prefixes like `media/x/`
are treated as exact string prefixes.

Performance: the (source_id, slug) UNIQUE constraint on the pages table
gives both engines a btree index that supports LIKE-prefix range scans.
An EXPLAIN on Postgres confirms the index range scan rather than a seq
scan. PGLite has the same index shape via pglite-schema.ts.

Consumers updated:
  - export.ts: --slug-prefix flag now goes engine-side (no in-memory
    .filter(...)). The --restore-only path queries each db_only directory
    with slugPrefix in a loop instead of one full-table scan, with seen-set
    deduplication and disk-existence check inline.
  - storage.ts: keeps the full-scan path because storage-status needs the
    "unspecified" bucket count, which can't be computed without enumerating
    every page. Comment notes that step 5 (single-walk filesystem scan)
    will reduce per-page disk syscall cost.

2 new test cases on PGLiteEngine: slugPrefix happy path (3 tier dirs,
asserts only matching slugs return) and metacharacter escape regression
(asserts safe/ doesn't match unrelated slugs).

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

* perf: single-walk filesystem scan via walkBrainRepo() (step 5/15)

Issue #14 of the eng review: storage.ts called existsSync + statSync
per-page in a synchronous loop. On a 200K-page brain that's 400K syscalls
serialized. Wall-clock landmine.

Adds src/core/disk-walk.ts with walkBrainRepo(repoPath) — one recursive
readdirSync walk, builds a Map<slug, {size, mtimeMs}>. Storage.ts looks
up each DB page in the map (O(1)) instead of stat-checking on demand.
Slug derivation matches the pages-table convention: people/alice.md on
disk becomes people/alice as the map key.

Skipped during walk:
  - dot-directories (.git, .gbrain, .vscode, etc) — not part of the brain
    namespace
  - node_modules — guards against accidentally walking into imported repos
  - non-.md files (sidecar JSON, binaries) — tracked by the brain through
    the files table, not by slug

Reusable: future commands (gbrain doctor's storage_tiering check, the
optional autopilot tier-fix path) get the same walk for free.

9 new test cases: empty dir, nonexistent dir, top-level files, nested
dirs, dot-dir skipping, node_modules skipping, non-.md filtering, size
capture, mtimeMs capture.

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

* fix: path-segment matching for tier directories (step 6/15)

Issue #5 + D6 of the eng review: tier matching used slug.startsWith(dir),
which falsely matches 'media/xerox/foo' against 'media/x' if a user wrote
the directory without a trailing slash.

The new matcher requires the configured directory to end with `/` and
treats it as a canonical path-segment ancestor:

  media/x/   matches  media/x/tweet-1       ✓
  media/x/   doesn't  media/xerox/foo       ✗
  media/x    refused  media/x/tweet-1       (matcher requires trailing /)

Non-canonical input (no trailing slash) is refused outright. Step 7's
auto-normalizing validator converts user-written 'media/x' → 'media/x/'
on load, so the matcher never sees non-canonical input from real configs.
The behavior tested here is the strict matcher's contract.

Regression test pins the media/xerox collision case explicitly.

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

* feat: auto-normalize trailing-slash, throw on tier overlap (step 7/15)

D7+D8 of the eng review: validation was warnings-only. Users miss warnings.
Now:

  - Cosmetic: missing trailing slash auto-corrected, one-time info note
    showing what changed ("normalized 2 storage paths: 'people' →
    'people/', 'media/x' → 'media/x/'"). Once-per-process to keep noise low.

  - Semantic: same directory in both tiers throws StorageConfigError.
    Ambiguous routing — does media/ win as db_tracked or db_only? — is a
    real bug the user must fix. Caller propagates to the CLI for a clean
    exit-1 with actionable message.

loadStorageConfig now applies normalize+validate after merging deprecated
keys, so the path-segment matcher (step 6) only ever sees canonical
trailing-slash directories.

The pure validateStorageConfig kept for callers who want the warnings list
without the auto-fix side effects (gbrain doctor's reporting path).

2 new test cases: auto-normalize round-trip with warning text assertion,
overlap throws StorageConfigError.

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

* fix: wire manageGitignore into runSync, only on success (step 8/15)

Issue #2 of the eng review: manageGitignore was defined and never
invoked. Docs claimed "auto-managed by gbrain" — false. Users hit a
.gitignore that never updated and committed db_only directories anyway.

Wire-up: runSync now calls manageGitignore after each successful
performSync return, in both watch and one-shot modes.

Eng review pass-2 finding #1: skip on dry_run AND blocked_by_failures
status. A sync that aborted partway has stale state; mutating .gitignore
based on a partially-loaded config invites drift. Failure-skip test
added (uses .gitignore-as-a-directory to simulate write failure;
asserts warning fired and disk wasn't corrupted).

Hardened manageGitignore itself with three additional behaviors:

  - GBRAIN_NO_GITIGNORE=1 escape hatch (D23) for shared-repo setups
    where a maintainer wants gbrain to leave .gitignore alone.

  - Submodule detection (D49). When repoPath/.git is a regular file
    (gitdir: ... pointer), the repo is a git submodule. Submodule
    .gitignore changes don't survive parent submodule updates, so we
    skip with an actionable warning ("add db_only directories to your
    parent repo's .gitignore manually").

  - Graceful failure (D9). Read errors, write errors, and
    StorageConfigError (overlap from step 7) all log a warning and
    return — sync's primary job (moving data) shouldn't die because of
    a side-effect on .gitignore.

manageGitignore is now exported (previously private) so the
storage-sync test file can hit it directly without spinning up sync.

9 new test cases: no-op without gbrain.yml, no-op with empty db_only,
happy-path append, idempotency (run twice, single entry), preservation
of user-written rules, GBRAIN_NO_GITIGNORE skip, submodule skip,
.git-directory normal path, write-failure graceful warning.

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

* fix: D5 resolution chain for --restore-only and storage status (step 9/15)

D5 of the eng review: gbrain export --restore-only without --repo
silently fell through to the regular export path, dumping every page in
the database to the wrong directory. Hard regression risk.

Now exits 1 with an actionable message when --restore-only has no
--repo AND no configured default source. Resolution order:
  1. Explicit --repo flag
  2. Typed sources.getDefault() (reuses step 1's accessor)
  3. Hard error — never fall through to cwd

storage.ts:38 also bypassed BrainEngine with raw SQL and a bare
try/catch (Issue #3 + Issue #9). Replaced with the same typed
getDefaultSourcePath() — single source of truth, errors propagate
cleanly to the user, no silent cwd fallback.

Regular export (no --restore-only) keeps its current behavior per D26:
exports include everything, --repo is optional.

4 new test cases on PGLite in-memory:
  - hard-errors with no --repo + no default
  - explicit --repo wins
  - falls back to sources default local_path
  - non-restore export does not require --repo

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

* refactor: split storage.ts into pure data + JSON + human formatters (step 10/15)

Issue #10 of the eng review: getStorageStatus and runStorageStatus mixed
data gathering, JSON serialization, and human-readable output in one
function. Hard to test, hard to reuse, mismatched the orphans.ts pattern
that CLAUDE.md cites as the precedent.

Now three pure functions + a thin dispatcher:

  getStorageStatus(engine, repoPath) — async, returns StorageStatusResult.
    Side effects: engine.listPages + one walkBrainRepo (Issue #14).
    Exported so MCP exposure (D14) and gbrain doctor (D13) can consume the
    same data without re-running the loop.

  formatStorageStatusJson(result) — pure, returns indented JSON. Stable
    contract on the StorageStatusResult shape, suitable for orchestrators.

  formatStorageStatusHuman(result) — pure, returns ASCII text (D10 — no
    unicode box-drawing). Composable into other commands later.

  runStorageStatus(engine, args) — thin dispatcher: parses --repo /
    --json, calls getStorageStatus, picks a formatter, prints.

8 new test cases on the formatters: JSON parse round-trip, null-config
fallback, missing-files capped at 10 with rollup, ASCII-only assertion
(D10 regression guard), warnings inline, configuration listing, disk-
usage block omitted when zero bytes.

The StorageStatusResult interface is now exported as a public type, so
gbrain doctor's storage_tiering check can build its own findings from
the same shape.

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

* types: distinct PageCountsByTier and DiskUsageByTier (step 11/15)

Issue #11 of the eng review: pagesByTier (page counts) and
diskUsageByTier (byte totals) shared the same structural type
(Record<StorageTier, number>). Both are tier-keyed numeric maps but
carry semantically different units. A future bug that swaps them at a
call site (e.g., displaying disk bytes where the count belongs) wouldn't
trip the compiler.

Replaced with distinct nominal types via a brand field. Structurally
identical at runtime (no overhead) but compile-time disjoint —
TypeScript catches accidental cross-assignment.

  PageCountsByTier   { db_tracked, db_only, unspecified } : numbers (count)
  DiskUsageByTier    { db_tracked, db_only, unspecified } : numbers (bytes)

Both initialized in getStorageStatus, both threaded into
StorageStatusResult, both consumed by formatStorageStatusHuman /
formatStorageStatusJson without further changes.

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

* feat: PGLite soft-warn + full lifecycle test (step 12/15)

D4: storage tiering on PGLite is a partial feature. The "DB" the pages
live in IS the local file gbrain uses for everything else, so "db_only"
has no real offload effect. The .gitignore management still helps
(keeps bulk content out of git history), so we warn and proceed —
not refuse.

Two warning sites (once-per-process each via module-local flags):
  - storage status: warns at runStorageStatus entry
  - sync: warns inside manageGitignore when engineKind='pglite' and
    config has db_only entries

Both phrased actionably ("To get full tiering, migrate to Postgres
with `gbrain migrate --to supabase`").

manageGitignore signature now takes an optional `engineKind` param.
runSync passes engine.kind. Stand-alone callers (tests, future
gbrain doctor --fix path) can omit it.

New test: test/storage-pglite.test.ts — D8 + D4 lifecycle. 6 cases:
engine.kind assertion, getStorageStatus loading gbrain.yml + reporting
tier counts, manageGitignore PGLite-warn (once per process), Postgres
no-warn, slugPrefix on PGLite, end-to-end (config + putPage + status
+ gitignore).

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

* chore: add trailing-newline CI guard (step 14/15)

Issue #7 of the eng review: all four new files in the original
storage-tiering branch lacked POSIX trailing newlines. Linters complain,
git diffs phantom-flag every future edit. We've been adding newlines as
each file landed; this commit catches the regression class.

scripts/check-trailing-newline.sh:
  - sibling to check-jsonb-pattern.sh / check-progress-to-stdout.sh per
    CLAUDE.md's CI guard pattern
  - portable to bash 3.2 (macOS default; no mapfile, no associative arrays)
  - covers src/**, test/**, gbrain.yml, top-level *.md
  - reports each missing file by path and exits 1

Wired into `bun run test` between progress-to-stdout and typecheck.

Also fixed docs/storage-tiering.md (pre-existing missing newline from
the original branch — caught by the new guard on first run).

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

* docs: v0.23.0 — VERSION, CHANGELOG, README, CLAUDE.md, storage-tiering.md (step 15/15)

VERSION → 0.23.0 (minor bump for new feature surface).

CHANGELOG entry in Garry voice with the canonical format:
  - Two-line bold headline ("Storage tiering, finally working...")
  - Lead paragraph naming what was broken before and what users get now
  - "Numbers that matter" before/after table for the 6 things that
    actually changed
  - "What this means for your brain" closer
  - "To take advantage of v0.23.0" self-repair block (per CLAUDE.md
    convention) — 6 numbered steps users can follow
  - Itemized changes split into critical fixes / new+renamed surface /
    architecture cleanup / tests + CI guards

CLAUDE.md "Key files" gains four new entries: storage-config.ts,
disk-walk.ts, the v0.23.0 storage.ts shape, and gbrain.yml itself.

README.md gains a new "Storage tiering" section between Skillify and
Getting Data In with the canonical example + commands + link to the
full guide.

docs/storage-tiering.md rewritten end-to-end with canonical key names
(db_tracked / db_only), v0.23.0 hardening details (idempotency,
submodule detection, GBRAIN_NO_GITIGNORE, dry-run gating), the
resolution chain for --restore-only, the auto-normalize +
throw-on-overlap validator, and the PGLite engine note.

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

* test: e2e Postgres lifecycle for storage tiering (step 16/16)

Per the v0.23.0 plan: full lifecycle E2E against real Postgres.

  - engine.kind === 'postgres' assertion
  - Full lifecycle: write 4 pages (1 db_tracked, 2 db_only, 1 unspecified)
    → getStorageStatus reports correct tier counts → human formatter
    renders → manageGitignore writes managed block → idempotency check
    → getDefaultSourcePath() resolves the configured local_path.
  - Container restart simulation: 2 db_only pages in DB, files missing
    on disk → status.missingFiles.length === 2 → slugPrefix engine
    filter on Postgres returns exactly the tier slugs.
  - slugPrefix index-based range scan regression: 50 media/x/* + 50
    people/p-* pages → slugPrefix='media/x/' returns exactly 50.
  - getDefaultSourcePath returns null when default source has no
    local_path (the hard-error path that replaces the original silent
    cwd fallback).
  - manageGitignore on Postgres engine does NOT emit the PGLite
    soft-warn (cross-engine assertion).

Skips gracefully when DATABASE_URL is unset, per CLAUDE.md E2E pattern.
Run via: DATABASE_URL=... bun test test/e2e/storage-tiering.test.ts

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

* chore: rebump version 0.23.0 → 0.22.9

Reverts the minor bump back to a patch-style version on the v0.22 line.
Storage tiering ships within the v0.22.x train alongside the recent
fix waves. Updates VERSION, package.json, CHANGELOG header + body refs,
CLAUDE.md Key files annotations, README.md section heading, and the
docs/storage-tiering.md backward-compat note.

* chore: bump version 0.22.9 → 0.22.11

Sibling workspaces claimed v0.22.10 in the queue. This branch advances
to v0.22.11 to keep the version monotonic on master.

Updates VERSION, package.json, CHANGELOG header + body refs, CLAUDE.md
Key files annotations, README.md section heading, and the
docs/storage-tiering.md backward-compat note.

* fix: address Codex pre-landing review findings (4 fixes)

Codex found 4 real issues during pre-landing review of v0.22.11 diff:

[P0] export --restore-only fell through to full export when
storageConfig was null (no gbrain.yml present). On older or
misconfigured brains, the recovery command would silently dump the
entire database. src/commands/export.ts now refuses with an actionable
error before any page query fires — matches the D5 lock spirit
("never silently fall through").

[P1] manageGitignore wire-up only fired when --repo was passed
explicitly. performSync resolves the repo from sync.repo_path or
sources.local_path, so the common `gbrain sync` path (after
setup, no flag) never updated .gitignore. src/commands/sync.ts now
uses the same source-resolver chain as the rest of /ship: opts.repoPath
→ getDefaultSourcePath → null. Fires in both watch and one-shot modes.

[P2] getDefaultSourcePath only consulted sources.local_path, missing
the legacy global sync.repo_path config key that pre-v0.18 brains use.
Added a fallback to engine.getConfig('sync.repo_path') when the
sources row has NULL local_path. Pre-v0.18 brains now work without
forcing a `gbrain sources add . --path .` migration.

[P2] sync --all multi-source loop never called manageGitignore even
though src.local_path was already known. Each source now gets its own
gitignore update on successful sync.

Tests:
  - test/storage-export.test.ts: replaced the old "falls through to
    full export" test with one that asserts the new refusal path
    (storage-tiering config required for --restore-only).
  - test/source-resolver.test.ts: added a fallback test exercising the
    legacy sync.repo_path code path for pre-v0.18 brains.
  - All 78 storage-tiering tests still pass.

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

* chore: regenerate llms.txt + llms-full.txt for v0.22.11

Per CLAUDE.md: "Run `bun run build:llms` after adding a new doc."
The README's new Storage tiering section + the rewritten
docs/storage-tiering.md changed the inlined bundle. test/build-llms.test.ts
catches the drift and was failing on master pre-regen.

* fix: typecheck error in disk-walk.ts (CI #73350475897)

tsc --noEmit failed in CI because ReturnType<typeof readdirSync> with
withFileTypes:true picks an overload union that includes
Dirent<Buffer<ArrayBufferLike>>. Strict tsc treats entry.name as Buffer,
so .startsWith / .endsWith / string comparisons all blew up.

Annotate the variable as Dirent[] (string-based) and cast through unknown,
matching the pattern sync.ts already uses for its own filesystem walk.
Same runtime behavior; clean typecheck.

Tests still 9/9.

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

---------

Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:21:07 -07:00
90c5d93fce feat: v0.18.0 — multi-source brains (one DB, many repos, federation + dotfile resolution) (#337)
* feat(v0.17.0 step 1/9): sources primitive — additive-only multi-source foundation

Lane A of the multi-repo plan. Installs the sources table and seeds a
'default' row that inherits sync.repo_path/last_commit from existing
config. This is the bisectable foundation every later step builds on;
the breaking schema changes (composite UNIQUE, files FK rewrite,
resolution_type, ingest_log.source_id) land with their paired code
rewrites in Steps 2/4/5/7 so no single commit breaks the engine.

- migration v16 (sources_table_additive) + v0_17_0 orchestrator skeleton
- sort-by-version guard in runMigrations (array insertion order can
  never cause a later migration to skip a lower one again)
- default source seeded with config '{"federated": true}' so pre-v0.17
  brains keep single-namespace search semantics after upgrade
- orchestrator phase B detects absence of file_migration_ledger and
  no-ops until Step 7 lands it
- 8 new structural tests in test/migrate.test.ts (shape, idempotency,
  scope-guard that nothing else was smuggled into v16)
- apply-migrations tests include v0.17.0 in the registered list

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

* feat(v0.17.0 step 2/9): pages.source_id + composite UNIQUE (Lane B)

Migration v17 adds pages.source_id with DEFAULT 'default' and swaps the
global UNIQUE(slug) for composite UNIQUE(source_id, slug). Ships atomically
with the engine's ON CONFLICT rewrite so the constraint swap and the code
that writes under it land in the same commit — no window where the engine
sees one shape and the schema has another.

Minimum-surface engine change: only putPage's ON CONFLICT target needs
re-targeting. Other slug-based queries work unchanged because single-
source brains (the only brain shape pre-Step-5) have exactly one source
'default', so slug remains effectively unique within it. Step 5+ will
surface an explicit sourceId param on putPage for cross-source sync.

- migration v17 (pages_source_id_composite_unique) in src/core/migrate.ts
- pages.source_id + composite UNIQUE added to schema.sql + pglite-schema.ts
  for fresh installs
- ON CONFLICT (slug) → ON CONFLICT (source_id, slug) in both pglite-engine
  and postgres-engine putPage
- DEFAULT 'default' closes the Codex-flagged race where an INSERT between
  ADD COLUMN and SET NOT NULL could leave source_id NULL
- 5 new v17 structural tests (29 pass / 0 fail in migrate.test.ts)
- Full suite: 1979 pass / 3 fail (same as baseline — no regressions)

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

* feat(v0.17.0 step 6/9): sources CLI + source-resolver (Lane C)

Adds the CLI surface for multi-source management. Users can now register,
list, rename, federate/unfederate, and attach-to-directory a source. The
source-resolver is the shared 6-priority helper that Steps 4/5 will use
when they start surfacing an explicit --source flag on sync/extract/query.

Commands:
  gbrain sources add <id> --path <p> [--name <n>] [--federated|--no-federated]
  gbrain sources list [--json]
  gbrain sources remove <id> [--yes] [--dry-run] [--keep-storage]
  gbrain sources rename <id> <new-name>
  gbrain sources default <id>
  gbrain sources attach <id>   — writes .gbrain-source in CWD
  gbrain sources detach
  gbrain sources federate <id> / unfederate <id>

Resolution priority (source-resolver.ts) — highest first:
  1. --source flag  2. GBRAIN_SOURCE env  3. .gbrain-source dotfile walk-up
  4. longest-prefix match on registered local_path (Codex #2 fix)
  5. sources.default config  6. fallback 'default'

- add: validates id format (kebab-case alnum, 1-32), rejects overlapping
  paths (eng review §4 finding 4.1), supports federated default opt-in
- remove: guards against --yes omission + refuses to remove 'default',
  supports --dry-run, reports cascade page count
- attach/detach: matches kubectl/terraform context-pinning semantics
- Throws on overlap rather than process.exit() so the CLI error wrapper
  reports it consistently (also makes unit testing clean)

28 new tests across sources.test.ts (dispatcher + validation + overlap
guard) and source-resolver.test.ts (full 6-priority coverage including
longest-prefix). Full suite: 2012 pass / 3 fail (pre-existing PGLite
infra timeouts).

NOT in scope for Step 6 (deferred):
  - import-from-github (SSRF + clone integration)
  - prune (retention/TTL, lands v0.18)
  - MCP tool-defs regen for source-scoping on read ops (Step 5)

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

* docs(v0.17.0 step 8/9): getting-started guide + migration skill + citation rule

Step 8 (Lane F) documents what Steps 1+2+6 have shipped and sets up
the agent-facing rules for multi-source.

New files:
- skills/migrations/v0.17.0.md — migration skill read by host agents
  after `gbrain apply-migrations`. Covers the v16+v17 chain, what's
  in v0.17.0 vs what lands later (v0.17.1 ACL, v0.18 sessions), and
  the new sources CLI surface. Cites docs/guides/multi-source-brains.md
  as the recipe.
- docs/guides/multi-source-brains.md — getting-started for end users.
  Three canonical scenarios (unified wiki+gstack / purpose-separated
  yc-media+garrys-list / mixed), full resolution priority, federation
  flag semantics, command reference, and citation format.

skills/brain-ops/SKILL.md — new "Cross-source citation format"
section mandating `[source-id:slug]` when the brain has multiple
sources. Matches the contract the /plan-devex-review DX review
pinned down (DX Finding 5: surface source_id in every page payload
+ citation contract). Key must be sources.id (immutable), never
sources.name.

No behavior change — this is pure documentation for what already
exists in the binary. 144 skills conformance tests still pass.

NOT in this commit (deferred to later steps):
- docs/guides/repo-architecture.md rewrite (lands with the full
  v0.17.0 PR description + release notes)
- skills/_brain-filing-rules.md "which source to file into"
  guidance (lands with Step 5 when sync surfaces --source)

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

* feat(v0.17.0 step 5/9): sync --source <id> routes through sources table (Lane D)

Adds the --source flag to `gbrain sync`. When set, sync reads local_path
+ last_commit from the matching sources(id) row instead of the global
sync.repo_path / sync.last_commit config keys, and writes last_commit +
last_sync_at back to the same row. Backward compat: --source omitted =
pre-v0.17 behavior exactly, global config path unchanged.

- SyncOpts.sourceId threaded through performSync + performFullSync
- readSyncAnchor/writeSyncAnchor helpers centralize the sources-vs-config
  branch so every read/write goes through one decision point. Makes
  Step 5's later per-source sync-failures tracking a one-file change.
- --source resolved via src/core/source-resolver.ts (Step 6), so any
  command that shell-exposes resolveSourceId gets env var + dotfile
  walk-up + longest-prefix for free.
- Error message for missing source local_path is actionable:
    Source "gstack" has no local_path. Run: gbrain sources add gstack --path <path>
- last_sync_at auto-updates on every last_commit advance so `gbrain
  sources list` shows real recency.

No regression: 2012 pass / 3 fail (same as baseline).

NOT in this commit (deferred per plan):
- Per-source failure tracking (~/.gbrain/sources/<id>/sync-failures.jsonl)
- runImport source-awareness (import.ts path — Step 5 continuation)
- Partial-success semantics when walking N sources — single-source flow
  today, multi-walk lands when the top-level `gbrain sync` without
  --source starts iterating all sources.

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

* feat(v0.17.0 step 4/9): qualified [[source:slug]] + links.resolution_type (Lane B)

Adds source-pinned wikilink syntax and records the resolution kind on
each edge so `gbrain extract --refresh-unqualified` (future) can
re-resolve bare references when the source topology changes.

Wikilink syntax extension:
  [[concepts/ai]]             — unqualified; resolves via local-first fallback
  [[wiki:concepts/ai]]        — qualified; target pinned to sources.id='wiki'
  [[gstack:projects/foo|Display]]  — qualified + display name

The qualified regex runs first and masks matched spans so the
unqualified pass can't double-emit. Source id format enforced to match
the sources CLI validation: [a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?

Schema:
- migration v18 adds links.resolution_type TEXT with CHECK constraint
  ('qualified'|'unqualified' or NULL for legacy/manual/frontmatter edges)
- schema.sql + pglite-schema.ts updated for fresh installs

EntityRef type:
- sourceId is OPTIONAL (only set on qualified wikilinks). Markdown
  [Name](path) and unqualified wikilinks omit it so strict toEqual
  tests pre-v0.17 keep working (69 existing tests still pass).

Tests:
- 5 new qualified-wikilink extraction tests + 1 migration v18 structural
  assertion. 75 tests in test/link-extraction.test.ts (up from 69).
- Full suite: 2018 pass / 3 fail (pre-existing PGLite infra timeouts).

NOT in this commit (deferred to Step 3 / Step 5 continuation):
- Writing resolution_type to the DB (addLink / addLinksBatch don't
  carry the field yet — that's the plumb-through that lands with
  Step 3 when search/dedup also needs source-aware result keys).
- `gbrain extract --refresh-unqualified` re-resolver.

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

* feat(v0.17.0 step 3/9): source-aware search dedup composite keys (Lane B)

Search dedup now keys on (source_id, slug) instead of slug alone. Pre-
v0.17 would collapse two same-slug pages in different sources into
one, destroying cross-source recall. Codex outside-voice review flagged
this as regression-critical — this commit ships the fix plus tests
that lock the invariant in.

Dedup pipeline (src/core/search/dedup.ts):
- pageKey(r) helper — one canonical composite-key derivation. Falls
  back to source_id='default' for pre-v0.17 rows so single-source
  brains behave identically to before.
- Layer 1 (dedupBySource): group-by composite key.
- Layer 4 (capPerPage): count-by composite key.
- guaranteeCompiledTruth: swap scoped to matching (source_id, slug),
  so wiki:topics/ai can't accidentally pull gstack:topics/ai's
  compiled_truth chunk.

SearchResult type gains optional source_id — populated by SQL JOINs
in both engines, falls through as 'default' for legacy callers.

Engine SQL:
- pglite-engine.ts + postgres-engine.ts: search SELECTs add p.source_id
- rowToSearchResult (utils.ts): maps row.source_id → result.source_id
  when present. Shape stays backward compatible (field optional).

Tests — 4 new in test/dedup.test.ts:
- same-slug-different-source does NOT collapse (the critical regression
  guard Codex called out)
- same-slug-same-source DOES still collapse (no over-correction)
- missing source_id falls back to 'default' for pre-v0.17 compat
- compiled_truth guarantee scopes to composite key (Codex second pass
  caught this specific path would leak otherwise)

Full suite: 2022 pass / 3 fail (3 pre-existing PGLite infra timeouts).

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

* feat(v0.17.0 step 7/9): file_migration_ledger + phase-B storage backfill (Lane E)

Adds files.source_id + files.page_id + the file_migration_ledger
state machine that drives storage object rewrites. Each per-file
transition is its own transaction so crash-point recovery is a
ledger read, not a filesystem inspection. Codex second-pass review
flagged that "skip if already has source prefix" was an unsafe
heuristic — the ledger replaces it with explicit state tracking.

Schema:
- migration v19 (files_source_id_page_id_ledger): handler-only
  (PGLite has no files table; Postgres-only gate). ADDs
  source_id + page_id to files, backfills page_id from page_slug
  scoped to source_id='default', creates file_migration_ledger
  with PK on file_id (Codex: not storage_path_old — two sources
  can share an old path during migration).
- schema.sql updated for fresh Postgres installs; file_migration_ledger
  gets RLS alongside other tables.

Runtime:
- src/commands/migrations/v0_17_0-storage-backfill.ts: drives the
  ledger state machine pending → copy_done → db_updated → complete.
  Idempotent per row: re-running resumes from whichever state
  crashed. Old objects preserved (no delete) so operators can
  verify the soak window before a future cleanup release.
- phase B in v0_17_0.ts orchestrator: wires the storage backend
  (Supabase/S3/local) through createStorage, runs runStorageBackfill,
  reports per-state counts + first-three error details.

Tests — 13 new in test/storage-backfill.test.ts:
- pending → copy_done → db_updated → complete happy path
- 3 crash-point recovery tests (resume from copy_done, resume from
  db_updated, failed rows don't auto-retry)
- already-complete rows are skipped with zero side effects
- idempotent re-upload (exists-check skips redundant upload)
- dry-run mode (no storage, reports counts without mutating)

Plus 5 new migrate.test.ts assertions for v19 structure (handler-
only, PGLite gate, source_id + page_id + ledger DDL, default-source
backfill scope, state machine values).

Full suite: 2035 pass / 3 fail (3 pre-existing PGLite infra
timeouts).

NOT in this commit (explicitly deferred):
- DROP old page_slug column — kept for backward compat until
  operators have time to verify page_id everywhere.
- DROP old UNIQUE(storage_path) in favor of UNIQUE(source_id,
  storage_path) — same reason, deferred to later cleanup.
- Actual cleanup phase that deletes old objects post-soak.

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

* test(v0.17.0 step 9/9): full multi-source PGLite integration suite (Lane G)

End-to-end exercise of every v0.17.0 surface against real PGLite
(in-memory, fast — no DATABASE_URL needed). The migration chain
v2→v19 runs start-to-finish and the test asserts each Step's
invariants hold together.

16 new integration tests across 7 describes:

1. Migration-installed state:
   - sources('default') exists with federated=true config
   - pages.source_id column has DEFAULT 'default'
   - composite UNIQUE (source_id, slug) is installed

2. Default-source write path:
   - putPage without explicit source → source_id='default' via schema
     default clause (no engine API change needed for single-source brains)

3. Composite UNIQUE regression guards (Codex-flagged):
   - Same slug in two different sources coexists
   - Third insert with same (source_id, slug) hits the UNIQUE constraint

4. sources CLI round-trip:
   - federate / unfederate flips config.federated
   - rename changes display, id stays immutable

5. Source resolution priority (integration):
   - Explicit flag > env var > fallback to default
   - Unregistered explicit source errors with actionable message

6. Cascade semantics:
   - sources remove cascades to pages; default source untouched

7. links.resolution_type (Step 4):
   - Qualified/unqualified values accepted
   - CHECK constraint rejects invalid values

All 16 tests pass. Full suite: 2042 pass / 4 fail (4 pre-existing
PGLite beforeEach timeouts in test/wait-for-completion,
test/extract-fs, test/e2e/search-quality, test/e2e/graph-quality
— count fluctuated 3-5 on baseline from variance alone).

Total new tests across Steps 1-9: ~85 unit + integration tests
(sources, source-resolver, migrate v16/v17/v18/v19 structural,
link-extraction qualified wikilinks, dedup regression-critical,
storage-backfill state machine + crash recovery, full
multi-source PGLite integration).

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

* chore: bump to v0.18.0 + CHANGELOG entry (multi-source brains)

One-viewport release summary + itemized changes covering all 9 steps
of the multi-source primitive. Notes the v0.17 → v0.18 version bump
rationale (master shipped gbrain dream as v0.17 while this branch was
in flight).

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

* fix(ci): v0_18_0 orchestrator TS narrow + mechanical test ON CONFLICT

Two CI failures on PR #337:

1. tsc TS2367 at src/commands/migrations/v0_18_0.ts:190 —
   after the early-return on `a.status === 'failed'` (line 179),
   TypeScript narrows `a.status` to `'skipped' | 'complete'`, so the
   subsequent `a.status === 'failed' ? 'failed' :` branch was dead
   code and refused to compile. Dropped the redundant check.

2. E2E `file_list LIMIT enforcement` at test/e2e/mechanical.test.ts:636 —
   the test pre-seeded a pages row with `ON CONFLICT (slug) DO NOTHING`
   but v21 swapped the global UNIQUE for `UNIQUE (source_id, slug)`, so
   Postgres rejects with "no unique or exclusion constraint matching".
   Updated the conflict target to the composite key.

Tier-1 E2E had only this one failing test; everything else passed.

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

* test(e2e): v0.18.0 multi-source against real Postgres (v20-v23 schema + cascade + sync)

Closes the three biggest confidence gaps the author flagged in the
self-audit of PR #337:

1. No real Postgres E2E — PGLite has no files table, so v23's
   files.source_id + files.page_id rewrite + file_migration_ledger
   seed was NEVER executed against the real DB. This file covers it.

2. `gbrain sync --source <id>` had zero direct tests. Now has two:
   one that asserts performSync({sourceId}) reads local_path from the
   sources row (not the global config), one that asserts no-sourceId
   falls back to the global sync.repo_path.

3. Cascade delete coverage — previously verified only pages count
   after source removal. Now verifies pages + content_chunks +
   timeline_entries + links + files ALL cascade-delete when a source
   is removed.

6 describes, 16 tests total:

- Schema shape (fresh install): 6 tests confirming sources('default'),
  pages.source_id NOT NULL with DEFAULT, composite UNIQUE pages
  (source_id, slug) replaces global UNIQUE(slug), links.resolution_type
  column + CHECK, files.source_id + page_id columns, file_migration_ledger
  table + status CHECK.

- Composite UNIQUE semantics: 3 tests confirming same-slug in two
  sources coexists (Codex-critical regression guard), duplicate
  (source_id, slug) hits the UNIQUE, putPage targets default source
  by schema DEFAULT.

- Cascade delete: 1 test building a fully populated source (2 pages,
  chunks, timeline, links, files) then removing it + asserting every
  dependent row is gone.

- Sync routing: 2 tests confirming performSync({sourceId}) reads
  per-source local_path vs global config.

- Sources surface: 3 tests for federate/unfederate flipping + rename
  preserving id.

- Storage backfill: 1 end-to-end test seeding ledger + running
  runStorageBackfill against a stub StorageBackend, asserting
  pending → complete transition and files.storage_path rewrite.

Gated by DATABASE_URL per CLAUDE.md E2E lifecycle. Each describe's
beforeAll defensively DELETEs non-default sources + file_migration_ledger
rows so reruns are hermetic (sources isn't in helpers.ALL_TABLES).

Verified: 16/16 pass on first run AND second run (residual-state fix
holds). Full E2E suite still green.

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

* fix(ci): TS2352 in multi-source E2E — cast postgres.js RowList via unknown

tsc rejects the direct
  `(rows as { column_name: string }[]).map(...)`
cast because postgres.js RowList rows have an iterable-row shape that
doesn't overlap with the plain-object target. Standard fix: cast via
`unknown` first so the narrowing is explicit.

Verified: `bunx tsc --noEmit` clean (ignoring the pre-existing baseUrl
deprecation warning).

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

* fix(v0.18.0): addLinksBatch + addTimelineEntriesBatch source-aware JOINs

Batch APIs JOINed on pages.slug globally, so two pages sharing the same
slug across sources would silently fan out — addLinksBatch(['a->b']) in
a brain with 'a' in both 'default' and 'alt' wrote 2 edges instead of 1.
Same bug on addTimelineEntriesBatch.

Fix:
- LinkBatchInput + TimelineBatchInput gain optional source_id fields
  (from_source_id, to_source_id, origin_source_id for links; source_id
  for timeline). All default to 'default' so existing callers are
  backward-compatible on single-source brains.
- pglite-engine + postgres-engine batch JOINs now composite-key on
  (slug, source_id). Postgres adds 3 more unnest arrays for links + 1
  for timeline — still one bind per column, no 65535-param cap risk.
- LEFT JOIN for origin pages also source-qualified so frontmatter-
  provenance edges don't cross-pollinate across sources.

Regression coverage:
- test/pglite-engine.test.ts: 5 new tests covering default-path isolation,
  explicit alt-source writes, and cross-source edges.
- test/e2e/multi-source.test.ts: 4 new tests against real Postgres so
  postgres-js's unnest() bind path is exercised (structurally different
  from PGLite's).

Gap #4 from the PR self-audit — latent bug, not previously reachable
because every existing caller wrote to the default source only.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:24:23 -07:00