Skip to content

TECH.md — bl-221: Decouple cocoindex walk-trigger from boot (retire SOURCEPATH burn-valve)

TECH.md — bl-221: Decouple cocoindex walk-trigger from boot (retire SOURCE_PATH burn-valve)

Section titled “TECH.md — bl-221: Decouple cocoindex walk-trigger from boot (retire SOURCE_PATH burn-valve)”
  • Backlog item: bl-221 (docs/reference/product-backlog.json)
  • Spec depth: single focused TECH.md (no RESEARCH / PRODUCT / PLAN — see §0)
  • Author: Task Planner (spec-only; implements nothing)
  • Date: 04/06/2026
  • Envs in scope: prod (kh-onprem) and staging (kh-onprem-staging)
  • Pinned versions verified: cocoindex[postgres]==1.0.3, anthropic==0.79.0, aiohttp>=3.9.0,<4.0.0 (requirements.txt)

bl-221 warrants a single TECH.md, not the full {RESEARCH → PRODUCT → TECH → PLAN} chain. Rationale:

  1. No user-facing surface / no behavioural ambiguity. This is a backend ops-architecture change to the ingestion pipeline’s boot model. The desired behaviour is already fully and unambiguously stated in the backlog item — there is no PRODUCT-level invariant set to discover.
  2. The one hard unknown is empirically resolved. The question “does cocoindex support a one-shot walk that does not block forever re-walking on every boot?” is answered: the installed cocoindex==1.0.3 App.update_blocking signature is (*, report_to_stdout=False, full_reprocess=False, live=False) -> R. live=False (the default) runs one update pass then returns; live=True (current boot wiring) arms the continuous fs-watch loop. This is the decoupling primitive — see §4 Verification. That collapses the RESEARCH need.
  3. Tightly contained blast radius. gitnexus_context confirms app_main (scripts/cocoindex_pipeline/flow.py:2139-2547) has no incoming graph callers (it is invoked by the cocoindex engine via update_blocking, not a Python call site), and start_cocoindex_thread (scripts/cocoindex_pipeline/server.py:268-325) has exactly one caller — main (server.py:333-354). The change surface is small and well-bounded.
  4. Operator steer. Liam: “I don’t imagine we’d need the full spec chain for the 221 implementation, but defer to the planner’s view.” Concur — single TECH.md.

This TECH.md folds in its own implementation-subtask breakdown (§7) rather than spawning a separate PLAN.md, because the decomposition is modest (5 sibling subtasks, well under the 25-subtask soft ceiling).


1. Context (code-intelligence orientation)

Section titled “1. Context (code-intelligence orientation)”

1.1 gitnexus_query — existing boot/walk execution flow

Section titled “1.1 gitnexus_query — existing boot/walk execution flow”

gitnexus_query({query: 'cocoindex source path walk trigger boot pipeline ingest app_main', repo: 'knowledge-hub'}) returned no grouped execution processes (the boot path runs on a cocoindex-engine daemon thread the graph does not trace into) but surfaced the load-bearing definitions verbatim:

  • Function:scripts/cocoindex_pipeline/flow.py:app_main — lines 2139–2547
  • Function:scripts/cocoindex_pipeline/server.py:start_cocoindex_thread — lines 268–325
  • Function:scripts/cocoindex_pipeline/server.py:main — lines 333–354
  • Function:scripts/cocoindex_pipeline/server.py:build_app — lines 251–260

1.2 gitnexus_context — blast radius of the symbols this spec mandates changing

Section titled “1.2 gitnexus_context — blast radius of the symbols this spec mandates changing”
  • gitnexus_context({name: 'app_main', repo: 'knowledge-hub'})incoming: {} (zero callers); outgoing calls to _classify_stage_exception, _emit_pipeline_run_webhook, _run_stage_5_resolution, load_workspace_manifest, the flow-scope counters, etc. Verdict: LOW — no Python caller to break; the engine invokes it. The app_main body itself is NOT modified by this spec (see §3.1) — only the argument cocoindex passes to update_blocking and the trigger that fires it change.
  • gitnexus_context({name: 'start_cocoindex_thread', repo: 'knowledge-hub'})incoming.calls: [main (server.py)] — exactly one caller. Verdict: LOW, caller count = 1, top affected flow: the server.main() boot sequence only.

1.3 The current trigger model (the problem)

Section titled “1.3 The current trigger model (the problem)”

The only walk-trigger today is “container boots with COCOINDEX_SOURCE_PATH set”. Trace:

  1. server.main() (server.py:333) → start_cocoindex_thread() (server.py:352).
  2. start_cocoindex_thread() spawns a daemon thread whose target runs KH_PIPELINE_APP.update_blocking(live=True) (server.py:310).
  3. update_blocking(live=True) runs app_main() (flow.py:2140), which reads COCOINDEX_SOURCE_PATH (flow.py:2152); if set + exists, it calls localfs.walk_dir(source_path, live=True, recursive=True) (flow.py:2307) → mount_each → per-file ingest_file → Anthropic extraction. live=True then never returns — it holds the fs-watch loop open for the container lifetime.

So deploy (image roll → container restart) and walk (corpus ingest → Anthropic burn) are conflated: every restart with a non-empty COCOINDEX_SOURCE_PATH re-walks the entire corpus and re-burns Anthropic tokens.

1.4 The current burn-valve (what we are retiring)

Section titled “1.4 The current burn-valve (what we are retiring)”

Because of §1.3, the only safety mechanism is to keep COCOINDEX_SOURCE_PATH empty (idle boot). Evidence:

  • app_main O-Q8 idle-mode early-return: SOURCE_PATH unset/missing → log + return before any walk (flow.py:2152-2169).
  • onprem-deploy.yml deploy-cocoindex job deliberately never sets COCOINDEX_SOURCE_PATH (only PATCHes COCOINDEX_IMAGE_TAG) so a deploy/auto-restart is burn-safe by construction (onprem-deploy.yml:205-251, header §24-31).
  • Both compose files default COCOINDEX_SOURCE_PATH: ${COCOINDEX_SOURCE_PATH:-} (empty → idle) — docker-compose.production.yaml:87, docker-compose.staging.yaml:100.
  • Runbook B2 “SOURCE_PATH idle-safe discipline” (docs/runbooks/onprem-b1-deploy.md:148-198) makes “confirm SOURCE_PATH empty before any deploy” a load-bearing manual operator step, after the pipeline burned Anthropic tokens twice across S294–S297.
  • Current live state (from bl-221 notes): prod = empty (safe); staging = /cocoindex-state/corpus then manually disarmed by Liam. The manual blanking IS the burn-valve this spec retires.

The defect: the burn-safety guarantee depends on a human remembering to blank an env var. bl-221 replaces that with an architectural guarantee — a restart can never auto-walk regardless of COCOINDEX_SOURCE_PATH’s value.

1.5 Existing precedents this spec leans on

Section titled “1.5 Existing precedents this spec leans on”
  • Co-resident HTTP route precedent: server.py already serves POST /stage (server.py:150-244) — an aiohttp route co-resident with the cocoindex worker that reads COCOINDEX_SOURCE_PATH and writes into the corpus dir. An HTTP walk-trigger route is a direct structural sibling of this.
  • Bearer-auth cron precedent: the Vercel app has an established app/api/cron/* family (process-queue, coverage-alerts, etc.) and the pipeline already reads CRON_SECRET for the _emit_pipeline_run_webhook bearer (flow.py:690-703; compose envs carry CRON_SECRET). A bearer-gated HTTP trigger reuses this exact secret/pattern.
  • Coolify scheduled-tasks capability: the Coolify MCP exposes scheduled_tasks(resource, action: list|create|update|delete|list_executions, …) with command, frequency (cron expr), container, enabled, timeout fields. scheduled_tasks(action='list', uuid='ybiczck7f7e1xbdev3bk89cr') returned [] — the prod app has no scheduled tasks today (greenfield for this approach).

COCOINDEX_SOURCE_PATH stays set to the corpus path on both envs. The corpus walk fires only on an explicit signal, never on boot/restart. Concretely:

  • G1. Container boot/restart NEVER triggers a corpus walk — regardless of whether COCOINDEX_SOURCE_PATH is set. The worker boots, provisions the DB pool + LMDB engine, serves /health, and waits idle for an explicit walk signal.
  • G2. An explicit walk signal runs exactly one corpus pass (one-shot, non-live), emits the terminal pipeline_runs webhook, and returns the worker to idle.
  • G3. Holds identically on prod (kh-onprem) and staging (kh-onprem-staging). The manual SOURCE_PATH-blanking valve is retired on both.
  • G4. Concurrency-safe: a second walk signal arriving while a walk is in flight is rejected (or queued) — never two concurrent walks burning Anthropic in parallel.
  • G5. Reversible / low-radius — no schema change, no DDL, no change to app_main’s six-stage body, no change to the deploy ordering guards already in onprem-deploy.yml.

3. Trigger-mechanism options (with trade-offs + recommendation)

Section titled “3. Trigger-mechanism options (with trade-offs + recommendation)”

All three options share the same boot-decoupling core (§3.4): flip the boot call from update_blocking(live=True) to a non-walking boot, and expose a one-shot walk (update_blocking(live=False)) behind an explicit signal. They differ only in what delivers the signal.

Section titled “Option A — HTTP/webhook trigger route on the existing aiohttp server (RECOMMENDED)”

Add a bearer-gated POST /walk route to server.py (sibling of the existing /stage route). The handler kicks a one-shot KH_PIPELINE_APP.update_blocking(live=False) on a worker thread, guarded by a single-flight lock, and returns 202 Accepted immediately (walk runs async; result is observed via the existing pipeline_runs webhook + {66.15} datapath monitor).

  • Signal source: anything that can issue an authenticated HTTP POST — a Coolify scheduled task (curl on a cron), the Vercel app, a GitHub Action, or a human with the bearer token.
  • Pros:
    • Reuses the exact existing co-resident-route pattern (/stage, /health in build_app) and the existing CRON_SECRET bearer pattern — minimal new surface, no new process, no new image.
    • Decouples mechanism (HTTP endpoint) from schedule (whoever calls it). The schedule can be a Coolify scheduled task today and a Vercel cron / manual call tomorrow with zero pipeline change — the most flexible substrate.
    • Naturally supports full_reprocess as a query/body flag (maps to update_blocking(full_reprocess=…)), giving an incremental-vs-full walk knob for free.
    • Single-flight lock gives G4 concurrency safety in one place.
    • Identical wire contract on prod + staging (G3) — only the host/port differs.
  • Cons:
    • The route must be reachable by its caller. On the compose network it is reachable host-locally; for an external caller (Vercel/GitHub) staging already exposes a host port (staging compose exposes one; prod does not today). If the trigger caller is a Coolify scheduled task running curl 127.0.0.1:8080/walk inside the container, no external exposure is needed at all — keeps the attack surface zero-external.
    • Async 202 means the caller does not get the walk result inline — acceptable, since the pipeline_runs webhook + {66.15} monitor are the canonical completion signal.

Option B — Coolify scheduled task that execs a one-shot module in the running container

Section titled “Option B — Coolify scheduled task that execs a one-shot module in the running container”

Add a Coolify scheduled task (scheduled_tasks(action='create', resource='application', command='python3 -m scripts.cocoindex_pipeline.walk_once', frequency='<cron>', container='cocoindex')) that execs a new one-shot entrypoint module which runs update_blocking(live=False) once and exits.

  • Pros:
    • Pure scheduling primitive owned by Coolify — no HTTP surface, no auth secret to manage for the trigger itself (Coolify execs inside the container).
    • The one-shot module is trivially also runnable by a human (docker exec … python3 -m scripts.cocoindex_pipeline.walk_once) for a supervised manual walk.
  • Cons:
    • A scheduled task execs a second process in the cocoindex container. That second process re-imports flow.py, re-registers the cocoindex App + lifespan, and opens its own asyncpg pool + attaches to the same LMDB engine store (/cocoindex-state/lmdb) that the long-lived server.py worker also has open. Two processes against one LMDB store is a correctness/locking risk (LMDB single-writer discipline) that needs careful validation — materially higher risk than Option A’s single-process model.
    • Concurrency safety (G4) is harder: a cron fire while a prior cron walk still runs would spawn a second exec. Coolify scheduled tasks have a timeout but no native single-flight guard.
    • Schedule is locked to Coolify cron — less flexible than “any HTTP caller”.
    • Coolify scheduled tasks are per-app config (not git-tracked in the compose), so the schedule lives outside source control — weaker reproducibility than a route in server.py.

Option C — Separate one-shot job container (compose profiles / run-to-completion)

Section titled “Option C — Separate one-shot job container (compose profiles / run-to-completion)”

Define a second compose service (e.g. cocoindex-walk) with restart: "no" that runs python3 -m scripts.cocoindex_pipeline.walk_once to completion and exits, started on-demand (docker compose run / a Coolify “run once” / a profile).

  • Pros:
    • Cleanest conceptual separation of deploy vs walk — the walk is a distinct, obviously-ephemeral unit.
    • The job container’s lifecycle (running/exited) is itself an observability signal.
  • Cons:
    • Same dual-process-on-one-LMDB-store hazard as Option B, plus it needs a second corpus + engine-store mount strategy or careful sharing of the existing volume — the LMDB store is single-writer and the long-lived worker holds it open.
    • Heaviest operational footprint: a second service definition × 2 compose files, plus a trigger to start it (which lands back at “a scheduled task or HTTP call starts the job”) — so it does not actually remove the trigger question, it adds a container to it.
    • Most code + config change for the least marginal benefit over Option A.

Recommendation: **Option A (HTTP/webhook POST /walk route), scheduled by a Coolify

Section titled “Recommendation: **Option A (HTTP/webhook POST /walk route), scheduled by a Coolify”

scheduled task issuing an in-container curl.**

This combines the best of A and B: the mechanism is a single-process, single-flight-locked, auth-gated route that reuses the /stage + CRON_SECRET precedents and keeps one process against the LMDB store (avoiding the B/C dual-writer hazard); the schedule is a Coolify scheduled task that curls http://127.0.0.1:8080/walk from inside the container (so no external exposure, no new secret beyond the existing CRON_SECRET). Because the schedule is decoupled from the mechanism, the same /walk route also serves manual walks (operator curl) and any future Vercel-cron / GitHub-Action driver with zero pipeline change. This directly satisfies G1–G5.

Schedule-config note (Option-A scheduling): the Coolify scheduled task is per-app config, not git-tracked. It is therefore an operator stand-up step (documented in the runbook, §6) rather than a committed artefact — the same operability boundary onprem-deploy.yml already relies on for “git-auto-deploy disabled”. The mechanism (the /walk route + boot decoupling) is fully git-tracked and tested; only the cron cadence lives in Coolify. If a git-tracked schedule is later required, a Vercel app/api/cron/cocoindex-walk route POSTing to the host-exposed /walk is the drop-in upgrade (staging already exposes a host port; prod would add one) — recorded as a follow-up, out of scope for bl-221.


4. Verification (pre-ratification empirical check — Q-EX2 / OQ-3)

Section titled “4. Verification (pre-ratification empirical check — Q-EX2 / OQ-3)”

External-library symbol cited by this spec: cocoindex.App.update_blocking (the load-bearing decoupling primitive).

DatePinned versionSymbol path checkedResult
04/06/2026cocoindex==1.0.3cocoindex.App.update_blockingPRESENT
04/06/2026cocoindex==1.0.3update_blocking keyword live: bool = FalsePRESENT
04/06/2026cocoindex==1.0.3update_blocking keyword full_reprocess: bool = FalsePRESENT
04/06/2026cocoindex==1.0.3cocoindex.App public methods['drop','drop_blocking','update','update_blocking']

Empirical command + observed signature:

$ python3 -c "import cocoindex as c, inspect; print(inspect.signature(c.App.update_blocking))"
(self, *, report_to_stdout: 'bool' = False, full_reprocess: 'bool' = False, live: 'bool' = False) -> 'R'

Interpretation (load-bearing):

  • update_blocking(live=True) — current boot wiring; arms the continuous fs-watch loop; blocks forever and re-walks. This is the conflation.
  • update_blocking(live=False) (default) — runs one update pass over the source then returns. This IS the native one-shot walk primitive the trigger fires (G2). No new cocoindex symbol, no version bump, no API drift — the primitive already ships in the pin.

No ABSENT / SIGNATURE_DRIFT / BEHAVIOUR_DRIFT — the spec is clear to proceed.


5.1 Boot-decoupling change (the core — satisfies G1)

Section titled “5.1 Boot-decoupling change (the core — satisfies G1)”

Today: server.py:start_cocoindex_thread() runs update_blocking(live=True) on a daemon thread at boot, which runs app_main → walks if SOURCE_PATH is set.

Change: at boot, the worker must NOT call any update_blocking that walks. The boot path must provision the cocoindex environment (DB pool via the @coco.lifespan, LMDB engine) and serve /health, but perform zero corpus walk — even when COCOINDEX_SOURCE_PATH is set and the corpus is non-empty.

Two viable boot-decoupling shapes (pick during implementation, validated empirically per §4 discipline):

  • Shape 1 (preferred): boot enters lifespan only, no app run. Boot uses the lifespan-only entry (coco.start_blocking() per the existing server.py docstring — “starts the default environment and ENTERS its lifespan … does NOT run any registered App’s main_fn”), so DB_CTX is provisioned and /health serves, but app_main (and therefore walk_dir) never runs at boot. The /walk route then runs update_blocking(live=False) on demand, which lazily starts the SAME lifespan-bearing default environment and runs app_main once. NOTE: the existing test_cocoindex_server.py boot-path guard asserts coco.start_blocking is NOT called (it currently guards AGAINST the lifespan-only boot regressing the walk). That guard’s intent inverts under bl-221 — the new contract is “boot is lifespan-only; walk is on-demand” — so the guard test must be updated to assert the new invariant (boot does not walk; /walk does). This is an intended, spec-sanctioned test contract change, not a regression.
  • Shape 2 (fallback): boot keeps update_blocking(live=False) once at boot ONLY if an explicit “walk-on-boot” flag is set, else idle. Gate the boot walk behind a new env (e.g. COCOINDEX_WALK_ON_BOOT, default false). When false (the steady-state default on both envs), boot does not walk; the /walk route is the trigger. This keeps the existing update_blocking boot call shape but makes walking opt-in. Slightly larger env surface; retained as fallback if Shape 1’s lifespan-only boot proves awkward against the cocoindex 1.0.3 environment lifecycle.

Either shape MUST preserve the existing crash-flag wiring (mark_worker_crashed() / /health 503 on worker death — server.py:71-147, 311-318) and the O-Q8 idle-mode early-return semantics inside app_main (which stay as a defence-in-depth backstop: SOURCE_PATH genuinely empty still returns cleanly).

5.2 Explicit walk trigger — POST /walk route (satisfies G2, G4)

Section titled “5.2 Explicit walk trigger — POST /walk route (satisfies G2, G4)”

Add to server.py:

  • A _walk_handler(request) aiohttp handler registered in build_app() alongside /health and /stage.
  • Auth: Authorization: Bearer <CRON_SECRET> — reuse the existing CRON_SECRET already present in both compose files and read by flow.py:_emit_pipeline_run_webhook. Missing/wrong bearer → 401. (Mirrors the bearer discipline the pipeline already uses outbound; here it is the inbound gate.)
  • Single-flight guard (G4): a module-level threading.Lock / threading.Event. If a walk is already in flight, return 409 Conflict with {"error": "walk already in progress"} — never start a second concurrent walk.
  • Body/flags: optional full_reprocess boolean (maps to update_blocking(full_reprocess=…)); default incremental.
  • Behaviour: acquire the single-flight guard → spawn a worker thread that runs KH_PIPELINE_APP.update_blocking(live=False, full_reprocess=<flag>) → return 202 Accepted with a requestId (mirrors /stage’s requestId echo). On the worker thread: on completion release the guard; on exception, set the crash flag path as appropriate and release the guard (a failed walk must not wedge the lock).
  • Idle-source safety: if COCOINDEX_SOURCE_PATH is unset/missing, the route still returns cleanly (the walk hits app_main’s O-Q8 idle early-return — a no-op walk), or returns a named 400 (implementation choice; 400 is friendlier for an operator who expected a corpus). Mirrors the /stage named-400 discipline (server.py:171-181).

5.3 Scheduling — Coolify scheduled task (operator stand-up; satisfies G3)

Section titled “5.3 Scheduling — Coolify scheduled task (operator stand-up; satisfies G3)”

Per-app Coolify scheduled task (one per env), created via the Coolify MCP or UI:

  • command: curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" http://127.0.0.1:8080/walk (in-container loopback — no external exposure). NOTE: the buildpack image ships no curl (the healthcheck uses bash /dev/tcp for exactly this reason — docker-compose.*.yaml healthcheck). The scheduled-task command MUST therefore use a dependency-free invocation: either a bash /dev/tcp POST, or python3 -c with urllib, or a tiny committed scripts/cocoindex_pipeline/trigger_walk.py helper that POSTs to loopback. Implementation picks the dependency-free form; the runbook records it.
  • frequency: a cron expression (cadence is an operator/product decision — e.g. nightly; out of scope to fix here).
  • container: the cocoindex service container; enabled: true.
  • This is not a committed artefact (per-app Coolify config) — it is a runbook stand-up step (§6), same operability class as the existing “git-auto-deploy disabled” guarantee.

5.4 Compose + workflow alignment (satisfies G3, G5)

Section titled “5.4 Compose + workflow alignment (satisfies G3, G5)”
  • docker-compose.production.yaml / docker-compose.staging.yaml: comment-update the COCOINDEX_SOURCE_PATH blocks (lines ~81-87 prod, ~95-100 staging) to reflect the NEW contract: “COCOINDEX_SOURCE_PATH may now stay set permanently; boot no longer walks; the walk fires only via the /walk trigger / Coolify scheduled task.” If Shape 2 (§5.1) is chosen, add COCOINDEX_WALK_ON_BOOT: ${COCOINDEX_WALK_ON_BOOT:-false} to both. No service-topology change, no port change required for the recommended in-container-curl schedule.
  • .github/workflows/onprem-deploy.yml: the deploy-cocoindex burn-guard comments (header §24-31, job §205-251, summary §270-282) currently say “NEVER set SOURCE_PATH → idle boot is the burn guard”. Update the rationale to: “the burn guard is now the boot-decoupling (boot never walks); SOURCE_PATH may be set — a deploy/restart is burn-safe because boot does not walk.” The workflow MUST keep PATCHing only COCOINDEX_IMAGE_TAG and keep the build→deploy ordering guard untouched (G5). It SHOULD NOT start setting SOURCE_PATH (leave corpus-path management to Coolify env / operator), but the deliberate avoidance is no longer load-bearing once boot is decoupled — downgrade it from “burn-critical” to “informational” in the comments.

5.5 Runbook update (docs/runbooks/onprem-b1-deploy.md)

Section titled “5.5 Runbook update (docs/runbooks/onprem-b1-deploy.md)”

Rewrite the B2 “SOURCE_PATH idle-safe discipline” section (lines ~148-198) and the rollback note (lines ~231-233): the load-bearing burn gate is no longer “keep SOURCE_PATH empty” — it is “boot never walks; walks fire only via the explicit trigger”. Document the Coolify scheduled-task stand-up steps (command form, frequency, per-env) and the manual /walk curl form for supervised walks. Mark the old manual-blanking valve as retired.


#FileChangeType
1scripts/cocoindex_pipeline/server.pyBoot-decouple start_cocoindex_thread() / main() so boot does NOT walk (Shape 1 lifespan-only, or Shape 2 flag-gated). Add _walk_handler + register /walk in build_app(). Add single-flight lock + bearer (CRON_SECRET) auth.code
2scripts/cocoindex_pipeline/trigger_walk.py (new, optional)Tiny dependency-free loopback POST helper for the Coolify scheduled-task command (buildpack image has no curl). Only if the bash /dev/tcp form is rejected.code (optional)
3scripts/tests/test_cocoindex_server.pyUpdate the boot-path guard: assert boot does NOT walk (invert the current start_blocking-not-called guard intent per §5.1), and add /walk route tests (auth 401, single-flight 409, 202 happy path, idle-source handling).test
4scripts/tests/test_cocoindex_flow_idle_mode.py (touch)Confirm the O-Q8 idle-mode early-return stays intact as defence-in-depth backstop under the new boot model.test
5deploy/coolify/docker-compose.production.yamlUpdate COCOINDEX_SOURCE_PATH comment block to the new contract; add COCOINDEX_WALK_ON_BOOT env only if Shape 2.config
6deploy/coolify/docker-compose.staging.yamlMirror change #5 for staging.config
7.github/workflows/onprem-deploy.ymlDowngrade the “never set SOURCE_PATH” burn-guard comments from burn-critical to informational; keep tag-PATCH + ordering guard untouched.config
8docs/runbooks/onprem-b1-deploy.mdRewrite B2 + rollback note; document the Coolify scheduled-task stand-up + manual /walk curl; mark the manual-blanking valve retired.docs
9deploy/coolify/.env.staging.example (touch)Note SOURCE_PATH may now stay set; add COCOINDEX_WALK_ON_BOOT example only if Shape 2.config

No schema migration. No DDL. No change to app_main’s six-stage body. No change to the deploy ordering / image-tag guards.


Five sibling subtasks (well under the 25-subtask soft ceiling). All sibling-only dependencies — no cross-Task deps. Every code-touching subtask MUST apply the code-intelligence tool-discipline per .ast-dataflow/CLAUDE.md Inv 3: run gitnexus_impact({target, direction:'upstream'}) before editing a symbol, report the blast radius, and run gitnexus_detect_changes() before committing.

The orchestrator assigns these local subtask IDs when bl-221 is promoted to a Task. The breakdown below is the dispatch-ready content. [S1]..[S5] are placeholders for the assigned {N.x} IDs.

[S1] Boot-decouple the cocoindex worker.

  • Details: In scripts/cocoindex_pipeline/server.py, change start_cocoindex_thread()
    • main() so boot provisions the cocoindex environment (DB pool via @coco.lifespan, LMDB engine) and serves /health but performs ZERO corpus walk even when COCOINDEX_SOURCE_PATH is set. Prefer Shape 1 (lifespan-only boot via coco.start_blocking()); fall back to Shape 2 (COCOINDEX_WALK_ON_BOOT flag, default false) only if the lifespan-only boot proves awkward against cocoindex 1.0.3 env lifecycle — validate empirically (python3 -c import-and-call against the pin) before committing the shape. Preserve mark_worker_crashed() / /health 503 wiring. Run gitnexus_impact({target:'start_cocoindex_thread', direction:'upstream'}) (expect 1 caller: main) and gitnexus_impact({target:'main', direction:'upstream'}) before edit; gitnexus_detect_changes() before commit.
  • Files: scripts/cocoindex_pipeline/server.py (+ compose env only if Shape 2 chosen).
  • testStrategy: bun run-equivalent python3 -m pytest scripts/tests/test_cocoindex_server.py proves boot does NOT invoke a walking update_blocking (no live=True walk at boot) with SOURCE_PATH set; /health serves 200 at boot.

[S2] Add the POST /walk trigger route (auth + single-flight + one-shot).

  • Details: Add _walk_handler to server.py and register it in build_app(). Bearer auth via CRON_SECRET (401 on missing/wrong). Module-level single-flight lock → 409 if a walk is in flight. On accept: spawn a worker thread running KH_PIPELINE_APP.update_blocking(live=False, full_reprocess=<body flag, default false>), return 202 + requestId. Release the lock in finally on the worker thread (a failed walk must not wedge the lock). Idle-source (COCOINDEX_SOURCE_PATH unset/missing) → named 400 or clean no-op (mirror /stage §171-181). Depends on [S1] (the boot must not already be walking). Apply tool-discipline (impact-before-edit on build_app; detect-changes-before-commit).
  • Files: scripts/cocoindex_pipeline/server.py.
  • testStrategy: pytest scripts/tests/test_cocoindex_server.py covers: 401 no/bad bearer; 202 + update_blocking(live=False) called on valid bearer; 409 when a walk is in flight; idle-source handled per chosen contract; lock released after walk completion AND after a walk exception.

[S3] Update boot-path + idle-mode guard tests to the new contract.

  • Details: In scripts/tests/test_cocoindex_server.py, invert the boot-path guard intent (assert boot does NOT walk; the on-demand /walk does — see §5.1). In test_cocoindex_flow_idle_mode.py, confirm the O-Q8 app_main idle early-return stays intact as the defence-in-depth backstop. Ensure the full python3 -m pytest scripts/tests/ suite is green. Depends on [S1], [S2].
  • Files: scripts/tests/test_cocoindex_server.py, scripts/tests/test_cocoindex_flow_idle_mode.py.
  • testStrategy: python3 -m pytest scripts/tests/test_cocoindex_server.py scripts/tests/test_cocoindex_flow_idle_mode.py passes; the new boot-does-not-walk invariant is asserted and the inverted guard is documented in the test docstring.

[S4] Align compose files + onprem-deploy workflow comments to the new contract.

  • Details: Update the COCOINDEX_SOURCE_PATH comment blocks in BOTH docker-compose.production.yaml (~81-87) and docker-compose.staging.yaml (~95-100) to “SOURCE_PATH may stay set; boot never walks; walk fires via /walk trigger”. Add COCOINDEX_WALK_ON_BOOT: ${COCOINDEX_WALK_ON_BOOT:-false} to both ONLY if Shape 2 was chosen in [S1]. In .github/workflows/onprem-deploy.yml, downgrade the “never set SOURCE_PATH” burn-guard comments (header §24-31, job §205-251, summary §270-282) from burn-critical to informational; keep the COCOINDEX_IMAGE_TAG PATCH + build→deploy ordering guard byte-unchanged. Touch .env.staging.example to note SOURCE_PATH may stay set. Depends on [S1]. (No symbol edits → no gitnexus_impact; still run a grep sweep for any other COCOINDEX_SOURCE_PATH doc/config references and gitnexus_detect_changes() scope check before commit.)
  • Files: deploy/coolify/docker-compose.production.yaml, deploy/coolify/docker-compose.staging.yaml, .github/workflows/onprem-deploy.yml, deploy/coolify/.env.staging.example.
  • testStrategy: docker compose -f deploy/coolify/docker-compose.production.yaml config and the staging equivalent parse cleanly; actionlint .github/workflows/onprem-deploy.yml passes; no occurrence of “burn guard = empty SOURCE_PATH” remains as a load-bearing claim.

[S5] Rewrite the runbook burn-prevention section + document the scheduled-task stand-up.

  • Details: In docs/runbooks/onprem-b1-deploy.md, rewrite B2 “SOURCE_PATH idle-safe discipline” (~148-198) and the rollback note (~231-233): the burn gate is now “boot never walks”, not “keep SOURCE_PATH empty”. Document, per env (prod ybiczck7f7e1xbdev3bk89cr; staging ljurtiab99kb6dst38k76419): the Coolify scheduled-task command (dependency-free in-container loopback POST to /walk with the CRON_SECRET bearer — buildpack image has no curl, so bash /dev/tcp or python3 -c urllib or the optional trigger_walk.py helper), the cadence placeholder, and the manual /walk curl for supervised walks. Mark the manual-blanking valve RETIRED on both envs. Depends on [S1], [S2].
  • Files: docs/runbooks/onprem-b1-deploy.md.
  • testStrategy: doc-freshness.test.ts (guard test) stays green; the runbook no longer instructs “confirm SOURCE_PATH empty before deploy” as the burn gate; the scheduled-task stand-up steps are present per env.

8. Open questions for the orchestrator / product owner

Section titled “8. Open questions for the orchestrator / product owner”
  • OQ-1 (cadence, product). What walk cadence does the Coolify scheduled task use (nightly / hourly / manual-only)? Out of scope to fix in bl-221’s code; needs an operator decision before the scheduled task is created.
  • OQ-2 (boot shape, implementation). Shape 1 (lifespan-only boot) vs Shape 2 (COCOINDEX_WALK_ON_BOOT flag) — [S1] picks empirically. Shape 1 is cleaner (no new env) but inverts the existing boot-path guard test; Shape 2 is a smaller behavioural delta but adds an env. Recorded so the executor knows it is a sanctioned choice, not a regression.
  • OQ-3 (relation to bl-222 / {66.15} / ID-75). bl-222 (staging deploy arm) is independent of bl-221 (different surface: workflow deploy job vs walk trigger) — no ordering dependency, but both touch onprem-deploy.yml, so sequence them to avoid a merge collision. {66.15} datapath monitor and ID-75 feeds are the observability of a walk’s output (pipeline_runs row arrival) — they are the completion signal the async 202 trigger relies on, unchanged by this spec. No code dependency; noted for the orchestrator’s wave sequencing.