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_countshouldn't grow unbounded (consumer should keep up).recent_failuresshould stay low; investigate anystill_weak:k=...clusters (genuinely-broken triggers).oldest_dispatched_age_secondsshould 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
| Strategy | When |
|---|---|
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_research | Critic 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-
dispatchedrows. - Backlog TTL (30 days on
completed_at) expires terminal rows automatically.
Monitoring hooks
| Tool | Use |
|---|---|
npm run reasoning:loop-inspect | Terminal summary; exit 1 on critical. |
npm run reasoning:loop-inspect --json | Pipe 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/backlog | Raw 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_secondsclimbing past 1 hour → loop_healthcritical. Fix: flip the consumer flag. - Setting
dispatched_from_backlog=Trueon organic Researcher calls. The marker is owned by the dispatcher and should never appear in regularresearch_taskspayloads. Suppresses auto-enqueue silently. Fix: don't. - Resolving a
claims_pending_reviewentry 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.
Related docs
architecture/reasoning-flow#self-improvement-loop— architectural reference.compliance/baa-registry— vendor coverage state (must be all-signed before production).Requirements/Group5/pending-tasks.md— current operator priorities + new follow-ups surfaced post-loop.