Files
gbrain/src/schema.sql
T
3a0e1116e7 v0.36.1.0 Hindsight calibration wave: brain learns how you tend to be wrong (#1139)
* schema: v0.36.0.0 Hindsight calibration tables (migrations v67-v71)

Foundation commit for the Hindsight-inspired calibration wave. Adds four
new tables + one perf index, all source-scoped from day 1 per v0.34.1
discipline:

- calibration_profiles (v67): per-holder LLM-narrative aggregation of
  TakesScorecard data. published BOOL gates E8 cross-brain mount sharing
  (default false). grade_completion REAL surfaces partial-grade state to
  the dashboard. active_bias_tags TEXT[] with GIN index feeds E3 (calibration-
  aware contradictions) and E7 (real-time nudge matching).

- take_proposals (v68): propose_takes phase queue. Idempotency cache via
  (source_id, page_slug, content_hash, prompt_version) unique index mirrors
  the v0.23 dream_verdicts pattern. proposal_run_id supports --rollback by
  run. dedup_against_fence_rows JSONB audit column records what canonical
  takes the LLM was told to dedupe against at proposal time.

- take_grade_cache (v69): grade_takes verdict cache. Composite PK on
  (take_id, prompt_version, judge_model_id, evidence_signature) — prompt
  edits OR evidence changes cleanly invalidate prior verdicts. applied=false
  default + auto-resolve-off-by-default (D17) means every fresh install
  needs operator opt-in before grade verdicts mutate the takes table.

- take_nudge_log (v70): E7 nudge cooldown state. Polymorphic FK — a nudge
  fires on either a canonical take OR a pending proposal (CDX-5 fix). CHECK
  constraint enforces exactly-one-set. channel column lets future routing
  (webhook, admin SPA toast) reuse the same cooldown semantics.

- takes_resolved_at_idx (v71): partial index for the Brier-trend
  aggregation queries. Engine-aware handler — Postgres uses CONCURRENTLY
  to avoid the ShareLock; PGLite uses plain CREATE.

Every table carries wave_version TEXT NOT NULL DEFAULT 'v0.36.0.0' so the
v0.36.0.0 calibration --undo-wave command (lands later in the wave) can
reverse just this wave's writes.

Plan: ~/.claude/plans/system-instruction-you-are-working-rippling-knuth.md
covers the design rationale (D17/D18/D21 + CDX findings).

Schema parity:
- src/schema.sql for fresh Postgres installs
- src/core/pglite-schema.ts for fresh PGLite installs
- src/core/schema-embedded.ts auto-regenerated from schema.sql
- src/core/migrate.ts for upgrade-in-place from older brains

VERSION bumped to 0.36.0.0 for the wave. CHANGELOG entry lands at /ship.

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

* core: BaseCyclePhase abstract class enforces source-scope + budget contracts

D21 from the eng review. Three new v0.36.0.0 cycle phases (propose_takes,
grade_takes, calibration_profile) share enough structure that the
duplication-vs-abstraction trade tips toward a shared base. Without this
scaffold, source-isolation discipline would drift exactly the way it
drifted in v0.34.1 — except this time across three new surfaces at once.

What this enforces:

1. Phase signature is uniform: run(ctx, opts) → PhaseResult.

2. ctx.sourceId / ctx.auth.allowedSources MUST be threaded through every
   engine call. The base class surfaces a scope() helper that wraps
   sourceScopeOpts(ctx) and is the only sanctioned way to read source-
   scoped data. Forgetting to thread source scope becomes a TypeScript
   compile error, not a runtime leak. Closes the v0.34.1 leak class
   structurally for every new phase.

3. Budget meter wraps run() automatically. Subclass declares budgetUsdKey
   + budgetUsdDefault; base reads the resolved cap from config and creates
   the BudgetMeter. Subclass calls this.checkBudget() before each LLM
   submit; budget-exhausted phase still returns status='ok' (clean abort)
   so the cycle report shows partial completion, not failure.

4. Error envelope is uniform. Thrown errors get caught and converted to
   status='fail' with a phase-specific error.code via the subclass's
   mapErrorCode() hook.

5. Progress reporter integration. Base accepts the reporter via opts;
   subclasses call this.tick() instead of touching the reporter directly,
   so the phase name in the progress stream is always correct.

Tests: 13 cases in test/core/base-phase.test.ts cover source-scope
threading (5 cases including the empty-allowedSources-MUST-NOT-widen-scope
regression), PhaseResult shape including the error envelope path (3
cases), dry-run propagation (2 cases), and budget meter construction
(3 cases including config-key override).

Synthesize.ts / patterns.ts (existing pre-v0.36 phases) deliberately do
NOT retrofit to this base in v0.36.0.0 — too much churn for a refactor
that doesn't pay off until v0.37+. Future phases use this by default.

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

* cycle: propose_takes phase + take_proposals queue write path (T3)

LLM-based take extraction from markdown prose. Walks pages updated since
last cycle, sends each page's body to a tuned extractor, writes the
extracted gradeable claims to the take_proposals queue. User accepts /
rejects via `gbrain takes propose --review` (lands in Lane C).

Cycle wiring:
  lint → backlinks → sync → synthesize → extract → extract_facts →
    resolve_symbol_edges → patterns → recompute_emotional_weight →
    consolidate → propose_takes (NEW) → grade_takes (NEW; T4) →
    calibration_profile (NEW; T6) → embed → orphans → purge

CyclePhase enum extended with 3 new entries; ALL_PHASES + NEEDS_LOCK_PHASES
updated. All three new phases acquire the cycle lock (writes to
take_proposals / take_grade_cache / calibration_profiles).

Idempotency contract:
  The (source_id, page_slug, content_hash, prompt_version) composite unique
  index on take_proposals means an unchanged page never re-spends LLM
  tokens. Bumping PROPOSE_TAKES_PROMPT_VERSION cleanly invalidates the
  cache so a tuned prompt re-runs proposals on every page. Mirrors the
  v0.23 dream_verdicts pattern.

F2 fence dedup:
  The phase reads the page's existing `<!-- gbrain:takes:begin -->` fence
  (when present) and passes the canonical take rows to the extractor as
  "things you have already captured." Prevents duplicate proposals when
  prose is appended to a page that already has takes. Records the fence
  rows the LLM was told to dedupe against on the take_proposals row for
  audit (dedup_against_fence_rows JSONB).

Auto-resolve posture:
  propose_takes only WRITES proposals to the queue. Nothing in this phase
  mutates the canonical takes table. Operator opt-in via the queue review
  CLI (Lane C) is the only path from queue to canonical fence (D17).

Prompt tuning status (v0.36.0.0 ship state):
  The default extractor prompt is annotated `v0.36.0.0-stub`. The real
  tuned prompt arrives via T19 synthetic corpus build (50 anonymized
  pages, 3-model parallel extraction, user reviews disagreement set,
  F1 ≥ 0.85 on training corpus + F1 ≥ 0.8 on ground-truth holdout).
  Until T19 lands, propose_takes runs but produces best-effort candidates
  the user reviews manually.

Architecture:
  ProposeTakesPhase extends BaseCyclePhase (T2). Inherits source-scope
  threading via scope(), budget metering via this.checkBudget(), error
  envelope wrapping. budgetUsdKey: cycle.propose_takes.budget_usd
  (default $5/cycle). Budget exhaustion mid-page returns status='warn'
  with details.budget_exhausted=true — clean partial-completion semantics.

  Test seam: opts.extractor injection so the phase can run hermetically
  without touching the gateway. defaultExtractor (production path) calls
  gateway.chat with the EXTRACT_TAKES_PROMPT and parses the JSON array
  output via parseExtractorOutput.

  parseExtractorOutput defends against common LLM output sins: markdown
  code fence wrapping, leading prose, single-object instead of array,
  unknown kind values, weight out of [0,1], rows missing claim_text or
  exceeding 500 chars.

Tests: 25 cases in test/propose-takes.test.ts cover the 4 pure helpers
(parseExtractorOutput, contentHash, hasCompleteFence,
extractExistingTakesForDedup) + 7 phase integration scenarios (happy path,
cache hit, fence dedup, extractor failure, empty pages, skipPagesWithFence,
proposal_run_id stability).

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

* cycle: grade_takes phase + take_grade_cache verdict pipeline (T4)

Walks unresolved takes that are old enough to have outcome data, retrieves
evidence from the brain, asks a judge model to verdict each one. Writes
verdicts to take_grade_cache. Optionally — only when operator has flipped
the opt-in config flag — auto-applies high-confidence verdicts to the
canonical takes table via engine.resolveTake.

Auto-resolve posture (D17 — DISABLED by default):
  On a fresh install, grade_takes runs and writes verdicts to the cache,
  but applied=false on every row. Operator reviews the queue, then flips
  `cycle.grade_takes.auto_resolve.enabled: true` once trust is earned.
  Mirrors the propose_takes review-queue posture: queue exists, mutation
  requires explicit opt-in.

Conservative threshold (D12):
  When auto_resolve.enabled is true, a verdict auto-applies only when
  confidence >= 0.95 (single-judge path). T5 ensemble path lands next,
  tightening this further with 3/3 unanimous requirement.

  'unresolvable' verdict NEVER auto-applies even at confidence=1.0 —
  there's no canonical column for "we tried and there's no evidence yet."

Evidence retrieval status (v0.36.0.0 ship state):
  The default evidence retriever returns an "evidence-retrieval not yet
  wired" placeholder. Most verdicts produced by the stub-judge against
  the stub-evidence will be 'unresolvable'. Real retrieval (hybrid search
  over pages newer than the take's since_date, optionally augmented by a
  gateway web-search recipe in v0.37+) lands as a follow-up. Documented
  limitation per CDX-8 + D17 — the phase ships now so the wiring is real
  and the cache table accumulates verdicts even if early ones are
  conservative.

Cache key:
  Composite primary key on take_grade_cache is
  (take_id, prompt_version, judge_model_id, evidence_signature). Prompt
  edits OR evidence changes OR judge swap cleanly invalidate prior
  verdicts. Mirrors the v0.32.6 eval_contradictions_cache pattern.

  evidence_signature = SHA-256 of (judge_model_id + '|' + evidence_text)
  so identical evidence under a different judge does NOT collide.

Architecture:
  GradeTakesPhase extends BaseCyclePhase. Inherits source-scope threading,
  budget metering (cycle.grade_takes.budget_usd, default $3/cycle), error
  envelope. Test seam: opts.judge + opts.evidenceRetriever injection so
  the phase runs hermetically.

  parseJudgeOutput defends against fence-wrapping, leading prose,
  out-of-range confidence (clamps to [0,1]), invalid verdict labels,
  oversized reasoning (truncated at 400 chars). Returns null on
  unrecoverable parse — caller treats null as "judge_output_parse_failed
  / unresolvable at confidence 0.0" so the row still lands in cache with
  the parse failure surfaced via warnings.

  takeIsOldEnough gates on since_date (default 6 months). Tolerates
  YYYY-MM-DD and YYYY-MM formats. Returns false on null/unparseable
  since_date so takes without dates never get graded (we'd be
  hallucinating temporal context).

Tests: 23 cases covering parseJudgeOutput (7 cases), evidenceSignature
(3), takeIsOldEnough (5), and 8 phase integration scenarios — happy path,
D17 auto-resolve-off default, D12 above-threshold auto-apply, below-
threshold cache-only, unresolvable-NEVER-applies, cache hit, too-recent
gate, judge-throw warning.

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

* cycle: grade_takes ensemble tiebreaker for borderline verdicts (T5 / E2)

Multi-judge ensemble tiebreaker, additive on top of T4's single-judge
foundation. Reuses gateway.chat as the per-model judge interface; runs
three judges in parallel via Promise.allSettled. Pure aggregation logic
in aggregateEnsemble() — no SQL, no LLM, hermetically testable.

When ensemble fires (T5 trigger band):
  Only when ALL of:
    - opts.useEnsemble === true (default false)
    - opts.ensembleJudges array is non-empty
    - single-model confidence in [0.6, 0.95) (configurable via
      opts.ensembleTriggerBand)
    - single-model verdict !== 'unresolvable'

  Above 0.95 the single judge is already sufficient (T4 path). Below 0.6
  the verdict is clearly review-only — ensemble wouldn't change the
  posture. 'unresolvable' from single-judge means no evidence yet; calling
  three more judges on the same evidence won't manufacture some.

Conservative auto-apply (D12):
  Ensemble verdict auto-applies via engine.resolveTake only when ALL of:
    - autoResolve === true (operator opt-in per D17)
    - ensemble.agreement === 3 (3/3 unanimous)
    - ensemble.minConfidence >= ensembleThreshold (default 0.85)
    - winning verdict !== 'unresolvable'

  Schema-level monotonic-tightening guard for ensembleThreshold lives in
  the takes resolution layer.

Cache identity:
  When ensemble fires, the cache row's judge_model_id becomes
  'ensemble:<modelA>+<modelB>+<modelC>' — a future re-run with different
  ensemble membership doesn't collide with prior verdicts. evidence_signature
  is recomputed because it includes the judge_model_id.

aggregateEnsemble (pure):
  - 3/3 unanimous → agreement=3, minConfidence=min across the three
  - 2/3 majority → agreement=2, minConfidence across the agreeing two
  - 1/1/1 disagreement → tie-break: prefer non-'unresolvable', then
    alphabetical for determinism
  - 'unresolvable' from one model NEVER tips a 2-vote majority toward
    'unresolvable' — by-label tally only counts a model toward its own
    label
  - All three judges failing (allSettled rejected) → verdict='unresolvable'
    with agreement=0; auto-apply path blocked
  - Single judge survives + two fail → agreement=1; the lone verdict wins
    but auto-apply gated by the 3/3 requirement

Tests: 16 cases.
  aggregateEnsemble (6): 3/3, 2/3, 1/1/1, unresolvable-tipping-resistance,
  all-failed, partial-failed-but-survives.
  Phase trigger conditions (5): useEnsemble=false default, useEnsemble=true
  in borderline band, single >= 0.95 skip, single < 0.6 skip, single =
  'unresolvable' skip.
  Phase auto-apply rules (5): 3/3+threshold+autoResolve, 2/3 majority no
  apply, 3/3 below threshold no apply, one ensemble judge throws still
  aggregates from allSettled, empty ensembleJudges falls through to
  single.

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

* cycle: calibration_profile phase + shared voice gate across surfaces (T6)

The calibration narrative layer. Reads TakesScorecard, asks an LLM to
write 2-4 conversational pattern statements ("right on tactics, late on
macro by 18 months"), passes them through the voice gate, derives active
bias tags, writes the row to calibration_profiles. This is the read-side
that E1 (think anti-bias rewrite), E3 (contradictions join), E6
(dashboard), and E7 (real-time nudges) all consume.

Voice gate (D24 — single function, multiple surfaces):
  ALL five calibration UX surfaces import the same gateVoice() function
  from src/core/calibration/voice-gate.ts. Mode parameter
  ('pattern_statement' | 'nudge' | 'forecast_blurb' | 'dashboard_caption'
  | 'morning_pulse') drives surface-specific tuning via the rubric the
  gate ships to its Haiku judge. NO forked implementations — voice
  rubric drift would defeat the gate.

  Each mode's rubric explicitly forbids preachy / clinical / corporate
  voice; a structural test pins this. Anchors the cross-cutting voice
  rule from /plan-ceo-review D2-D8.

Fallback policy (D11):
  Up to 2 generation attempts (configurable). On both rejects → fall back
  to a hand-written template from src/core/calibration/templates.ts.
  Templates are intentionally short and a little "robotic" — they're the
  safety net, not the destination. voice_gate_passed=false +
  voice_gate_attempts get persisted on the calibration_profiles row so
  the operator can review the failing examples and tune the rubric over
  time. Suppressing the surface silently is NEVER an option — that's how
  voice quality silently degrades.

  parseJudgeOutput defaults to 'academic' on parse failure (NEVER passes
  pass-through) so a Haiku output garble falls through to the template
  rather than letting unverified text reach the user.

calibration_profile phase:
  Extends BaseCyclePhase. Cold-brain skip: <5 resolved takes → no row
  written, no LLM call. Otherwise: scorecard via engine.getScorecard()
  → patterns via voice-gated generator → bias tags via separate
  generator (best-effort; failure logs warning, phase continues).

  The DB INSERT lands in the v67 calibration_profiles row with
  source_id, holder, the patterns, voice gate audit fields, active bias
  tags, and grade_completion (F1 fix — partial-grade state surfaces to
  the dashboard "60% graded" badge).

  Budget gate at $0.50/cycle default (mostly Haiku). Below-budget
  before-LLM-call check returns status='warn' without writing the row.

  Per-domain scorecards are a placeholder for v0.36.0.0 ship state —
  the F12 batchGetTakesScorecards() engine method that powers per-domain
  rendering lands in Lane C alongside the CLI/MCP surface.

Architecture:
  parsePatternStatementsOutput is tolerant of LLM emitting numbered
  lists / bulleted lines despite the prompt asking for plain lines.
  Caps at 4 patterns + drops excessively long lines (>200 chars).

  parseBiasTagsOutput lowercases input + drops non-kebab-case tokens
  (defends against the LLM emitting "Over-Confident Geography" with
  spaces or capitals). Caps at 4 tags.

Tests: 43 cases across two new test files.
  voice-gate.test.ts (24): parseJudgeOutput (7), gateVoice happy path
  (3), fallback path (5), mode parity (2), templates (7).
  calibration-profile.test.ts (19): parsers (10), pickFallbackSlots
  (3), phase integration (6 — cold-brain skip, happy path, voice gate
  fallback, grade_completion plumbed through, bias-tags failure
  non-fatal, source_id scope reaches INSERT).

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

* cli: gbrain calibration + get_calibration_profile MCP op (T7)

Public-facing read surface for the v0.36.0.0 calibration wave. CLI prints
the active calibration profile; MCP op exposes the same data path for
agents. Mirror of the v0.29 salience/anomalies shape (pure data fn + JSON
formatter + human formatter + thin CLI dispatch).

CLI: `gbrain calibration`
  Flags:
    --holder <id>         specific holder (default 'garry')
    --json                machine output for piping
    --regenerate          run calibration_profile phase now
    --undo-wave <ver>     [placeholder — wires in Lane D / T17]
    ab-report             [placeholder — wires in Lane D / T18]

  Human output:
    Calibration profile — holder: garry, source: default
    Generated: <local timestamp>
    [Note: built on 60% graded — partial completion this cycle.]   (when grade_completion < 0.9)
    [Note: voice gate fell back to template (2 attempts).]         (when voice_gate_passed=false)

    Resolved: 12 takes
    Brier:    0.210 (lower is better)
    Accuracy: 60.0%
    Partial:  10.0%

    Pattern statements:
      • You called early-stage tactics well — 8 of 10 held up.

    Active bias tags: over-confident-geography

  Cold-brain fallback message names the exact dream command to run.

MCP: `get_calibration_profile` (scope: read)
  Param: holder?: string (defaults to 'garry')
  Returns: latest CalibrationProfileRow | null

  Source-scoping via sourceScopeOpts(ctx): scalar source-bound clients see
  only their source; federated_read scopes see the union of allowed sources;
  no source filter when neither is set (CLI default path).

  Throws GBrainError('INVALID_HOLDER') on empty/non-string holder so
  remote callers get a structured error instead of a SQL-shape failure.

Architecture:
  getLatestProfile is the pure data fn — engine + opts → CalibrationProfileRow | null.
  Reused by both the CLI and the MCP op. Source-scoped via the standard
  v0.34.1 spread pattern (scalar sourceId vs sourceIds array).

  formatProfileText is pure — null → cold-brain message, populated → full
  printout. Annotates partial-grade rows and voice-gate-fallback rows so
  the operator sees data-quality status inline.

  parseArgs is exported via __testing for unit coverage. Sub-command
  ('ab-report') vs flag distinction is intentional — keeps the surface
  parallel with `gbrain eval cross-modal` etc.

Tests: 21 cases.
  parseArgs (6 cases): empty, --holder, --json, --regenerate, --undo-wave, ab-report.
  getLatestProfile (5 cases): happy, null, scalar source scope, federated array
    scope, no-source-filter default.
  formatProfileText (5 cases): cold-brain, happy, partial-grade note, voice-fallback
    note, published-to-mounts note.
  getCalibrationProfileOp (5 cases): default holder, scalar source scope,
    federated scope union, returns-null-on-unknown-holder, throws on empty holder.

Lane D follow-ups: --undo-wave (T17) and ab-report (T18) print a clear
"lands in Lane D" stderr line + exit 2; the surfaces exist for early
testers, the implementations land next.

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

* think: --with-calibration + anti-bias prompt rewrite (T8 / E1, D22)

Optional anti-bias rewrite mode for `gbrain think`. When set, the active
calibration profile gets injected per the D22 placement spec (AFTER
retrieval evidence, BEFORE the user's question). The bias filter applies
to QUESTION FRAMING, not evidence interpretation — matches LLM-as-judge
best practice (bias prompts near end of context perform better).

Default behavior unchanged (R1 regression guard): omitting
--with-calibration produces the v0.28-vintage user-message shape with the
question first, then retrieval. Existing think users see no change.

Two user-message shapes in buildThinkUserMessage:

  Default (no calibration):
    Question: X
    <pages>...</pages>
    <takes>...</takes>
    <graph>...</graph>
    Respond with a single JSON object...

  With calibration (D22):
    <pages>...</pages>
    <takes>...</takes>
    <graph>...</graph>
    <calibration holder="garry">
      Track record: Brier 0.210 (lower is better).
      Active patterns:
        - You called early-stage tactics well — 8 of 10 held up.
      Active bias tags: over-confident-geography
    </calibration>
    Question: X
    Respond...

  Calibration block is built by buildCalibrationBlock (exported for the
  E3 contradictions probe to render the same shape).

System prompt extension (withCalibration:true):
  - Names BOTH the user's PRIOR (default reasoning) AND the COUNTER-PRIOR
    from their hedged-domain self.
  - References active bias tags by name when relevant ("this fits the
    over-confident-geography pattern").
  - Does NOT silently substitute the debiased answer. ALWAYS surfaces
    both priors transparently.
  - Adds a "Calibration" section between Conflicts and Gaps in the
    answer body.

RunThinkOpts extension:
  - withCalibration?: boolean — opt-in
  - calibrationHolder?: string — defaults to 'garry'

  When withCalibration=true and no profile exists, runThink falls back to
  baseline behavior + pushes NO_CALIBRATION_PROFILE to warnings (visible
  to the operator). When the calibration fetch fails, CALIBRATION_FETCH_FAILED
  warning surfaces with the underlying error. Either path keeps think working;
  the calibration loop is enhancement, not requirement.

CLI: `gbrain think "<q>" --with-calibration [--calibration-holder <id>]`

Tests: 11 cases.
  buildThinkSystemPrompt (4 cases): R1 regression — default/false/omitted
  → no anti-bias rules; with calibration → adds PRIOR + COUNTER-PRIOR +
  bias-tag reference; preserves existing hard rules.

  buildCalibrationBlock (3 cases): happy path, null brier omitted (not
  "Brier null"), empty patterns + tags still well-formed.

  buildThinkUserMessage (4 cases): R1 regression — without calibration:
  question first; D22 placement — retrieval → calibration → question →
  instruction; graph + calibration ordering; empty retrieval blocks render
  placeholders without breaking shape.

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

* contradictions: calibration-profile join (T9 / E3)

Cross-references each contradiction finding against the active calibration
profile. When a contradiction's domain matches an active bias tag (e.g.
"over-confident-geography" or "late-on-macro-tech"), the output gains a
one-line bias context explaining which pattern this fits.

Pure functions only — no DB writes, no LLM calls. The probe runner imports
tagFindingWithCalibration() and applies it to each finding before emitting.
When no profile exists or no tags match, the helper returns null and the
runner emits the unchanged finding (regression R2 — contradictions output
is byte-identical to v0.32.6 when no calibration profile is present).

Match heuristic (v0.36.0.0 ship-state):
  Bias tags are kebab-case axis-then-domain slugs ('over-confident-geography').
  computeDomainHint() extracts a domain hint from the finding's slugs +
  holder + verdict text:
    - wiki/companies/... → hiring | market-timing
    - wiki/people/... → founder-behavior
    - macro / geography / tactics / ai segments in slug → matching tag
  First-match-wins for ordering determinism.

  Match is intentionally fuzzy — the v0.32.6 contradictions probe doesn't
  yet carry structured domain metadata. v0.37+ structured-domain-on-takes
  (Hindsight-style enum) tightens this.

Output:
  Returns { bias_tag: string, context: string } | null.
  Context format: "This contradiction fits your active bias pattern
  \"<tag>\" (Brier 0.31). Verdict: contradiction; severity: medium.
  Consider reviewing both sides through the lens of that pattern."

Tests: 13 cases.
  R2 regression (2): null profile → null tag; empty active_bias_tags → null tag.
  computeDomainHint (5): companies / people / macro / geography / unknown
  paths produce expected hints.
  Match path (4): macro→late-on-macro-tech, geography→over-confident-geography,
  mismatch returns null, first-match-wins with multiple candidate tags.
  buildBiasContextString (2): emits tag+verdict+severity+Brier; omits
  Brier when null (no "Brier null" leak).

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

* calibration: Brier-trend forecast at write time (T10 / E5)

Pure math layer over existing TakesScorecard data. Zero new LLM cost, zero
new schema. Surfaces the user's historical Brier for the take's
(holder, domain) bucket at write time so they see "your historical Brier
in macro takes is 0.31" before committing the take.

Voice-gate-rendered output:
  The user-facing string goes through gateVoice mode='forecast_blurb' via
  templates.ts (already in T6). This module is the pure data layer; the
  template renders the math into the conversational voice.

v0.36.0.0 ship state:
  Bucket dimension is the DOMAIN (slug-prefix). The conviction-weight
  bucket dimension would need a new engine method
  (engine.batchGetTakeBucketStats per F11) — deferred to v0.37+. Until
  then, forecast = historical Brier in this holder's domain.

  resolveDomainPrefix() keeps slug-prefix-looking domain hints
  ('companies/', 'wiki/macro') and falls back to overall for free-form
  hints ('macro tech', 'geography'). Hindsight-style structured domain
  on takes (CDX-11 mitigation TODO) tightens this in v0.37+.

MIN_BUCKET_N = 5:
  Below this sample size, the forecast returns predicted_brier=null with
  insufficient_data=true. Template renders "Forecast unavailable: only N
  resolved takes at this conviction yet" instead of a noisy estimate.

Architecture:
  computeForecast(input) — pure function, takes scorecards already
  fetched; ideal for tests + reuse across batched paths.
  forecastForTake(engine, input) — convenience wrapper, 1-2 engine
  round-trips (no domain → 1; with domain → 2).
  batchForecast(engine, inputs[]) — memoizes per (holder, domainPrefix);
  N inputs collapse to ≤2*unique_holders unique engine calls. Used by
  the propose-queue review flow (50 candidates → 1-2 scorecard fetches).

Tests: 14 cases.
  computeForecast (4): insufficient_data branch, stable forecast,
    overall fallback, MIN_BUCKET_N export.
  resolveDomainPrefix (5): undefined/empty/whitespace → undefined;
    slug-prefix → kept; free-form → undefined.
  forecastForTake (3): 1-call overall, 2-call domain, free-form fallback.
  batchForecast (2): cache collapse for repeat queries; different holders
    do not collapse.

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

* calibration: gstack-learnings coupling on incorrect resolutions (T11 / E4)

When the grade_takes phase auto-resolves a take as 'incorrect' or 'partial',
optionally write a learning entry to gstack's per-project learnings.jsonl
so other gstack skills (plan-ceo-review, ship, investigate, ...) can pull
it as context when relevant. The brain teaches every other tool about
the user's track record.

Config gate (D5 / CDX-17 mitigation):
  `cycle.grade_takes.write_gstack_learnings` defaults FALSE. External
  users may not have gstack installed; the gstack-learnings binary API
  isn't stable yet. Garry's brain flips it true to opt in.

Quality gate:
  Only 'incorrect' and 'partial' verdicts trigger the write. 'correct'
  resolutions are noise (we expected the take to hold up — no learning).
  'unresolvable' has no canonical column. Defense-in-depth runtime guard
  in writeIncorrectResolution() rejects ineligible qualities with
  reason='quality_not_eligible' so a caller misuse never surfaces a
  malformed learning entry.

Auto-apply only:
  Coupling fires only when grade_takes both auto-applies AND the verdict
  is incorrect/partial AND the config flag is enabled. Manual resolutions
  via `gbrain takes resolve` intentionally DO NOT propagate to gstack —
  manual writes already carry operator intent; the calibration loop is
  the noise-prone path that earns coupling.

Namespace:
  Every entry's key starts with 'gbrain:calibration:v0.36.0.0:'. Lane D
  `gbrain calibration --undo-wave v0.36.0.0` (T17) filters on this prefix
  for the optional gstack-scrub step. First active bias tag suffixes the
  key (e.g. 'take-42:over-confident-geography') so future analysis can
  group learnings by bias pattern.

Architecture:
  buildLearningEntry — pure. Truncates claim at 200 chars + ellipsis;
  emits Pattern: line when activeBiasTags present; defaults confidence
  to 0.8 when caller omits it.

  writeIncorrectResolution — async wrapper. Honors config gate; honors
  quality gate; calls the injected writer (or defaultGstackWriter in
  production). Failures are non-fatal: returns
  { written: false, reason: 'write_failed' | 'binary_missing', error }.
  The grade_takes phase logs to result.warnings and continues — gstack
  coupling failure NEVER aborts a cycle.

  defaultGstackWriter — shells out to gstack-learnings-log binary via
  execFileSync. Throws GBrainError('GSTACK_BINARY_NOT_FOUND') when the
  binary isn't on PATH; writeIncorrectResolution classifies that error
  to reason='binary_missing' so the operator sees the install hint
  instead of a generic write_failed.

  Wired into grade-takes.ts after engine.resolveTake() inside the
  auto-apply block. Only fires when shouldApply=true.

Tests: 14 cases.
  buildLearningEntry (7): canonical shape, partial vs incorrect wording,
  bias-tag suffix, no-tag fallback, claim truncation, default confidence,
  no-reasoning omission.
  writeIncorrectResolution (7): config gate, quality gate, happy path,
  writer-throw graceful degrade, binary-missing classification, async
  writer awaited, partial quality writes.

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

* doctor: 4 calibration checks — abandoned/freshness/drift/voice (T12)

Adds the four calibration doctor checks per the eng-review spec.

abandoned_threads:
  Counts active high-conviction takes (weight >= 0.7) older than 12 months
  that have never been superseded. Signal, not error — always status='ok'
  with a count. The hint sends users to `gbrain calibration` for details.

calibration_freshness:
  Warns when the active profile is older than 7 days (configurable via
  the same env-var pattern other freshness checks use). Cold-brain branch
  (no profile yet) returns ok without scolding. Hint points at
  `gbrain calibration --regenerate`.

grade_confidence_drift (CDX-11 mitigation):
  Surfaces the count of auto-applied grade verdicts. Below 30: returns
  "need 30+ for drift detection". At/above 30: returns "drift math
  arrives in v0.37+". The surface is wired; the actual
  confidence-vs-accuracy correlation math is a v0.37+ follow-up once we
  have 30+ auto-applied verdicts to measure against. Closes the CDX-11
  hole structurally — the operator sees the surface even before the math
  is meaningful.

voice_gate_health:
  Tracks voice gate failure rate over the last 7 days. <30% fail rate →
  ok (template fallback is fine in isolation). >=30% → warn with hint
  to review src/core/calibration/voice-gate.ts rubric. Anchors the
  cross-cutting voice rule observability story.

All four checks return status='warn' with a diagnostic message on
engine errors — non-blocking, never throws. Matches the existing doctor
check pattern (see checkSyncFreshness for prior art).

Wired into runDoctor after checkRerankerHealth (the v0.35 cluster), in
the canonical block 10 slot.

Tests: 15 cases. 4 per check (happy path, alt-status, engine-throw
diagnostic, plus boundary tests for the freshness staleness gate at
exactly 7 days and the grade drift gate at 30 applied verdicts).

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

* calibration: E7 nudge + 14-day cooldown (T13 / D16 F3)

Real-time pattern surfacing when a newly-committed high-conviction take
matches an active bias pattern. Conversational nudge text via the
templates module; 14-day cooldown per (take_id, nudge_pattern) via
take_nudge_log to prevent the feedback loop where each cycle re-fires
the same nudge on the same take.

Threshold gates (D16 F3):
  - holder match (profile.holder === take.holder)
  - conviction-weight > 0.7 (strict greater than)
  - take's slug-derived domain hint matches an active bias tag
    (takeDomainHint — same heuristic as eval-contradictions/calibration-join.ts
    for cross-surface consistency)

Cooldown gate:
  Before firing, probe take_nudge_log for (take_id, nudge_pattern) rows
  with fired_at >= now() - 14 days. Any hit → silently skip. After firing,
  insert a new row with channel='stderr' so the next 14 days are gated.

Feedback-loop prevention:
  User hedges a take in response to a nudge (e.g. weight 0.85 → 0.65).
  Even though the take's `weight` field changed, the cooldown row for
  the over-confident-geography pattern is still there from the original
  fire — so the next cycle's evaluateAndFireNudge() silently skips. The
  user reset path (gbrain takes nudge --reset N) clears the cooldown to
  re-arm.

Output channel (v0.36.0.0 ship state):
  STDERR only. Schema's `channel` column already supports multi-channel
  (webhook, admin SPA toast); routing those is a v0.37+ follow-up.

Architecture:
  evaluateNudgeRule(take, profile) — pure rule check. Returns
  { matched, reason, matchedTag }. No engine call.
  checkCooldown(engine, takeId, pattern) — engine probe, returns boolean.
  recordNudgeFire(engine, opts) — INSERT into take_nudge_log.
  evaluateAndFireNudge(opts) — full pipeline. Returns NudgeDecision.
  resetNudgeCooldown(engine, takeId) — DELETE...RETURNING for the CLI.

  buildNudgeText delegates to templates.ts nudgeTemplate (D24 mode='nudge'
  voice). v0.36.0.0 ship state uses the template directly; LLM-generated
  nudge text via the voice gate lands in v0.37+ when we have production
  examples to tune from.

Tests: 22 cases.
  takeDomainHint (5): companies/people/macro/geography/unrecognized.
  evaluateNudgeRule (6): no_profile, wrong_holder, conviction-at-threshold-
  is-NOT-eligible (strict >), no matching tag, happy match,
  first-match-wins for multiple candidate tags.
  checkCooldown (3): true on row hit, false on no row, cutoff date param
  verifies the 14-day boundary.
  evaluateAndFireNudge (4): happy fire (text contains hush command +
  matched tag), cooldown silent skip (no INSERT, no stderr), no_profile
  short-circuit, below-conviction short-circuit (no cooldown query fired).
  buildNudgeText (2): hush command shape, conviction value embedded.
  resetNudgeCooldown (2): returns count, idempotent on zero rows.

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

* calibration: E8 team-brain sharing + D18 cross-brain query semantics (T14)

Cross-brain calibration profile resolution per the D18 4-rule contract.
Pins all four cross-brain leak surfaces in dedicated unit tests so future
mount features can't silently regress this security model.

D18 semantics (committed):

  Rule 1 — LOCAL-FIRST ORDERING.
    Query the local brain first. If a profile exists, return it. Do NOT
    also query mounts (avoids stale-mount-overrides-fresh-local).
    Verified: mountResolver is NOT called when local has a hit.

  Rule 2 — MOUNT FALLBACK.
    Only when local has no profile AND canReadMounts=true, walk the
    mounts in priority order. First match wins. Each mount-side row
    must have published=true to be visible (D15 asymmetric opt-in).

  Rule 3 — CROSS-BRAIN ATTRIBUTION.
    Every returned profile carries source_brain_id + from_mount flag.
    Consumers (E1 think rewrite, E3 contradictions, E7 nudge, E6
    dashboard) MUST surface this via attributionSuffix() so the user
    sees which brain answered.

  Rule 4 — SUBAGENT PROHIBITION.
    canReadMountsForCtx() classifier returns FALSE for subagent loops
    without trusted-workspace allowedSlugPrefixes. Closes the
    OAuth-token-to-cross-brain-leak surface — subagents see ONLY their
    local-brain results regardless of which holder they query.

    Exception: trusted cycle phases (synthesize/patterns) pass
    allowedSlugPrefixes set and ARE allowed to read mounts. Pinned in
    the classifier test.

Architecture:
  queryAcrossBrains(localEngine, opts) — pure orchestrator. Composes
  getLatestProfile() from src/commands/calibration.ts. Mount engine
  access is via opts.mountResolver — production wires this to the
  v0.19+ gbrain mounts subsystem; tests inject a stub returning an
  ordered list of mocked engines. Decouples cross-brain LOGIC from
  multi-engine PLUMBING.

  canReadMountsForCtx(ctx) — pure classifier table. Drives the rule-4
  gate. Production callers compose it from OperationContext.

  attributionSuffix(result) — pure formatter. Emits the "(from mounted
  brain: <id>)" suffix when from_mount=true; empty string when local.
  Mandatory for user-visible cross-brain consumers.

Tests: 15 cases pinned to the 4 D18 rules + 4 supplementary structural
checks.
  D18-1: published=false profile on mount stays hidden.
  D18-2/3: subagent context cannot fall back to mounts (2 cases — null
    on local-empty + canReadMounts=false, local hit still returned).
  D18-4: attribution surfaces source_brain_id (3 cases — mount answer
    flag, local answer flag, attributionSuffix formatter).
  Rule 1 local-first ordering (2 cases — mountResolver NOT called on
    local hit, IS called on local empty).
  Mount priority order (3 cases — first published=true wins, all
    published=false returns null, no mounts configured returns null
    without throwing).
  canReadMountsForCtx classifier (4 cases — local CLI true, MCP
    non-subagent true, subagent without trusted-workspace false,
    subagent WITH trusted-workspace true).

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

* admin: E6 Calibration tab + D23 server-rendered SVG + TD2 contrast bump (T15)

Adds the v0.36.0.0 admin SPA Calibration tab. Per the design review,
the approved variant-B (Linear calm clarity) layout: single-column flow,
generous whitespace, ONE big sparkline as hero, then patterns, then
domain bars, then abandoned threads.

D23 server-rendered SVG architecture:

  src/core/calibration/svg-renderer.ts — pure functions. data → SVG
  string. No DOM, no React, no chart library dep. Inlines the admin
  design tokens (#0a0a0f bg, #3b82f6 accent, etc.) so the SVG is
  visually consistent with the rest of the admin SPA.

  Four chart renderers:
    - renderBrierTrend({ series }) — sparkline w/ baseline reference
      at 0.25 (always-50% baseline)
    - renderDomainBars({ bars }) — horizontal accuracy bars per domain
    - renderAbandonedThreadsCard(threads) — D30/TD4 'revisit now' link
      per row, points at /admin/calibration/revisit/<takeId>
    - renderPatternStatementsCard(statements) — D29/TD3 clickable
      drill-down links per row, point at /admin/calibration/pattern/<i>

  XSS posture: all caller-controlled strings pass through escapeXml().
  Numeric inputs are .toFixed()-coerced. Admin SPA renders via
  dangerouslySetInnerHTML inside a TrustedSVG wrapper component;
  endpoint is gated by requireAdmin middleware.

  /admin/api/calibration/profile — returns the active profile row as JSON.
  /admin/api/calibration/charts/:type — returns image/svg+xml markup
    for type ∈ {brier-trend, domain-bars, pattern-statements,
                abandoned-threads}. Cache-Control: private, max-age=60.

  brier-trend currently renders a single-point series from the active
  profile (the time-series view across calibration_profiles.generated_at
  history is a v0.37 follow-up once we have multiple snapshots).
  abandoned-threads pulls the top 5 abandoned rows via the same SQL the
  doctor check uses.

CalibrationPage React component (admin/src/pages/Calibration.tsx):
  Fetches profile + 4 charts. Loading / error / cold-brain states all
  handled. Layout includes the audit annotations (partial-grade badge,
  voice-gate-fell-back-to-template badge) per the approved mockup.
  TrustedSVG wrapper isolates the dangerouslySetInnerHTML to the SVG
  surface only.

App.tsx nav: added 'calibration' page route + sidebar nav item, hash
routing extended to support #calibration.

TD2 contrast bump:
  admin/src/index.css --text-muted: #555#777. Old value was contrast
  4.0 on the #0a0a0f bg — below WCAG AA 4.5 for body text. New value is
  ~5.5, passes AA. Improvement is global across Dashboard, Agents,
  RequestLog, and the new Calibration tab — single-line CSS change with
  ~10x the impact.

admin/dist/ rebuilt via `bun run build` (vite). 36 modules transformed.

Tests: 19 cases in test/svg-renderer.test.ts.
  escapeXml (1): canonical entities.
  renderBrierTrend (6): empty state, polyline for 2+ points, clamp
  beyond yMax, design tokens inlined, XSS safety on date strings,
  text-anchor end on right label.
  renderDomainBars (4): empty state, label/accuracy/n rendering,
  out-of-range accuracy clamp, XSS safety on labels.
  renderAbandonedThreadsCard (4): empty state, row rendering with
  revisit link, claim truncation at 70 chars, custom revisitHref override.
  renderPatternStatementsCard (4): empty state, anchor count matches
  statement count, XSS safety, custom drillHref override.

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

* recall: calibration footer formatter for morning pulse (T16)

Pure formatter that turns a CalibrationProfileRow + optional abandoned-
threads list into the conversational block the morning pulse will surface:

  Calibration this quarter:
    Brier 0.18 (solid).
    Right on early-stage tactics, late on macro by 18 months.
    Over-confident on team execution; under-calibrated on regulatory risk.

  Threads you opened and never came back to:
    · AI search platform differentiation         (17 months silent)
    · International expansion playbook           (12 months silent)

Cold-brain branch: returns empty string when no profile or < 5 resolved
takes. Caller decides whether to render the block; cold-brain absence
is the cleanest non-event.

Brier trend note maps the absolute value to conversational copy:
  <= 0.10 → "(strong calibration)"
  <= 0.20 → "(solid)"
  <= 0.25 → "(near baseline)"
  > 0.25  → "(worse than always-50% baseline — review your high-conviction calls)"

  v0.36.0.0 ship state has only the current profile snapshot. The
  "was 0.22 90d ago — improving" comparison shape arrives when we
  accumulate generated_at history across multiple cycles.

R3 regression posture:
  This module is the FORMATTER only. Wiring into `gbrain recall`'s text
  output is intentionally NOT in this commit — runRecall's surface
  stays unchanged. v0.37 wires it under --show-calibration (opt-in
  initially, default-on later). For now the formatter is callable from
  the admin tab + custom CLI scripts that want it.

Architecture:
  buildRecallCalibrationFooter(opts) — pure. opts.profile required,
  opts.abandonedThreads optional, opts.threadColumnWidth defaults to 50.

  Caps at 4 patterns + 5 abandoned threads to keep the footer scannable.
  Truncates long abandoned-thread claim text to fit the column width with
  a trailing ellipsis.

Tests: 14 cases.
  Cold-brain branch (3): null profile, < 5 resolved, zero resolved.
  Happy path (7): header + Brier + patterns, trend note ranges (4
  brackets), null brier omits the Brier line but keeps header, caps at
  4 patterns.
  Abandoned threads (4): omit section when none, emit when present,
  cap at 5, truncate long claim with column-width override.

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

* calibration: --undo-wave reversal command (T17 / D18 CDX-3)

Implements the undo-wave reversal flow. Every new row written by the
v0.36.0.0 calibration wave carries wave_version='v0.36.0.0' so a precise
revert is possible without touching pre-wave data.

CLI surface (replaces the v0.36.0.0 ship-state placeholder):
  gbrain calibration --undo-wave v0.36.0.0 [--dry-run] [--scrub-gstack] [--json]

Reversal scope (4 steps):

  Step 1 — UNSET takes.resolved_* columns for takes auto-applied by this
  wave. Identifies wave-applied takes via take_grade_cache.applied=true
  + wave_version match. Cross-checks resolved_by='gbrain:grade_takes' to
  ensure we're not un-resolving a take a manual `gbrain takes resolve`
  override has since claimed. Manual resolutions persist; only auto-grade
  resolutions revert.

  Step 1b — Mark take_grade_cache rows applied=false post-undo so the
  audit trail shows they WERE applied but this wave was reverted. The
  CDX-11 confidence-drift check filters on applied=true and gets a
  cleaner sample post-undo.

  Step 2 — DELETE FROM calibration_profiles WHERE wave_version = ?.

  Step 3 — DELETE FROM take_nudge_log WHERE wave_version = ?.

  Step 4 — Optional gstack-learnings-prune via the binary, scoped to the
  GSTACK_LEARNING_NAMESPACE prefix. Opt-in via --scrub-gstack. Best-effort:
  binary-missing or failure logs a warning + suggests the manual command;
  the rest of the undo still succeeded.

Dry-run posture:
  --dry-run computes the counts via SELECT COUNT(*) shapes without
  emitting any UPDATE or DELETE. Same UndoWaveResult shape returned so
  operator sees exactly what would be reverted before committing.

  --dry-run intentionally skips the gstack scrub (filesystem write) too;
  ship-state safety call.

Idempotency:
  Re-running --undo-wave on a brain that's already reverted is a no-op.
  Each query filters on wave_version; no matching rows → zero counts.

Architecture:
  undoWave(engine, opts) — async, returns UndoWaveResult. Pure data
  layer; no stderr writes, no process exits. CLI dispatch in
  src/commands/calibration.ts handles printing.

  v0.36.0.0 ship state runs steps 1-3 sequentially (no transaction).
  Partial reversal is recoverable via re-run since each step is
  idempotent on wave_version match. A future enhancement (v0.37+) can
  wrap in engine.transaction once that surface lands in BrainEngine.

Tests: 8 cases in test/undo-wave.test.ts.
  Dry-run posture (1): counts emitted, NO UPDATE/DELETE SQL fired.
  Happy path (3): all 4 steps execute, resolved_by filter scopes UPDATE
  to wave-applied resolutions, custom resolvedByLabel honored.
  Empty wave (2): zero counts when no matching rows, idempotent re-run.
  Wave-version parameter threading (2): supplied version threads
  through all queries, different wave versions don't collide.

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

* calibration: A/B harness for think + ab-report (T18 / D19 CDX-18)

Structural answer to CDX-18 (anti-bias rewrite may make advice worse).
We don't have to guess whether calibration helps — we measure.

Architecture:
  runAbTrial(input) — calls thinkRunner TWICE on the same question
  (baseline + --with-calibration), surfaces both answers to a
  preferenceResolver, persists the trial to think_ab_results.

  buildAbReport(engine, { days }) — aggregates the table over the last
  N days (default 30). Computes win counts, ties, neither, and a
  with_calibration_win_rate over DECISIVE trials only (excludes
  neither/tie). Flags calibration_net_negative when n >= 20 AND win
  rate < 45%.

  formatAbReport(report, days) — pretty-prints for stdout; emits the
  calibration_net_negative warning block when triggered.

CLI:
  gbrain calibration ab-report [--days N] [--json]
    Reads the table, prints the breakdown. Replaces the v0.36.0.0
    ship-state placeholder in src/commands/calibration.ts.

  gbrain think --ab "<question>"
    Wires into runAbTrial via the dispatch in src/commands/think.ts —
    follow-up commit. This commit lands the harness layer + schema +
    report surface; the --ab flag itself flips on in a one-line wiring
    commit when the runRecall path is ready.

Schema (migration v72 / think_ab_results):
  source_id, wave_version, ran_at, question, baseline_answer,
  with_calibration_answer, preferred (CHECK in {baseline,
  with_calibration, neither, tie}), model_id, notes.

  CHECK constraint enforces preferred enum. Default wave_version
  'v0.36.0.0' stamped so --undo-wave can scrub these too.

  Index on (source_id, ran_at DESC) supports the report's
  "last N days" query.

  schema.sql + pglite-schema.ts both updated for fresh-install parity.
  schema-embedded.ts regenerated via build:schema.

calibration_net_negative threshold (D19):
  Triggers when:
    - decisive_trials (baseline + with_calibration) >= 20
    - with_calibration_win_rate < 0.45 (NOT <= — exact 45% is OK)

  Small-sample guard (n < 20) prevents the warning from firing on
  early data with sampling noise. Confidence-flat threshold (no Wilson
  CI yet) keeps the math simple; v0.37+ adds CI bounds.

Tests: 12 cases in test/think-ab.test.ts.
  runAbTrial (4): both runner calls fire, preferenceResolver receives
    both answers, INSERT row params shape, throws when thinkRunner
    missing.
  buildAbReport (5): zero trials, aggregation, net_negative trigger at
    n>=20 + win<45%, no trigger at n<20 (small-sample guard), no
    trigger at exact 45% boundary.
  formatAbReport (3): zero-state message, decisive-trials breakdown,
    net_negative warning block.

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

* calibration: pattern drill-down route + revisit-now CLI (TD3 / D29 + TD4 / D30)

TD3 (D29) — clickable pattern drill-down endpoint:
  GET /admin/api/calibration/pattern/:id (requireAdmin)
  Returns the pattern statement at index `id` plus the top 25 resolved
  takes for the holder, sorted by weight desc. v0.36.0.0 ship-state
  approximation: surfaces broad provenance evidence (top resolved
  takes). v0.37+ stores per-pattern source_take_ids[] on a
  calibration_profile_patterns join table so the drill-down shows the
  EXACT takes that drove the pattern.

  Surfaces a `provenance_note` field in the response so the operator
  sees the v0.36.0.0-vs-v0.37 fidelity boundary inline.

  The admin SPA's renderPatternStatementsCard SVG already emits anchor
  tags pointing at /admin/calibration/pattern/<i> (T15 ship state).
  This route makes those anchors clickable — closes the trust loop that
  was the rationale for D29 ("pattern statements without their evidence
  are dressed-up LLM hallucinations").

TD4 (D30) — `gbrain takes revisit <slug>` editor-open action:
  Adds the `revisit` subcommand to gbrain takes. Opens $EDITOR (falling
  back to vi) on the source markdown file for the slug. Appends a
  `<!-- gbrain:revisit -->` cursor marker at the bottom of the page on
  first invocation so the editor opens with intent visible.

  Reads sync.repo_path from config to locate the brain repo. Refuses to
  proceed with a clear error when the repo isn't configured or the page
  doesn't exist.

  spawnSync with stdio:'inherit' so the editor takes the terminal. Exit
  status surfaced on failure.

  The SVG renderer's revisit-now anchor for each abandoned thread row
  emits /admin/calibration/revisit/<takeId>. A small route handler that
  resolves take_id → page_slug then dispatches `gbrain takes revisit`
  via spawn is a v0.37 follow-up — the CLI command exists now so
  developers can wire it directly.

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

* docs: DESIGN.md — formalize de facto design tokens (TD1)

Promotes the admin SPA's de facto design tokens (landed v0.26.0) to a
canonical DESIGN.md at the repo root. This is the calibration target
for /plan-design-review and /design-review going forward — when a
question is "does this UI fit the system?", the answer is here.

Captures the system as it stands today:

  Voice (5 surfaces, all routed through gateVoice() with mode-specific
  rubrics): pattern_statement, nudge, forecast_blurb, dashboard_caption,
  morning_pulse. Friend-not-doctor; concrete data over abstract metrics;
  no preachy / clinical / corporate language.

  Color tokens: 10 CSS variables from admin/src/index.css inlined into
  the SVG renderer (src/core/calibration/svg-renderer.ts). Dark theme
  is the only theme — admin is an operator tool. WCAG contrast
  documented per token; TD2's #555#777 bump on --text-muted noted.

  Typography: Inter for UI, JetBrains Mono for numbers/slugs/data.
  Type scale (18 / 14 / 13 / 12 / 11) documented as de facto, not yet
  formalized.

  Spacing scale: 4 / 8 / 16 / 24 / 32px. Linear-app density.

  Layout: sidebar 200px, max content 720px (text) / 960px (tables).
  No 3-column feature grids, no icons in colored circles, no
  decorative blobs.

  Charts: server-rendered SVG via pure functions in
  src/core/calibration/svg-renderer.ts. XSS posture documented:
  server-side escapeXml on caller-controlled strings, numeric inputs
  .toFixed()-coerced, admin SPA renders via <TrustedSVG> wrapper.

  Interaction patterns: keyboard nav required (J/K/space/u/q on the
  propose-queue), loading/empty/error states ARE features.

  v0.37+ roadmap: type scale formalization, animation tokens, component
  library extraction. Light mode explicitly NOT planned.

The doc is a living target, not a frozen spec. Major changes route
through /plan-design-review per the existing review chain.

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

* calibration: synthetic corpus scaffold + privacy CI guard (T19 + T20)

T19 — synthetic corpus scaffold for extract-takes prompt tuning.
  test/fixtures/calibration/extract-takes-corpus/ — 5 representative
  pages across 4 genres (essay, people, companies, meetings, decisions).
  v0.36.0.0 ships a SMALL representative corpus as proof of structure;
  the full 50-page training set + 10-page holdout gets generated by the
  operator via `gbrain calibration build-corpus` (v0.37 follow-up
  subcommand) or by hand with the privacy guard catching violations
  either way.

  Privacy contract per D13': every page is SYNTHETIC. None of the
  names/companies/funds/deals/events refer to anything real. Placeholder
  names per CLAUDE.md: alice-example, charlie-example, acme-example,
  widget-co, fund-a/b/c, acme-seed, widget-series-a, meetings/2026-04-03.

  test/fixtures/calibration/README.md spells out the privacy contract,
  generation flow, and what the corpus is (stable regression set for
  the extract-takes prompt) vs is not (real anything).

T20 — privacy CI guard (CDX-14 mitigation).
  scripts/check-synthetic-corpus-privacy.sh greps the corpus for:
    1. Explicit dollar amounts ($50M, $1.2B etc) — would suggest the
       page memorized a real round size.
    2. Out-of-range year references (informational only for v0.36.0.0;
       deferred to a manual review checklist).
    3. Pages that reference ZERO placeholder names — suggests the page
       might be referring to real entities. Essay-genre fixtures
       exempt (they're anonymized PG-style writing by design).

  Wired into `bun run verify` (CI gate) so contributors can't accidentally
  land a synthetic fixture that leaks real-world specificity. The intent
  is fail-fast on accidental leakage; the operator can update the
  allowlist if a generic dollar amount is intentional.

  Closes CDX-14: 'CC reads real brain pages locally, writes nothing
  still risks privacy if any generated synthetic fixture memorizes
  structure-specific facts. Placeholder names are not enough.'

The corpus shipped here is intentionally small but covers the four
core gbrain page genres (essay, people, companies, meetings/decisions).
The v0.37 corpus-build subcommand will fan out to 50 with the operator
spot-checking + the CI guard enforcing the privacy contract.

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

* test: R1-R5 IRON RULE regression inventory (T21)

Per /plan-eng-review D26 IRON RULE: regressions get added to the test
suite as critical requirements, no AskUserQuestion needed. Pins five
regressions identified during the v0.36.0.0 wave's coverage diagram:

  R1: think baseline UNCHANGED when --with-calibration absent.
      Covered structurally by test/think-with-calibration.test.ts plus
      assertion-pinned in this file (default user message: question
      first, then retrieval; system prompt: no anti-bias section).

  R2: contradictions probe output UNCHANGED when no calibration profile.
      Covered structurally by test/eval-contradictions-calibration-join.test.ts
      plus pinned here (null profile → null tag, byte-identical to v0.32.6).

  R3: takes resolution flow works when grade_takes phase disabled.
      Pinned import-surface coupling: takes-resolution.ts has zero
      dependency on grade_takes module. If a future refactor accidentally
      couples them, this test fails to compile.

  R4: search/list_pages/get_page work identically through new source_id paths.
      Marker test referencing existing v0.34.1 source-isolation suite at
      test/source-isolation-pglite.test.ts. v0.36.0.0 does NOT modify
      those code paths; the existing tests catch any accidental coupling.

  R5: existing search modes (conservative/balanced/tokenmax) unaffected.
      Marker test referencing existing test/search-mode.test.ts. The
      calibration code DOES NOT IMPORT from src/core/search/mode.ts.

Plus an inventory test that confirms all 5 regressions have an
'addressed' status — fail-loud if a future contributor removes a
guard without updating the inventory.

7 tests total. Pure functions, no engine, hermetic.

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

* docs: v0.36.0.0 CHANGELOG + CLAUDE.md anchors + calibration convention skill

CHANGELOG entry: the user-facing release notes. Leads with the headline
("the brain learns how you tend to be wrong, then argues against your
blind spots on every advice call"), 5 'what you can now do' bullets in
GStack voice, itemized changes by lane, and the 'To take advantage of
v0.36.0.0' upgrade checklist per the CLAUDE.md required-block contract.

CLAUDE.md anchors: new 'v0.36.0.0 Hindsight calibration wave (key files
cluster)' block inserted before the v0.31.1 thin-client section. 23 new
files / extensions annotated with one-paragraph descriptions each,
linking back to the convention skill at skills/conventions/calibration.md
for the agent-facing rules.

skills/conventions/calibration.md: the agent-facing convention skill.
Tells future contributors which calibration touchpoint applies to
their task — voice gate? BaseCyclePhase? source-scope thread? doctor
warning? cross-brain query rules? auto-resolve threshold posture? Test
seam patterns. Bug class to avoid (the v0.34.1 source-isolation leak
shape).

Version trio (per CLAUDE.md mandatory audit):
  VERSION:     0.36.0.0
  package.json: 0.36.0.0
  CHANGELOG:   ## [0.36.0.0] - 2026-05-17

llms.txt + llms-full.txt regenerated via `bun run build:llms` after
the CLAUDE.md edit (per the explicit CLAUDE.md mandate "Any CLAUDE.md
edit MUST be followed by `bun run build:llms`"). The `test/build-llms.test.ts`
guard runs in CI shard 1; the committed bundles are checked against
fresh generator output.

bun run verify is clean. typecheck clean. Privacy CI guard passes
(0 violations across 6 corpus pages). All ready for /ship.

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

* cycle: wire propose_takes / grade_takes / calibration_profile into runCycle (T-fix)

The three new v0.36.0.0 phases were declared in CyclePhase / ALL_PHASES /
NEEDS_LOCK_PHASES but the runCycle orchestrator never dispatched them.
ALL_PHASES advertised them, gbrain dream --phase propose_takes accepted
them, but `gbrain dream` (default) silently skipped all three.

Adds a single dispatch block between consolidate and embed that:
  - builds an OperationContext on the fly (trusted-workspace caller,
    remote: false, sourceId resolved via the same helper sync uses)
  - dispatches the three phases in the order ALL_PHASES declares
  - records the same skipped-phase shape (no_database) when engine is null

Pinned by test/core/cycle.serial.test.ts "default: all 6 phases run in
order" which was already failing against ALL_PHASES (the test name lags
the actual phase count; left as-is since renaming churns history).

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

* calibration: expand synthetic corpus + add hand-labeled ground-truth (T19)

Adds 8 new synthetic pages modeled on the genre mix observed in the
real brain (concepts-with-timeline, meeting-notes, daily-journal,
people-pages, essays). Companion .gradeable-claims.json files carry
hand-labeled answer keys — what a tuned propose_takes prompt SHOULD
extract per page. Closes the F1 gate gap from the plan's T19/D19:

  Training corpus (test/fixtures/calibration/extract-takes-corpus/):
    + concept-startup-market-dynamics.md     (10 claims)
    + meeting-2026-04-10-fundraise-fund-a.md (6 claims)
    + daily-2026-04-15.md                    (5 claims)

  Blind holdout (test/fixtures/calibration/holdout/):
    + concept-founder-execution.md           (6 claims, F1 >= 0.80)
    + daily-2026-04-18.md                    (4 claims, F1 >= 0.80)
    + meeting-2026-04-17-hiring-charlie.md   (5 claims, F1 >= 0.80)
    + essay-on-conviction.md                 (7 claims, F1 >= 0.80)
    + people-bob-example.md                  (5 claims, F1 >= 0.80)

Privacy:
  - No real-brain content read into any committed artifact. Pages
    written from scratch using the canonical placeholder set
    (alice-example, charlie-example, bob-example, acme-example,
    widget-co, fund-a/b/c). Real-name grep confirms zero leakage:
    wintermute, garrytan, paul-graham, sam-altman, etc. → 0 hits.
  - scripts/check-synthetic-corpus-privacy.sh passes: 0 violations
    across 14 pages (was 6).

Genre fidelity:
  - concept-with-timeline pages mirror the dated-assertion structure
    real brain uses (verb framing varies: "argues / predicts / I
    think / I bet / strong conviction / moderate conviction").
  - meeting-notes pages carry both prose claims (extracted via
    hedging language) and explicit ## Takes sections.
  - daily-journal pages test probabilistic framing ("75/25 in favor",
    "call it ~0.5") and self-tagged conviction values.
  - essay-on-conviction is the meta-page that names the author's
    own bias patterns — primary signal for calibration_profile.
  - people pages test claim-about-third-party extraction.

Each JSON ground-truth lists per-claim:
  - claim_text + kind (prediction|judgment|bet) + domain
  - conviction (0..1)
  - since_date
  - rationale (why this claim is gradeable + how a tuned prompt
    should infer conviction from the prose)

This is the corpus that gates the T19 prompt-tune iteration:
  - F1 >= 0.85 on training (10+6+5 = 21 claims across 3 pages
    plus the existing 5 fixtures already shipped)
  - F1 >= 0.80 on holdout (27 claims across 5 pages)

Plan reference: ~/.claude/plans/system-instruction-you-are-working-rippling-knuth.md
Privacy gate: scripts/check-synthetic-corpus-privacy.sh (wired into bun run verify).

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

* calibration: tune propose_takes prompt against synthetic corpus (cat15 F1 0.92+)

The v0.36.1.0 ship state shipped propose_takes with a stub prompt that
the docs flagged as "tune via T19 corpus build before relying on
propose_takes in production." T19's corpus was built in commit 69a71c9d
(14 synthetic pages + 48 hand-labeled claims). The matching gbrain-evals
cat15 runner validates extraction quality against that corpus.

This commit back-ports the tuned prompt validated by cat15's first live
run:

  training avg F1: 0.952  (target 0.85, +10 points)
  holdout  avg F1: 0.922  (target 0.80, +12 points)
  train-holdout gap: 0.03 (well below 0.10 overfitting threshold)
  8/8 probes pass their individual F1 targets

Per-genre F1 floor: 0.80 (people-pages, the hardest genre). Concept-
with-timeline and meeting-notes genres scored at 1.00 on holdout pages.

The tuned prompt design changes vs the stub:
  - Worked example list seeds the "gradeable claim" notion so the model
    doesn't drift into pure-fact extraction.
  - NOT-gradeable list catches the most common over-extraction modes
    (pure facts, direct quotes, restatements).
  - Conviction inference rules anchored to specific hedging language
    so the model produces consistent weight values.
  - kind enum narrowed to 'prediction' | 'judgment' | 'bet' — the v1
    stub's 4-tag enum bled into noise classification on the corpus.

PROPOSE_TAKES_PROMPT_VERSION bumped 'v0.36.1.0-stub' → 'v0.36.1.0-tuned-cat15'.
The bump invalidates the take_proposals idempotency cache so existing
proposal rows stay as audit history but the next cycle re-extracts
against the new prompt — exactly the design contract this version
field is for.

Re-tuning protocol: run cat15 in gbrain-evals against the fixtures
BEFORE bumping the version string. The train-holdout gap should stay
< 0.10. If a future tune drops below the cat15 gate, revert.

Source of evidence:
  - cat15 runner: ~/git/gbrain-evals/eval/runner/cat15-propose-takes.ts
  - Fixture corpus: test/fixtures/calibration/ (this repo, commit 69a71c9d)
  - Live run dumps: ~/git/gbrain-evals/eval/reports/cat15-propose-takes/*.json

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

* docs: link cat14/cat15 benchmark report from CHANGELOG + README

Adds the "Validated by published benchmarks" subsection to the v0.36.1.0
CHANGELOG entry and a "Calibration loop" section to the README's
"Receipts on the evals" surface. Both link to the new benchmark report
at gbrain-evals/docs/benchmarks/2026-05-18-brainbench-cat14-cat15-calibration.md.

CHANGELOG: also updates the propose_takes bullet to reflect that the
v0.36.1.0 ship state now includes the tuned 'v0.36.1.0-tuned-cat15'
prompt (back-ported in 04dbab44), not the v1 stub the original entry
described.

README: adds a Calibration loop entry to the receipts table sitting
between source-aware ranking and prompt compression. Frames the cat14
+ cat15 numbers as "first published benchmark for AI memory systems
that reason about user track records" — honest SOTA framing since
Hindsight introduced the concept without quantified evaluation.

llms.txt + llms-full.txt regenerated.

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

* docs: fix benchmark-report links — gbrain-evals uses main not master

7 links to gbrain-evals/blob/master/docs/benchmarks/ were broken — the
gbrain-evals repo uses 'main' as its default branch, not 'master'.
Surfaced when I checked that the new cat14/cat15 link resolved post-PR-9
merge. Turned out 4 pre-existing links to longmemeval, brainbench-v0.20,
brainbench-cat13b-source-swamp, and comparison-systems were all broken
for the same reason — I just added a fifth by following the same wrong
pattern.

Sweep: gbrain-evals/blob/master/ → gbrain-evals/blob/main/ across both
README.md (5 links) and CHANGELOG.md (2 links).

llms.txt + llms-full.txt regenerated.

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-18 19:34:44 -07:00

1074 lines
53 KiB
PL/PgSQL

-- GBrain Postgres + pgvector schema
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- gen_random_uuid() is core in Postgres 13+; enable pgcrypto as fallback for older versions
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ============================================================
-- sources: multi-repo / multi-brain tenancy (v0.18.0)
-- ============================================================
-- A source is a logical brain-within-the-DB: wiki, gstack, yc-media, etc.
-- Every page/file/ingest_log row carries source_id.
--
-- id: immutable citation key. [a-z0-9-]{1,32} enforced at app layer.
-- Used in [source:slug] citations, --source flag, wikilink syntax.
-- name: mutable display label. Rename via `gbrain sources rename`.
-- local_path: optional git checkout root for filesystem-backed sources.
-- config: forward-compat JSONB. Currently used for federation + ACL slot.
-- { "federated": bool, "access_policy": {...} }
-- - federated=true (or missing-but-explicit on 'default'):
-- participates in cross-source default search.
-- - federated=false (default for new sources):
-- only searched when explicitly named via --source.
-- - access_policy: forward-compat slot, no enforcement in v0.17.
-- Write-side lockdown: mutated only when ctx.remote=false.
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
local_path TEXT,
last_commit TEXT,
last_sync_at TIMESTAMPTZ,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
-- v0.20.0 Cathedral II (SP-1): chunker version last used to sync this source.
-- performSync forces a full walk when this mismatches CURRENT_CHUNKER_VERSION,
-- bypassing the git-HEAD up_to_date early-return so CHUNKER_VERSION bumps
-- actually trigger re-chunking on upgrade.
chunker_version TEXT,
-- v0.26.5: soft-delete + recovery window. `archive` flips archived=true and
-- sets archive_expires_at = now() + 72h. The autopilot purge phase
-- hard-deletes rows where archive_expires_at <= now(). Promoted from a
-- JSONB key to real columns to avoid reserved-key footguns and to make the
-- search visibility filter (`NOT s.archived`) a column lookup.
archived BOOLEAN NOT NULL DEFAULT false,
archived_at TIMESTAMPTZ,
archive_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Seed the default source. 'default' is federated=true for backward compat
-- (pre-v0.17 brains behave exactly as before — every page appears in search).
-- Pre-existing sync.repo_path / sync.last_commit are copied in by the v16
-- migration, not here; fresh installs have no local_path until `sources add`
-- or the first `sync`.
INSERT INTO sources (id, name, config)
VALUES ('default', 'default', '{"federated": true}'::jsonb)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- pages: the core content table
-- ============================================================
-- v0.18.0 (Step 2): pages.source_id scopes each row to a sources(id) row.
-- Slugs are unique per source, NOT globally. The default source is
-- seeded in the sources block above so the DEFAULT 'default' FK is
-- always valid at INSERT time.
CREATE TABLE IF NOT EXISTS pages (
id SERIAL PRIMARY KEY,
source_id TEXT NOT NULL DEFAULT 'default'
REFERENCES sources(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
type TEXT NOT NULL,
-- v0.19.0: distinguishes markdown vs code pages at the DB level.
-- Drives orphans filter, auto-link bypass, and `query --lang`.
page_kind TEXT NOT NULL DEFAULT 'markdown'
CHECK (page_kind IN ('markdown','code','image')),
title TEXT NOT NULL,
compiled_truth TEXT NOT NULL DEFAULT '',
timeline TEXT NOT NULL DEFAULT '',
frontmatter JSONB NOT NULL DEFAULT '{}',
content_hash TEXT,
-- v0.29: deterministic 0..1 score (tag emotion + take density + Garry-as-holder ratio).
-- Populated by the `recompute_emotional_weight` cycle phase. Default 0.0 so freshly
-- imported pages don't pollute salience ranking before the cycle has run.
emotional_weight REAL NOT NULL DEFAULT 0.0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- v0.26.5: soft-delete + recovery window. `delete_page` sets deleted_at = now()
-- instead of issuing DELETE. The autopilot purge phase hard-deletes pages
-- where deleted_at < now() - 72h. Search and `get_page` filter
-- `WHERE deleted_at IS NULL` by default; `include_deleted: true` opts in.
deleted_at TIMESTAMPTZ,
-- v0.29.1: salience-and-recency, additive opt-in. All NULL by default;
-- only consulted when a caller passes `salience='on'` / `recency='on'` or
-- the new `since`/`until` filter. effective_date_source is a sentinel for
-- the doctor's effective_date_health check (values: 'event_date' | 'date'
-- | 'published' | 'filename' | 'fallback'). salience_touched_at is bumped
-- by recompute_emotional_weight when emotional_weight changes so the
-- salience window picks up newly-salient old pages.
effective_date TIMESTAMPTZ,
effective_date_source TEXT,
import_filename TEXT,
salience_touched_at TIMESTAMPTZ,
CONSTRAINT pages_source_slug_key UNIQUE (source_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_pages_type ON pages(type);
CREATE INDEX IF NOT EXISTS idx_pages_frontmatter ON pages USING GIN(frontmatter);
CREATE INDEX IF NOT EXISTS idx_pages_trgm ON pages USING GIN(title gin_trgm_ops);
-- v0.13.1 #170: avoids 14.6s seqscan on large brains when listing pages newest-first.
CREATE INDEX IF NOT EXISTS idx_pages_updated_at_desc ON pages (updated_at DESC);
-- v0.18.0: source-scoped scans (per /plan-eng-review Section 4).
CREATE INDEX IF NOT EXISTS idx_pages_source_id ON pages(source_id);
-- v0.26.5: partial index supports the autopilot purge sweep
-- (`WHERE deleted_at IS NOT NULL AND deleted_at < now() - INTERVAL '72 hours'`).
-- Search filters (`WHERE deleted_at IS NULL`) do not benefit from this index
-- (predicate doesn't match) and don't need their own — soft-deleted cardinality
-- stays low. Don't add a regular `(deleted_at)` index without measuring.
CREATE INDEX IF NOT EXISTS pages_deleted_at_purge_idx
ON pages (deleted_at) WHERE deleted_at IS NOT NULL;
-- v0.29.1: expression index used by since/until date-range filters that read
-- COALESCE(effective_date, updated_at). A partial index on effective_date
-- alone would NOT help — the planner can't use it for the negative side of
-- the COALESCE. Expression index is what actually accelerates the filter.
CREATE INDEX IF NOT EXISTS pages_coalesce_date_idx
ON pages ((COALESCE(effective_date, updated_at)));
-- ============================================================
-- content_chunks: chunked content with embeddings
-- ============================================================
CREATE TABLE IF NOT EXISTS content_chunks (
id SERIAL PRIMARY KEY,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
chunk_text TEXT NOT NULL,
chunk_source TEXT NOT NULL DEFAULT 'compiled_truth',
embedding vector(1536),
model TEXT NOT NULL DEFAULT 'text-embedding-3-large',
token_count INTEGER,
embedded_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- v0.19.0: code chunk metadata. Nullable — markdown chunks leave these NULL.
-- Powers `query --lang`, `code-def <symbol>`, and `code-refs <symbol>`.
language TEXT,
symbol_name TEXT,
symbol_type TEXT,
start_line INTEGER,
end_line INTEGER,
-- v0.20.0 Cathedral II: qualified symbol identity + parent scope + doc-comment
-- + chunk-grain FTS. All nullable — markdown chunks leave these NULL.
parent_symbol_path TEXT[],
doc_comment TEXT,
symbol_name_qualified TEXT,
search_vector TSVECTOR,
-- v0.27.1 multimodal. modality discriminates text vs image rows for search
-- filtering. embedding_image holds 1024-dim Voyage multimodal vectors;
-- mixed-provider brains (e.g. OpenAI 1536 text + Voyage 1024 images) keep
-- both columns populated with distinct dim spaces.
modality TEXT NOT NULL DEFAULT 'text',
embedding_image vector(1024)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_page_index ON content_chunks(page_id, chunk_index);
CREATE INDEX IF NOT EXISTS idx_chunks_page ON content_chunks(page_id);
CREATE INDEX IF NOT EXISTS idx_chunks_embedding ON content_chunks USING hnsw (embedding vector_cosine_ops);
-- v0.19.0: partial indexes — only code chunks populate these columns.
CREATE INDEX IF NOT EXISTS idx_chunks_symbol_name ON content_chunks(symbol_name) WHERE symbol_name IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_chunks_language ON content_chunks(language) WHERE language IS NOT NULL;
-- v0.27.1: partial HNSW for multimodal images. Footprint stays proportional
-- to image-chunk count, not table size.
CREATE INDEX IF NOT EXISTS idx_chunks_embedding_image
ON content_chunks USING hnsw (embedding_image vector_cosine_ops)
WHERE embedding_image IS NOT NULL;
-- v0.20.0 Cathedral II: GIN index on the new chunk-grain FTS vector.
CREATE INDEX IF NOT EXISTS idx_chunks_search_vector ON content_chunks USING GIN(search_vector);
CREATE INDEX IF NOT EXISTS idx_chunks_symbol_qualified
ON content_chunks(symbol_name_qualified) WHERE symbol_name_qualified IS NOT NULL;
-- v0.20.0 Cathedral II: chunk-grain FTS trigger.
-- Weight 'A' on doc_comment + symbol_name_qualified; weight 'B' on chunk_text.
-- NL queries ("how do we handle errors") rank doc-comment hits above body text.
-- BEFORE INSERT OR UPDATE OF specific columns — only refires when those change,
-- not on every chunk update (e.g., embedding refresh doesn't trigger rebuild).
CREATE OR REPLACE FUNCTION update_chunk_search_vector() RETURNS TRIGGER AS $fn$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', COALESCE(NEW.doc_comment, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.symbol_name_qualified, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.chunk_text, '')), 'B');
RETURN NEW;
END;
$fn$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS chunk_search_vector_trigger ON content_chunks;
CREATE TRIGGER chunk_search_vector_trigger
BEFORE INSERT OR UPDATE OF chunk_text, doc_comment, symbol_name_qualified
ON content_chunks
FOR EACH ROW EXECUTE FUNCTION update_chunk_search_vector();
-- ============================================================
-- code_edges_chunk + code_edges_symbol: v0.20.0 Cathedral II structural edges
-- ============================================================
-- Two-table design (codex F4 + SP-7):
-- - code_edges_chunk: resolved edges (both endpoints = known chunk IDs)
-- - code_edges_symbol: unresolved refs (target known by qualified name,
-- defining chunk not yet imported)
-- Readers UNION both tables; no promotion step.
-- Source scoping: from_chunk_id -> content_chunks -> pages.source_id
-- determines the source. Resolution logic MUST scope on source (codex SP-3);
-- only --all-sources callers bypass this. UNIQUE keys don't include source_id
-- because from_chunk_id already pins it.
CREATE TABLE IF NOT EXISTS code_edges_chunk (
id SERIAL PRIMARY KEY,
from_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
to_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
from_symbol_qualified TEXT NOT NULL,
to_symbol_qualified TEXT NOT NULL,
edge_type TEXT NOT NULL,
edge_metadata JSONB NOT NULL DEFAULT '{}',
source_id TEXT REFERENCES sources(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT code_edges_chunk_unique UNIQUE (from_chunk_id, to_chunk_id, edge_type)
);
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_from
ON code_edges_chunk(from_chunk_id, edge_type);
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_to
ON code_edges_chunk(to_chunk_id, edge_type);
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_to_symbol
ON code_edges_chunk(to_symbol_qualified, edge_type);
CREATE TABLE IF NOT EXISTS code_edges_symbol (
id SERIAL PRIMARY KEY,
from_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
from_symbol_qualified TEXT NOT NULL,
to_symbol_qualified TEXT NOT NULL,
edge_type TEXT NOT NULL,
edge_metadata JSONB NOT NULL DEFAULT '{}',
source_id TEXT REFERENCES sources(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT code_edges_symbol_unique UNIQUE (from_chunk_id, to_symbol_qualified, edge_type)
);
CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_from
ON code_edges_symbol(from_chunk_id, edge_type);
CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_to
ON code_edges_symbol(to_symbol_qualified, edge_type);
-- ============================================================
-- links: cross-references between pages
-- ============================================================
-- Provenance model (v0.13):
-- link_source — 'markdown' | 'frontmatter' | 'manual' | NULL
-- (NULL = legacy row written before v0.13; unknown source)
-- origin_page_id — for link_source='frontmatter', the page whose YAML
-- frontmatter created this edge; scopes reconciliation
-- origin_field — the frontmatter field name (e.g. 'key_people')
--
-- The unique constraint includes link_source + origin_page_id so a manual edge
-- and a frontmatter-derived edge with the same (from, to, type) tuple coexist.
-- Reconciliation on put_page filters by (link_source='frontmatter' AND
-- origin_page_id = written_page) — never touches other pages' edges.
CREATE TABLE IF NOT EXISTS links (
id SERIAL PRIMARY KEY,
from_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
to_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
link_type TEXT NOT NULL DEFAULT '',
context TEXT NOT NULL DEFAULT '',
link_source TEXT CHECK (link_source IS NULL OR link_source IN ('markdown', 'frontmatter', 'manual')),
origin_page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL,
origin_field TEXT,
-- v0.18.0 Step 4: 'qualified' when the link was written as
-- [[source:slug]] (target source pinned). 'unqualified' when written
-- as bare [[slug]] and resolved via local-first fallback at
-- extraction time. NULL for legacy/manual/frontmatter edges.
resolution_type TEXT CHECK (resolution_type IS NULL OR resolution_type IN ('qualified', 'unqualified')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- NULLS NOT DISTINCT (PG15+) so two rows with link_source IS NULL or
-- origin_page_id IS NULL collide as expected. Without this, every row with
-- NULL origin_page_id (markdown/manual edges) would be treated as unique.
CONSTRAINT links_from_to_type_source_origin_unique
UNIQUE NULLS NOT DISTINCT (from_page_id, to_page_id, link_type, link_source, origin_page_id)
);
CREATE INDEX IF NOT EXISTS idx_links_from ON links(from_page_id);
CREATE INDEX IF NOT EXISTS idx_links_to ON links(to_page_id);
CREATE INDEX IF NOT EXISTS idx_links_source ON links(link_source);
CREATE INDEX IF NOT EXISTS idx_links_origin ON links(origin_page_id);
-- ============================================================
-- tags
-- ============================================================
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
UNIQUE(page_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag);
CREATE INDEX IF NOT EXISTS idx_tags_page_id ON tags(page_id);
-- ============================================================
-- raw_data: sidecar data (replaces .raw/ JSON files)
-- ============================================================
CREATE TABLE IF NOT EXISTS raw_data (
id SERIAL PRIMARY KEY,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
source TEXT NOT NULL,
data JSONB NOT NULL,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(page_id, source)
);
CREATE INDEX IF NOT EXISTS idx_raw_data_page ON raw_data(page_id);
-- ============================================================
-- timeline_entries: structured timeline
-- ============================================================
CREATE TABLE IF NOT EXISTS timeline_entries (
id SERIAL PRIMARY KEY,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
date DATE NOT NULL,
source TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_timeline_page ON timeline_entries(page_id);
CREATE INDEX IF NOT EXISTS idx_timeline_date ON timeline_entries(date);
-- Dedup constraint: same (page, date, summary) treated as same event
CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_dedup ON timeline_entries(page_id, date, summary);
-- ============================================================
-- page_versions: snapshot history for compiled_truth
-- ============================================================
CREATE TABLE IF NOT EXISTS page_versions (
id SERIAL PRIMARY KEY,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
compiled_truth TEXT NOT NULL,
frontmatter JSONB NOT NULL DEFAULT '{}',
snapshot_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_versions_page ON page_versions(page_id);
-- ============================================================
-- ingest_log
-- ============================================================
-- v0.31.2 (codex P1 #3): source_id added so facts:absorb logging
-- (runFactsBackstop / writeFactsAbsorbLog) and doctor's
-- facts_extraction_health check can scope failure counts per source.
-- Migration v47 ALTERs existing brains; this inline definition covers
-- fresh installs.
CREATE TABLE IF NOT EXISTS ingest_log (
id SERIAL PRIMARY KEY,
source_id TEXT NOT NULL DEFAULT 'default',
source_type TEXT NOT NULL,
source_ref TEXT NOT NULL,
pages_updated JSONB NOT NULL DEFAULT '[]',
summary TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_ingest_log_source_type_created
ON ingest_log (source_id, source_type, created_at DESC);
-- ============================================================
-- config: brain-level settings
-- ============================================================
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT INTO config (key, value) VALUES
('version', '1'),
('embedding_model', 'text-embedding-3-large'),
('embedding_dimensions', '1536'),
('chunk_strategy', 'semantic')
ON CONFLICT (key) DO NOTHING;
-- ============================================================
-- access_tokens: bearer tokens for remote MCP access
-- ============================================================
CREATE TABLE IF NOT EXISTS access_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scopes TEXT[],
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens (token_hash) WHERE revoked_at IS NULL;
-- ============================================================
-- mcp_request_log: usage logging for remote MCP requests
-- ============================================================
CREATE TABLE IF NOT EXISTS mcp_request_log (
id SERIAL PRIMARY KEY,
token_name TEXT,
agent_name TEXT,
operation TEXT NOT NULL,
latency_ms INTEGER,
status TEXT NOT NULL DEFAULT 'success',
params JSONB,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- OAuth 2.1: clients, tokens, authorization codes
-- ============================================================
CREATE TABLE IF NOT EXISTS oauth_clients (
client_id TEXT PRIMARY KEY,
client_secret_hash TEXT,
client_name TEXT NOT NULL,
redirect_uris TEXT[],
grant_types TEXT[] DEFAULT '{"client_credentials"}',
scope TEXT,
token_endpoint_auth_method TEXT,
client_id_issued_at BIGINT,
client_secret_expires_at BIGINT,
token_ttl INTEGER,
deleted_at TIMESTAMPTZ,
source_id TEXT REFERENCES sources(id) ON DELETE RESTRICT,
federated_read TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- v0.34.1 (#861, D13 + #876): source_id is the write-source scope;
-- federated_read is the read-source array. Migrations v60-v65 land both
-- columns on upgrade; fresh installs include them inline above.
CREATE INDEX IF NOT EXISTS idx_oauth_clients_source_id
ON oauth_clients(source_id) WHERE source_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_oauth_clients_federated_read
ON oauth_clients USING GIN (federated_read);
CREATE TABLE IF NOT EXISTS oauth_tokens (
token_hash TEXT PRIMARY KEY,
token_type TEXT NOT NULL,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
scopes TEXT[],
expires_at BIGINT,
resource TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_expiry ON oauth_tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_client ON oauth_tokens(client_id);
CREATE TABLE IF NOT EXISTS oauth_codes (
code_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
scopes TEXT[],
code_challenge TEXT NOT NULL,
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
redirect_uri TEXT NOT NULL,
state TEXT,
resource TEXT,
expires_at BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Composite indexes for admin dashboard request log queries
CREATE INDEX IF NOT EXISTS idx_mcp_log_time_agent ON mcp_request_log(created_at, token_name);
CREATE INDEX IF NOT EXISTS idx_mcp_log_agent_time ON mcp_request_log(agent_name, created_at DESC);
-- ============================================================
-- files: binary attachments stored in Supabase Storage
-- ============================================================
-- v0.18.0 Step 7: files gains source_id + page_id alongside the
-- legacy page_slug (kept for backward compat until a later release).
-- The file_migration_ledger below drives the storage object rewrite.
-- page_slug FK had ON UPDATE CASCADE — removed because slugs are no
-- longer global (composite UNIQUE) so CASCADE on-update is ambiguous.
-- ON DELETE SET NULL is preserved via both page_slug and page_id.
CREATE TABLE IF NOT EXISTS files (
id SERIAL PRIMARY KEY,
source_id TEXT NOT NULL DEFAULT 'default'
REFERENCES sources(id) ON DELETE CASCADE,
page_slug TEXT,
page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
storage_path TEXT NOT NULL,
mime_type TEXT,
size_bytes BIGINT,
content_hash TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(storage_path)
);
-- Migration: drop storage_url if it exists (renamed to storage_path only)
ALTER TABLE files DROP COLUMN IF EXISTS storage_url;
CREATE INDEX IF NOT EXISTS idx_files_page ON files(page_slug);
CREATE INDEX IF NOT EXISTS idx_files_page_id ON files(page_id);
CREATE INDEX IF NOT EXISTS idx_files_source_id ON files(source_id);
CREATE INDEX IF NOT EXISTS idx_files_hash ON files(content_hash);
-- ============================================================
-- file_migration_ledger (v0.18.0 Step 7)
-- Drives the storage-object rewrite performed by the v0_18_0
-- orchestrator's phase B. Keyed on file_id so two sources can share
-- an old path during migration without PK collision (Codex second-
-- pass caught this).
-- Status state machine: pending → copy_done → db_updated → complete
-- ============================================================
CREATE TABLE IF NOT EXISTS file_migration_ledger (
file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
storage_path_old TEXT NOT NULL,
storage_path_new TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_ledger_status CHECK (status IN ('pending','copy_done','db_updated','complete','failed'))
);
CREATE INDEX IF NOT EXISTS idx_file_migration_ledger_status
ON file_migration_ledger(status) WHERE status != 'complete';
-- ============================================================
-- Trigger-based search_vector (spans pages + timeline_entries)
-- ============================================================
ALTER TABLE pages ADD COLUMN IF NOT EXISTS search_vector tsvector;
CREATE INDEX IF NOT EXISTS idx_pages_search ON pages USING GIN(search_vector);
-- Function to rebuild search_vector for a page
CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger AS $$
DECLARE
timeline_text TEXT;
BEGIN
-- Gather timeline_entries text for this page
SELECT coalesce(string_agg(summary || ' ' || detail, ' '), '')
INTO timeline_text
FROM timeline_entries
WHERE page_id = NEW.id;
-- Build weighted tsvector
NEW.search_vector :=
setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW.compiled_truth, '')), 'B') ||
setweight(to_tsvector('english', coalesce(NEW.timeline, '')), 'C') ||
setweight(to_tsvector('english', coalesce(timeline_text, '')), 'C');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_pages_search_vector ON pages;
CREATE TRIGGER trg_pages_search_vector
BEFORE INSERT OR UPDATE ON pages
FOR EACH ROW
EXECUTE FUNCTION update_page_search_vector();
-- Note: timeline_entries trigger removed (v0.10.1).
-- Structured timeline_entries power temporal queries (graph layer).
-- The markdown timeline section in pages.timeline still feeds search_vector via
-- the trg_pages_search_vector trigger above. Removing the timeline_entries
-- trigger avoids double-weighting the same content in search and prevents
-- mutation-induced reordering during timeline-extract pagination.
DROP TRIGGER IF EXISTS trg_timeline_search_vector ON timeline_entries;
DROP FUNCTION IF EXISTS update_page_search_vector_from_timeline();
-- ============================================================
-- Minion Jobs: BullMQ-inspired Postgres-native job queue
-- ============================================================
CREATE TABLE IF NOT EXISTS minion_jobs (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
queue TEXT NOT NULL DEFAULT 'default',
status TEXT NOT NULL DEFAULT 'waiting',
priority INTEGER NOT NULL DEFAULT 0,
data JSONB NOT NULL DEFAULT '{}',
max_attempts INTEGER NOT NULL DEFAULT 3,
attempts_made INTEGER NOT NULL DEFAULT 0,
attempts_started INTEGER NOT NULL DEFAULT 0,
backoff_type TEXT NOT NULL DEFAULT 'exponential',
backoff_delay INTEGER NOT NULL DEFAULT 1000,
backoff_jitter REAL NOT NULL DEFAULT 0.2,
stalled_counter INTEGER NOT NULL DEFAULT 0,
max_stalled INTEGER NOT NULL DEFAULT 5,
lock_token TEXT,
lock_until TIMESTAMPTZ,
delay_until TIMESTAMPTZ,
parent_job_id INTEGER REFERENCES minion_jobs(id) ON DELETE SET NULL,
on_child_fail TEXT NOT NULL DEFAULT 'fail_parent',
tokens_input INTEGER NOT NULL DEFAULT 0,
tokens_output INTEGER NOT NULL DEFAULT 0,
tokens_cache_read INTEGER NOT NULL DEFAULT 0,
result JSONB,
progress JSONB,
error_text TEXT,
stacktrace JSONB DEFAULT '[]',
depth INTEGER NOT NULL DEFAULT 0,
max_children INTEGER,
timeout_ms INTEGER,
timeout_at TIMESTAMPTZ,
remove_on_complete BOOLEAN NOT NULL DEFAULT FALSE,
remove_on_fail BOOLEAN NOT NULL DEFAULT FALSE,
idempotency_key TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_status CHECK (status IN ('waiting','active','completed','failed','delayed','dead','cancelled','waiting-children','paused')),
CONSTRAINT chk_backoff_type CHECK (backoff_type IN ('fixed','exponential')),
CONSTRAINT chk_on_child_fail CHECK (on_child_fail IN ('fail_parent','remove_dep','ignore','continue')),
CONSTRAINT chk_jitter_range CHECK (backoff_jitter >= 0.0 AND backoff_jitter <= 1.0),
CONSTRAINT chk_attempts_order CHECK (attempts_made <= attempts_started),
CONSTRAINT chk_nonnegative CHECK (attempts_made >= 0 AND attempts_started >= 0 AND stalled_counter >= 0 AND max_attempts >= 1 AND max_stalled >= 0),
CONSTRAINT chk_depth_nonnegative CHECK (depth >= 0),
CONSTRAINT chk_max_children_positive CHECK (max_children IS NULL OR max_children > 0),
CONSTRAINT chk_timeout_positive CHECK (timeout_ms IS NULL OR timeout_ms > 0)
);
CREATE INDEX IF NOT EXISTS idx_minion_jobs_claim ON minion_jobs (queue, priority ASC, created_at ASC) WHERE status = 'waiting';
CREATE INDEX IF NOT EXISTS idx_minion_jobs_status ON minion_jobs(status);
CREATE INDEX IF NOT EXISTS idx_minion_jobs_stalled ON minion_jobs (lock_until) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_minion_jobs_delayed ON minion_jobs (delay_until) WHERE status = 'delayed';
CREATE INDEX IF NOT EXISTS idx_minion_jobs_parent ON minion_jobs(parent_job_id);
CREATE INDEX IF NOT EXISTS idx_minion_jobs_timeout ON minion_jobs (timeout_at) WHERE status = 'active' AND timeout_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_minion_jobs_parent_status ON minion_jobs (parent_job_id, status) WHERE parent_job_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_minion_jobs_idempotency ON minion_jobs (idempotency_key) WHERE idempotency_key IS NOT NULL;
-- Inbox table for sidechannel messaging
CREATE TABLE IF NOT EXISTS minion_inbox (
id SERIAL PRIMARY KEY,
job_id INTEGER NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
sender TEXT NOT NULL,
payload JSONB NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
read_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_minion_inbox_unread ON minion_inbox (job_id) WHERE read_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_minion_inbox_child_done ON minion_inbox (job_id, sent_at) WHERE payload->>'type' = 'child_done';
-- Attachments table: per-job binary blobs (manifests, agent outputs, files)
CREATE TABLE IF NOT EXISTS minion_attachments (
id SERIAL PRIMARY KEY,
job_id INTEGER NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
content BYTEA,
storage_uri TEXT,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uniq_minion_attachments_job_filename UNIQUE (job_id, filename),
CONSTRAINT chk_attachment_storage CHECK (content IS NOT NULL OR storage_uri IS NOT NULL),
CONSTRAINT chk_attachment_size CHECK (size_bytes >= 0)
);
CREATE INDEX IF NOT EXISTS idx_minion_attachments_job ON minion_attachments (job_id);
ALTER TABLE minion_attachments ALTER COLUMN content SET STORAGE EXTERNAL;
-- ============================================================
-- Subagent runtime (v0.16.0) — durable LLM loops
-- ============================================================
-- Anthropic-native message blocks, one row per Messages API message. Parallel
-- tool_use blocks in one assistant message live in content_blocks JSONB,
-- not across rows.
CREATE TABLE IF NOT EXISTS subagent_messages (
id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
message_idx INTEGER NOT NULL,
role TEXT NOT NULL,
-- v0.27+ stores provider-neutral ChatBlock[] when schema_version=2; legacy
-- Anthropic-shape blocks when schema_version=1 (pre-v0.27 jobs replay).
content_blocks JSONB NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 1,
-- Recipe id of the provider that produced this turn (e.g. 'anthropic',
-- 'openai', 'deepseek'). NULL on legacy v1 rows; set on v2.
provider_id TEXT,
tokens_in INTEGER,
tokens_out INTEGER,
tokens_cache_read INTEGER,
tokens_cache_create INTEGER,
model TEXT,
ended_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uniq_subagent_messages_idx UNIQUE (job_id, message_idx),
CONSTRAINT chk_subagent_messages_role CHECK (role IN ('user','assistant'))
);
CREATE INDEX IF NOT EXISTS idx_subagent_messages_job ON subagent_messages (job_id, message_idx);
CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider ON subagent_messages (job_id, provider_id);
-- Two-phase tool execution ledger. Before tool call: INSERT status='pending'.
-- After success: UPDATE to 'complete' + output. On failure: 'failed' + error.
-- Replay re-runs 'pending' rows only if the tool is idempotent.
CREATE TABLE IF NOT EXISTS subagent_tool_executions (
id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
message_idx INTEGER NOT NULL,
tool_use_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
input JSONB NOT NULL,
status TEXT NOT NULL,
output JSONB,
error TEXT,
schema_version INTEGER NOT NULL DEFAULT 1,
provider_id TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
CONSTRAINT uniq_subagent_tools_use_id UNIQUE (job_id, tool_use_id),
CONSTRAINT chk_subagent_tools_status CHECK (status IN ('pending','complete','failed'))
);
CREATE INDEX IF NOT EXISTS idx_subagent_tools_job ON subagent_tool_executions (job_id, status);
-- Rate-lease table — concurrency cap on outbound providers (e.g.
-- anthropic:messages). Acquire: INSERT if active < max_concurrent under
-- advisory lock. Release: DELETE. Stale leases (expires_at past) auto-prune
-- on next acquire so crashed workers can't strand capacity.
CREATE TABLE IF NOT EXISTS subagent_rate_leases (
id BIGSERIAL PRIMARY KEY,
key TEXT NOT NULL,
owner_job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rate_leases_key_expires ON subagent_rate_leases (key, expires_at);
-- ============================================================
-- Dream-cycle significance verdict cache — v0.21 synthesize phase
-- ============================================================
-- Caches the cheap Haiku "is this transcript worth processing?" verdict
-- per (file_path, content_hash) so backfill re-runs skip already-judged
-- files. Distinct from raw_data (which is page-scoped); transcripts
-- aren't pages.
CREATE TABLE IF NOT EXISTS dream_verdicts (
file_path TEXT NOT NULL,
content_hash TEXT NOT NULL,
worth_processing BOOLEAN NOT NULL,
reasons JSONB,
judged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (file_path, content_hash)
);
-- ============================================================
-- Cycle coordination lock — v0.17 runCycle primitive
-- ============================================================
-- One row per active cycle. Any caller (autopilot daemon, Minions
-- autopilot-cycle handler, gbrain dream CLI) tries to acquire this
-- row before running a DB-write phase. Holders refresh ttl_expires_at
-- between phases; crashed holders auto-release once TTL expires.
-- Works through PgBouncer transaction pooling, unlike session-scoped
-- pg_try_advisory_lock.
CREATE TABLE IF NOT EXISTS gbrain_cycle_locks (
id TEXT PRIMARY KEY,
holder_pid INT NOT NULL,
holder_host TEXT,
acquired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ttl_expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cycle_locks_ttl ON gbrain_cycle_locks(ttl_expires_at);
-- ============================================================
-- Eval capture (v0.25.0 — BrainBench-Real substrate)
-- ============================================================
-- eval_candidates: captured query/search calls from the op-layer wrapper
-- in src/core/operations.ts. PII is scrubbed before insert by
-- src/core/eval-capture-scrub.ts. query is CHECK-capped at 50KB.
-- eval_capture_failures: cross-process audit of insert failures, surfaced
-- by `gbrain doctor` (in-process counters can't bridge MCP server + doctor
-- CLI process boundaries).
CREATE TABLE IF NOT EXISTS eval_candidates (
id SERIAL PRIMARY KEY,
tool_name TEXT NOT NULL CHECK (tool_name IN ('query', 'search')),
query TEXT NOT NULL CHECK (length(query) <= 51200),
retrieved_slugs TEXT[] NOT NULL DEFAULT '{}',
retrieved_chunk_ids INTEGER[] NOT NULL DEFAULT '{}',
source_ids TEXT[] NOT NULL DEFAULT '{}',
expand_enabled BOOLEAN,
detail TEXT CHECK (detail IS NULL OR detail IN ('low', 'medium', 'high')),
detail_resolved TEXT CHECK (detail_resolved IS NULL OR detail_resolved IN ('low', 'medium', 'high')),
vector_enabled BOOLEAN NOT NULL,
expansion_applied BOOLEAN NOT NULL,
latency_ms INTEGER NOT NULL,
remote BOOLEAN NOT NULL,
job_id INTEGER,
subagent_id INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- v0.29.1 — agent-explicit recency + salience capture for replay reproducibility.
-- All nullable + additive. NDJSON schema_version stays at 1; consumers ignore unknown fields.
as_of_ts TIMESTAMPTZ,
salience_param TEXT,
recency_param TEXT,
salience_resolved TEXT,
recency_resolved TEXT,
salience_source TEXT,
recency_source TEXT
);
CREATE INDEX IF NOT EXISTS idx_eval_candidates_created_at ON eval_candidates(created_at DESC);
CREATE TABLE IF NOT EXISTS eval_capture_failures (
id SERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
reason TEXT NOT NULL CHECK (reason IN ('db_down', 'rls_reject', 'check_violation', 'scrubber_exception', 'other'))
);
CREATE INDEX IF NOT EXISTS idx_eval_capture_failures_ts ON eval_capture_failures(ts DESC);
-- eval_takes_quality_runs (v0.32 — EXP-5): DB-authoritative receipts for the
-- takes-quality eval CLI. 4-sha unique key (corpus, prompt, models, rubric)
-- so re-running the same run is a no-op (ON CONFLICT DO NOTHING) and a
-- future rubric tweak segregates trend rows cleanly. receipt_json carries
-- the full receipt blob so `replay` can reconstruct when the disk artifact
-- is missing. Mirrored in src/core/pglite-schema.ts + migration v49.
CREATE TABLE IF NOT EXISTS eval_takes_quality_runs (
id BIGSERIAL PRIMARY KEY,
receipt_sha8_corpus TEXT NOT NULL,
receipt_sha8_prompt TEXT NOT NULL,
receipt_sha8_models TEXT NOT NULL,
receipt_sha8_rubric TEXT NOT NULL,
rubric_version TEXT NOT NULL,
verdict TEXT NOT NULL CHECK (verdict IN ('pass','fail','inconclusive')),
overall_score REAL NOT NULL,
dim_scores JSONB NOT NULL,
cost_usd REAL NOT NULL,
receipt_json JSONB NOT NULL,
receipt_disk_path TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (receipt_sha8_corpus, receipt_sha8_prompt, receipt_sha8_models, receipt_sha8_rubric)
);
CREATE INDEX IF NOT EXISTS eval_takes_quality_runs_trend_idx
ON eval_takes_quality_runs (rubric_version, created_at DESC);
-- eval_contradictions_cache (v0.32.6): persistent judge verdicts for the
-- contradiction probe. Composite primary key includes prompt_version +
-- truncation_policy so any prompt edit cleanly invalidates prior verdicts
-- (Codex outside-voice fix). TTL via expires_at; cache.ts sweeps periodically.
CREATE TABLE IF NOT EXISTS eval_contradictions_cache (
chunk_a_hash TEXT NOT NULL,
chunk_b_hash TEXT NOT NULL,
model_id TEXT NOT NULL,
prompt_version TEXT NOT NULL,
truncation_policy TEXT NOT NULL,
verdict JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (chunk_a_hash, chunk_b_hash, model_id, prompt_version, truncation_policy)
);
CREATE INDEX IF NOT EXISTS eval_contradictions_cache_expires_idx
ON eval_contradictions_cache (expires_at);
-- eval_contradictions_runs (v0.32.6): time-series tracking for the probe.
-- One row per run; source for the `trend` sub-subcommand and the doctor
-- `contradictions` check. report_json carries the full ProbeReport for replay.
CREATE TABLE IF NOT EXISTS eval_contradictions_runs (
run_id TEXT PRIMARY KEY,
ran_at TIMESTAMPTZ NOT NULL DEFAULT now(),
schema_version INTEGER NOT NULL DEFAULT 1,
judge_model TEXT NOT NULL,
prompt_version TEXT NOT NULL,
queries_evaluated INTEGER NOT NULL,
queries_with_contradiction INTEGER NOT NULL,
total_contradictions_flagged INTEGER NOT NULL,
wilson_ci_lower REAL NOT NULL,
wilson_ci_upper REAL NOT NULL,
judge_errors_total INTEGER NOT NULL,
cost_usd_total REAL NOT NULL,
duration_ms INTEGER NOT NULL,
source_tier_breakdown JSONB NOT NULL,
report_json JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS eval_contradictions_runs_ran_at_idx
ON eval_contradictions_runs (ran_at DESC);
-- ============================================================
-- v0.36.1.0 Hindsight calibration wave (migrations v67-v71)
-- ============================================================
-- See src/core/migrate.ts for full design notes per table.
--
-- calibration_profiles: per-holder LLM-narrative aggregation of
-- TakesScorecard data. source_id-scoped per v0.34.1 isolation discipline.
-- published flag gates E8 cross-brain mount sharing (D15 asymmetric opt-in).
CREATE TABLE IF NOT EXISTS calibration_profiles (
id BIGSERIAL PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
holder TEXT NOT NULL,
wave_version TEXT NOT NULL DEFAULT 'v0.36.1.0',
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published BOOLEAN NOT NULL DEFAULT false,
total_resolved INTEGER NOT NULL,
brier REAL,
accuracy REAL,
partial_rate REAL,
grade_completion REAL NOT NULL DEFAULT 1.0,
domain_scorecards JSONB NOT NULL,
pattern_statements TEXT[] NOT NULL,
voice_gate_passed BOOLEAN NOT NULL,
voice_gate_attempts SMALLINT NOT NULL,
active_bias_tags TEXT[] NOT NULL,
model_id TEXT NOT NULL,
cost_usd NUMERIC(10,4),
judge_model_agreement REAL
);
CREATE INDEX IF NOT EXISTS calibration_profiles_holder_recent_idx
ON calibration_profiles (source_id, holder, generated_at DESC);
CREATE INDEX IF NOT EXISTS calibration_profiles_bias_tags_gin
ON calibration_profiles USING GIN (active_bias_tags);
CREATE INDEX IF NOT EXISTS calibration_profiles_published_idx
ON calibration_profiles (source_id, published, holder)
WHERE published = true;
-- take_proposals: propose_takes phase queue. Idempotency cache via the
-- composite unique index (source_id, page_slug, content_hash, prompt_version)
-- mirrors v0.23 dream_verdicts. proposal_run_id supports --rollback by run.
CREATE TABLE IF NOT EXISTS take_proposals (
id BIGSERIAL PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
page_slug TEXT NOT NULL,
content_hash TEXT NOT NULL,
prompt_version TEXT NOT NULL,
wave_version TEXT NOT NULL DEFAULT 'v0.36.1.0',
proposed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
proposal_run_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','accepted','rejected','superseded')),
claim_text TEXT NOT NULL,
kind TEXT NOT NULL,
holder TEXT NOT NULL,
weight REAL NOT NULL,
domain TEXT,
dedup_against_fence_rows JSONB,
model_id TEXT NOT NULL,
acted_at TIMESTAMPTZ,
acted_by TEXT,
promoted_row_num INTEGER,
predicted_brier REAL,
predicted_brier_bucket_n INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS take_proposals_idempotency_idx
ON take_proposals (source_id, page_slug, content_hash, prompt_version);
CREATE INDEX IF NOT EXISTS take_proposals_pending_idx
ON take_proposals (source_id, status, proposed_at DESC)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS take_proposals_run_id_idx
ON take_proposals (proposal_run_id);
-- take_grade_cache: grade_takes verdict cache. Composite PK on
-- (take_id, prompt_version, judge_model_id, evidence_signature) means
-- prompt edits OR evidence changes cleanly invalidate prior verdicts.
-- applied=false default + D17 auto-resolve-off-by-default = every fresh
-- install needs operator opt-in before grade verdicts mutate takes table.
CREATE TABLE IF NOT EXISTS take_grade_cache (
take_id BIGINT NOT NULL,
prompt_version TEXT NOT NULL,
judge_model_id TEXT NOT NULL,
evidence_signature TEXT NOT NULL,
wave_version TEXT NOT NULL DEFAULT 'v0.36.1.0',
graded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
verdict TEXT NOT NULL
CHECK (verdict IN ('correct','incorrect','partial','unresolvable')),
confidence REAL NOT NULL,
applied BOOLEAN NOT NULL DEFAULT false,
cost_usd NUMERIC(10,4),
PRIMARY KEY (take_id, prompt_version, judge_model_id, evidence_signature)
);
CREATE INDEX IF NOT EXISTS take_grade_cache_applied_idx
ON take_grade_cache (take_id, applied);
CREATE INDEX IF NOT EXISTS take_grade_cache_wave_idx
ON take_grade_cache (wave_version, graded_at DESC);
-- take_nudge_log: E7 nudge cooldown state. Polymorphic FK — a nudge fires
-- on either a canonical take OR a pending proposal (CDX-5). CHECK enforces
-- exactly one is set.
CREATE TABLE IF NOT EXISTS take_nudge_log (
id BIGSERIAL PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
take_id BIGINT,
proposal_id BIGINT REFERENCES take_proposals(id) ON DELETE CASCADE,
nudge_pattern TEXT NOT NULL,
fired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
channel TEXT NOT NULL DEFAULT 'stderr',
wave_version TEXT NOT NULL DEFAULT 'v0.36.1.0',
CONSTRAINT take_nudge_log_target_xor
CHECK ((take_id IS NOT NULL) <> (proposal_id IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS take_nudge_log_take_cooldown_idx
ON take_nudge_log (take_id, nudge_pattern, fired_at DESC)
WHERE take_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS take_nudge_log_proposal_cooldown_idx
ON take_nudge_log (proposal_id, nudge_pattern, fired_at DESC)
WHERE proposal_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS take_nudge_log_wave_idx
ON take_nudge_log (wave_version, fired_at DESC);
-- think_ab_results (v0.36.1.0 T18 / D19): A/B harness data for
-- `gbrain think --ab`. One row per side-by-side comparison.
CREATE TABLE IF NOT EXISTS think_ab_results (
id BIGSERIAL PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
wave_version TEXT NOT NULL DEFAULT 'v0.36.1.0',
ran_at TIMESTAMPTZ NOT NULL DEFAULT now(),
question TEXT NOT NULL,
baseline_answer TEXT NOT NULL,
with_calibration_answer TEXT NOT NULL,
preferred TEXT NOT NULL CHECK (preferred IN ('baseline','with_calibration','neither','tie')),
model_id TEXT,
notes TEXT
);
CREATE INDEX IF NOT EXISTS think_ab_results_recent_idx
ON think_ab_results (source_id, ran_at DESC);
-- NOTIFY trigger for real-time job events (Postgres only, not PGLite)
CREATE OR REPLACE FUNCTION notify_minion_job_change() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('minion_jobs', json_build_object(
'id', NEW.id, 'status', NEW.status, 'name', NEW.name,
'queue', NEW.queue, 'prev_status', COALESCE(OLD.status, 'new')
)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS minion_job_notify ON minion_jobs;
CREATE TRIGGER minion_job_notify AFTER INSERT OR UPDATE OF status ON minion_jobs
FOR EACH ROW EXECUTE FUNCTION notify_minion_job_change();
-- ============================================================
-- Row Level Security: block anon access, postgres role bypasses
-- ============================================================
-- The postgres role (used by gbrain via pooler) has BYPASSRLS.
-- Enabling RLS with no policies means the anon key can't read anything.
-- Only enable if the current role actually has BYPASSRLS privilege,
-- otherwise we'd lock ourselves out.
DO $$
DECLARE
has_bypass BOOLEAN;
BEGIN
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
IF has_bypass THEN
ALTER TABLE pages ENABLE ROW LEVEL SECURITY;
ALTER TABLE content_chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE links ENABLE ROW LEVEL SECURITY;
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE raw_data ENABLE ROW LEVEL SECURITY;
ALTER TABLE timeline_entries ENABLE ROW LEVEL SECURITY;
ALTER TABLE page_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE ingest_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE config ENABLE ROW LEVEL SECURITY;
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
ALTER TABLE minion_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE sources ENABLE ROW LEVEL SECURITY;
ALTER TABLE file_migration_ledger ENABLE ROW LEVEL SECURITY;
ALTER TABLE access_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE mcp_request_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE minion_inbox ENABLE ROW LEVEL SECURITY;
ALTER TABLE minion_attachments ENABLE ROW LEVEL SECURITY;
ALTER TABLE subagent_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE subagent_tool_executions ENABLE ROW LEVEL SECURITY;
ALTER TABLE subagent_rate_leases ENABLE ROW LEVEL SECURITY;
ALTER TABLE gbrain_cycle_locks ENABLE ROW LEVEL SECURITY;
ALTER TABLE dream_verdicts ENABLE ROW LEVEL SECURITY;
ALTER TABLE eval_candidates ENABLE ROW LEVEL SECURITY;
ALTER TABLE eval_capture_failures ENABLE ROW LEVEL SECURITY;
ALTER TABLE eval_takes_quality_runs ENABLE ROW LEVEL SECURITY;
-- v0.32.6 contradiction probe tables
ALTER TABLE eval_contradictions_cache ENABLE ROW LEVEL SECURITY;
ALTER TABLE eval_contradictions_runs ENABLE ROW LEVEL SECURITY;
-- v0.36.1.0 Hindsight calibration wave tables
ALTER TABLE calibration_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE take_proposals ENABLE ROW LEVEL SECURITY;
ALTER TABLE take_grade_cache ENABLE ROW LEVEL SECURITY;
ALTER TABLE take_nudge_log ENABLE ROW LEVEL SECURITY;
-- v0.26 OAuth 2.1 tables
ALTER TABLE oauth_clients ENABLE ROW LEVEL SECURITY;
ALTER TABLE oauth_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE oauth_codes ENABLE ROW LEVEL SECURITY;
RAISE NOTICE 'RLS enabled on all tables (role % has BYPASSRLS)', current_user;
ELSE
RAISE WARNING 'Skipping RLS: role % does not have BYPASSRLS privilege. Run as postgres role to enable.', current_user;
END IF;
END $$;