On-prem B1 deploy runbook (ID-66)
On-prem B1 deploy runbook (ID-66)
Section titled “On-prem B1 deploy runbook (ID-66)”⚠️ SUPERSEDED (S436, 2026-07-02): current topology is the two-server split — Server A client
88.208.255.178, Server B platform87.106.72.59(ONE Coolify, on B); the77.68.122.71build IP throughout this record is the original single box, and Coolify now runs on Server B. Pipeline ingress is HTTPS on all four apps with path scope/walk|/health|/extract(only/stageunrouted) — but it is no longer public on all four: the two PLATFORM apps on Server B went private (S558, 2026-08-12) behind a Cloudflare Tunnel + Access service-token policy, with Server B’s inbound 443+80 closed at the VPS firewall. Every “reachable over public HTTPS” statement below therefore still holds for the two CLIENT apps on Server A and is superseded for the platform pair; probes against a platform FQDN needCF-Access-Client-Id/CF-Access-Client-Secretalongside the bearer. Seerunbooks/private-ingress-cutover.mdandreference/deployment-architecture.md§2/§4/§9.⚠️ SECRET SPLIT ({127.18}; dual-accept retired S457 — noted 22/07/2026): every
CRON_SECRETbearer on/walkin this record is HISTORICAL. The live/walk(and/producer-run, and the pipeline-runs webhook route) bearer is the dedicatedPIPELINE_TRIGGER_SECRET—CRON_SECRETno longer authenticates there (it survives only on the app’s Vercel/api/cron/*routes). Any command below that reads$CRON_SECRET/os.environ["CRON_SECRET"]against/walkmust usePIPELINE_TRIGGER_SECRETtoday — the copy-paste forms in §B2 are corrected inline. Also historical: PullMD is retired — no compose file carries pullmd services any more (the{75.18}section below is an execution record of a removed stack).
SUPERSEDED TOPOLOGY — read this first (S408 / ID-127.15). This runbook is the historical execution record of the ID-66 Cloud-Run→on-prem migration: the first client (Phew) pipeline stood up as prod + staging co-tenanted on ONE IONOS VPS (
77.68.122.71), under thekh-onprem-pipeline-*names and the TEMPORARYaisolutionhub.co.ukdomain, before the platform pipeline existed. The ratified target topology is now the two-server split (S413/S414): Server A = the client pipeline (kh-onprem-pipeline-*, the existing stack this runbook describes); Server B = a dedicated platform pipeline VPS (ca-pipeline-platform-*), with platform staging + prod isolated byca-suffix. One Coolify control-plane (on Server B) manages both servers; client vs platform is distinguished by server + app UUID + deploy input, NOT by separate credentials, and there is no new GH environment (OQ-2). Naming migrateskh→ca/knowledge-hub→canonical; the client domain flips offaisolutionhub.co.uktokh.phew.org.ukat the ID-45 handover. Read this runbook for HOW the client box was built and is operated; readspecs/id-127-platform-pipeline/AMENDMENT-staging-prod-two-server.md(ratified)
specs/id-127-platform-pipeline/GREENFIELD-standup-runbook.mdfor the two-server /ca-naming target. Where the two conflict, the amendment wins.
B1 re-platforms the ingestion pipeline only (cocoindex + pullmd + Playwright +
Trafilatura) off Google Cloud Run onto one IONOS VPS managed by Coolify. The
product app (Vercel + managed Supabase) is untouched. See docs/specs/ID-66-onprem-pivot/
(RESEARCH / PRODUCT / TECH / PLAN) for the full design and the 25 PRODUCT invariants.
Scope note (post-split). “one IONOS VPS” below describes the client box (Server A) as built. It is no longer the whole picture: the platform pipeline lives on a separate VPS (Server B) per the two-server split — see the superseded-topology banner above. This runbook is not the place to stand up the platform pipeline; use the ID-127 amendment + GREENFIELD standup runbook.
Status legend: ✅ done · 🔶 in progress / blocked on operator · ⬜ not started.
Cloud Run retirement (S298)
Section titled “Cloud Run retirement (S298)”Cloud Run is fully removed — not reversible. This supersedes the spec’s one-env-var
revert framing: there is no Cloud Run deploy path to fall back to. The cloudrun/
manifest directory and .github/workflows/cloud-run-deploy.yml have been deleted from the
repository (full teardown). On-prem / Coolify is now the SOLE pipeline deploy path.
What this removes:
- Cost floor. No Filestore instance (~$200/mo). No warm ×2 Cloud Run Services holding
minScale = maxScale = 1. No per-deploy two-image Cloud Build. - The dedicated Cloud Run operational runbooks (
cloud-run-phase-1.md,cocoindex-deploy.md,pullmd-deploy.md) are retained for historical context only and carry RETIRED banners — their referencedcloudrun/manifests no longer exist.
What replaces it:
- Standing cost = a single IONOS VPS (~£21/mo). No warm-instance floor.
- LMDB is the primary genuinely stateful artefact. Its loss means a memoisation rebuild
(recompute, slower next run) — never KH data loss. All canonical KH data lives in
managed Supabase, which is untouched by this teardown. (Since S320 the pullmd share-link
store is a second, lower-stakes stateful artefact — see the
{75.18}section below.)
| Field | Value |
|---|---|
| Provider | IONOS (IONOS Images, Ubuntu 24.04.4 LTS) |
| Public IPv4 | 77.68.122.71 |
| Spec | 8 vCPU · ~16 GB RAM · 480 GB NVMe (/dev/vda1 → /, 462 G free) |
| Kernel | 6.8.0-124-generic |
Access (✅)
Section titled “Access (✅)”SSH is key-only (password auth disabled). Dedicated key generated for this host:
ssh -i ~/.ssh/kh_ionos_ed25519 root@77.68.122.71- Private key:
~/.ssh/kh_ionos_ed25519(ed25519, no passphrase — usable by CI later). Back this key up outside the laptop; losing it locks out the host (password auth is off). - The original IONOS root password was used once to install the key and is stored in
.env.localasIONOS(gitignored). It is now unusable for SSH (password auth disabled) but kept for the IONOS Cloud Panel login.
{66.5} Base hardening (✅)
Section titled “{66.5} Base hardening (✅)”Applied 30/05/2026:
- SSH key-only —
/etc/ssh/sshd_config.d/00-kh-hardening.conf(sorts before50-cloud-init.conf; sshd uses the first-obtained value). SetsPasswordAuthentication no,KbdInteractiveAuthentication no,ChallengeResponseAuthentication no,PubkeyAuthentication yes,PermitRootLogin prohibit-password. Verified: key login works, password auth refused. - Firewall (ufw, active) — inbound allow
22, 80, 443, 8000, 6001, 6002(tcp, v4+v6); default deny incoming. These are the SSH + Coolify ports. - OS updates —
apt upgrade(image already current at provision time);unattended-upgradesinstalled + enabled (/etc/apt/apt.conf.d/20auto-upgrades).
sshd drop-in precedence gotcha: the cloud image ships
50-cloud-init.conf(PasswordAuthentication yes) and60-cloudimg-settings.conf(no). sshd uses the first match acrosssshd_config.d/*.confin lexical order, so theyeswon until the00-override was added. Do not delete00-kh-hardening.conf.
{66.6} Coolify install (✅ done — port 8000 open, admin created, dashboard validated S285)
Section titled “{66.6} Coolify install (✅ done — port 8000 open, admin created, dashboard validated S285)”- Installed via
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | sudo bash. Coolify 4.1.1. Containers healthy:coolify,coolify-db,coolify-realtime,coolify-redis,coolify-sentinel. Docker installed by the script. - Dashboard responds host-locally (
curl localhost:8000→ HTTP 302).
✅ External dashboard access — RESOLVED (S285; runbook corrected S287)
Section titled “✅ External dashboard access — RESOLVED (S285; runbook corrected S287)”Option A was taken in S285 (this section previously read “blocked” and was never updated —
corrected S287): TCP 8000 was opened in the IONOS Cloud Panel cloud firewall (host ufw
already allowed it), the Coolify admin account was created at http://77.68.122.71:8000,
and the dashboard was validated. Re-confirmed reachable S287: http://77.68.122.71:8000
→ HTTP 302 (was timeout); 80 → 404 (Coolify proxy answering). The {66.6} acceptance
items (Compose build pack, per-app encrypted env, S3 backup, deploy-webhook,
auto-Let’s-Encrypt) are operator-verifiable in the UI now, and Coolify config for {66.10}
secrets / {66.11} deploy-webhook is no longer firewall-blocked.
Option B (still the preferred long-term hardening, ties into {66.13}) — dashboard behind a
domain on 443: point a DNS A record (e.g. coolify.kh.phew.org.uk) → 77.68.122.71, set it
as Coolify’s instance FQDN so the dashboard serves over https://… on 443 and 8000 can be
re-closed at the provider (auto-Let’s-Encrypt issues the cert).
⚠️ Back up the Coolify env
Section titled “⚠️ Back up the Coolify env”install.sh warns: back up /data/coolify/source/.env (Coolify app key + DB creds) to a
password manager — required to restore the Coolify control plane.
{66.11} Burn-safe deploy + activation (🔶 wired in code; live activation = operator)
Section titled “{66.11} Burn-safe deploy + activation (🔶 wired in code; live activation = operator)”The .github/workflows/onprem-deploy.yml deploy-cocoindex job wires the deploy. This
section is the load-bearing operating procedure: the on-prem pipeline burned Anthropic
tokens twice across S294-S297, so “no push path can restart the app on a stale/broken
image or trigger an Anthropic crash-loop” carries equal weight to wiring the deploy itself.
The automated flow
Section titled “The automated flow”Every push to main or staging starts the workflow, as does a manual
workflow_dispatch (the workflow-level paths: filter is retired — see the
change-detection note below):
detect-changes(ID-86 / bl-236) diffs the fullbefore..shacompare and emitsrelevant=trueonly when the push touchedscripts/**/*.py,requirements.txt, ordeploy/coolify/**(aworkflow_dispatchis always relevant). Irrelevant pushes stop here — the heavy jobs below skip, visibly, in the run log.build-and-push-cocoindex(gated onrelevant == 'true') builds the image off-host and pushesghcr.io/ai-solution-hub/kh-cocoindex-pipeline:sha-<commit>, then pull-verifies it.deploy-cocoindex(needs:the detection + build jobs) PATCHesCOCOINDEX_IMAGE_TAGto that exactsha-<commit>(update-in-place, never a duplicate key) …- … then triggers a Coolify deploy. Coolify re-clones the git compose and pulls the freshly-built pinned tag (the host never builds).
Change detection replaces the
paths:filter (ID-86 / bl-236). GitHub’s push-eventpaths:filter inspects only the first 300 changed files of a compare, so align-class pushes (S319: 1,522 files, firstscripts/hit at position 1,467) silently skipped the workflow on bothmainandstaging— no error, no run. That silent-skip class is fixed by the in-jobdetect-changesgate, which diffs the full compare with no file-count ceiling. The manualworkflow_dispatchfallback remains available as belt-and-braces (it always evaluatesrelevant=true), but is no longer the only recourse after a large push.
Because the deploy job needs: the build job, it can never deploy a tag that was not
just built, pushed and pull-verified — the “never a stale image” ordering guard.
B1 — race reconciliation: the Action must be the SOLE deploy trigger
Section titled “B1 — race reconciliation: the Action must be the SOLE deploy trigger”There are two mechanisms that can trigger a Coolify deploy:
- Coolify git-auto-deploy — fires immediately on a git push to
main. The build takes ~15 min, so an auto-deploy would redeploy on the STALE currentCOCOINDEX_IMAGE_TAGlong before the new image exists. This is the stale-image footgun. - The Action’s
deploy-cocoindexjob — fires only after the build + tag bump.
To guarantee “never a stale image”, Coolify git-auto-deploy MUST be disabled so the Action is the sole trigger. Two disable mechanisms:
- (a) RECOMMENDED — disable auto-deploy in the Coolify UI for
kh-onprem-pipeline-production(the per-app auto-deploy toggle). This toggle is not exposed by the Coolify public API / MCP, so it is a manual UI action — flagged for the operator. - (b) API-achievable alternative —
watch_pathssentinel. Set the app’swatch_pathsto a path that can never match a real change (e.g..coolify-never-auto-deploy) so a git push never satisfies the auto-deploy filter, while the Action’s API deploy still works.
Coordination hazard: apply the disable at the same time the workflow change lands on
mainand the GH secrets (COOLIFY_BASE_URL) are created. If you disable auto-deploy before the Action can deploy, deploy-on-push silently stops working in the gap; if you land the Action before disabling auto-deploy, a push double-fires (stale + fresh).
B2 — Boot-never-walks burn gate + the /walk trigger (ID-83 / bl-221)
Section titled “B2 — Boot-never-walks burn gate + the /walk trigger (ID-83 / bl-221)”The manual
SOURCE_PATH-blanking valve is RETIRED (ID-83 / bl-221). The burn gate is no longer “keepCOCOINDEX_SOURCE_PATHempty” — it is architectural: the cocoindex worker boots lifespan-only (coco.start_blocking()enters the@coco.lifespan— asyncpg pool + LMDB engine — and returns) and runs ZERO corpus walk at boot, regardless of whetherCOCOINDEX_SOURCE_PATHis set. A deploy / auto-restart can therefore never auto-walk the corpus or burn Anthropic tokens.COCOINDEX_SOURCE_PATHMAY now stay set permanently to/cocoindex-state/corpuson both envs — no operator must remember to blank it.
The corpus walk fires only on an explicit signal: a bearer-gated POST /walk on the
co-resident aiohttp server (scripts/cocoindex_pipeline/server.py). The handler runs one
non-live update pass (KH_PIPELINE_APP.update_blocking(live=False)), reusing the cached
lifespan env entered at boot, then returns the worker to idle.
- Auth:
Authorization: Bearer $PIPELINE_TRIGGER_SECRET({127.18} — originallyCRON_SECRET; the dual-accept window was retired S457 andCRON_SECRETno longer authenticates here). Missing/wrong bearer →401;PIPELINE_TRIGGER_SECRETunset →503(fail closed). - Single-flight: a second
/walkwhile one is in flight →409(never two concurrent walks burning Anthropic). A failed walk releases the lock (does not wedge it). - Idle source: if
COCOINDEX_SOURCE_PATHis unset/missing → named400(“nothing to walk”), not a silent no-op. - Optional body:
{"full_reprocess": true}→ a full, cache-invalidating re-walk (default is incremental). - Async: returns
202 Accepted+requestIdimmediately; the walk runs on a worker thread and its completion is observed via the existingpipeline_runswebhook + the{66.15}datapath monitor — NOT inline in the/walkresponse.
Per-env Coolify scheduled-task stand-up (operator step)
Section titled “Per-env Coolify scheduled-task stand-up (operator step)”✅ CREATED — disabled (S312); command form FIXED + verified (ID-62.12, 06/06/2026). Both
/walkscheduled tasks exist butenabled: false(mechanism in place; no walk fires, no Anthropic burn until cutover): prod task uuidgqi55umn3m447gguf2vbp5iq(containercocoindex), stagingz1197cpafcghwlfsxa22xzlq(containercocoindex-staging— corrected fromcocoindexin {62.12}); command = the absolute-interpreter-pathpython3+urllibloopbackPOST /walkform below (the S312 bare-python3form exits 127 in the buildpack container — see the caution at the end of this section); cadence0 2 * * *(placeholder — Liam picks the final cadence, or switches to purely event-driven, when enabling at cutover).
The walk schedule is a per-app Coolify scheduled task — per-app config, not
git-tracked (the same operability class as “git-auto-deploy disabled”). The mechanism
(/walk route + boot decouple) is fully git-tracked + tested; only the cron cadence lives in
Coolify. Stand up one scheduled task per env:
| Env | Coolify app uuid | Loopback host |
|---|---|---|
prod (kh-onprem) | ybiczck7f7e1xbdev3bk89cr | http://127.0.0.1:8080/walk |
staging (kh-onprem-staging) | ljurtiab99kb6dst38k76419 | http://127.0.0.1:8080/walk |
No
curlin the image. The slim buildpack cocoindex image ships nocurl(its healthcheck uses bash/dev/tcpfor exactly this reason). The scheduled-taskcommandMUST therefore be dependency-free. Use one of:
- Absolute-path
python3+urllib(RECOMMENDED — the verified-202 form from {62.12}, deployed on both tasks 06/06/2026). Invoking the interpreter by its buildpack layer path needs no PATH injection (the exec PATH omits the python layers — the bare-python3trap below), and noPYTHONUSERBASE(the poke is stdlib-only:os+urllib). Failure signalling is correct:urlopenraises on any non-2xx, so the task execution recordssuccess+ message202on a good walk and a failed execution otherwise:Terminal window /layers/google.python.runtime/python/bin/python3 -c 'import os,urllib.request as u;print(u.urlopen(u.Request("http://127.0.0.1:8080/walk",method="POST",headers={"Authorization":"Bearer "+os.environ["PIPELINE_TRIGGER_SECRET"]}),timeout=30).status)'({127.18} correction: the form was verified live in {62.12} with
CRON_SECRET; the env var read MUST now bePIPELINE_TRIGGER_SECRET— confirm the stored Coolify task commands were re-pointed too before enabling at cutover.) Coolify command-length limit (~255 chars, {62.12}). The Coolify API rejects (HTTP 500) scheduled-taskcommandstrings longer than ~255 characters. The otherwise-equivalentenv PYTHONUSERBASE=… PATH=…-prefixed form (§B5 env block) is ~358 chars and cannot be stored — the absolute-interpreter-path form above is the fix. The env-prefix itself was proven to execute fine in-container (python3 --version→Python 3.13.13); it is only the stored-command length that rules it out. - bash
/dev/tcp(no Python interpreter spin-up; PATH-immune fallback): beware its exit code does NOT reflect the HTTP status (a 401/500 response still recordssuccess) — prefer the python form for the standing tasks:Terminal window bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080; printf "POST /walk HTTP/1.0\r\nHost: 127.0.0.1\r\nAuthorization: Bearer %s\r\nContent-Length: 0\r\n\r\n" "$PIPELINE_TRIGGER_SECRET" >&3; cat <&3' - the committed helper
python3 -m scripts.cocoindex_pipeline.trigger_walk— only stand this up if the inline forms above prove awkward in the Coolify task editor (the helper is NOT committed by default; the two inline forms cover the need).
Create it via the Coolify MCP scheduled_tasks(action='create', …) or the UI:
container: the cocoindex service container; command: one of the above; frequency: a
cron expr (cadence is an operator/product decision — OQ-1, e.g. nightly 0 2 * * *);
enabled: true. For a supervised manual walk, run the same loopback POST by hand
(Coolify “Run now” on the task, or docker exec <cocoindex-container> <command>).
docker exec+python3= exit 127 without the buildpack env (S316) — RESOLVED for the scheduled tasks (ID-62.12, 06/06/2026). The buildpack image’s exec PATH does not include the python runtime layer. Any manualdocker exec … python3 …form MUST inject-e PYTHONUSERBASE=/layers/google.python.pip/pip -e PATH=/layers/google.python.pip/pip/bin:/layers/google.python.runtime/python/bin:/usr/bin:/bin(the bash/dev/tcpform is unaffected). Coolify scheduled-task commands also execute inside the container, so the S312 bare-python3task command would have exited 127 at OQ-1 enable. {62.12} re-pointed both task commands at the absolute interpreter path (/layers/google.python.runtime/python/bin/python3— no PATH orPYTHONUSERBASEneeded for the stdlib-only poke) and verified the form live: a temporary every-minute staging task executed it with resultsuccess/ message202(bearer-authed walk accepted) before being deleted. Manualdocker execinvocations of anything beyond the stdlib poke still need the full env block above.
Standing cadence — BI-19 / ID-75 D-3 (staging live 06/06/2026)
Section titled “Standing cadence — BI-19 / ID-75 D-3 (staging live 06/06/2026)”The documented end-to-end cadence for URL-sourced content (BI-19 acceptance requires a DOCUMENTED cadence; mechanism ratified by Liam S318, OQ-T2):
- Poller discovery ≤ 15 minutes — the Vercel cron poller runs every 15 minutes
(
vercel.json, bearer-gated byCRON_SECRET), so a newly published article is discovered within 15 minutes of appearing in a feed. - Nudge-triggered walk ≈ minutes — when a poller run passes ≥ 1 article, it fires a
fire-and-forget
POST {COCOINDEX_WORKER_URL}/walkwithAuthorization: Bearer {PIPELINE_TRIGGER_SECRET}({127.18} — the nudge inlib/intelligence/pipeline.tsnow sendsPIPELINE_TRIGGER_SECRETas the SOLE bearer and skips the nudge loudly if it is unset; the originalCRON_SECRETform is retired). The walk lands the new URLs within minutes. The nudge CODE shipped with{75.13}; a failed nudge is catch-and-log — a delay, never a loss (snapshot enumeration loses nothing). - Worst case (nudge lost) ≤ 1 hour — an HOURLY Coolify scheduled task on the
staging worker app is the belt-and-braces fallback: task uuid
ixptsepifshcwtr22zf6zrzo(cocoindex-walk-hourly-fallback, appljurtiab99kb6dst38k76419, containercocoindex-staging), frequency0 * * * *, enabled, command = the verified absolute-interpreter-path form above. Staging only — the prod hourly fallback lands with{75.15}after Liam’s prod ratification.
Env (staging only, set 06/06/2026): COCOINDEX_WORKER_URL exists on Vercel as a
Preview-environment variable scoped to the staging git branch
(vercel env ls preview staging), value http://77.68.122.71:8080 (the IONOS host +
the worker’s canonical port). Production is NOT set — that lands with {75.15}.
Reachability — RESOLVED by the ID-88.1 Traefik ingress (compose labels landed; live cert issuance +
COCOINDEX_WORKER_URLrepoint = operator). The{75.13}-era caveat (worker publishes no host port; hostufwdoes not allow inbound 8080, so every nudge catch-and-logged) is superseded: both compose files now carry path-scoped Traefik router labels on the cocoindex service — see §“Pipeline HTTPS ingress — Traefik path-scoped FQDN (ID-88.1)” below. Once the cert issues, repointCOCOINDEX_WORKER_URLtohttps://kh-pipeline-staging.aisolutionhub.co.uk(staging; prod mirrors withhttps://kh-pipeline.aisolutionhub.co.ukat{75.15}). The compose still publishes no host port andufwstill blocks raw 8080 — that stays deliberate: opening raw8080would transit the bearer in cleartext. The hourly fallback bounds nudge latency to 1 hour until the repoint is done.
Pipeline HTTPS ingress — Traefik path-scoped FQDN (ID-88.1)
Section titled “Pipeline HTTPS ingress — Traefik path-scoped FQDN (ID-88.1)”⚠️ SUPERSEDED (S436, 2026-07-02): refreshed against the live compose files — path scope is
/walk|/health|/extract(not just/walk+/health) across FOUR apps on two servers (client88.208.255.178+ platform87.106.72.59, ONE Coolify on B), all hostnamesca-*, Let’s Encrypt certs. Authoritative per-app matrix:reference/deployment-architecture.md§4/§9.Further superseded for the PLATFORM pair (S558, 2026-08-12): the two Server B hostnames are no longer publicly reachable — a proxied Cloudflare Tunnel CNAME fronts them, a Cloudflare Access service-token policy gates them, TLS terminates at the Cloudflare edge (not Let’s Encrypt), and Server B’s inbound 443+80 are closed. The Traefik path scope below is unchanged and still load-bearing — the tunnel routes through coolify-proxy precisely to preserve it. The two Server A client hostnames remain public HTTPS with LE certs exactly as described. See
runbooks/private-ingress-cutover.md.
The cocoindex worker is reachable over HTTPS via the coolify-proxy (Traefik), routed by
compose labels on the cocoindex service in the four compose files
(deploy/coolify/docker-compose.{staging,production,platform,platform-staging}.yaml).
| App (server) | Hostname (all LE-certed) | Router name (verbatim in compose) |
|---|---|---|
| client staging (A) | ca-client-pipeline-staging.aisolutionhub.co.uk | kh-cocoindex-staging-walk (legacy name, current host) |
| client prod (A) | ca-client-pipeline.aisolutionhub.co.uk | kh-cocoindex-prod-walk (legacy name, current host) |
| platform staging (B) | ca-platform-pipeline-staging.aisolutionhub.co.uk | ca-cocoindex-platform-staging-walk |
| platform prod (B) | ca-platform-pipeline.aisolutionhub.co.uk | ca-cocoindex-platform-walk |
Path scope (Inv-13). The router rule covers exactly PathPrefix(/walk) +
PathPrefix(/health) + PathPrefix(/extract) — nothing else. /stage is deliberately NOT
routed: it stays compose-internal only, and this path scope is the public-exposure guard now
that an FQDN exists. /walk is bearer-gated by the app itself (Authorization: Bearer ${PIPELINE_TRIGGER_SECRET} — {127.18}; was CRON_SECRET when this section was written);
/extract is bearer-gated by its own Bearer ${EXTRACT_API_TOKEN}
(rate-limited, 20 MB cap); /health is intentionally public-readable (liveness only, no data) —
on the two client hosts only since the platform pair went behind Access (S558, 2026-08-12).
Any unrouted path 404s at the Traefik edge.
Cert resolver. tls.certresolver=letsencrypt — the Coolify v4 default ACME resolver
name in the coolify-proxy Traefik config (instance: Coolify 4.1.1, Traefik 3.6.17). The
coolify-proxy is already attached to both app networks (ljurtiab99kb6dst38k76419
staging, ybiczck7f7e1xbdev3bk89cr prod), so the labels alone route — no compose
networks: change was needed. Service port: container 8080 (the worker’s $PORT
default in server.py).
Operator note — labels-only routing. The Coolify app FQDN (“Domains”) field stays UNSET for both pipeline apps — this is the implemented approach. Coolify MERGES its own generated Traefik labels when an app FQDN is set; those generated routers are path-UNSCOPED (they would route the entire container root, defeating the Inv-13 guard). If the FQDN field is ever set regardless, it MUST match the hostnames above exactly, and the distinct router names + explicit router→service bindings in the compose labels keep the path-scoped routers unambiguous alongside the merged Coolify labels.
Naming (S321, TEMPORARY → resolved by the two-server split, S408).
aisolutionhub.co.uk is the platform-owner domain — a TEMPORARY home pending the
commercial-brand decision. The deployment model is per-client: this B1 stack is
the Phew deployment’s pipeline (Server A in the two-server split), and the
hostnames will be revisited per client once the brand decision lands. (The
earlier caveat’s cocoindex-staging.kh.phew.org.uk suggestion is superseded by
this S321 decision.)
Reconciliation (ID-127.15). The two ingress hosts above (
kh-pipeline.aisolutionhub.co.uk/kh-pipeline-staging.aisolutionhub.co.uk) and thekh-cocoindex-*-walkrouter names are the client box’skh-prefixed, pre-split, temporary-domain artefacts. Under the ratified two-server /ca-naming model: (a) the client pipeline keeps thekh-onprem-pipeline-*app names for now (ID-127 does not own the client rename), but its public domain flips offaisolutionhub.co.uktokh.phew.org.ukat the ID-45 re-ingest / handover; (b) the platform pipeline on Server B uses theca-scheme (ca-pipeline-platform,ca-cocoindex-platform-walk,Host(ca-pipeline-platform.aisolutionhub.co.uk)) — seespecs/id-127-platform-pipeline/AMENDMENT-staging-prod-two-server.md§2
- TECH OQ-127-NAMING. The
kh-cocoindex-pipelineGHCR image name is a deliberate exception that stays unchanged (shared promote artefact, ID-113).
B2a — Workspace manifest: route tag (ID-80 forms/content fork)
Section titled “B2a — Workspace manifest: route tag (ID-80 forms/content fork)”Spec:
80.2-forms-content-separation.md§B.1–B.4 + §B.8 — OQ-80.2-A/B/C all RATIFIED (Liam, S314, 05/06/2026). Shipped{80.6}–{80.10}(S314).
What a walk WRITES is now forked per manifest prefix. Each entry in the workspace
manifest (<COCOINDEX_SOURCE_PATH>/.kh-workspace-map.json — on B1 that is
/cocoindex-state/corpus/.kh-workspace-map.json) carries an optional
route: "forms" | "content" tag:
- Default
"content"— existing manifests parse unchanged; every untagged prefix stays on the content path (Path-A). Zero behaviour change until an operator opts a prefix into"forms". route: "forms"prefixes take the forms branch (Path-B): the walk writesform_templates+form_template_fieldsrows ONLY. A form lands ZERO content rows — nocontent_items/source_documents/content_chunks/q_a_extractions/entity_mentions[RATIFIED OQ-80.2-A].
Folder contract (operator-owned). The route tag trusts folder placement — it never sniffs file content:
- BLANK form instruments (unanswered templates — ID-52 Mode-3) live under forms-mapped prefixes.
- ANSWERED/completed forms are knowledge containers — they stay with the client
corpus under
route: "content"prefixes and land content rows as today [RATIFIED OQ-80.2-A caveat, Liam, 05/06/2026].
Mis-wire behaviour (all loud, never silent):
- An invalid
routevalue (typo / non-enum) →ManifestLoadErrorat the manifest-load gate: the flow aborts loudly at start, before any file is processed (spec §B.8). - A
.md/.txt/.htmlfile under a forms prefix → loudcocoindex.stage_error(extraction_validation_failed) and ZERO rows for that file; the rest of the walk continues (spec §B.3 suffix guard). - Ambiguous manifest — operator-visible behaviour change (S314): pre-fork, an
ambiguous manifest resolution still landed content rows BEFORE the error surfaced;
post-fork an
AmbiguousResolutionat the fork lands ZERO rows on all targets for the affected file (spec §B.1/§B.8). When diagnosing an ambiguous-manifest walk, do NOT expect partial rows — their absence is correct behaviour, not data loss.
Re-tagging caveat. Flipping an existing prefix content ↔ forms orphans the
rows the prior branch wrote (they are neither migrated nor deleted). This is an
operator action with operator-owned consequences; a detection sweep (branch-flip by
storage_path) is a recorded follow-up, out of {80.2}’s automated scope (spec §B.8).
Terminal webhook semantics [RATIFIED OQ-80.2-C]. Per-item faults never flip the
walk’s terminal status. The flow-end webhook reports flow_status: "completed" plus an
itemFailures tally ({forms: n, content: m}) when only per-item faults occurred —
including a 100%-failure walk, where itemFailures equal to items processed is a loud
signal, not a silent green. "failed" is reserved for walk-wide faults only (manifest
load, Stage-5, mount errors).
Staging re-smoke brief (PARENT-owned — Orchestrator executes at the Spine-S1 smoke)
Section titled “Staging re-smoke brief (PARENT-owned — Orchestrator executes at the Spine-S1 smoke)”Operator block — do NOT run from an Executor worktree. This is a supervised staging burn, executed by the Orchestrator/operator via the
{62.9}live-verify trigger (see the “ID-62{62.9}live-verify trigger” section below): stage the two files into the staging corpus, then rundeploy/onprem/verify/live-verify.sh(stage → walk → assert).
One staging walk containing two staged files:
- the charnwood blank form (
ITT Services.docxfromdocs/testing/test-data/templates/itt-services-charnwood/) under a forms-mapped prefix (route: "forms"in the staging manifest), and - one ordinary content doc under a content-mapped (or untagged) prefix.
Assert all four:
| # | Assertion |
|---|---|
| 1 | charnwood lands form_templates = 1 plus form_template_fields rows |
| 2 | the content doc lands content rows (content_items + chunks/extractions) |
| 3 | the flow-end webhook reports flow_status = "completed" |
| 4 | charnwood writes NO content_items row |
B3 — crash-loop backstop + kill-switch
Section titled “B3 — crash-loop backstop + kill-switch”- The load-bearing burn gate is now architectural (ID-83 / bl-221): the worker boots
lifespan-only and never walks the corpus at boot, so any auto-restart is harmless by
construction regardless of
COCOINDEX_SOURCE_PATH’s value. The corpus walk runs only on an explicitPOST /walksignal (B2). The old empty-SOURCE_PATHidle-boot gate is retired. restart: unless-stopped(compose default for the cocoindex service) is kept for availability — the app auto-recovers after a host reboot, and recovering into a non-walking lifespan-only boot is safe.- During a supervised walk (a manual
POST /walk), watchdocker logs. The operator kill-switch is, in order of convenience: Coolifycontrol stop(MCP) / the UI Stop button / SSHdocker stop <container>. Stopping the container aborts an in-flight walk; the next/walkstarts a fresh pass (the single-flight lock dies with the process). - Boot-failure triage: asyncpg
gaierror/TargetServerAttributeNotMatchedin the boot logs ⇒ wrong region-qualified pooler host inCOCOINDEX_DB_DSN;ValueError: Environment settings must provide Settings.db_path⇒COCOINDEX_DB/ the state-volume mount is missing. An emptyCOCOINDEX_SOURCE_PATHis NOT a failure — the worker boots idle, no crash-loop (idle contract:scripts/tests/test_cocoindex_flow_idle_mode.py). - Optional hardening (NOT applied): swap
restart: unless-stopped→restart: on-failure:Non the cocoindex service to cap crash-restarts at N. Trade-off: it loses idle auto-restart after a clean stop or Docker-daemon restart, so the app would not come back idle after a host reboot. Present as an option for a future smoke-hardening pass, not the default.
Operator activation checklist
Section titled “Operator activation checklist”These steps cannot be done from the agent terminal (they are live burn-safety + repo-admin actions):
- (a) Create GH secret
COOLIFY_BASE_URL=http://77.68.122.71:8000in theProductionenvironment (case-sensitive).COOLIFY_API_TOKENalready exists there (added S294). - (b) Disable Coolify git-auto-deploy for
kh-onprem-pipeline-production— UI toggle (B1.a, recommended) or set thewatch_pathssentinel via API (B1.b). Coordinate timing with the workflow landing onmain(see the B1 coordination hazard). - (c) Confirm the ghcr package
ghcr.io/ai-solution-hub/kh-cocoindex-pipelineis public (made public S288 → Coolify pulls anonymously, no registry creds). If it is ever made private, add ghcr registry credentials in Coolify. - (d) ✅ DONE S312 — Set
COCOINDEX_SOURCE_PATH=/cocoindex-state/corpuson both Coolify apps (it may now stay set permanently — boot never walks, so this is burn-safe; see B2). The manual blanking step is RETIRED — no need to confirm it empty before a deploy. - (e) Run a deploy dry-run via
workflow_dispatch(deploy enabled) and confirm the tag-bump + deploy produce a lifespan-only boot —/healthserves 200, no corpus walk, no ingest, no burn — even withCOCOINDEX_SOURCE_PATHset. - (f) Enable the per-env Coolify scheduled task that POSTs to
/walk(B2 — pick the cadence, OQ-1; both tasks already exist disabled with the verified command form), then trigger a supervised manual/walkfor the first end-to-end live-ingest verification. This is FOCUS/operator work. Command-form verification step (ID-62.12): before flippingenabled: true, confirm each task’scommandis the absolute-interpreter-path form from §B2 (/layers/google.python.runtime/python/bin/python3 -c …— NOT barepython3, which exits 127 in the buildpack container) and that the staging task’scontaineriscocoindex-staging(prod:cocoindex). After the first scheduled run, check the task execution log showssuccess+ message202.
{66.12} Rollback by tag (✅ documented S308)
Section titled “{66.12} Rollback by tag (✅ documented S308)”Rollback and roll-forward are the same operation with a different
COCOINDEX_IMAGE_TAG value — re-point the image tag, no host rebuild. The only
stateful artefact (the LMDB memo store) is untouched by an image swap. The mechanism
is the deploy-cocoindex job’s PATCH+deploy, already proven by every
onprem-deploy.yml run (each deploy is a tag pull, never a host build).
Two equivalent paths:
- Coolify UI (operator): app
kh-onprem-pipeline-production(uuidybiczck7f7e1xbdev3bk89cr) → Environment Variables → editCOCOINDEX_IMAGE_TAG(env uuidl92p743mjory7qaopxqjx58e) to the prior pinnedsha-<commit>→ Save → Deploy. Coolify re-clones the git compose and pulls the prior tag (near-instant; the host never builds). - API (scriptable — mirrors the Action):
Terminal window # PATCH = update-in-place. POST would create a DUPLICATE key (the §6 footgun).curl --fail-with-body -sS -X PATCH \"${COOLIFY_BASE_URL}/api/v1/applications/ybiczck7f7e1xbdev3bk89cr/envs" \-H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \-H "Content-Type: application/json" \-d '{"key":"COCOINDEX_IMAGE_TAG","value":"sha-<PRIOR_COMMIT>","is_buildtime":true,"is_runtime":true}'curl --fail-with-body -sS -X GET \"${COOLIFY_BASE_URL}/api/v1/deploy?uuid=ybiczck7f7e1xbdev3bk89cr&force=false" \-H "Authorization: Bearer ${COOLIFY_API_TOKEN}"
Burn-safe by construction: a rollback PATCHes only COCOINDEX_IMAGE_TAG —
never COCOINDEX_SOURCE_PATH (env uuid rqjlptckuuqvt4q8nz0wd98t). The rolled-back
image boots lifespan-only and never walks the corpus at boot (ID-83 / bl-221 — the
architectural burn gate, see {66.11} B2), so a rollback can never re-arm the burn footgun
regardless of whether COCOINDEX_SOURCE_PATH is set. The corpus walk fires only on an
explicit POST /walk — a rollback does not issue one.
Find rollback targets:
gh api /orgs/ai-solution-hub/packages/container/kh-cocoindex-pipeline/versions (or the
ghcr UI / onprem-deploy.yml run history — each run pins TAG=sha-<github.sha>). The
image is public — Coolify pulls anonymously, no registry creds needed.
Operator verification (one-time): PATCH COCOINDEX_IMAGE_TAG from the current tag
to the prior pinned tag and redeploy; confirm the Coolify deploy log shows a pull, not
a build, the container reports the prior image within seconds, and the LMDB volume is
untouched. (Satisfies inv 15. The mechanism is already exercised by every production
deploy — this confirms the rollback direction specifically.)
{66.14} LMDB → S3 backup (✅ ARMED + verified S312 — COLD snapshot to Supabase Storage)
Section titled “{66.14} LMDB → S3 backup (✅ ARMED + verified S312 — COLD snapshot to Supabase Storage)”The cocoindex engine keeps its memo state in an LMDB store on the cocoindex-state
named volume (mounted at /cocoindex-state, COCOINDEX_DB=/cocoindex-state/lmdb).
Coolify’s native S3 backups are database-only (scoped to a database_uuid) and do
not cover a raw named volume (OQ-66-4), so the LMDB store needs its own backup job.
✅ ARMED + verified (S312) — COLD snapshot (the mdb_copy design further below is SUPERSEDED)
Section titled “✅ ARMED + verified (S312) — COLD snapshot (the mdb_copy design further below is SUPERSEDED)”The hot mdb_copy snapshot (lmdb-backup.sh, detailed in the subsections below) cannot
be used: cocoindex’s Rust engine bundles an LMDB whose env the distro mdb_copy refuses
to open — MDB_VERSION_MISMATCH (-30794), reproduced with lmdb-utils 0.9.24 (Debian
bookworm) and 0.9.31 (Debian trixie) — even though the data file is unmistakably
standard LMDB (meta magic 0xBEEFC0DE, data version 1). Rather than pin + build cocoindex’s
exact bundled LMDB, the backup takes a consistent COLD snapshot, justified because the
LMDB is a non-critical memo cache (inv 20/25 — loss costs a memo rebuild, never KH data
loss), is ~256 KiB, and the worker boots lifespan-only (ID-83 — no walk at boot) so the
stop/start window is a couple of seconds at a quiet hour.
What runs (all committed + verified live S312):
deploy/onprem/backup/lmdb-backup-cold.sh(host): resolves the running cocoindex container + on-host volume fromAPP_UUID, thendocker stop→cp data.mdb→docker start(immediate), then tar+ships via the sidecar. The container is always restarted (EXIT trap), even on copy/upload failure.deploy/onprem/backup/Dockerfile.tools→kh-lmdb-backup-tools:latest(host-built): the upload sidecar carryingawscli+zstd(the host has neither — Ubuntu 24.04 dropped theawscliapt package). The cold script runs it with--env-fileso the host shell never sees the S3 secret values.deploy/onprem/backup/restore-verify.sh: pulls the newest object + validates the restoreddata.mdbvia its meta-magic (mdb_statcan’t open it either — same version skew).
Live armed state (prod app ybiczck7f7e1xbdev3bk89cr):
| Item | Value |
|---|---|
| Store path (on-host) | /var/lib/docker/volumes/ybiczck7f7e1xbdev3bk89cr_cocoindex-state/_data/lmdb/mdb/ — note the /mdb subdir: cocoindex 1.x nests the env one level under COCOINDEX_DB=/cocoindex-state/lmdb. (Corrects the earlier “expect data.mdb directly under /cocoindex-state/lmdb” assumption.) |
| Bucket | onprem-backups (Supabase Storage S3) · key lmdb/cocoindex-state/production/lmdb-<UTC-ISO8601>.tar.zst |
| Secrets | /root/kh-secrets/lmdb-backup.env (chmod 600 — BACKUP_S3_BUCKET + 4×AWS_*); Liam-placed, never in the repo |
| Schedule | host crontab 30 3 * * * (after the /walk 02:00 window) → >> /var/log/lmdb-backup.log |
| Verified S312 | one backup run (object landed, 3.9 KiB compressed) + restore-verify (restored data.mdb = 262144 B, magic dec0efbe) + container returned healthy. |
Restore: stop the cocoindex container; run restore-verify.sh to fetch + validate, or
aws s3 cp the newest object via the sidecar; untar data.mdb into the on-host
…/lmdb/mdb/ dir; start the container. Retention: objects are ~4 KiB each (negligible);
add a Supabase Storage lifecycle rule or a --prune >90d flag only if it ever matters.
Artefact: deploy/onprem/backup/lmdb-backup.sh
— a self-contained, surface-agnostic hot-snapshot → S3 job. It is committed unarmed:
no cron, no Coolify schedule, no bucket names, no credentials. Activation is operator work
(checklist below).
Target: Supabase Storage (S3-compatible) — chosen S311 (premise corrected)
Section titled “Target: Supabase Storage (S3-compatible) — chosen S311 (premise corrected)”Premise correction (S311). Earlier drafts said “reuse the S3 bucket Coolify already targets for its DB backups.” That bucket does not exist: a live check (
coolify list_databases) shows Coolify manages zero databases — the canonical Postgres is Supabase (managed separately from Coolify), so Coolify has no DB-backup S3 target to reuse. The backup target is therefore chosen fresh.
Chosen target: Supabase Storage, which exposes an S3-compatible endpoint the backup
script already supports via AWS_ENDPOINT_URL. Rationale: lowest-friction (no new vendor; the
Supabase project + credentials already exist), and the LMDB store is a non-critical memo
cache (loss = a memo rebuild on next ingest, never KH data loss — inv 20/25), so
co-locating its snapshot with Supabase is acceptable. Follow-up (tracked): migrate the
target to IONOS object storage to remove the Supabase co-location — see the backlog item
“lmdb-backup S3 target → IONOS object storage” (raised S311). The script needs no change
for that move — only the AWS_ENDPOINT_URL / bucket / creds env values differ.
Provision steps (Supabase Storage S3):
- In the Supabase Dashboard → Project Settings → Storage, create a private bucket (e.g.
onprem-backups) and generate an S3 access key (access key id + secret). - Note the S3 endpoint + region shown there —
AWS_ENDPOINT_URLishttps://<project-ref>.storage.supabase.co/storage/v1/s3;AWS_REGIONis the project region (e.g.eu-west-2). - Set the script env:
BACKUP_S3_BUCKET=onprem-backups,AWS_ENDPOINT_URL=<above>,AWS_REGION=<above>,AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY=<the S3 key>. LeaveBACKUP_S3_PREFIX/BACKUP_ENV_LABELat defaults (lmdb/cocoindex-state/production).
Where these values are stored (the repo goes PUBLIC — never commit them). The backup runs as a host cron + tooling container on the IONOS VPS (surface (b) below), so the operational home for the creds is a root-owned env file on the host, not the repo and not Coolify:
- Operational copy (what the cron reads): a
chmod 600, root-only file on the IONOS host, e.g./root/kh-secrets/lmdb-backup.env, holding the five*=valuelines above. The host cron passes it to the tooling container withdocker run --env-file /root/kh-secrets/lmdb-backup.env …(replaces the inline-e KEY=…flags in the surface-(b) recipe). The host shell cannot read a Coolify app’s injected env, so a host file is the correct home for a host-level cron. - Secret-of-record (so they are never lost): your password manager — the same place the
runbook already says to keep the IONOS SSH key and the Coolify
/data/coolify/source/.env. - Repo (public-safe): carries only
deploy/onprem/backup/lmdb-backup.sh(reads from env — no secrets) and.env.*.exampleplaceholders. ID-68 going public exposes none of these. The on-prem secrets convention this follows is onprem-secrets-migration.md ({66.10}— archived).
Do not put these in .env.local, any committed .env*, or a Coolify app env intended for the
slim cocoindex image — none of those is read by a host cron, and the first two risk the public flip.
What the script does
Section titled “What the script does”- Consistency (inv 18): snapshots with
mdb_copy -c "$LMDB_SRC" "$SNAPSHOT_DIR".mdb_copycopies through an LMDB read transaction — LMDB’s MVCC read-snapshot is immutable for the txn lifetime regardless of concurrent writes, so it yields a consistent hot snapshot with no container stop / no quiesce. The-cflag compacts and drops free pages. A naivecp/tarof the livedata.mdbis never used (torn-page risk).mdb_copyis a CLI tool fromliblmdb(Debian/Ubuntu packagelmdb-utils), not an importable symbol.- Source-path gotcha: cocoindex opens
COCOINDEX_DB=/cocoindex-state/lmdbas a directory env, so themdb_copysource is that directory (holdingdata.mdb/lock.mdb), not thedata.mdbfile. On first run confirm the layout:ls /cocoindex-state/lmdb→ expectdata.mdb lock.mdb. (If the env were everMDB_NOSUBDIR, the source would be thedata.mdbfile withmdb_copy -n.)
- Source-path gotcha: cocoindex opens
- Package + ship:
tars the snapshot (zstd if available, else gzip) and uploads to the chosen S3-compatible bucket (Supabase Storage — see “Target” above; withAWS_ENDPOINT_URLset). Object key:s3://${BACKUP_S3_BUCKET}/${BACKUP_S3_PREFIX}/<env>/lmdb-<UTC-ISO8601>.tar.<zst|gz>(default prefixlmdb/cocoindex-state, default<env>segmentproduction). Usesaws s3 cpand honours--endpoint-url "$AWS_ENDPOINT_URL"for non-AWS S3. - Fail loudly:
set -euo pipefail, atrapcleaning the temp snapshot + tarball on EXIT, and preconditions that exit non-zero with a package-naming error ifmdb_copyorawsis missing, ifBACKUP_S3_BUCKETis unset, or ifLMDB_SRChas nodata.mdb. - Supabase: touched not at all (inv 20). The LMDB store is a memo cache; Supabase is canonical.
The script is surface-agnostic — all paths and S3 config come from env
(LMDB_SRC, SNAPSHOT_DIR, BACKUP_S3_BUCKET, BACKUP_S3_PREFIX, BACKUP_ENV_LABEL,
AWS_ENDPOINT_URL, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY).
Execution surfaces (pick one at schedule time)
Section titled “Execution surfaces (pick one at schedule time)”- (a) Coolify scheduled task inside the cocoindex container (
scheduled_tasks:resource=application,uuid=ybiczck7f7e1xbdev3bk89cr,container=cocoindex). The container already mounts the volume at/cocoindex-state, but the slim buildpack image ships nomdb_copy, no zstd, noaws— so this surface needs those tools baked into the image (rejected: bloats the image / breaks the Inv-9 boundary). Not viable as-is. - (b) Host cron + a throwaway tooling container (RECOMMENDED) — a tiny image carrying
lmdb-utils+awscli, bind-mounting the on-host volume read-only:Keeps the cocoindex image slim and the backup tooling independent.Terminal window docker run --rm \-v /var/lib/docker/volumes/ybiczck7f7e1xbdev3bk89cr_cocoindex-state/_data:/cocoindex-state:ro \-e BACKUP_S3_BUCKET=... -e AWS_ACCESS_KEY_ID=... -e AWS_SECRET_ACCESS_KEY=... \-e AWS_ENDPOINT_URL=... -e AWS_REGION=... \-v /path/to/deploy/onprem/backup/lmdb-backup.sh:/lmdb-backup.sh:ro \<tooling-img> /lmdb-backup.sh - (c) Host cron directly on the IONOS host if
lmdb-utils+awscliare installed there. Simplest; pointLMDB_SRCat the on-host_data/lmdbpath directly.
Recommended surface: (b). Schedule daily (or twice-daily) — B1 write volume is low and the LMDB is small (≤256–512 MiB historically). Retention is the operator’s choice within the 100 GB IONOS backup allowance (OQ-66-1); record the chosen cron + retention here once armed.
Restore procedure
Section titled “Restore procedure”- Stop the
cocoindexcontainer — Coolify Stop, ordocker stop <cocoindex>. (LMDB must not be open while you replace its files.) - Fetch the latest snapshot tar from S3:
(List with
Terminal window aws s3 cp "s3://${BACKUP_S3_BUCKET}/lmdb/cocoindex-state/production/<lmdb-...tar.zst>" /tmp/restore.tar.zstaws s3 ls s3://${BACKUP_S3_BUCKET}/lmdb/cocoindex-state/production/and take the newest by ISO-8601 timestamp in the key.) - Untar onto the persistent volume so the env dir lands at
/cocoindex-state/lmdb. The tar holds the env files at top level, so untar into thelmdbdir on the on-host volume path:Terminal window VOL=/var/lib/docker/volumes/ybiczck7f7e1xbdev3bk89cr_cocoindex-state/_data/lmdbrm -rf "$VOL" && mkdir -p "$VOL"tar -xf /tmp/restore.tar.zst -C "$VOL" # restores data.mdb (+ lock.mdb if present) - Restart the container (Coolify Start /
docker start). The restored store is a valid cocoindex memo state; the engine resumes with memo hits.
Fallback (inv 7 / inv 20): if the snapshot is stale or unavailable, the cost is only a memo-state rebuild on the next ingest — slower, but never KH data loss, because Supabase is canonical (inv 20). LMDB is a cache, not the system of record.
Operator activation checklist (UNARMED until done)
Section titled “Operator activation checklist (UNARMED until done)”The script is committed but does nothing until an operator provisions the following. These cannot be done from an agent terminal (live S3 credentials + host scheduling):
- (a) Provision the Supabase Storage bucket + S3 access key (see “Target” above —
Coolify has no DB-backup bucket to reuse); decide the prefix (default
lmdb/cocoindex-state) and<env>segment (defaultproduction). - (b) Provision S3 credentials (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION, andAWS_ENDPOINT_URLif the bucket is non-AWS S3) scoped to write the chosen prefix. - (c) Choose the execution surface — (b) host cron + tooling container is
recommended; install
lmdb-utils+awscliinto that image (or the host for surface (c)). - (d) First-run check:
ls /cocoindex-state/lmdb(or the on-host_data/lmdb) → confirmdata.mdb lock.mdb(directory env, notMDB_NOSUBDIR). - (e) Add the cron entry / Coolify schedule (daily or twice-daily) and record the chosen cron expression + retention policy in this section.
- (f) Verify one run lands a
lmdb-<UTC-ISO8601>.tar.<zst|gz>object in the bucket, then exercise the restore procedure once into a scratch path and confirm the restored env opens (mdb_stat <restored-dir>).
{75.18} pullmd share-link store persistence (✅ compose wired S320; live recreate-verify = operator)
Section titled “{75.18} pullmd share-link store persistence (✅ compose wired S320; live recreate-verify = operator)”⚠️ SUPERSEDED — PullMD RETIRED (verified 22/07/2026). No compose file under
deploy/coolify/carriespullmd/playwright/pullmd-data-*services, volumes, orPULLMD_*env any more — URL extraction is served in-process by the cocoindex worker’s/extract(trafilatura) path in every environment. This section is retained as the execution record of the removed stack; do not stand any of it up.
Premise — pullmd’s SQLite store must survive container recreates. pullmd keeps
everything in one SQLite DB at the CACHE_DB path: the conversions table holds the
8-hex share ids that GET /s/:id serves (KH persists them as
reference_items.pullmd_share_id), and the auth layer (users, sessions, minted pmd_
tokens) reuses the same db handle (upstream server.js:
createCache(process.env.CACHE_DB || './data/cache.db') then
createAuth({ db: cache.db, … })). Until S320 the compose pointed CACHE_DB at
/tmp/pullmd-cache.db — container-ephemeral — so every Coolify recreate/redeploy
invalidated ALL persisted pullmd_share_id values (S319 live-observed: a pre-recreate
/s/:id returned 404 post-recreate, failing the ID-75 share-link AC).
The fix (staging compose, deploy/coolify/docker-compose.staging.yaml):
CACHE_DB: "/data/pullmd-cache.db"— repointed from/tmpinto the upstream-blessed/datastore dir (the pullmd Dockerfile runsmkdir -p /data && chown -R app:app /app /databeforeUSER app; upstream’s own compose mounts a volume at/data).- Named volume
pullmd-data-staging:/dataon thepullmd-stagingservice + a top-levelpullmd-data-staginglocal-driver volume. Named volumes survive Coolify recreates/redeploys (same mechanism ascocoindex-state-staging); only an explicit volume teardown destroys the store. A fresh named volume inherits the image’sapp:appmountpoint ownership (Docker copies image content + ownership into an empty named volume), so nouser:override is needed.
Recreate-survival semantics: after this change a redeploy/recreate keeps every minted
share id resolvable — GET /s/:id 200 for ids created before the recreate, and persisted
reference_items.pullmd_share_id values stay live across the container lifecycle. The
volume also makes the PULLMD_AUTH_TOKEN static-bearer bootstrap (S319, 686ee5dd) less
load-bearing: minted pmd_ tokens now survive too, though the static bearer stays as the
documented auth path. Loss budget: losing this volume costs share-link 404s only —
the extracted markdown is canonical in Supabase (source_documents), never in pullmd.
This makes the pullmd store the second stateful artefact on B1 alongside LMDB
(lower stakes: permalink invalidation vs memo-rebuild; neither is KH data loss).
Operator steps (live verify — ACs 2/3 of {75.18}, operator-gated):
- (a) Redeploy the staging stack so the new compose (volume +
CACHE_DBrepoint) takes effect; confirmdocker volume lsshows thepullmd-data-stagingvolume anddocker exec kh-pullmd-staging ls /datashowspullmd-cache.dbafter first use. - (b) Mint a share id (any pipeline HTML fetch, or
GET /api?url=…with the Bearer token — theX-Share-Idresponse header), confirmGET /s/<id>→ 200. - (c) Recreate the container (Coolify redeploy/restart), then re-issue
GET /s/<id>for the pre-recreate id → expect 200 (was 404 pre-fix). - (d) Spot-check one persisted
source_documents.pullmd_share_idvalue resolves (column lives onsource_documents, notreference_items— S321 correction). - (e) Note the first recreate after this change still invalidates ids minted into
the old
/tmpstore — they were never persisted anywhere recoverable. Re-ingest (or accept 404) for share ids minted before S320.
Prod mirror: ✅ landed S321 (7dd4b153, ahead of the {75.15} cutover) — the prod
compose now carries both the PULLMD_AUTH_TOKEN static bearer and the persistent store
(volume name pullmd-data-production, mounted at /data). Inert until a manual prod
deploy materialises it; the {75.15} execution checklist owns that leg.
{66.15} Datapath monitor — pipeline_runs stall-watch (✅ re-homed to a Vercel cron, S311; alert sink = operator)
Section titled “{66.15} Datapath monitor — pipeline_runs stall-watch (✅ re-homed to a Vercel cron, S311; alert sink = operator)”Premise — row arrival, not /health. “container up + /health green” is
insufficient evidence the datapath works. /health (server.py:_health_handler)
reflects only the cocoindex worker thread’s liveness — it returns 503 only if that
thread crashed. It says nothing about whether ingestion is producing rows. Datapath
health is confirmed by pipeline_runs row arrival in Supabase, never by the probe.
(This is not CocoInsight — Gotcha-B — which is a dev-time LMDB inspector, not a prod
stall-watch.)
Artefact: deploy/onprem/monitor/datapath-watch.ts
— a standalone, read-only stall-watch (it only SELECTs from pipeline_runs; no
writes, no DDL, no KH pipeline/app code change). The core is a pure predicate
(detectStalls(rows, config, now)) unit-tested in
__tests__/deploy/onprem/monitor/datapath-watch.test.ts.
The standalone artefact carries no schedule of its own; its predicate is consumed by the
Vercel-cron home below.
Home (S311): a Vercel cron — not host cron. Because the monitor is a read-only Supabase
poll with zero on-prem dependency (its premise is pipeline_runs row arrival, never
/health / container state), it is re-homed to a Vercel cron route —
app/api/cron/datapath-watch/route.ts,
scheduled every 15 min in vercel.json, bearer-gated by the shared CRON_SECRET (the
same pattern as the other eight app crons). The route reuses the tested detectStalls
predicate + loadConfig + types verbatim from the standalone artefact and does app-native
I/O (createServiceClient) + alerting (MONITOR_ALERT_WEBHOOK_URL, else the app
logger.error → Sentry bridge). This keeps a Node runtime off the slim cocoindex image
(Inv-9) and co-locates the monitor with the other app crons + their alerting. The host-cron
surfaces further below are retained only as alternatives (e.g. if you later want
host/container-state checks too), not the chosen home.
The three stall conditions + idle-mode guard
Section titled “The three stall conditions + idle-mode guard”- (A) Stuck-in-flight: an
op_idwith anin_progressrow but no terminal-status row (completed/completed_with_errors/failed) for the sameop_idafterSTALL_THRESHOLD_MINUTES. Keyed offop_id+status(is a terminal row present?), not offcompleted_at— becauserecordPipelineRunstampscompleted_at = now()on every insert incl. the flow-start row (Gotcha-C), socompleted_atis not a reliable in-flight signal. ⚠ Gated on the Gotcha-A constraint fix (below) — until it lands, noin_progressrow ever exists, so (A) is a dead signal by DB state. - (B) Terminal failure: a recent
status='failed'row in the window.recordPipelineRunalready Sentry-alerts onfailed/completed_with_errors; this monitor adds a host-side cross-check / digest so a missed Sentry alert is still caught. - (C) Silence during active ingestion: when ingestion is known active
(
INGESTION_EXPECTED=true) and the deploy is not idle, no newpipeline_runsrow withinEXPECTED_RUN_WINDOW_MINUTES→ datapath stall (“rows should be arriving but aren’t”). - Idle-mode guard (load-bearing): when
COCOINDEX_SOURCE_PATHis empty (idle boot) orINGESTION_EXPECTEDis false, no rows is the CORRECT state — condition (C) is suppressed, so the monitor does not page on every burn-safe idle deploy.
Config (env — all operator-set at activation)
Section titled “Config (env — all operator-set at activation)”| Env | Purpose | Default |
|---|---|---|
SUPABASE_URL | Supabase project URL (REQUIRED) | — |
SUPABASE_SERVICE_ROLE_KEY | Service-role reader (bypasses RLS structurally, like the cron handlers — not the app’s request-scoped getAuthorisedClient) (REQUIRED) | — |
STALL_THRESHOLD_MINUTES | Condition (A) threshold | 30 |
EXPECTED_RUN_WINDOW_MINUTES | Condition (C) tolerated silence | 60 |
INGESTION_EXPECTED | Condition (C) gate — "true" when a corpus is staged/active | false |
COCOINDEX_SOURCE_PATH | Idle-boot detector — empty/unset ⇒ idle, (C) suppressed | — |
MONITOR_ALERT_WEBHOOK_URL | Alert sink — Discord/Telegram/email webhook (POST) | — |
SENTRY_DSN | Fallback sink — Sentry.captureMessage (operator wires init at activation) | — |
Execution surface — alternatives to the Vercel cron (NOT the chosen home)
Section titled “Execution surface — alternatives to the Vercel cron (NOT the chosen home)”The chosen home is the Vercel cron above. These host-side surfaces are retained only as
fallbacks (e.g. if you later want the monitor to also check host/container state, which the
Vercel cron deliberately does not). As with {66.14}, the slim buildpack cocoindex image
ships no Node/tsx runtime, so the monitor must not run inside the cocoindex
container. If you ever move it host-side, the standalone runOnce() entry point reads
SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY directly (host env names), via:
- (a) Host cron + a tiny tooling container — a small Node/
bun/tsximage that runsdatapath-watch.ts(runOnce()) on a schedule. Keeps the cocoindex image slim. - (b) Host cron directly on the IONOS host if a Node/
bun/tsxruntime is installed there. - (c) Scheduled Supabase SQL alternative — an equivalent
pg_cron/external-cronpsqlquery returning the same boolean stall flags (TECH.md §6).
Alert sink options
Section titled “Alert sink options”A single env-configurable sink — the operator picks the channel by env, no code change:
MONITOR_ALERT_WEBHOOK_URLset → alerts POST as JSON to that webhook (Discord/Telegram/email).- else
SENTRY_DSNset → route viaSentry.captureMessage(wire the@sentry/nodeinit at activation; the committed artefact leaves this unarmed — no DSN baked in, no import). - neither → the artefact writes a stderr note so a misconfigured deploy is visible.
Gotcha-A — RESOLVED (S309, migration #106). This block previously flagged that the live
pipeline_runs_status_checkconstraint omitted'in_progress', so flow-start inserts were rejected and condition (A) was a dead signal by DB state. Migration #106 (20260604113356, applied +pg_get_constraintdef-verified on BOTH stagingturayklvaunphgbgscatand prodrovrymhhffssilaftdwd, parity held at 106) widened the constraint to{running,in_progress,completed,completed_with_errors,failed,cancelled}— it now admits'in_progress'(added alongside ID-76’s'cancelled'; the two landed convergently). Soin_progressrows now land and condition (A) is live. No further migration is required. Conditions (B) and (C) were always live.
Operator activation checklist (Vercel-cron home — mostly wired, S311)
Section titled “Operator activation checklist (Vercel-cron home — mostly wired, S311)”The route + schedule + test are committed; the cron goes live on the next prod Vercel deploy (read-only + idle-guarded, so shipping it active is safe). What’s already wired vs. what the operator still does:
Already wired (S311):
- ✅ Execution surface — Vercel cron route
app/api/cron/datapath-watch, no on-prem runtime. - ✅ Supabase read — the route uses the app’s validated env via
createServiceClient(NEXT_PUBLIC_SUPABASE_URL+SUPABASE_SERVICE_ROLE_KEY); no separateSUPABASE_URLto set. - ✅ Schedule —
*/15 * * * *invercel.json(git-tracked; change it there, not in Coolify). - ✅ Condition (A) prerequisite — the Gotcha-A
in_progressconstraint widening landed (migration #106, S309); condition (A) is live.
Operator status (Vercel project env):
- ✅ Alert sink — DECIDED (S311): Sentry fallback only.
MONITOR_ALERT_WEBHOOK_URLis not set by deliberate choice — alerts route through the applogger.error→ Sentry bridge (already wired), so the monitor is never silent. A dedicated webhook can still be added later if a separate channel is wanted; not required. - ✅
COCOINDEX_SOURCE_PATH— set in Vercel (S311). Harmless on its own: it only flips the condition-(C) idle detector to “not idle”. Condition (C) also requiresINGESTION_EXPECTED=true, so withCOCOINDEX_SOURCE_PATHset butINGESTION_EXPECTEDunset/false, (C) stays quiet — no false pages. Conditions (A)/(B) ignore both. - Condition (C) toggle (per active re-ingest): set
INGESTION_EXPECTED=truewhile a corpus is actively ingesting; unset /falsewhen idle. Conditions (A)/(B) are live as soon as the cron deploys and need neither toggle. - Thresholds (optional): override
STALL_THRESHOLD_MINUTES/EXPECTED_RUN_WINDOW_MINUTESon the Vercel env if the 30 / 60 defaults need tuning.
ID-62 {62.9} live-verify trigger (operator/host hook — on-demand, Inv-28)
Section titled “ID-62 {62.9} live-verify trigger (operator/host hook — on-demand, Inv-28)”Status: built, operator-gated. The trigger artefact is the committed host script
deploy/onprem/verify/live-verify.sh. The live proof (a supervised on-host run) is FOCUS/operator work — the same supervised-burn discipline as the S297 smokes. The on-prem pipeline writes to live Supabase (prod-wired today) — fixtures are test-prefixed and cleaned by the test layer’sdropFixture.
The trigger replaces the OLD cocoindex-live-verify.yml WIF + gcloud run jobs execute
mechanism: NO gcloud, NO WIF, NO Cloud Run Job anywhere in the trigger path, and it is
NOT inlined into the PR-blocking ci.yml integration job (that job is unchanged)
[ID-62 Inv-28]. It is on-demand today; the launch flip to a scheduled cadence is a
one-line uncomment — see the # FLIP ON AT LAUNCH (ID-62 Inv-29) block at the bottom of
the script [ID-62 Inv-29].
The sequence (ID-83 / bl-221 aware — stage → walk → assert)
Section titled “The sequence (ID-83 / bl-221 aware — stage → walk → assert)”Staging bytes no longer ingests on its own (boot is lifespan-only; ingestion fires
only on a bearer-gated POST /walk, see §B2). The script therefore threads the walk
through the trigger path:
- Preflight — in-container
/healthprobe (loopback,python3+urllib). - Fixture-corpus seed (step 0b) — the buildpack image packages only
scripts/+requirements.txt; the committeddocs/testing/test-data/**corpus is not in the image (verified live on B1, S316). The scriptdocker cps$KH_REPO_DIR/docs/testinginto the container at<app-dir>/docs/testing(default/workspace— override viaCOCOINDEX_APP_DIR) soverify_driver’s repo-relative fixture reads resolve. Idempotent: the/.source-suffix form overwrites in place on re-run ({62.11}, ratified option a — no image change, no compose change). - Driver stage —
docker exec <cocoindex> python3 -m scripts.cocoindex_pipeline.verify_driver --fixtures templates({62.7}, stage-only). A non-zero driver exit fails before the Vitest step. POST /walk— bearer = the container’s ownPIPELINE_TRIGGER_SECRET({127.18} — wasCRON_SECRETat authoring; the secret never touches the host; the POST runs viadocker exec). 409 = walk already in flight.- Wait for completion —
/walkis async (202 +requestId); the script watchesdocker logsfor the worker’s"/walk completed (requestId=…)"terminal line (timeoutWALK_TIMEOUT_SECS, default 900). - Vitest go-live tier + walk pump — the cocoindex-scoped Vitest invocation runs on
the host with a background walk pump (
POST /walkeveryWALK_PUMP_INTERVAL_SECS, default 30; 409s tolerated). The pump is load-bearing: Tier-1/Tier-2 files stage their own fixtures insidebeforeAllduring the Vitest run, so the single pre-Vitest walk cannot ingest them — without the pump every Tier-1 poll times out against nothing. Each pump pass is incremental (unchanged files memo-hit and burn nothing).
Buildpack
docker execenv (S316 — exit-127 trap). The cocoindex image is a Google buildpack build:python3lives at/layers/google.python.runtime/python/bin/python3and site-packages resolve via the pip user-site (PYTHONUSERBASE=/layers/google.python.pip/pip). A baredocker exec … python3exits 127 because the exec PATH omits the buildpack layers. Every in-containerpython3step above therefore runs with-e PYTHONUSERBASE=/layers/google.python.pip/pip -e PATH=/layers/google.python.pip/pip/bin:/layers/google.python.runtime/python/bin:/usr/bin:/bininjected — the script does this for you; any MANUALdocker exec … python3needs the same env block.
Two verify drivers — pick by proof type (ID-62 quality-review note, S321). There are TWO distinct
verify_driver.pymodules with different concerns:scripts/cocoindex_pipeline/verify_driver.pyis the file-fixture driver (stage-only, loopback/stage, no DB access — invoked in step 3 above and bylive-verify.sh);deploy/onprem/verify/verify_driver.pyis the URL-mode driver (the {62.10} O4 landing-set proof: service-role Supabase seed → walk → pullmd share-id round-trip → idempotency walk). Running the wrong one gives a confusing partial proof — file-fixture proofs never touch Supabase; URL-mode proofs never readdocs/testingfixtures.
Host prerequisites (operator)
Section titled “Host prerequisites (operator)”- A knowledge-hub repo checkout on the host +
bun install(KH_REPO_DIR). - A host secrets env file (default
/root/.kh-live-verify.env, never committed) withNEXT_PUBLIC_SUPABASE_URL+SUPABASE_SERVICE_ROLE_KEY(live, non-placeholder —hasRealLiveDbCredentials()rejects placeholders). - The compose stack publishes no host port for the sidecar, so the spec’s
http://localhost:<port>env block is realised host-side as the container’s bridge IP (resolved viadocker inspect; still host-local). To use the literallocalhostform instead, addports: ["127.0.0.1:8080:8080"]to the cocoindex compose service and exportCOCOINDEX_STAGING_URLbefore invoking. The ID-88.1 Traefik HTTPS ingress (§“Pipeline HTTPS ingress — Traefik path-scoped FQDN (ID-88.1)”) does NOT change this: it routes only/walk+/health, and the live-verify harness drives/stage, which is deliberately unrouted (Inv-13) — so the harness stays host-local by design. - The image does not carry the committed fixture corpus (it packages only
scripts/requirements.txt) — the script seeds it itself: step 0bdocker cps$KH_REPO_DIR/docs/testinginto the container at<COCOINDEX_APP_DIR>/docs/testing(default/workspace). No operator pre-seed needed; just make sureKH_REPO_DIRis a full checkout. To spot-check post-seed:docker exec <cocoindex> ls /workspace/docs/testing/test-data/templates.
Env block the script exports for the Vitest step: COCOINDEX_STAGING_URL +
COCOINDEX_FIXTURE_STAGING_URL (host-local sidecar URL), COCOINDEX_SOURCE_PATH
(/cocoindex-state/corpus — the real B1 value, not the spec’s /corpus literal),
PULLMD_SERVICE_URL (pullmd sibling-container URL — gates Tier-4), plus the
live-Supabase creds from the secrets file.
Per-file go-live ledger (TECH §Testing tier map / OQ-62-TECH-12 — none silently green)
Section titled “Per-file go-live ledger (TECH §Testing tier map / OQ-62-TECH-12 — none silently green)”The Vitest invocation’s --exclude list IS the defer ledger. Every excluded file is
named with its reason — the deferred files are not claimed verified.
| Tier | Files | Decision |
|---|---|---|
| Tier 1 (20 stage+poll files) | admin-merge-coexistence … file-change-detection (all call stageFixture in beforeAll) | LIVE — via the walk pump |
| Tier 1b | extract-memoisation | DEFER — staging half deferred at authoring (“FUTURE … 28.18”) and never landed; seededContentIds stays empty so the file fails deterministically when enabled. Needs its own stage + a second walk pass (re-ingest), not a Tier-1 sibling’s stage. |
| Tier 1b | memo-hit-pipeline-run | DEFER — never stages its own prefix; assumes the retired continuous poll-cycle model (pre-ID-83). Needs its own stage + two explicit walks. |
| Tier 1b | stage-5-failure-non-destructive | DEFER — the ?failStage5=<mode> destPath directive its injectStage5Failure helper sends IS now implemented by the live /stage route (id-414 AC-5/AC-6, S535): server.py allowlists it, strips it off the path, and arms a one-shot walk-scoped credential override that _run_walk consumes and restores in a finally. It stays excluded on a NARROWER, still-honest ground: nothing has yet verified end-to-end against a live sidecar that the injected credential yields the documented AuthenticationError on the MOCK tier (a mock provider need not 401 on a bogus key), nor that flow.py then resolves status='failed' with error_class='entity_resolution_failed'. Lift the exclude once that live-sidecar verification lands. |
| Tier 2 | form-extraction, form-extraction-rls (integration root) | LIVE — self-stage; need the seeded /cocoindex-state/corpus/.kh-workspace-map.json ({62.6}) |
| Tier 3 | health-probe | LIVE — pure /health fetch, fully self-sufficient |
| Tier 3 | transient-retry | LIVE — observational over recent pipeline_runs (tolerates an empty DB) |
| Tier 3 | sidecar-cold-start | DEFER — needs an operator-driven cold-start cycle (COCOINDEX_COLD_START=true opt-in gate + deliberate restart); the Cloud-Run scale-to-zero framing needs a B1 re-anchor |
| Tier 3 | sidecar-mime-coverage | DEFER — beforeAll staging is a 28.18-era stub; polls prefixes nothing stages (deterministic fail). HTML branch additionally ID-75-gated (PullMD cannot read staged local files) |
| Tier 3 | sidecar-version-metadata | DEFER — beforeAll empty; polls its own random prefix nothing stages (28.18 stub) |
| Tier 3 | stage-topology | DEFER — beforeAll staging stubbed (28.18 stub) |
| Tier 3 | latency-budget | DEFER — beforeAll staging stubbed; the 30 s per-file budget also needs an ID-83 re-anchor (walk-pump cadence now dominates measured latency) |
| Tier 3 | audit-log-shipping | DEFER — polls its own random prefix nothing stages (28.18 stub); the v1.1 audit_log half self-gates on table existence |
| Tier 4 | agpl-boundary | LIVE — when PULLMD_SERVICE_URL resolves (pullmd sibling-container IP) |
| Tier 5 (5 no-gate files) | extractor-version-cross-ref, faiss-pin, no-partial-row-writes, non-pipeline-write, persistent-failure-dlq | LIVE — already run everywhere; unaffected |
Tracked note (Curator routing): the six deferred Tier-3 files + the
extract-memoisation/memo-hit-pipeline-runTier-1b files are 28.18/53.14-era substrates whose staging halves were deferred at authoring and never completed. They need test-layer completion work (ownstageFixturecalls; a second-walk mechanism for the re-ingest pair) before they can join the live tier — that is code work outside{62.9}’s artefact set (YAML/runbook/doc/env only). Thestage-5-failure-non-destructivefile’s server-side blocker (?failStage5=directive support) landed at id-414 AC-5/AC-6 (S535); the remaining blocker there is live-sidecar verification of the MOCK-tier 401 path.
Operator activation checklist ({62.9})
Section titled “Operator activation checklist ({62.9})”- Repo checkout +
bun installon the host; secrets env file in place. - Fixture corpus: nothing to pre-seed — the script’s step 0b
docker cps it fromKH_REPO_DIR(spot-checklsabove if in doubt). - Run
KH_REPO_DIR=… deploy/onprem/verify/live-verify.shunder supervision (watchdocker logs -f; kill-switch =docker stop <container>). - Confirm
dropFixturecleanup left no test-prefixed rows behind (write-model R1 — noON DELETE CASCADE; children are deleted explicitly). - At launch: flip the schedule (one-line uncomment at the bottom of the script —
# FLIP ON AT LAUNCH (ID-62 Inv-29)), or stand up the equivalent Coolify scheduled task.
Remaining subtasks (⬜)
Section titled “Remaining subtasks (⬜)”{66.7} GH Action build→ghcr.io (off-host, no host dep — dispatchable now) ·
{66.8} co-location compose · {66.9} LMDB + corpus volumes · {66.10} secrets → Coolify
env — see onprem-secrets-migration.md (archived; needs operator GCP
Secret Manager access) · {66.11} deploy webhook (🔶 wired in code — see the
{66.11} section above; live activation is operator) · {66.12}
rollback-by-tag (✅ documented above) · {66.13} webhook repoint + DNS/SSL · {66.14} LMDB→S3 backup ·
{66.15} datapath monitor (✅ re-homed to a Vercel cron, S311 — see the {66.15} section
above; operator sets the alert sink) · {66.16} E2E smoke + ID-62 reachability hand-off ·
{66.17} retire cloud-run-deploy.yml · {66.18} kpf dead-manifest cleanup.
ID-62 gate: ID-62’s Task-level dependency on ID-66 is satisfied only by
{66.16}(datapath proven + HTTPS ingress reachable). ID-62’s{62.5}/stage route +{62.8}multipart are host-agnostic code and may be built ahead of the host.
Revert path (inv 24)
Section titled “Revert path (inv 24)”cocoindex→pullmd reverts with one env var (PULLMD_SERVICE_URL); inbound webhook reverts
with one env var (PIPELINE_RUN_WEBHOOK_URL). The Cloud Run leg of inv-24 is void post-S298:
there is no .github/workflows/cloud-run-deploy.yml to revert to (deleted in the full teardown —
see the Cloud Run retirement section above) and no GCP Secret Manager; secrets are Coolify env
vars and on-prem / Coolify is the sole deploy path. LMDB is the
primary genuinely stateful artefact — its loss costs a memo-rebuild, never KH data loss (inv
25). The pullmd share-link store ({75.18} section above) is the only other stateful
artefact — its loss costs share-link 404s, likewise never KH data loss.