Skip to main content

Self-Improvement Loop — Activation Runbook

Operator-facing runbook for turning on the Spec 066 self-improvement loop. The loop is built across ~30 PRs, feature-complete, and flag-gated default OFF — nothing fires in production until an operator flips three flags in order. This runbook walks through pre-flight, the 3-phase rollout, verification at each step, and rollback.

See also: reasoning-flow.md § Self-improvement loop for the architectural reference.

TL;DR — flag flip order

Phase 0 — pre-flight (BAA registry, LLM keys, MEMORY_STORE_URL)
Phase 1 — flip BACKLOG_CONSUMER_ENABLED=1 in research-engine
Phase 2 — flip RESEARCH_AUTO_ENQUEUE_ENABLED=1 in research-engine
Phase 3 — flip CRITIC_AUTO_ENQUEUE_ENABLED=1 in research-engine

Order matters. Consumer first; if a producer fires before the consumer is alive, pending rows accumulate (stale-sweep recovers dispatched rows but not pending ones).

Phase 0 — pre-flight

Run these checks before flipping anything. All must pass.

BAA registry (FR-032)

BaaGovernance startup probe MUST pass on the target environment. As of 2026-04-25 the registry has every vendor signed:

# In research-engine pod — startup logs should show:
research.govern.registry_check.passed env=staging vendor_count=14 pending_compliance_vendors=[]

If pending_compliance_vendors is non-empty, stop. Get those signatures first; the loop sends LLM traffic to OpenAI / Voyage which would fail-closed in production.

LLM keys

The Researcher consumes OPENAI_API_KEY (primary) and GEMINI_API_KEY (fallback). Both must be present in research-engine env or the worker drops to stub mode and the consumer-driven Researcher dispatch returns trivial responses.

# In research-engine pod:
echo "OPENAI_API_KEY length: ${#OPENAI_API_KEY}"
echo "GEMINI_API_KEY length: ${#GEMINI_API_KEY}"
# Both should print > 30

MEMORY_STORE_URL

The consumer reaches memory-store via this URL. Default http://memory-store:6401 works in-cluster; staging may need an explicit override.

Operator CLI ready

# Confirm the inspect CLI runs against staging:
MEMORY_STORE_BASE_URL=https://memory-store-staging.internal \
MEMORY_STORE_ADMIN_TOKEN=$STAGING_ADMIN_TOKEN \
npm run reasoning:loop-inspect

Expected: Loop health: ✓ healthy, all status counts at 0, Recent failures (0).

Phase 1 — Activate consumer

# In research-engine env:
BACKLOG_CONSUMER_ENABLED=1

Restart research-engine. Check startup logs:

BacklogConsumerJob constructed id=backlog_consumer-... interval=60s max_per_tick=10 dispatcher=research_task

If dispatcher=None, the worker isn't alive (no MONGODB_URI?) — fix that first or the consumer will mark every pulled row failed_voluntary with reason no_dispatcher_configured.

Verify

npm run reasoning:loop-inspect
  • Status distribution all 0 (queue is empty).
  • Loop health: ✓ healthy.
  • Recent failures: 0.

The consumer is now polling but has nothing to dispatch. Wait 5+ minutes to confirm it doesn't surface unexpected errors before moving on.

Phase 2 — Activate Researcher producer

# In research-engine env:
RESEARCH_AUTO_ENQUEUE_ENABLED=1

Restart research-engine.

What changes

When the Researcher returns a weak result (zero hits OR composite_k < 0.6) and the call wasn't dispatched-from-backlog, it auto-files a deeper-research backlog row with trigger_type='research_task', requested_by='researcher_auto', and reason='no_evidence_hits' or 'weak_composite_k'.

The consumer (already running from Phase 1) pulls the row, runs the Researcher again with the loop-break marker (dispatched_from_backlog=True) so the auto-enqueue path skips, and completes the row with flow_id (strong result) or error="still_weak:k=..." (weak retry).

Verify

npm run reasoning:loop-inspect --json | jq '.backlog.status_distribution'

Within ~10 minutes you should see pending and completed rising as Researcher invocations naturally trigger weak-result retries.

Audit grep:

# Producer-side audits:
grep "reasoning.research.deeper_request_created" /var/log/audit/...
# Consumer-side audits:
grep "reasoning.research.backlog.dispatched" /var/log/audit/...
grep "reasoning.research.backlog.completed" /var/log/audit/...

If oldest_dispatched_age_seconds climbs past 15 min the loop_health verdict flips to ! warn. Past 25 min: X critical. The CLI's exit code 1 fires on critical so a cron && echo OK || alert paging hook works.

Wait 24 hours before Phase 3 to confirm the Researcher loop is stable. Watch:

  • pending_count shouldn't grow unbounded (consumer should keep up).
  • recent_failures should stay low; investigate any still_weak:k=... clusters (genuinely-broken triggers).
  • oldest_dispatched_age_seconds should stay below 15 min during normal operation.

Phase 3 — Activate Critic producer

# In research-engine env:
CRITIC_AUTO_ENQUEUE_ENABLED=1

Restart research-engine.

What changes

When the Critic issues verdict='reject' on a claim, it auto-files a backlog row with trigger_type='critic_evaluation', trigger_ref=<claim_id>, reason='claim_rejected'. The consumer's BacklogDispatchRouter routes this trigger type to CriticEvaluationMarkForReviewDispatcher, which writes the claim to claims_pending_review for human triage and completes the backlog row with the review_id as flow_id back-ref.

Verify

Open the Research Console at /dashboard/admin/reasoning/research-console. The orange "Claims pending review" panel appears once pending_count > 0.

npm run reasoning:loop-inspect --json | jq '.review.pending_count'

Audit grep:

grep "reasoning.claims_pending_review.marked" /var/log/audit/...

Operator workflow: click Resolve (issue addressed) or Dismiss (false positive). The row drops off the pending panel; a confirmation row appears in the gray "Recently resolved (last 24h)" strip.

Phase 4 — (optional) switch Critic to auto re-research

Default Critic dispatch routes rejected claims to the human-triage queue. After Phase 3 has been running cleanly and the queue dynamics are understood, an operator may choose to switch the Critic-side path to automatic re-research with rejection context instead.

# In research-engine env:
CRITIC_DISPATCH_STRATEGY=re_research

Restart research-engine. Startup logs confirm:

BacklogDispatchRouter critic_evaluation strategy=re_research

What changes

When the Critic rejects a claim, the consumer's CriticReResearchDispatcher looks up the claim, synthesizes a research-task input including the rejection reason, and re-runs the Researcher with dispatched_from_backlog=True (loop-break). On strong re-research the new claim flows through the normal pipeline; on weak re-research the row voluntary-fails with still_weak_after_critic_reject:k=....

When to choose which strategy

StrategyWhen
mark_for_review (default)Critic rejections are mostly semantic — claim is wrong, not just under-supported. Re-research would reproduce the defect. Curator workload acceptable.
re_researchCritic rejections are evidence-quality — low support, low novelty, weak composite_k. Fresh research with rejection context yields stronger evidence. No curator round-trip.

The two strategies are mutually exclusive at runtime — CRITIC_DISPATCH_STRATEGY picks one. Existing pending-review entries from the prior strategy stay in the triage queue (not auto-converted).

Rollback

Flip the env back to mark_for_review (or unset; default is mark_for_review). Restart. New rejections route through the triage queue again; in-flight re_research flows complete normally via the Researcher path.

Rollback

Each flag is reversible at the env-var level:

# Stop a producer (queue stops growing; consumer drains residual):
RESEARCH_AUTO_ENQUEUE_ENABLED=0
CRITIC_AUTO_ENQUEUE_ENABLED=0

# Stop the consumer (residual rows stay pending; stale-sweep ignores
# pending — they sit until TTL expires at 30 days OR until the
# consumer wakes up):
BACKLOG_CONSUMER_ENABLED=0

Restart research-engine after any flip. The loop self-cleans:

  • Stale-sweep (always-on, every 5 min) recovers stuck-dispatched rows.
  • Backlog TTL (30 days on completed_at) expires terminal rows automatically.

Monitoring hooks

ToolUse
npm run reasoning:loop-inspectTerminal summary; exit 1 on critical.
npm run reasoning:loop-inspect --jsonPipe to jq for ad-hoc queries.
Research Console pill (/dashboard/admin/reasoning/research-console)At-a-glance verdict for ops staring at the dashboard.
GET /admin/v1/reasoning/research/backlogRaw observability route for monitoring tools that scrape the loop_health field.
Audit chain (audit_events)Long-term record of every loop transition.

Common anti-patterns

  • Flipping CRITIC_AUTO_ENQUEUE_ENABLED before BACKLOG_CONSUMER_ENABLED. Critic rejections accumulate in pending state with no consumer to drain them. Visible in oldest_pending_age_seconds climbing past 1 hour → loop_health critical. Fix: flip the consumer flag.
  • Setting dispatched_from_backlog=True on organic Researcher calls. The marker is owned by the dispatcher and should never appear in regular research_tasks payloads. Suppresses auto-enqueue silently. Fix: don't.
  • Resolving a claims_pending_review entry twice. The PATCH route returns 409 on already-terminal entries — the UI handles it gracefully (silent refresh) but a custom client should expect this code.