Files
gbrain/test/schema-bootstrap-coverage.test.ts
T
d6db3f0ce3 v0.41.34.0 feat(search): retrieval cathedral — max-pool + title + alias + evidence (#1657)
* feat(search): per-page max-pool in searchVector (both engines)

T1 of the retrieval-cathedral wave (supersedes #1616). Vector search returned
chunk-grain top-k with no DISTINCT ON, so a page could be represented by a
weak chunk while a hub page's chunks crowded a distinct page's strong chunk
out of the candidate set entirely. Keyword search always pooled per page; the
vector path did not.

- New shared buildBestPerPagePoolCte() in sql-ranking.ts — single source of
  truth consumed by searchKeyword + searchVector across postgres + pglite, so
  the two engines can't drift (the recurring parity bug class).
- searchVector both engines: compute score as a select-list expr (HNSW
  ORDER BY stays pure-distance), pool DISTINCT ON (slug) over the full
  candidate set before the user LIMIT, deterministic tiebreak
  (slug, score DESC, page_id ASC, chunk_id ASC).
- All keyword pooling blocks refactored onto the shared builder (DRY).
- Regression test: a hub page's chunks no longer crowd out a distinct page's
  strong chunk; results are one-per-page by best chunk. Fails on old path.

Verified: real-Postgres engine-parity 22/22, PGLite hermetic suite green.

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

* feat(search): title-phrase boost (page.title first-class signal)

T2 of the retrieval-cathedral wave. A query that is a phrase from a page's
title ("Greek amphitheater" -> "The Mingtang - Indoor Greek Amphitheater")
matched a weak body chunk instead of being recognized as a title hit. Names
of things deserve weight.

- New pure title-match.ts: isTitlePhraseMatch (contiguous token-run inside
  page.title OR exact full-title match). Precision guards: >= 2 content
  tokens OR exact full-title; stopword filter; token-boundary match (no raw
  substring). Reused by the eval later so production + bench can't drift.
- applyTitleBoost post-fusion stage in hybrid.ts: reads page.title (not the
  brittle "first chunk"), floor-ratio-gated, stamps title_match_boost for
  --explain, never touches base_score (the agent's dedup confidence).
- ModeBundle.title_boost knob (1.25, on in all modes - cheap gated
  correctness fix), search.title_boost config key, dashboard description.
- KNOBS_HASH_VERSION 6 -> 7 so a boost-on cache write can't serve a
  boost-off lookup; all version-pin + canonical-bundle assertions updated.
- 18 new tests (matcher 13 + stage 5); typecheck clean.

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

* feat(search): page_aliases data layer (T3 foundation)

Free-text alias resolution for search. gbrain stored a page's chosen names
in pages.frontmatter `aliases:` JSONB but search never consulted them, so a
query like "Hall of Light" or "明堂" couldn't surface the "Mingtang" page.

DELIBERATELY SEPARATE from slug_aliases (re-grounded against current code):
  - slug_aliases:  old-slug -> canonical-slug (wikilink/get_page redirect,
    populated only from concept-redirect conversions)
  - page_aliases:  normalized free-text name -> canonical slug (search hop)
Overloading slug_aliases would muddy two distinct semantics, so this is a
new table, not an extension (honors DRY by keeping concepts separate).

- src/core/search/alias-normalize.ts: ONE normalizeAlias() (NFKC + lowercase
  + ws-collapse + quote-strip) + normalizeAliasList() shared by the write
  (ingest) and read (search) paths so they match on the same key (CQ2).
- Migration v108 page_aliases (source_id, alias_norm, slug); btree
  (source_id, alias_norm) for indexed-equality hop, NOT ILIKE; unique TRIPLE
  (not source_id+alias_norm) so two pages may claim one alias — collisions
  reported + resolved at query time, not blocked at ingest (Codex#8).
  Mirror in pglite-schema.ts; Postgres fresh gets it from the migration.
- engine.resolveAliases(aliasNorms, {sourceId|sourceIds}) read +
  setPageAliases(slug, source, aliasNorms) write, both engines, source-scoped.
- 17 tests: normalize round-trip, collision, source-scope, replace, clear.

Ingest projection + the hybridSearch alias hop land next (T3 wiring).

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

* feat(search): alias hop + ingest projection (T3 wiring)

Wires the page_aliases data layer into ingest (write) and hybridSearch (read)
so a query that is a page's declared chosen name surfaces that page — the
named-thing class neither max-pool nor title-boost can fix (true synonyms with
zero surface overlap: "Hall of Light" / "明堂" -> the Mingtang page).

- Ingest projection (import-file.ts): after the page write commits,
  normalizeAliasList(frontmatter.aliases) -> engine.setPageAliases. Always
  called (even []) so removing an alias clears its row; content_hash includes
  non-timestamp frontmatter so alias edits reach this path, not the skip branch.
  Fail-soft + pre-v108-safe (isUndefinedTableError swallowed).
- applyAliasHop (hybrid.ts), AFTER rerank so a named query reliably surfaces
  its page: FULL normalized-query exact match only (no substring/n-grams),
  skip >6-token prose queries, present-boost 1.10x / inject absent canonical at
  top-of-organic + epsilon (never absolute 1.0, D3), collisions alpha-ordered +
  capped at 3, fail-open on pre-v108 / lookup error (D9). Stamps alias_hit for
  the T4 evidence contract.
- SearchResult.alias_hit attribution field.
- 8 tests: inject/boost/CJK/no-match/long-skip/collision + ingest projection
  round-trip + alias-removal-clears. 73 pass across the T1/T2/T3 + import suite.

Backfill of existing pages' aliases lands as T8 (reindex --aliases).

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

* feat(search): evidence/create_safety contract + search→cheap-hybrid + per-call mode (T4)

The agent-facing fix for the incident's ROOT behavior: tonight the agent read a
single blended 0.64 score, decided "no strong match, safe to write a new page",
and wrote a duplicate on a developed concept page. A blended RRF/cosine score is
not a calibrated probability, so the don't-duplicate decision must key off WHY a
page matched, not a raw number.

- evidence.ts: classifyEvidence (alias_hit > exact_title_match > high_vector_match
  > keyword_exact > weak_semantic) + createSafetyFor (exists | probable | unknown).
  stampEvidence runs at the end of every hybrid return path (main + both keyword
  fallbacks). SearchResult gains evidence + create_safety. The agent keys
  don't-duplicate off create_safety='exists', not a score threshold.
- search op → cheap-hybrid everywhere (D4/D15): full vector+keyword+RRF+pool+
  title+alias, expansion OFF (no per-call LLM cost); `query` stays full-control.
  search.mcp_keyword_only escape hatch (D17) keeps the old keyword-only behavior
  for operators who don't want query text sent to an embedding provider.
- Alias hop + evidence now also run on the keyword-only fallback paths (the
  named-thing fix is most valuable exactly when vector is unavailable).
- Per-call `mode` (D5): honored ONLY for local/trusted callers (ctx.remote===
  false) so a remote OAuth client can't escalate to costly tokenmax; local +
  unknown mode rejects loudly; threaded into resolveSearchMode + the cache key.
- 30 tests (evidence classifier incl. before/after-incident cases, per-call mode
  gate, alias hop). Updated mcp-eval-capture to the new cheap-hybrid contract.

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

* fix(cli): reconcile `gbrain search` dispatch (T5)

After T4 made the `search` op cheap-hybrid, `gbrain search "x"` already does the
right thing — but `gbrain search modes/stats/tune` would have run a hybrid search
for the literal word "modes" instead of opening the config dashboard (the op
intercepts before the unreachable handleCliOnly dashboard path).

Add a pre-dispatch interception in main(): `search` + subArgs[0] in
{modes,stats,tune} → runSearch dashboard (with the v0.41.6.0 read-only connect+
dispatch 10s timeout preserved); everything else (free-text) falls through to the
cheap-hybrid `search` op. Subprocess test pins all three routes:
modes/stats → dashboard, free-text → search op ("No results", not "Unknown
subcommand").

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

* feat(eval): NamedThingBench retrieval-quality gate (T6)

The eval that makes the retrieval-maxpool incident impossible to reintroduce
silently. 7 query families, each a failure class the incident exposed:
title-substring, generic-to-named, alias-synonym, multi-chunk-dilution,
short-vs-rich, graph-relationship, hard-negative.

- src/eval/retrieval-quality/harness.ts: pure scoring (Hit@1/Hit@3/MRR per
  family) + injected SearchFn (CLI uses hybridSearch; tests stub it) +
  evaluateGate. D12 gate: hard-gate the families that ARE the bug from day one
  (title-substring Hit@1>=0.95, alias-synonym Hit@1>=0.98, dilution Hit@3=1.0),
  warn-then-enforce the softer families. Env-overridable floors.
- `gbrain eval retrieval-quality <fixture.jsonl> [--json] [--source]` +
  dispatch in eval.ts. Exit 0 PASS / 1 FAIL / 2 USAGE.
- Synthetic fixture (placeholder names only, privacy-grep guarded) + hermetic
  gate test: seeds a synthetic brain, forces the keyword+title+alias path
  (embed transport stubbed to throw — free, deterministic), asserts the bug
  families pass. The vector max-pool guarantee is pinned separately by
  searchvector-maxpool.test.ts.
- CI gate: the hermetic test is a normal unit test, so it runs in every PR
  shard — the gate is live on every change.
- 23 tests (harness unit + hermetic gate + fixture privacy guard).

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

* feat(telemetry): rank-1 score drift signal (T7)

Standing observability so a retrieval regression is caught before a human hits
it in chat (like tonight). Aggregate columns on search_telemetry (NOT per-query
rows, D10): sum_rank1_score + count_rank1 + 3 coarse buckets (<0.6 / 0.6-0.85 /
>=0.85). The mean rank-1 base_score is the headline; a downward drift = retrieval
quality regressing.

- hybrid.ts: capture rank-1 base_score at all three return paths, thread through
  emitMeta → recordSearchTelemetry opts (like results_count).
- telemetry.ts: Bucket + record + flush ON CONFLICT-add + readSearchStats expose
  avg_rank1_score (null when no samples — no NaN) + rank1_distribution.
- Migration v109 ADD COLUMN IF NOT EXISTS (both engines; search_telemetry lives
  only in migration v57, so the v57+v109 chain covers fresh + upgrade). Columns
  exempted in schema-bootstrap-coverage (no forward-ref index → no bootstrap need).
- `gbrain search stats` surfaces the avg + bucket line; JSON envelope auto-carries
  the fields. "true-positive" wording dropped per Codex#14 — production has no
  labels, so this is an unlabeled rank-1 score histogram; labeled calibration
  lives in NamedThingBench (T6).
- 3 round-trip tests (mean+buckets, no-result excluded, empty=null).

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

* feat(reindex): gbrain reindex --aliases backfill (T8)

Import-time projection (T3) covers new + changed pages; this backfills EXISTING
pages whose frontmatter `aliases:` predate v108 / the projection. Walks
listAllPageRefs (cheap cross-source (source_id, slug) enumeration), reads each
page's frontmatter aliases, writes page_aliases via setPageAliases.

Idempotent (setPageAliases replaces) so re-running is convergent — no op-checkpoint
needed (fast, no embedding). --dry-run reports would-write counts, --source
narrows, --limit caps, --json envelope, progress reporter. Wired into the
`reindex` dispatch alongside --markdown / --multimodal.

4 tests: backfill from array + comma-scalar frontmatter, --dry-run writes
nothing, idempotent second run.

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

* test(search): pre-migration fail-open regression (T9)

Pins that pre-v108 brains (no page_aliases table) keep working: applyAliasHop
returns input unchanged + doesn't throw, importFromContent with frontmatter
aliases still imports (projection swallows table-missing via isUndefinedTableError),
and resolveAliases surfaces the error for the caller to catch.

Completes the T9 mandatory regression set (dilution → searchvector-maxpool,
dispatch → cli-search-dispatch, MCP contract → mcp-eval-capture, engine parity
→ engine-parity 22/22, pre-migration → here).

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

* feat(search): Phase-0 retrieval diagnostic — `gbrain search diagnose` (T0)

The operator-facing trace the user runs against the production brain to pin
which retrieval layer surfaces (or misses) a target page — the diagnostic the
plan front-loaded so we don't ship a fix that doesn't move the incident.

`gbrain search diagnose "<query>" --target <slug> [--json] [--source]` reports,
for the target: keyword rank+score, vector rank+score (skipped/graceful if no
embedding provider), whether the query is a registered alias, and the hybrid
final rank + evidence + create_safety + which boosts fired (title/alias). The
verdict names the layer that surfaces the target at rank 1 (or "none"), telling
you whether the lever is max-pool/innerLimit (vector) vs title/alias.

Wired into the `search` dispatch alongside modes/stats/tune (60s timeout since
it runs real retrieval). 2 hermetic tests (alias-query trace + title-phrase
trace). For the Mingtang incident, run:
  gbrain search diagnose "Greek amphitheater" --target projects/new-greek-theater/concept_v0

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

* docs(retrieval): corrected incident record + named-thing layers + glossary (T10)

- RETRIEVAL_MAXPOOL_INCIDENT.md: replaces closed PR #1616's RFC with the
  verified record — what happened, the disease, the corrections to the RFC's
  mechanics (search was keyword-only, --mode unthreaded, hybrid already pooled
  at dedup, aliases dead to search), the four-layer fix that shipped, and the
  triage commands (search diagnose / reindex --aliases / search stats / eval
  retrieval-quality).
- RETRIEVAL.md: new "Named-thing retrieval" section documenting per-page pool +
  title boost + alias hop + the evidence contract, reconciling the doc with the
  shipped pipeline (closes the doc/reality gap).
- metric-glossary.ts + regenerated METRIC_GLOSSARY.md: Hit@1, Hit@3,
  avg_rank1_score (drift signal, not labeled accuracy), and create_safety
  (the evidence contract) now carry plain-English glossary entries.

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

* test(eval): NamedThingBench fixture privacy guard via slug-shape (T6 fixup)

The banned-name literal list itself tripped check-privacy/check-test-real-names.
Replace it with the load-bearing assertion: every fixture slug must be an
*-example placeholder (no real brain page can be referenced).

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

* fix(search): source-isolation in per-page pool + alias hop (P0, codex adversarial)

Codex outside-voice caught two source-isolation P0s in the retrieval wave — the
exact class the v0.34.1 seal guards. Both fixed before merge.

P0-1: buildBestPerPagePoolCte pooled on `slug` alone. In a federated brain, two
pages with the same slug in different sources collapsed before ranking/pagination
(the neighbor-source page dropped). Now DISTINCT ON (COALESCE(source_id,'default'),
slug) — composite key matching dedup.ts's pageKey. Also fixes the PRE-EXISTING
keyword-path bug (best_per_page was slug-only before this wave); real-PG parity 23/23.

P0-2: the alias hop dropped source_id. resolveAliases returned bare slugs and
applyAliasHop hydrated via getPage(slug, undefined), so a federated caller could
get the default-source page injected or the right allowed-source page suppressed.
resolveAliases now returns {slug, source_id} pairs; applyAliasHop matches by
(source_id, slug) and fetches each canonical in its OWN source.

Regression tests: alias hop boosts only the aliased source (not same-slug in
another source); resolveAliases keeps cross-source same-slug distinct.

Deferred as documented tradeoffs (TODO): evidence high_vector_match label uses
blended base_score not pure cosine; deep-pagination candidate budget is
chunk-bounded; telemetry writes swallow errors pre-v109 on rolling deploys.

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

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

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

* docs: v0.41.30.0 retrieval cathedral — CLAUDE.md key files + llms regen

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

* chore: renumber release v0.41.30.0 → v0.41.34.0 (queue moved)

Version trio + CHANGELOG header + CLAUDE.md key-file annotations + TODOS
heading + regenerated llms bundles, all moved to 0.41.34.0.

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

* fix(ci): restore glossary roster + harden facts-anti-loop hook budget

Two CI failures surfaced after the master merges that brought the branch to
111 migrations:

1. shard 1 — `ALL_METRICS roster > matches the renderer output (no orphans)`:
   the merge took master's `renderMetricGlossaryMarkdown` whose `groups`
   array lacked this branch's 4 retrieval-quality keys (hit@1, hit@3,
   avg_rank1_score, create_safety). `ALL_METRICS` (derived via Object.keys)
   kept them, so the roster test saw 4 orphans. The freshness check
   (check:eval-glossary) passed because renderer-output == committed doc —
   it can't catch a renderer that drops a metric; the roster test can.
   Restored the "Retrieval-Quality / Evidence Metrics (NamedThingBench)"
   group + regenerated docs/eval/METRIC_GLOSSARY.md.

2. shard 2 — facts-anti-loop's two engine-dependent put_page tests failed
   while the two engine-free extractFactsFromTurn tests passed (the
   signature of a partially-failed beforeAll). This file has a documented
   PGLite-cold-start-under-deep-shard-load timeout history; the 30s budget
   was tuned for 95 migrations and the chain is now 111. Bumped to 60s.

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

* fix(ci): isolate facts-anti-loop in its own process (serial)

Follow-up to the prior hook-timeout bump, which was the wrong theory: the
[58ms]/[71ms] body times in the re-run prove beforeAll did NOT time out —
the engine connects and the two put_page tests run and fail for real, while
the two engine-free extractFactsFromTurn tests in the same file pass.

put_page (via dispatchToolCall) touches process-global singletons (the
facts queue + the AI gateway used by importFromContent's embed step). Some
sibling file in the 78-file shard-2 process leaves residual global state
that makes put_page's pre-backstop path fail on the CI runner. The failure
is NOT reproducible alone, in a Linux oven/bun:1 container, or in a full
local shard-2 run (1172 pass) — only on the GitHub runner, deterministically.

Per CLAUDE.md's test-isolation rules, a test coupled to shared process
state belongs in its own process. Renamed to *.serial.test.ts so it runs
in the dedicated serial-tests job (scripts/run-serial-tests.sh spawns a
fresh `bun test` per serial file), where it passes deterministically;
test-shard.sh excludes serial files from the matrix. Updated the comment
to reflect the real cause and refreshed the test-weights.json key.

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

* fix(ci): close cross-file gateway-config pollution in test shards

The prior serial-move theory was incomplete. The real, single root cause
behind all three shard failures (2, 5, 10) is cross-file AI-gateway config
pollution within a shard's bun process:

- A test calls configureGateway() and doesn't restore the gateway on exit.
  The legacy-embedding preload pins OpenAI/1536 ONCE at process start and
  re-pins per-test ONLY when the gateway slot is empty — so a leaker that
  reconfigured the gateway to the v0.37 default (zeroentropyai:zembed-1 /
  1280-d) and never reset poisons every later file in the shard.
- Victim A (shard 5, test/search/searchvector-maxpool.test.ts): runs
  initSchema in beforeAll under the leaked gateway → content_chunks.embedding
  becomes vector(1280) → inserting its hardcoded 1536-d basis vectors throws
  pgvector CheckExpectedDim.
- Victims B/C (shard 10 facts-backstop-gating, shard 2 facts-anti-loop):
  put_page's importFromContent embeds by design (embed failure PROPAGATES,
  Codex C2). Under a leaked fake-key gateway the embed step 401s and put_page
  returns isError → the backstop assertions fail.

My branch's shard re-partition (added test files + weight changes) merely
co-located leakers with victims; the hazard was latent.

Fixes (root cause + self-sufficient victims):
- test/search/rerank.test.ts (the shard-5 leaker): add afterAll(resetGateway).
  Its stub omits embedding_model, so it fell back to the ZE/1280 default;
  now it restores the empty slot so the preload re-pins legacy for the next
  file.
- test/search/searchvector-maxpool.test.ts: pin configureGateway(openai/1536)
  in beforeAll BEFORE initSchema (initSchema runs before any preload
  beforeEach, so it can't rely on the inherited slot).
- test/facts-backstop-gating.test.ts + test/facts-anti-loop.test.ts: reset
  the gateway in beforeEach so put_page's embed is a graceful no-op; reverted
  anti-loop from the serial quarantine back into the matrix (the serial move
  was the wrong fix for a gateway-state problem).

Validated deterministically: a non-resetting leaker that poisons the gateway
to ZE, run first in one bun process, no longer breaks any of the three
victims (14/14 pass). verify 29/29, typecheck clean, isolation lint clean.

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-05-30 12:07:38 -07:00

872 lines
44 KiB
TypeScript

/**
* CI guard: PGLITE_SCHEMA_SQL must not forward-reference state that
* `applyForwardReferenceBootstrap` doesn't know how to create.
*
* Background: gbrain ships an "embedded latest schema" blob
* (`pglite-schema.ts`) for fast bootstraps, alongside a numbered migration
* chain (`migrate.ts`) for incremental upgrades. Across 2 years and 6 schema
* versions, every release that added a column-with-index in the schema blob
* without a corresponding bootstrap addition has triggered the same wedge
* incident class (#239, #243, #266, #266, #357, #366, #374, #375, #378,
* #395, #396).
*
* The bootstrap is the structural fix. This test enforces the contract:
* for every "forward reference" the schema blob makes (FK or indexed column
* defined later than its reference site, or any column that older brains
* lack), the bootstrap MUST add enough state so that running the schema
* blob is replay-safe on a brain that lacks every member of
* `REQUIRED_BOOTSTRAP_COVERAGE`.
*
* **When you add a new schema-blob forward reference:**
* 1. Extend `applyForwardReferenceBootstrap` in pglite-engine.ts +
* postgres-engine.ts to add the new state.
* 2. Add an entry to `REQUIRED_BOOTSTRAP_COVERAGE` below.
* 3. This test will pass.
*
* If you add a forward reference but skip step 1, this test fails. If you
* skip step 2, this test passes but the bootstrap silently drifts behind
* the schema. The eng-review polish notes recommended layered coverage
* (per-engine integration tests in `test/bootstrap.test.ts` +
* `test/e2e/postgres-bootstrap.test.ts`) to catch step 2 oversights.
*/
import { test, expect } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
// Tier 3 opt-out: this file tests the bootstrap coverage contract explicitly,
// running applyForwardReferenceBootstrap against fresh PGlite instances. A
// snapshot-loaded engine would skip the bootstrap entirely.
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
// Forward-reference targets that PGLITE_SCHEMA_SQL requires.
// When you add a new one, extend this list AND the bootstrap.
type ForwardReference =
| { kind: 'table'; name: string }
| { kind: 'column'; table: string; column: string };
const REQUIRED_BOOTSTRAP_COVERAGE: ForwardReference[] = [
// Forward-referenced by `pages.source_id REFERENCES sources(id)` and the
// `INSERT INTO sources (id, name, config) VALUES ('default', ...)` seed.
{ kind: 'table', name: 'sources' },
// Forward-referenced by `CREATE INDEX idx_pages_source_id ON pages(source_id)`.
{ kind: 'column', table: 'pages', column: 'source_id' },
// Forward-referenced by `CREATE INDEX idx_links_source ON links(link_source)`.
{ kind: 'column', table: 'links', column: 'link_source' },
// Forward-referenced by `CREATE INDEX idx_links_origin ON links(origin_page_id)`.
{ kind: 'column', table: 'links', column: 'origin_page_id' },
// v0.19+ — forward-referenced by `CREATE INDEX idx_chunks_symbol_name
// ON content_chunks(symbol_name) WHERE symbol_name IS NOT NULL`.
{ kind: 'column', table: 'content_chunks', column: 'symbol_name' },
// v0.19+ — forward-referenced by `CREATE INDEX idx_chunks_language
// ON content_chunks(language) WHERE language IS NOT NULL`.
{ kind: 'column', table: 'content_chunks', column: 'language' },
// v0.20+ Cathedral II — forward-referenced by `CREATE INDEX
// idx_chunks_search_vector ON content_chunks USING GIN(search_vector)`.
{ kind: 'column', table: 'content_chunks', column: 'search_vector' },
// v0.20+ Cathedral II — forward-referenced by `CREATE INDEX
// idx_chunks_symbol_qualified ON content_chunks(symbol_name_qualified)`.
{ kind: 'column', table: 'content_chunks', column: 'symbol_name_qualified' },
// v0.20+ Cathedral II — populated by update_chunk_search_vector trigger;
// present in PGLITE_SCHEMA_SQL CREATE TABLE definition.
{ kind: 'column', table: 'content_chunks', column: 'parent_symbol_path' },
{ kind: 'column', table: 'content_chunks', column: 'doc_comment' },
// v0.26.5 — forward-referenced by `CREATE INDEX pages_deleted_at_purge_idx
// ON pages (deleted_at) WHERE deleted_at IS NOT NULL`.
{ kind: 'column', table: 'pages', column: 'deleted_at' },
// v0.27.1 — forward-referenced by `CREATE INDEX idx_chunks_embedding_image
// ON content_chunks USING hnsw (embedding_image vector_cosine_ops)
// WHERE embedding_image IS NOT NULL`.
{ kind: 'column', table: 'content_chunks', column: 'embedding_image' },
// v0.27.1 — added in the same migration as embedding_image. Sibling column;
// not directly forward-referenced by an index but the bootstrap adds it
// alongside embedding_image for the v39 contract.
{ kind: 'column', table: 'content_chunks', column: 'modality' },
// v0.26.3 (v33) — forward-referenced by `CREATE INDEX idx_mcp_log_agent_time
// ON mcp_request_log(agent_name, created_at DESC)`.
{ kind: 'column', table: 'mcp_request_log', column: 'agent_name' },
// v0.27 (v36) — forward-referenced by `CREATE INDEX
// idx_subagent_messages_provider ON subagent_messages (job_id, provider_id)`.
// Composite-index second column; the array-based test pattern misses these
// by default, which is why this fix wave's Step 3 replaces this with a
// SQL parser that extracts every column referenced by any DDL.
{ kind: 'column', table: 'subagent_messages', column: 'provider_id' },
// v0.29 (v40) — pages.emotional_weight populated by recompute_emotional_weight;
// bootstrapped alongside the v41 columns since they share the v0.29.1 wave.
{ kind: 'column', table: 'pages', column: 'emotional_weight' },
// v0.29.1 (v41) — forward-referenced by `CREATE INDEX pages_coalesce_date_idx
// ON pages ((COALESCE(effective_date, updated_at)))`. The expression-index
// claim from earlier plan iterations was wrong; PG's planner won't use a
// partial index for the negative side of a COALESCE — expression index is.
{ kind: 'column', table: 'pages', column: 'effective_date' },
// v0.29.1 (v41) — sibling columns added in the same migration as
// effective_date; bootstrap adds them all together.
{ kind: 'column', table: 'pages', column: 'effective_date_source' },
{ kind: 'column', table: 'pages', column: 'import_filename' },
{ kind: 'column', table: 'pages', column: 'salience_touched_at' },
// v0.31.2 (v50) — forward-referenced by `CREATE INDEX
// idx_ingest_log_source_type_created ON ingest_log (source_id, source_type,
// created_at DESC)`. Old brains have ingest_log without source_id; bootstrap
// adds the column before SCHEMA_SQL replay creates the index.
{ kind: 'column', table: 'ingest_log', column: 'source_id' },
// v0.18 (v18) — forward-referenced by `CREATE INDEX idx_files_source_id ON
// files(source_id)` and `CREATE INDEX idx_files_page_id ON files(page_id)`.
// Pre-v18 brains have files without these columns; bootstrap adds them
// before SCHEMA_SQL replay creates the indexes.
{ kind: 'column', table: 'files', column: 'source_id' },
{ kind: 'column', table: 'files', column: 'page_id' },
// v0.34.1 (v60+v61+v65) — forward-referenced by the FK
// `oauth_clients.source_id REFERENCES sources(id)` and the GIN index
// `idx_oauth_clients_federated_read ON oauth_clients USING GIN (federated_read)`.
// Pre-v60 brains have oauth_clients without these columns; bootstrap adds
// them before SCHEMA_SQL replay creates the FK + index.
{ kind: 'column', table: 'oauth_clients', column: 'source_id' },
{ kind: 'column', table: 'oauth_clients', column: 'federated_read' },
// v0.26.5 (v34) — promotes archive lifecycle from JSONB config to real
// columns on sources. CREATE TABLE IF NOT EXISTS is a no-op on existing
// sources tables, so the visibility filters in search/list_pages that
// reference these columns trip on pre-v34 brains. Bootstrap adds them
// before any visibility-filter SQL runs.
{ kind: 'column', table: 'sources', column: 'archived' },
{ kind: 'column', table: 'sources', column: 'archived_at' },
{ kind: 'column', table: 'sources', column: 'archive_expires_at' },
// v0.37.0 (v79) — forward-referenced by `CREATE INDEX
// pages_last_retrieved_at_idx ON pages (last_retrieved_at)`. Pre-v79 brains
// have pages without this column; bootstrap adds it before SCHEMA_SQL
// replay creates the index.
{ kind: 'column', table: 'pages', column: 'last_retrieved_at' },
// v0.38.0 (v81) — pages_provenance_columns adds four nullable columns
// (ingested_via, ingested_at, source_uri, source_kind) to track WHERE
// every page came from (capture-cli, webhook, put_page, dream, etc.).
// No SCHEMA_SQL index/FK references them today, but bootstrap probes
// are added defense-in-depth so future schema work that does reference
// them doesn't wedge pre-v81 brains. Renumbered v80→v81 during master
// merge with v0.37.2.0 takes_unresolvable_quality hotfix.
{ kind: 'column', table: 'pages', column: 'ingested_via' },
{ kind: 'column', table: 'pages', column: 'ingested_at' },
{ kind: 'column', table: 'pages', column: 'source_uri' },
{ kind: 'column', table: 'pages', column: 'source_kind' },
// v0.40.3.0 (v90, renumbered from v0.40.3.0 v81 on master merge) —
// contextual_retrieval_columns adds five additive columns wiring the
// three-tier wrapper ladder. Bootstrap probes added defense-in-depth
// for future schema work.
{ kind: 'column', table: 'pages', column: 'contextual_retrieval_mode' },
{ kind: 'column', table: 'pages', column: 'corpus_generation' },
{ kind: 'column', table: 'sources', column: 'contextual_retrieval_mode' },
{ kind: 'column', table: 'sources', column: 'trust_frontmatter_overrides' },
// v0.40.3.0 (v91) — pages.generation BIGINT bumped by the
// bump_page_generation_fn trigger. Forward-referenced by
// pages_generation_idx (CREATE INDEX ON pages (generation)) so bootstrap
// probes guard pre-v91 brains.
{ kind: 'column', table: 'pages', column: 'generation' },
// v0.41.31 (v108) — pages.embedding_signature TEXT for real stale
// semantics. No SCHEMA_SQL index references it; bootstrap probe is
// defense-in-depth (and satisfies the MIGRATIONS ADD COLUMN coverage gate).
{ kind: 'column', table: 'pages', column: 'embedding_signature' },
];
test('applyForwardReferenceBootstrap covers every forward reference declared in REQUIRED_BOOTSTRAP_COVERAGE', async () => {
const engine = new PGLiteEngine();
await engine.connect({});
try {
await engine.initSchema();
const db = (engine as any).db;
// Strip every required forward-reference target so the brain looks like
// it pre-dates the migrations that introduced these objects. Drop columns
// before the table-level constraints that depend on them.
await db.exec(`
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_source_slug_key;
ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug);
DROP INDEX IF EXISTS idx_pages_source_id;
ALTER TABLE pages DROP COLUMN IF EXISTS source_id;
DROP TABLE IF EXISTS sources CASCADE;
DROP INDEX IF EXISTS idx_links_source;
DROP INDEX IF EXISTS idx_links_origin;
ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_source_origin_unique;
ALTER TABLE links DROP COLUMN IF EXISTS link_source;
ALTER TABLE links DROP COLUMN IF EXISTS origin_page_id;
DROP INDEX IF EXISTS idx_chunks_symbol_name;
DROP INDEX IF EXISTS idx_chunks_language;
DROP INDEX IF EXISTS idx_chunks_search_vector;
DROP INDEX IF EXISTS idx_chunks_symbol_qualified;
DROP TRIGGER IF EXISTS chunk_search_vector_trigger ON content_chunks;
DROP FUNCTION IF EXISTS update_chunk_search_vector;
ALTER TABLE content_chunks DROP COLUMN IF EXISTS symbol_name;
ALTER TABLE content_chunks DROP COLUMN IF EXISTS language;
ALTER TABLE content_chunks DROP COLUMN IF EXISTS parent_symbol_path;
ALTER TABLE content_chunks DROP COLUMN IF EXISTS doc_comment;
ALTER TABLE content_chunks DROP COLUMN IF EXISTS symbol_name_qualified;
ALTER TABLE content_chunks DROP COLUMN IF EXISTS search_vector;
DROP INDEX IF EXISTS pages_deleted_at_purge_idx;
ALTER TABLE pages DROP COLUMN IF EXISTS deleted_at;
DROP INDEX IF EXISTS idx_chunks_embedding_image;
ALTER TABLE content_chunks DROP COLUMN IF EXISTS embedding_image;
ALTER TABLE content_chunks DROP COLUMN IF EXISTS modality;
DROP INDEX IF EXISTS idx_mcp_log_agent_time;
DROP INDEX IF EXISTS idx_mcp_log_time_agent;
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS agent_name;
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS params;
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS error_message;
DROP INDEX IF EXISTS idx_subagent_messages_provider;
ALTER TABLE subagent_messages DROP COLUMN IF EXISTS provider_id;
DROP INDEX IF EXISTS pages_coalesce_date_idx;
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date;
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date_source;
ALTER TABLE pages DROP COLUMN IF EXISTS import_filename;
ALTER TABLE pages DROP COLUMN IF EXISTS salience_touched_at;
ALTER TABLE pages DROP COLUMN IF EXISTS emotional_weight;
DROP INDEX IF EXISTS idx_ingest_log_source_type_created;
ALTER TABLE ingest_log DROP COLUMN IF EXISTS source_id;
DROP INDEX IF EXISTS idx_files_source_id;
DROP INDEX IF EXISTS idx_files_page_id;
ALTER TABLE files DROP COLUMN IF EXISTS source_id;
ALTER TABLE files DROP COLUMN IF EXISTS page_id;
DROP INDEX IF EXISTS idx_oauth_clients_federated_read;
ALTER TABLE oauth_clients DROP COLUMN IF EXISTS source_id;
ALTER TABLE oauth_clients DROP COLUMN IF EXISTS federated_read;
-- v0.40.3.0 v90 + v91 column strips so applyForwardReferenceBootstrap
-- has work to do. Only strip pages columns + the trigger; sources
-- columns were already nuked by the earlier DROP TABLE IF EXISTS
-- sources CASCADE, and the bootstrap needsPagesBootstrap branch
-- recreates sources from schema-embedded.ts (which now includes the
-- CR columns inline). Same convention as the sources.archived note.
DROP TRIGGER IF EXISTS bump_page_generation_trg ON pages;
DROP FUNCTION IF EXISTS bump_page_generation_fn;
DROP INDEX IF EXISTS pages_generation_idx;
ALTER TABLE pages DROP COLUMN IF EXISTS generation;
ALTER TABLE pages DROP COLUMN IF EXISTS contextual_retrieval_mode;
ALTER TABLE pages DROP COLUMN IF EXISTS corpus_generation;
`);
// Note: we don't strip sources.archived* here because they're inline in the
// sources CREATE TABLE definition (no separate ALTER TABLE), and the
// earlier `DROP TABLE IF EXISTS sources CASCADE` already nuked them.
// The bootstrap's needsPagesBootstrap branch recreates sources without the
// archive columns; the new needsSourcesArchive probe adds them.
// Run bootstrap in isolation (NOT initSchema). This is what we're testing.
await (engine as any).applyForwardReferenceBootstrap();
// Assert every required forward-reference target now satisfies the
// schema-blob's expectations.
for (const ref of REQUIRED_BOOTSTRAP_COVERAGE) {
if (ref.kind === 'table') {
const { rows } = await db.query(
`SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1`,
[ref.name],
);
expect(rows.length).toBeGreaterThan(0);
} else {
const { rows } = await db.query(
`SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`,
[ref.table, ref.column],
);
expect(rows.length).toBeGreaterThan(0);
}
}
} finally {
await engine.disconnect();
}
}, 30000);
test('after bootstrap, PGLITE_SCHEMA_SQL replays without crashing on missing forward references', async () => {
// End-to-end contract: bootstrap → SCHEMA_SQL must succeed even on a brain
// that lacks every forward-referenced target. This catches the case where
// REQUIRED_BOOTSTRAP_COVERAGE drifts behind PGLITE_SCHEMA_SQL — if the
// schema blob added a new index on a column the bootstrap doesn't create,
// the SCHEMA_SQL exec below would crash even though the per-target asserts
// above pass.
const engine = new PGLiteEngine();
await engine.connect({});
try {
await engine.initSchema();
const db = (engine as any).db;
await db.exec(`
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_source_slug_key;
ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug);
DROP INDEX IF EXISTS idx_pages_source_id;
ALTER TABLE pages DROP COLUMN IF EXISTS source_id;
DROP TABLE IF EXISTS sources CASCADE;
DROP INDEX IF EXISTS idx_links_source;
DROP INDEX IF EXISTS idx_links_origin;
ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_source_origin_unique;
ALTER TABLE links DROP COLUMN IF EXISTS link_source;
ALTER TABLE links DROP COLUMN IF EXISTS origin_page_id;
DROP INDEX IF EXISTS pages_deleted_at_purge_idx;
ALTER TABLE pages DROP COLUMN IF EXISTS deleted_at;
DROP INDEX IF EXISTS idx_chunks_embedding_image;
ALTER TABLE content_chunks DROP COLUMN IF EXISTS embedding_image;
ALTER TABLE content_chunks DROP COLUMN IF EXISTS modality;
DROP INDEX IF EXISTS pages_coalesce_date_idx;
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date;
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date_source;
ALTER TABLE pages DROP COLUMN IF EXISTS import_filename;
ALTER TABLE pages DROP COLUMN IF EXISTS salience_touched_at;
ALTER TABLE pages DROP COLUMN IF EXISTS emotional_weight;
`);
// Bootstrap, then schema replay. Either step crashing fails the test.
const { PGLITE_SCHEMA_SQL } = await import('../src/core/pglite-schema.ts');
await (engine as any).applyForwardReferenceBootstrap();
await db.exec(PGLITE_SCHEMA_SQL);
} finally {
await engine.disconnect();
}
}, 30000);
// ─────────────────────────────────────────────────────────────────
// v0.28.5 — A2 structural prevention: auto-derive coverage from SQL.
// ─────────────────────────────────────────────────────────────────
// The hand-maintained REQUIRED_BOOTSTRAP_COVERAGE array is the contract
// that's failed 11 times across 6 schema versions: every release that
// added a column-with-index in the schema blob without a corresponding
// bootstrap addition has triggered a wedge incident.
//
// Codex outside-voice review of v0.28.5's plan caught a critical hole in
// the array-based approach: composite indexes like
// `idx_subagent_messages_provider ON subagent_messages (job_id, provider_id)`
// have a SECOND-column forward reference (`provider_id`) that a first-col-
// only extractor would miss entirely. v0.27 wedged exactly this way.
//
// This parser extracts every column referenced by a CREATE INDEX in
// PGLITE_SCHEMA_SQL — including composite-index second/third columns —
// and asserts each one is either in the baseline CREATE TABLE OR added
// by `applyForwardReferenceBootstrap`. Self-updating: any future
// CREATE INDEX in the schema blob is structurally covered the moment
// it's added, with no human required to remember to update an array.
// ─────────────────────────────────────────────────────────────────
/**
* Parse `CREATE TABLE [IF NOT EXISTS] <name> (<body>)` blocks.
* Returns a map from table name → set of column names declared in the body.
*
* Body parser is naive but sufficient for `pglite-schema.ts`: splits on
* commas at depth 0 (respecting nested parens for things like `vector(N)`,
* `numeric(p, s)`, `CHECK (col IN ('a', 'b'))`), skips constraint lines
* (CONSTRAINT/PRIMARY/UNIQUE/CHECK/FOREIGN), and grabs the first identifier
* of each remaining row as the column name.
*/
function parseBaseTableColumns(sql: string): Map<string, Set<string>> {
const result = new Map<string, Set<string>>();
const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s*\(/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(sql)) !== null) {
const tableName = m[1].toLowerCase();
const bodyStart = m.index + m[0].length;
let depth = 1;
let i = bodyStart;
while (i < sql.length && depth > 0) {
const ch = sql[i];
if (ch === '(') depth++;
else if (ch === ')') depth--;
i++;
}
const body = sql.slice(bodyStart, i - 1);
const columns = new Set<string>();
// Split body on commas at depth 0.
let parenDepth = 0;
let start = 0;
const parts: string[] = [];
for (let j = 0; j < body.length; j++) {
const ch = body[j];
if (ch === '(') parenDepth++;
else if (ch === ')') parenDepth--;
else if (ch === ',' && parenDepth === 0) {
parts.push(body.slice(start, j));
start = j + 1;
}
}
parts.push(body.slice(start));
for (const partRaw of parts) {
// Strip SQL line comments (`-- ...` to end of line) and block
// comments (`/* ... */`) before identifying the column name.
// Without this, a column definition preceded by a comment inside
// the CREATE TABLE body is silently dropped (the comment is the
// "first identifier" and the parser bails out).
const stripped = partRaw
.replace(/--[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '');
const part = stripped.trim();
if (!part) continue;
// Skip constraint lines.
if (/^(CONSTRAINT|PRIMARY|UNIQUE|CHECK|FOREIGN|EXCLUDE)\b/i.test(part)) continue;
// First whitespace-separated token is the column name.
const colMatch = part.match(/^["`]?(\w+)["`]?/);
if (colMatch) columns.add(colMatch[1].toLowerCase());
}
result.set(tableName, columns);
}
// Also walk ALTER TABLE ... ADD COLUMN statements in the schema blob
// itself. Several columns (e.g. `pages.search_vector`) are added by an
// inline ALTER inside PGLITE_SCHEMA_SQL after the original CREATE TABLE.
// The schema-blob replay adds them in order, so they are NOT
// forward-references that bootstrap must provide — the schema blob
// itself self-heals on already-existing tables.
const alterRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(\w+)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/gi;
let am: RegExpExecArray | null;
while ((am = alterRe.exec(sql)) !== null) {
const tableName = am[1].toLowerCase();
const colName = am[2].toLowerCase();
if (!result.has(tableName)) result.set(tableName, new Set());
result.get(tableName)!.add(colName);
}
return result;
}
/**
* Parse `CREATE [UNIQUE] INDEX [IF NOT EXISTS] <name> ON <table> [USING method] (<cols>)`.
* Returns every (table, column) pair referenced — including composite-index
* second/third columns. Function-call wrappers like `lower(col)` are unwrapped
* to their inner identifier; literal-only expressions like `(slug, NULLS LAST)`
* keep the bare column.
*
* Out of scope: WHERE-clause columns in partial indexes (rare in our schema;
* those columns are always also referenced in the index column list itself).
* Trigger function bodies are out of scope (they reference NEW.col / OLD.col
* which the existing test file's strip-list handles separately).
*/
function parseIndexColumnReferences(sql: string): Array<{ table: string; column: string }> {
const result: Array<{ table: string; column: string }> = [];
// Match CREATE INDEX up through the column-list paren group.
const re = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?\w+\s+ON\s+(\w+)\s*(?:USING\s+\w+\s*)?\(/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(sql)) !== null) {
const table = m[1].toLowerCase();
const argsStart = m.index + m[0].length;
let depth = 1;
let i = argsStart;
while (i < sql.length && depth > 0) {
const ch = sql[i];
if (ch === '(') depth++;
else if (ch === ')') depth--;
i++;
}
const args = sql.slice(argsStart, i - 1);
// Split args on commas at depth 0.
let parenDepth = 0;
let start = 0;
const parts: string[] = [];
for (let j = 0; j < args.length; j++) {
const ch = args[j];
if (ch === '(') parenDepth++;
else if (ch === ')') parenDepth--;
else if (ch === ',' && parenDepth === 0) {
parts.push(args.slice(start, j));
start = j + 1;
}
}
parts.push(args.slice(start));
for (const partRaw of parts) {
// Strip ASC/DESC, NULLS FIRST/LAST modifiers.
const partClean = partRaw
.replace(/\s+(?:ASC|DESC)\s*$/i, '')
.replace(/\s+NULLS\s+(?:FIRST|LAST)\s*$/i, '')
.trim();
if (!partClean) continue;
// Two shapes to extract from:
// `col` — plain identifier
// `col vector_cosine_ops` — column followed by operator class (HNSW)
// `col COLLATE "C"` — column with collation
// `lower(col)` — function-wrapped
// For shapes 1-3, the column is the LEADING identifier. For shape 4,
// the column is the LAST identifier before a close paren.
let col: string | null = null;
if (partClean.includes('(')) {
// Function-wrapped: `lower(col)` → grab the last identifier inside.
const fnMatch = partClean.match(/(\w+)\s*\)\s*$/);
if (fnMatch) col = fnMatch[1];
} else {
// Plain or operator-class-suffixed: leading identifier wins.
const leadMatch = partClean.match(/^["`]?(\w+)["`]?/);
if (leadMatch) col = leadMatch[1];
}
if (col && !/^(true|false|null|asc|desc)$/i.test(col)) {
result.push({ table, column: col.toLowerCase() });
}
}
}
return result;
}
test('parseBaseTableColumns + parseIndexColumnReferences extract structural references', () => {
// Sanity checks for the parser helpers themselves. Runs in-process (no DB).
const fixture = `
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL,
embedding vector(1536),
CONSTRAINT pages_slug_key UNIQUE (slug)
);
CREATE INDEX IF NOT EXISTS idx_pages_slug ON pages (slug);
CREATE INDEX idx_pages_lower ON pages (lower(slug));
CREATE INDEX idx_pages_composite ON pages (slug, id DESC);
CREATE INDEX idx_pages_hnsw ON pages USING hnsw (embedding vector_cosine_ops);
`;
const baseCols = parseBaseTableColumns(fixture);
expect(baseCols.get('pages')).toBeDefined();
expect(baseCols.get('pages')!.has('id')).toBe(true);
expect(baseCols.get('pages')!.has('slug')).toBe(true);
expect(baseCols.get('pages')!.has('embedding')).toBe(true);
// Constraint lines must NOT leak as columns.
expect(baseCols.get('pages')!.has('constraint')).toBe(false);
const refs = parseIndexColumnReferences(fixture);
// Single-col index.
expect(refs).toContainEqual({ table: 'pages', column: 'slug' });
// Function-wrapped column.
expect(refs.some(r => r.table === 'pages' && r.column === 'slug')).toBe(true);
// Composite — BOTH columns must be captured (codex's case).
expect(refs).toContainEqual({ table: 'pages', column: 'id' });
// USING hnsw with operator class.
expect(refs).toContainEqual({ table: 'pages', column: 'embedding' });
});
test('parseIndexColumnReferences catches v0.27 composite second-column case', () => {
// The exact codex regression: `idx_subagent_messages_provider ON
// subagent_messages (job_id, provider_id)` has provider_id as the SECOND
// column. A first-col-only extractor would miss this — v0.27 wedged exactly
// because earlier patterns missed it.
const fixture = `
CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider
ON subagent_messages (job_id, provider_id);
`;
const refs = parseIndexColumnReferences(fixture);
expect(refs).toContainEqual({ table: 'subagent_messages', column: 'job_id' });
expect(refs).toContainEqual({ table: 'subagent_messages', column: 'provider_id' });
});
/**
* Parse `ALTER TABLE [IF EXISTS] [ONLY] <table> ADD COLUMN [IF NOT EXISTS] <col>`
* statements out of an arbitrary SQL string. Used to extract the (table, column)
* pairs that `applyForwardReferenceBootstrap` adds, so we can verify static
* coverage without running a DB.
*/
function parseAlterAddColumns(sql: string): Array<{ table: string; column: string }> {
const result: Array<{ table: string; column: string }> = [];
const re = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(\w+)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(sql)) !== null) {
result.push({ table: m[1].toLowerCase(), column: m[2].toLowerCase() });
}
return result;
}
test('every CREATE INDEX column in PGLITE_SCHEMA_SQL is covered by CREATE TABLE or bootstrap (A2 static check)', async () => {
// The structural test that closes the 11-incident wedge class. Static
// contract: every column referenced by a CREATE INDEX in PGLITE_SCHEMA_SQL
// must be either (a) declared in the current CREATE TABLE body, or
// (b) added by `applyForwardReferenceBootstrap` in pglite-engine.ts.
//
// Codex outside-voice review caught the 11th wedge: composite-index second
// columns (`provider_id` in `(job_id, provider_id)`) are forward references
// that earlier extractors missed. This parser walks the full column list
// of every index — composite or not — and asserts each one is covered.
//
// Self-updating: when a future migration adds a CREATE INDEX in
// PGLITE_SCHEMA_SQL on a column that bootstrap doesn't yet provide, this
// test fails loud at PR time. No human required to update an array.
const { readFileSync } = await import('fs');
const { resolve: resolvePath } = await import('path');
const { PGLITE_SCHEMA_SQL } = await import('../src/core/pglite-schema.ts');
const enginePath = resolvePath(process.cwd(), 'src/core/pglite-engine.ts');
const engineSrc = readFileSync(enginePath, 'utf-8');
const tableColumns = parseBaseTableColumns(PGLITE_SCHEMA_SQL);
const indexRefs = parseIndexColumnReferences(PGLITE_SCHEMA_SQL);
const bootstrapAdds = parseAlterAddColumns(engineSrc);
// Build the "covered" set: for each (table, column) pair, true iff it's in
// the table's CREATE TABLE columns OR added by an ALTER TABLE in the
// bootstrap function.
const covered = (table: string, column: string): boolean => {
const cols = tableColumns.get(table);
if (cols && cols.has(column)) return true;
return bootstrapAdds.some(a => a.table === table && a.column === column);
};
// Sanity checks: parser caught the codex case AND bootstrap provides it.
expect(indexRefs).toContainEqual({ table: 'subagent_messages', column: 'provider_id' });
expect(bootstrapAdds).toContainEqual({ table: 'subagent_messages', column: 'provider_id' });
expect(covered('subagent_messages', 'provider_id')).toBe(true);
// The actual contract: every index column reference must be covered.
const uncovered: Array<{ table: string; column: string }> = [];
for (const ref of indexRefs) {
if (!covered(ref.table, ref.column)) {
uncovered.push(ref);
}
}
if (uncovered.length > 0) {
const list = uncovered.map(u => ` ${u.table}.${u.column}`).join('\n');
throw new Error(
`PGLITE_SCHEMA_SQL has ${uncovered.length} CREATE INDEX column reference(s) ` +
`that are neither in the table's CREATE TABLE body nor added by ` +
`applyForwardReferenceBootstrap:\n${list}\n\n` +
`Fix: extend applyForwardReferenceBootstrap in src/core/pglite-engine.ts ` +
`(and the matching Postgres engine) with the missing ALTER TABLE ADD COLUMN.`,
);
}
}, 30000);
// ─────────────────────────────────────────────────────────────────
// v0.36+ — MIGRATIONS introspection: catch the column-only forward-ref class.
// ─────────────────────────────────────────────────────────────────
// The CREATE INDEX parser above kills the column-with-index forward-ref class.
// v0.26.5 (v34) introduced a column-ONLY class: `sources.archived` +
// `sources.archived_at` + `sources.archive_expires_at` aren't indexed but
// `CREATE TABLE IF NOT EXISTS sources` is a no-op on pre-v34 brains. The
// schema-blob replay never adds the archive columns, so downstream visibility
// filters trip immediately.
//
// This test walks every `ALTER TABLE ... ADD COLUMN` in the MIGRATIONS array
// (our own structured code, not arbitrary Postgres DDL) and asserts every
// (table, column) pair is also added by `applyForwardReferenceBootstrap`.
// Future contributors who add a migration with ALTER TABLE ADD COLUMN AND
// forget to extend the bootstrap will see this test fail at PR time with a
// paste-ready `Add probe for <table>.<column>` message.
//
// Why regex-on-our-own-SQL is safe vs regex-on-prod-Postgres-DDL: every
// migration's SQL string is authored by us with consistent shape. The
// ALTER TABLE ADD COLUMN pattern is stable across all 60+ existing
// migrations. We control the input, not Postgres.
//
// Exemption mechanism: some migrations add columns that are intentionally
// not in the schema blob (one-off transition columns later dropped, etc.).
// Those go in the COLUMN_EXEMPTIONS set below with a brief rationale.
// ─────────────────────────────────────────────────────────────────
const COLUMN_EXEMPTIONS = new Set<string>([
// T7 — search_telemetry rank-1 drift columns (migration v111). search_telemetry
// is created entirely by migration v57 (not in the schema blob), so the v57+v111
// chain handles fresh + upgrade; no CREATE INDEX references these columns, so
// there's no forward reference for the bootstrap to cover.
'search_telemetry.sum_rank1_score',
'search_telemetry.count_rank1',
'search_telemetry.rank1_lt_solid',
'search_telemetry.rank1_solid',
'search_telemetry.rank1_high',
// Schema-blob-not-yet-refreshed: each of these columns is added by a
// migration but NOT (yet) referenced by `PGLITE_SCHEMA_SQL` (neither in a
// CREATE TABLE body nor in any CREATE INDEX). Bootstrap doesn't need to
// add them because there's no forward reference for the schema blob's
// replay to trip on. The migration handles every upgrade path correctly:
// - fresh install: schema blob replays, then migration adds the column.
// - pre-existing brain missing the column: migration adds it via ALTER.
// - pre-existing brain already on this column: ALTER ... IF NOT EXISTS no-ops.
// If a future migration adds a CREATE INDEX that references one of these
// columns, the existing v0.28.5 CREATE-INDEX parser will catch it and
// force a bootstrap probe (and the exemption should be removed).
//
// Refreshing PGLITE_SCHEMA_SQL is a separate concern handled by
// `bun run build:schema` from src/schema.sql; not gated by this test.
'minion_jobs.quiet_hours',
'minion_jobs.stagger_key',
'sources.chunker_version',
'access_tokens.permissions',
'takes.resolved_quality',
'pages.emotional_weight_recomputed_at',
'facts.notability',
'facts.row_num',
'facts.source_markdown_slug',
'pages.chunker_version',
'pages.source_path',
'content_chunks.edges_backfilled_at',
'query_cache.knobs_hash',
// v0.40.3.0 (migration v90, renumbered from v0.40.3.0 v81 on master merge)
// — query_cache is migration-only (added in v55), not in PGLITE_SCHEMA_SQL.
// The v90 ALTER TABLE query_cache ADD COLUMN page_generations runs after
// v55 in the migration sequence, so fresh installs get it correctly. No
// forward-reference exists for PGLITE_SCHEMA_SQL to trip on because
// query_cache isn't in the schema blob to begin with. Same exemption
// rationale as knobs_hash.
'query_cache.page_generations',
// v0.40.3.0 (migration v91) — same exemption rationale: query_cache is
// migration-only; max_generation_at_store is added by v91 ALTER and never
// forward-referenced by PGLITE_SCHEMA_SQL.
'query_cache.max_generation_at_store',
// v0.35.6 (migration v67) — typed-claim columns + facts_typed_claim_idx
// partial index are co-defined in the same migration, so the schema-blob
// forward-reference path isn't tripped. Bootstrap is only required when an
// index in PGLITE_SCHEMA_SQL references a column added by a later migration.
'facts.claim_metric',
'facts.claim_value',
'facts.claim_unit',
'facts.claim_period',
// v0.40.2.0 (migration v89) — event_type column. Same precedent as
// facts.claim_metric et al: no forward-reference index in
// PGLITE_SCHEMA_SQL, no downstream filter breaks on old brains
// (existing callers — founder-scorecard, eval-trajectory,
// gbrain think trajectory injection — all defensively skip
// NULL-metric rows in per-metric math, so event_type=NULL on old
// brains is invisible to them). Migration is column-only, no FK,
// no index — bootstrap probe would be pure overhead.
'facts.event_type',
// v0.39.1.0 (migration v88) — schema-pack provenance per-source captured as
// inline canonical closure snapshot on every eval_candidates row. NULL by
// default; no index in PGLITE_SCHEMA_SQL references it. Migration handles
// both fresh installs and pre-existing brains via ADD COLUMN IF NOT EXISTS.
// Schema-pack codegen (scripts/generate-gbrain-base.ts) consumes the value
// only via the eval-replay CLI, not via SQL filters that would force a
// bootstrap probe.
'eval_candidates.schema_pack_per_source',
// v0.41 (migration v94) — minions cathedral budget columns. Same precedent
// as facts.claim_metric and friends: column-only additions on `minion_jobs`,
// no forward-reference index in PGLITE_SCHEMA_SQL (the partial indexes
// `minion_jobs_budget_owner_idx` + `minion_jobs_budget_root_owner_idx`
// live INSIDE the same v93 migration, not in the schema blob), and
// downstream callers explicitly handle NULL via the Eng D10 NULL-bypass
// branch in budget-tracker (jobs without `budget_owner_job_id` skip
// reservation entirely). Old brains pre-v93 silently get NULL on these
// columns; the budget enforcement path treats NULL as "no budget."
'minion_jobs.budget_remaining_cents',
'minion_jobs.budget_owner_job_id',
'minion_jobs.budget_root_owner_id',
]);
test('every ALTER TABLE ADD COLUMN in MIGRATIONS is covered by applyForwardReferenceBootstrap (column-only class)', async () => {
const { extractAddedColumnsFromMigrations } = await import('./helpers/extract-added-columns.ts');
const { readFileSync } = await import('fs');
const { resolve: resolvePath } = await import('path');
const { PGLITE_SCHEMA_SQL } = await import('../src/core/pglite-schema.ts');
const enginePath = resolvePath(process.cwd(), 'src/core/pglite-engine.ts');
const engineSrc = readFileSync(enginePath, 'utf-8');
const bootstrapAdds = parseAlterAddColumns(engineSrc);
// Bootstrap's own CREATE TABLE statements (e.g. needsPagesBootstrap inlines
// `archived BOOLEAN ...` inside the CREATE TABLE sources block). Those
// count as covered without a separate ALTER TABLE ADD COLUMN.
const bootstrapCreateTableCols = parseBaseTableColumns(engineSrc);
// PGLITE_SCHEMA_SQL's CREATE TABLE definitions. The schema blob defines
// every modern table inline; columns added by migrations are typically
// ALSO updated in the schema blob so fresh installs get them natively.
// The bootstrap is only needed when: (a) the table existed before the
// migration ran (so CREATE TABLE IF NOT EXISTS is a no-op on old brains)
// AND (b) the column has a forward-reference index OR a downstream filter
// that breaks on old brains. Schema-blob coverage handles the fresh case.
const schemaCreateTableCols = parseBaseTableColumns(PGLITE_SCHEMA_SQL);
const migrationAdds = extractAddedColumnsFromMigrations();
const covered = (table: string, column: string): boolean => {
if (COLUMN_EXEMPTIONS.has(`${table}.${column}`)) return true;
if (bootstrapAdds.some(a => a.table === table && a.column === column)) return true;
const bootstrapCols = bootstrapCreateTableCols.get(table);
if (bootstrapCols && bootstrapCols.has(column)) return true;
const schemaCols = schemaCreateTableCols.get(table);
if (schemaCols && schemaCols.has(column)) return true;
return false;
};
const uncovered: typeof migrationAdds = [];
for (const ref of migrationAdds) {
if (!covered(ref.table, ref.column)) {
uncovered.push(ref);
}
}
if (uncovered.length > 0) {
const list = uncovered
.map(u => ` ${u.table}.${u.column}`)
.join('\n');
throw new Error(
`MIGRATIONS file (src/core/migrate.ts) adds ${uncovered.length} (table, column) pair(s) that ` +
`applyForwardReferenceBootstrap does NOT cover:\n${list}\n\n` +
`Fix one of:\n` +
` 1. Add a probe + ALTER TABLE ADD COLUMN in applyForwardReferenceBootstrap ` +
`(src/core/pglite-engine.ts AND src/core/postgres-engine.ts), OR\n` +
` 2. If the column is intentionally not in the schema blob ` +
`(transitional / handler-only / later-dropped), add the (table, column) ` +
`to COLUMN_EXEMPTIONS in test/schema-bootstrap-coverage.test.ts with a ` +
`brief rationale comment.`,
);
}
});
test('extractAddedColumnsFromMigrations sanity-checks against known migration column additions', async () => {
// Lightweight sanity test that the helper extracts the columns we expect
// for a few well-known v34 / v60 / v61 migrations. Catches regex
// regressions in the helper itself.
const { extractAddedColumnsFromMigrations } = await import('./helpers/extract-added-columns.ts');
const refs = extractAddedColumnsFromMigrations();
const has = (table: string, column: string) =>
refs.some(r => r.table === table && r.column === column);
// v34 sources.archived* (the codex C1 case)
expect(has('sources', 'archived')).toBe(true);
expect(has('sources', 'archived_at')).toBe(true);
expect(has('sources', 'archive_expires_at')).toBe(true);
// v60+v61 oauth_clients.*
expect(has('oauth_clients', 'source_id')).toBe(true);
expect(has('oauth_clients', 'federated_read')).toBe(true);
// v18 files.*
expect(has('files', 'source_id')).toBe(true);
expect(has('files', 'page_id')).toBe(true);
});
test('extractAlterAddColumnsFromSql handles representative migration SQL shapes', async () => {
const { __internal } = await import('./helpers/extract-added-columns.ts');
const fn = __internal.extractAlterAddColumnsFromSql;
// Standard shape (with IF NOT EXISTS)
expect(fn('ALTER TABLE sources ADD COLUMN IF NOT EXISTS archived BOOLEAN')).toEqual([
{ table: 'sources', column: 'archived' },
]);
// No IF NOT EXISTS (older migrations)
expect(fn('ALTER TABLE pages ADD COLUMN deleted_at TIMESTAMPTZ;')).toEqual([
{ table: 'pages', column: 'deleted_at' },
]);
// Multi-statement, mixed
expect(fn(`
CREATE INDEX foo ON bar(x);
ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS source_id TEXT REFERENCES sources(id);
ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS federated_read TEXT[] NOT NULL DEFAULT '{}';
UPDATE oauth_clients SET source_id = 'default';
`)).toEqual([
{ table: 'oauth_clients', column: 'source_id' },
{ table: 'oauth_clients', column: 'federated_read' },
]);
// Quoted identifiers
expect(fn('ALTER TABLE "pages" ADD COLUMN "effective_date" TIMESTAMPTZ')).toEqual([
{ table: 'pages', column: 'effective_date' },
]);
// ALTER TABLE IF EXISTS / ONLY variants
expect(fn('ALTER TABLE IF EXISTS ONLY content_chunks ADD COLUMN language TEXT')).toEqual([
{ table: 'content_chunks', column: 'language' },
]);
});
test('planted-bug: simulated unprovided column produces a clear failure message', async () => {
// Negative case — regression guard. If the contract test silently passes
// on uncovered columns, the gate is fake. This test plants a fake column
// in a fake SQL string and verifies the helper extracts it (proving the
// gate would catch it in the real contract test).
const { __internal } = await import('./helpers/extract-added-columns.ts');
const fn = __internal.extractAlterAddColumnsFromSql;
const planted = fn('ALTER TABLE pages ADD COLUMN IF NOT EXISTS planted_test_col TEXT');
expect(planted).toEqual([{ table: 'pages', column: 'planted_test_col' }]);
});