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)
0. Spec-depth decision and rationale
Section titled “0. Spec-depth decision and rationale”bl-221 warrants a single TECH.md, not the full {RESEARCH → PRODUCT → TECH → PLAN}
chain. Rationale:
- 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.
- 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.3App.update_blockingsignature 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. - 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 viaupdate_blocking, not a Python call site), andstart_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. - 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–2547Function:scripts/cocoindex_pipeline/server.py:start_cocoindex_thread— lines 268–325Function:scripts/cocoindex_pipeline/server.py:main— lines 333–354Function: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. Theapp_mainbody itself is NOT modified by this spec (see §3.1) — only the argument cocoindex passes toupdate_blockingand 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: theserver.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:
server.main()(server.py:333) →start_cocoindex_thread()(server.py:352).start_cocoindex_thread()spawns a daemon thread whose target runsKH_PIPELINE_APP.update_blocking(live=True)(server.py:310).update_blocking(live=True)runsapp_main()(flow.py:2140), which readsCOCOINDEX_SOURCE_PATH(flow.py:2152); if set + exists, it callslocalfs.walk_dir(source_path, live=True, recursive=True)(flow.py:2307) →mount_each→ per-fileingest_file→ Anthropic extraction.live=Truethen 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_mainO-Q8 idle-mode early-return: SOURCE_PATH unset/missing → log +returnbefore any walk (flow.py:2152-2169).onprem-deploy.ymldeploy-cocoindexjob deliberately never setsCOCOINDEX_SOURCE_PATH(only PATCHesCOCOINDEX_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/corpusthen 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.pyalready servesPOST /stage(server.py:150-244) — an aiohttp route co-resident with the cocoindex worker that readsCOCOINDEX_SOURCE_PATHand 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 readsCRON_SECRETfor the_emit_pipeline_run_webhookbearer (flow.py:690-703; compose envs carryCRON_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, …)withcommand,frequency(cron expr),container,enabled,timeoutfields.scheduled_tasks(action='list', uuid='ybiczck7f7e1xbdev3bk89cr')returned[]— the prod app has no scheduled tasks today (greenfield for this approach).
2. Goal / target steady state
Section titled “2. Goal / target steady state”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_PATHis 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 terminalpipeline_runswebhook, and returns the worker to idle. - G3. Holds identically on prod (
kh-onprem) and staging (kh-onprem-staging). The manualSOURCE_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 inonprem-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.
Option A — HTTP/webhook trigger route on the existing aiohttp server (RECOMMENDED)
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 (
curlon 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,/healthinbuild_app) and the existingCRON_SECRETbearer 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_reprocessas a query/body flag (maps toupdate_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.
- Reuses the exact existing co-resident-route pattern (
- 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/walkinside 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_runswebhook + {66.15} monitor are the canonical completion signal.
- 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
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-importsflow.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-livedserver.pyworker 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
timeoutbut 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.
- A scheduled task
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.ymlalready relies on for “git-auto-deploy disabled”. The mechanism (the/walkroute + boot decoupling) is fully git-tracked and tested; only the cron cadence lives in Coolify. If a git-tracked schedule is later required, a Vercelapp/api/cron/cocoindex-walkroute POSTing to the host-exposed/walkis 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).
| Date | Pinned version | Symbol path checked | Result |
|---|---|---|---|
| 04/06/2026 | cocoindex==1.0.3 | cocoindex.App.update_blocking | PRESENT |
| 04/06/2026 | cocoindex==1.0.3 | update_blocking keyword live: bool = False | PRESENT |
| 04/06/2026 | cocoindex==1.0.3 | update_blocking keyword full_reprocess: bool = False | PRESENT |
| 04/06/2026 | cocoindex==1.0.3 | cocoindex.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. Proposed changes
Section titled “5. Proposed changes”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 existingserver.pydocstring — “starts the default environment and ENTERS its lifespan … does NOT run any registered App’smain_fn”), soDB_CTXis provisioned and/healthserves, butapp_main(and thereforewalk_dir) never runs at boot. The/walkroute then runsupdate_blocking(live=False)on demand, which lazily starts the SAME lifespan-bearing default environment and runsapp_mainonce. NOTE: the existingtest_cocoindex_server.pyboot-path guard assertscoco.start_blockingis 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;/walkdoes). 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, defaultfalse). When false (the steady-state default on both envs), boot does not walk; the/walkroute is the trigger. This keeps the existingupdate_blockingboot 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 inbuild_app()alongside/healthand/stage. - Auth:
Authorization: Bearer <CRON_SECRET>— reuse the existingCRON_SECRETalready present in both compose files and read byflow.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, return409 Conflictwith{"error": "walk already in progress"}— never start a second concurrent walk. - Body/flags: optional
full_reprocessboolean (maps toupdate_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>)→ return202 Acceptedwith arequestId(mirrors/stage’srequestIdecho). 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_PATHis unset/missing, the route still returns cleanly (the walk hitsapp_main’s O-Q8 idle early-return — a no-op walk), or returns a named400(implementation choice;400is friendlier for an operator who expected a corpus). Mirrors the/stagenamed-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 nocurl(the healthcheck uses bash/dev/tcpfor exactly this reason —docker-compose.*.yamlhealthcheck). The scheduled-task command MUST therefore use a dependency-free invocation: either a bash/dev/tcpPOST, orpython3 -cwithurllib, or a tiny committedscripts/cocoindex_pipeline/trigger_walk.pyhelper 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 theCOCOINDEX_SOURCE_PATHblocks (lines ~81-87 prod, ~95-100 staging) to reflect the NEW contract: “COCOINDEX_SOURCE_PATHmay now stay set permanently; boot no longer walks; the walk fires only via the/walktrigger / Coolify scheduled task.” If Shape 2 (§5.1) is chosen, addCOCOINDEX_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: thedeploy-cocoindexburn-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 onlyCOCOINDEX_IMAGE_TAGand 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.
6. File-level change list
Section titled “6. File-level change list”| # | File | Change | Type |
|---|---|---|---|
| 1 | scripts/cocoindex_pipeline/server.py | Boot-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 |
| 2 | scripts/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) |
| 3 | scripts/tests/test_cocoindex_server.py | Update 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 |
| 4 | scripts/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 |
| 5 | deploy/coolify/docker-compose.production.yaml | Update COCOINDEX_SOURCE_PATH comment block to the new contract; add COCOINDEX_WALK_ON_BOOT env only if Shape 2. | config |
| 6 | deploy/coolify/docker-compose.staging.yaml | Mirror change #5 for staging. | config |
| 7 | .github/workflows/onprem-deploy.yml | Downgrade the “never set SOURCE_PATH” burn-guard comments from burn-critical to informational; keep tag-PATCH + ordering guard untouched. | config |
| 8 | docs/runbooks/onprem-b1-deploy.md | Rewrite B2 + rollback note; document the Coolify scheduled-task stand-up + manual /walk curl; mark the manual-blanking valve retired. | docs |
| 9 | deploy/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.
7. Implementation subtask breakdown
Section titled “7. Implementation subtask breakdown”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, changestart_cocoindex_thread()main()so boot provisions the cocoindex environment (DB pool via@coco.lifespan, LMDB engine) and serves/healthbut performs ZERO corpus walk even whenCOCOINDEX_SOURCE_PATHis set. Prefer Shape 1 (lifespan-only boot viacoco.start_blocking()); fall back to Shape 2 (COCOINDEX_WALK_ON_BOOTflag, default false) only if the lifespan-only boot proves awkward against cocoindex 1.0.3 env lifecycle — validate empirically (python3 -cimport-and-call against the pin) before committing the shape. Preservemark_worker_crashed()//health503 wiring. Rungitnexus_impact({target:'start_cocoindex_thread', direction:'upstream'})(expect 1 caller:main) andgitnexus_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-equivalentpython3 -m pytest scripts/tests/test_cocoindex_server.pyproves boot does NOT invoke a walkingupdate_blocking(nolive=Truewalk at boot) with SOURCE_PATH set;/healthserves 200 at boot.
[S2] Add the POST /walk trigger route (auth + single-flight + one-shot).
- Details: Add
_walk_handlertoserver.pyand register it inbuild_app(). Bearer auth viaCRON_SECRET(401 on missing/wrong). Module-level single-flight lock → 409 if a walk is in flight. On accept: spawn a worker thread runningKH_PIPELINE_APP.update_blocking(live=False, full_reprocess=<body flag, default false>), return 202 +requestId. Release the lock infinallyon the worker thread (a failed walk must not wedge the lock). Idle-source (COCOINDEX_SOURCE_PATHunset/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 onbuild_app; detect-changes-before-commit). - Files:
scripts/cocoindex_pipeline/server.py. - testStrategy:
pytest scripts/tests/test_cocoindex_server.pycovers: 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/walkdoes — see §5.1). Intest_cocoindex_flow_idle_mode.py, confirm the O-Q8app_mainidle early-return stays intact as the defence-in-depth backstop. Ensure the fullpython3 -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.pypasses; 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_PATHcomment blocks in BOTHdocker-compose.production.yaml(~81-87) anddocker-compose.staging.yaml(~95-100) to “SOURCE_PATH may stay set; boot never walks; walk fires via/walktrigger”. AddCOCOINDEX_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 theCOCOINDEX_IMAGE_TAGPATCH + build→deploy ordering guard byte-unchanged. Touch.env.staging.exampleto note SOURCE_PATH may stay set. Depends on [S1]. (No symbol edits → no gitnexus_impact; still run a grep sweep for any otherCOCOINDEX_SOURCE_PATHdoc/config references andgitnexus_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 configand the staging equivalent parse cleanly;actionlint .github/workflows/onprem-deploy.ymlpasses; 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 (prodybiczck7f7e1xbdev3bk89cr; stagingljurtiab99kb6dst38k76419): the Coolify scheduled-task command (dependency-free in-container loopback POST to/walkwith theCRON_SECRETbearer — buildpack image has nocurl, so bash/dev/tcporpython3 -c urllibor the optionaltrigger_walk.pyhelper), the cadence placeholder, and the manual/walkcurl 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_BOOTflag) — [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_runsrow 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.