PRODUCT — {42.2} Deploy pullmd Cloud Run Service + retire current HTML extraction cascade
PRODUCT — {42.2} Deploy pullmd Cloud Run Service + retire current HTML extraction cascade
Section titled “PRODUCT — {42.2} Deploy pullmd Cloud Run Service + retire current HTML extraction cascade”Task: ID-42 — Deploy pullmd Cloud Run Service + retire current HTML extraction cascade.
Spec-chain artefact: {42.2} PRODUCT (second of {42.1}→{42.2}→{42.3}→{42.4}).
Date: 25/05/2026 (S262 / ID-42 spec wave).
Author: Task Planner (Opus 4.7, 1M context) — fresh instance per Q-PLANNER-2; NOT the
{42.1} RESEARCH author. This is a fresh review pass over the ratified RESEARCH.
Predecessor: docs/specs/id-42-pullmd-deploy/RESEARCH.md (Checker-PASS, main@04dac485).
Status: PRODUCT draft. Returns to Orchestrator for ratification before {42.3} TECH.
UK English throughout. Behaviour invariants are numbered and individually testable so a Checker can acceptance-verify each against the deployed Service, a real HTML ingest, and the retirement grep gate.
Summary
Section titled “Summary”Deploy the pullmd HTML→Markdown extraction Service to Cloud Run in both
kh-staging-494815 and kh-prod-494815, point the cocoindex pipeline at it via a real
PULLMD_SERVICE_URL, prove one HTML source ingests end-to-end through deployed pullmd
(non-empty body + pullmd provenance recorded), and only THEN retire the legacy HTML
extraction surfaces (the live 4-tier sector-intelligence cascade and the live URL-ingest
HTML branch). PDF/binary extraction stays on Docling/unpdf; the Q&A-parser deletion and
scripts/kb_pipeline/ removal are out of scope (separately tracked T7/T14 Tasks).
Problem
Section titled “Problem”PULLMD_SERVICE_URL is an S258 placeholder (pullmd-not-yet-deployed-…); pullmd was never
deployed, so the cocoindex HTML path is runtime-dead. Meanwhile the live HTML extraction
path is a 4-tier cascade (rss_content → direct-fetch regex → Jina → Firecrawl →
summary-fallback) plus a separate Readability/JSDOM URL-ingest branch. Liam ratified (S262)
that ID-42 retires the HTML extraction surfaces independently of T7 — the T7/T14 split
is approved, so T7 (full-corpus reingest) and T14 (collapse-list cleanup) are now
separately-tracked Tasks. ID-42’s job is the HTML surfaces only.
Scope (ratified S262)
Section titled “Scope (ratified S262)”In scope
Section titled “In scope”- Deploy pullmd as a Cloud Run Service in staging + prod.
- Replace the
PULLMD_SERVICE_URLplaceholder with the real Service URL in both envs. - Make HTML ingest work end-to-end through deployed pullmd (the cocoindex
.html/.htm→pullmd path), capturing pullmd provenance (pullmd_share_id+ pullmd-sourced extraction method). - Net-new schema for the provenance contract (
pullmd_share_idonsource_documents; the extraction-method column placement is OQ-2 below — do NOT pre-decide it here). - Retire the HTML branches of the legacy surfaces (see per-surface table) — gated on the end-to-end ingest proof.
Out of scope (explicitly)
Section titled “Out of scope (explicitly)”- PDF / binary extraction — stays on Docling (cocoindex) /
unpdf(URL-ingest). pullmd does not parse PDFs (returns binary garbage at quality ≈ 0.5; RESEARCH §2.2). - Q&A-parser deletion (
lib/bid-library-ingest/extract-qa-pairs.ts) andscripts/kb_pipeline/removal — gated on T7 (Phew Q&A re-ingest). Not ID-42. - Full-corpus /
feed_articlesreingest through pullmd — belongs to T7. ID-42 proves one HTML source ingests; it does not backfill the existing corpus. unpdfremoval — gated on Surface-B PDF disposition (a later Task).
Per-surface disposition (the three live HTML surfaces, RESEARCH §1)
Section titled “Per-surface disposition (the three live HTML surfaces, RESEARCH §1)”| Surface | What it is | file (entrypoint) | HTML branch in ID-42? |
|---|---|---|---|
| A | P9 sector-intelligence 4-tier cascade (LIVE in prod) — rss_content / fetch (extractMainContentHtml regex) / jina_reader / firecrawl / summary_fallback | lib/intelligence/content-extractor.ts::extractContent() | IN — retire Tier 2 fetch+extractMainContentHtml, Tier 2.5 jina_reader, Tier 3 firecrawl (decision: see Inv-10/Inv-11 on Tier 1 rss_content + summary_fallback preservation) |
| B | P4 URL-ingest HTML branch (LIVE in prod) — JSDOM + Mozilla Readability + Turndown | lib/extraction/url.ts::extractFromUrl() → lib/extraction/html.ts::extractFromHtml() | IN (HTML branch only) — re-point at pullmd; keep the PDF branch (unpdf/extractPdfText) |
| C | cocoindex HTML→pullmd adapter (CODE EXISTS, runtime-dead) | scripts/cocoindex_pipeline/adapters.py::_pullmd_to_markdown() | IN — this is the path made live (deploy + adapter contract fix). It is the target of the swap, not a retirement target. |
Surfaces A + B are retired (HTML branches) once Surface C is proven live end-to-end.
Behaviour
Section titled “Behaviour”Group 1 — pullmd Service deployment
Section titled “Group 1 — pullmd Service deployment”-
(Service resolves — staging.) A pullmd Cloud Run Service exists in
kh-staging-494815and resolves at a stable HTTPS URL (https://…run.appor a mapped custom domain). The URL is stable across redeploys — a subsequent revision deploy does not change the resolvable base URL the cocoindex Service consumes.- Acceptance:
gcloud run services describe <pullmd-service> --project kh-staging-494815 --format='value(status.url)'returns one HTTPS URL; a readiness probeGET <url>/returns HTTP 200 (NOT/health— pullmd has no documented/healthendpoint; RESEARCH §2.4). Two consecutive describes (before/after a no-op redeploy) return the same URL.
- Acceptance:
-
(Service resolves — prod.) The same holds in
kh-prod-494815: a pullmd Service resolves at a stable HTTPS URL andGET <url>/returns 200.- Acceptance: the Inv-1 commands against
--project kh-prod-494815.
- Acceptance: the Inv-1 commands against
-
(Pinned image, not
:latest.) The deployed pullmd Service runs a pinned image tag, not a floating:latest— the phase-0 eval found:latestlagged behind2.0.0(RESEARCH §4.3).- Acceptance:
gcloud run services describe … --format='value(spec.template.spec.containers[0].image)'shows a concrete version tag (e.g.…/pullmd:2.x.y), not…:latest.
- Acceptance:
-
(Deploy is reproducible from source control.) pullmd deploy is wired into the existing deploy mechanism (
.github/workflows/cloud-run-deploy.yml+ acloudrun/services/*pullmd*manifest and/or build config), and the workflow’s path-trigger allowlist fires on changes to the pullmd manifest. A redeploy from a clean checkout reproduces the same Service (no manual console-only steps).- Acceptance: a
cloudrun/services/{staging,prod}-pullmd*.yaml(or equivalent) exists under source control; the workflow path-trigger list includes the pullmd manifest glob; re-running the deploy job produces a healthy Service.
- Acceptance: a
Group 2 — wiring (PULLMD_SERVICE_URL reflects reality)
Section titled “Group 2 — wiring (PULLMD_SERVICE_URL reflects reality)”-
(
PULLMD_SERVICE_URLreflects the real URL — both envs.) ThePULLMD_SERVICE_URLSecret Manager value inkh-staging-494815equals the real staging pullmd Service URL, and the value inkh-prod-494815equals the real prod pullmd Service URL. Neither retains the S258 placeholder (https://pullmd-not-yet-deployed-…example.com).- Acceptance:
gcloud secrets versions access latest --secret=PULLMD_SERVICE_URL --project <env>returns a URL that (a) matches the Inv-1/Inv-2 Service URL for that env and (b) contains nonot-yet-deployed/example.comsubstring. The cocoindex Service revision that consumes the mount (cloud-run-deploy.yml:345,COCOINDEX_SECRETS=…,PULLMD_SERVICE_URL=PULLMD_SERVICE_URL:latest) has been re-deployed so the running revision sees the new value.
- Acceptance:
-
(cocoindex can reach pullmd.) From the cocoindex Service’s network position, an HTTP call to
PULLMD_SERVICE_URLsucceeds (no unreachable-host / DNS / auth-rejection error). If pullmd runs with auth enabled, the cocoindex Service presents valid credentials and is accepted; if pullmd runs auth-disabled behind internal ingress, the call is reachable without credentials.- Acceptance: a smoke call (
GET {PULLMD_SERVICE_URL}/api?url=<a-known-good-html-url>) issued from the cocoindex Service context returns HTTP 200 with a non-emptytext/markdownbody. (Auth posture is OQ-1 / a {42.3} decision; this invariant only asserts the call is accepted, however auth is configured.)
- Acceptance: a smoke call (
Group 3 — HTML ingest works end-to-end through deployed pullmd
Section titled “Group 3 — HTML ingest works end-to-end through deployed pullmd”-
(One HTML source ingests → non-empty body.) Ingesting one real HTML source through the cocoindex
.html/.htm→pullmd path produces acontent_itemsrow whose body (content_items.content/content_text) is non-empty and contains Markdown-shaped text extracted from that source (not raw HTML, not an empty string, not a placeholder).- Acceptance: stage a known HTML source through the cocoindex flow against the
deployed pullmd Service; query the produced
content_itemsrow and assertlength(content) > 0and that the body is recognisably the source’s main content rendered as Markdown. No mocking of pullmd — the call hits the deployed Service.
- Acceptance: stage a known HTML source through the cocoindex flow against the
deployed pullmd Service; query the produced
-
(Pullmd-sourced extraction method recorded.) The produced row records an extraction-method value that identifies pullmd as the extractor — derived from pullmd’s
X-Sourceresponse header (one ofreadability/trafilatura/playwright/cloudflare/reddit), surfaced as apullmd_*extraction-method value (e.g.pullmd_readability).- Acceptance: the produced row’s extraction-method field equals a
pullmd_*value consistent with the liveX-Sourceheader for that fetch. The target table/column for this value is OQ-2 (below) — this invariant asserts the value is recorded and pullmd-attributed wherever OQ-2 lands it; it does NOT pre-decide the column.
- Acceptance: the produced row’s extraction-method field equals a
-
(
pullmd_share_idpresent on the producedsource_documentsrow.) Thesource_documentsrow produced by the HTML ingest carries a non-nullpullmd_share_idequal to pullmd’sX-Share-Idresponse header (the 8-hex permalink handle), and that handle round-trips:GET {PULLMD_SERVICE_URL}/s/<share_id>re-serves the extracted Markdown.- Acceptance: the produced
source_documentsrow haspullmd_share_idmatching theX-Share-Idreturned by the live fetch; a follow-upGET …/s/<share_id>returns 200 with non-empty Markdown. (source_documents.pullmd_share_idis net-new DDL — RESEARCH §5.1 / verified ABSENT today; this invariant presumes the migration has landed.)
- Acceptance: the produced
Group 4 — sequencing gate (LOAD-BEARING)
Section titled “Group 4 — sequencing gate (LOAD-BEARING)”- (No deletion before verified end-to-end ingest.) No legacy extraction surface
(Surface A Tier 2/2.5/3, Surface B HTML branch, any HTML-extraction npm dependency, any
Firecrawl/Jina call site) is deleted or disabled in
mainuntil Inv-7, Inv-8 and Inv-9 are demonstrably green in prod. Retirement commits MUST be ordered strictly after the end-to-end-ingest proof; a single commit that both deletes a legacy surface AND first introduces the pullmd path is a violation (the two must be separable and ordered).- Acceptance: the commit/PR history shows the pullmd-ingest-proof landing before any
surface-deletion commit; at no point on
maindoes a state exist where the legacy HTML cascade is removed but pullmd HTML ingest is unproven. The Checker can verify by bisecting: at the deletion commit’s parent, Inv-7/8/9 already pass.
- Acceptance: the commit/PR history shows the pullmd-ingest-proof landing before any
surface-deletion commit; at no point on
Group 5 — retirement (gated on Group 4)
Section titled “Group 5 — retirement (gated on Group 4)”-
(Surface A HTML tiers retired.) After the gate, the sector-intelligence cascade no longer performs HTML extraction via direct-fetch regex (
extractMainContentHtml), Jina (r.jina.ai), or Firecrawl (@mendable/firecrawl-js). The HTML→Markdown work for web/RSS sources flows through pullmd.- Acceptance:
extractMainContentHtml, ther.jina.aifetch, and the@mendable/firecrawl-jscascade-Tier-3 call are gone fromlib/intelligence/content-extractor.ts; the Firecrawl scrape inlib/intelligence/feed-poller.ts::pollWebSource()is gone or re-pointed at pullmd.
- Acceptance:
-
(Tier 1
rss_content+summary_fallbackdisposition is explicit.) The cascade’s Tier 1rss_contentpath (inlinecontent:encoded, no URL fetch — has no pullmd equivalent) and Tier 4summary_fallbackare either preserved or explicitly retired by deliberate decision, not deleted as collateral of the HTML-tier removal. (RESEARCH §1 flagsrss_contentas having no pullmd equivalent.)- Acceptance: {42.3}/PLAN states the decision for each; the implemented behaviour
matches. The Checker verifies an RSS source with inline
content:encodedstill ingests ifrss_contentis preserved, OR that its retirement is documented and the replacement path works.
- Acceptance: {42.3}/PLAN states the decision for each; the implemented behaviour
matches. The Checker verifies an RSS source with inline
-
(Surface B HTML branch retired; PDF branch intact.) After the gate,
app/api/ingest/urlHTML extraction flows through pullmd, and the JSDOM/Mozilla-Readability HTML branch (lib/extraction/html.ts::extractFromHtml) is retired. The PDF branch ofextractFromUrl(unpdf/extractPdfText) still works.- Acceptance: ingesting an HTML URL via
app/api/ingest/urlproduces a non-empty extraction via pullmd; ingesting a PDF URL still produces a non-empty extraction via the PDF branch;extractFromHtml(JSDOM/Readability) is removed or no longer reached for HTML.
- Acceptance: ingesting an HTML URL via
-
(npm HTML-extraction dependencies removed.) The HTML-only extraction npm dependencies are removed from
package.json:@mendable/firecrawl-js,@mozilla/readability,jsdom.unpdfis NOT removed by ID-42 (PDF branch / OQ out of scope).- Acceptance:
package.jsonno longer lists@mendable/firecrawl-js,@mozilla/readability, orjsdom;unpdfremains.bun installsucceeds and the app builds.
- Acceptance:
-
(Retirement grep gate — scoped.) After retirement, a grep for
firecrawl|FIRECRAWL|jina.ai|extractMainContentHtmlover the live HTML-extraction code paths returns zero hits. Because a fresh-pass grep acrosslib/,app/,scripts/finds eight non-test files today, the gate is scoped to the in-ID-42 surfaces and the legitimately-out-of-scope matches are enumerated as explicit exclusions so the gate is unambiguous:MUST be zero after retirement (in-scope live HTML surfaces):
lib/intelligence/content-extractor.ts(Tier 2/2.5/3 +extractMainContentHtml+ Firecrawl guardscheckFirecrawlApiKey/isFirecrawlConfigured)lib/intelligence/feed-poller.ts(pollWebSourceFirecrawl scrape)lib/intelligence/types.ts(the'firecrawl'extraction-method union member — removed alongside the cascade)app/api/intelligence/workspaces/[id]/sources/[sourceId]/test/route.ts(only if it carries a Firecrawl/Jina reference after the cascade swap — verify post-retirement)- the
cloud-run-deploy.ymlFIRECRAWL_API_KEYsecret mount
Explicitly EXCLUDED from the gate (legitimately out of ID-42 scope — must NOT block the gate, and must NOT be deleted by ID-42):
-
scripts/kb_pipeline/extract.py(ther.jina.ailine) —scripts/kb_pipeline/removal is T7-gated, not ID-42. -
scripts/backfill-source-url-firecrawl.ts— a standalone one-shot migration/backfill script; its disposition is a separate clean-up, not part of the live cascade. (If Liam wants it removed in ID-42, that is an OQ — see note below.) -
lib/env-server.ts(FIRECRAWL_API_KEYZod env schema entry) andlib/intelligence/url-validation.ts(a code comment referencing firecrawl) — removed only if the corresponding runtime usage is gone; the comment/schema entry is cosmetic and may trail the runtime removal into {42.3}/PLAN clean-up. -
any
__tests__/**and*.test.tshits (the gate is non-test by construction). -
documentation (
docs/ontology/16-extraction-method.md, RESEARCH.md, this PRODUCT.md) — docs reference the retired terms by design. -
Acceptance: the Checker runs the gate as
grep -rIn -E 'firecrawl|FIRECRAWL|jina\.ai|extractMainContentHtml' lib/ app/ --include='*.ts' | grep -vE '__tests__|\.test\.'and asserts zero hits in the in-scope set above; any residual hit must fall in the explicit-exclusion list with a recorded rationale, or the gate fails. (Note:scripts/is intentionally NOT in the gate’s path set because the onlyscripts/hits are the T7-gatedkb_pipelineextractor and the standalone backfill script.)
Group 6 — regression guard (cascade keeps working through the swap)
Section titled “Group 6 — regression guard (cascade keeps working through the swap)”-
(Sector-intelligence cron still ingests post-swap.) After the pullmd swap and Surface-A retirement, the scheduled sector-intelligence ingest still runs and still produces
feed_articles+ (for passed articles)content_itemsrows. RSS sources, web sources, the manual trigger, the MCPrunPipelinetool, and the admin “test this source” endpoint all still ingest.- Acceptance: a cron poll (
app/api/cron/intelligence-poll) and a manual trigger (app/api/intelligence/trigger-poll) each produce newfeed_articlesrows for a live source; the admin source-test endpoint (app/api/intelligence/workspaces/[id]/sources/[sourceId]/test) returns a successful dry-run extraction; no entrypoint 500s due to a removed-symbol reference. The full regression suite (bun run test) passes.
- Acceptance: a cron poll (
-
(Existing
feed_articlescorpus is not corrupted.) Retirement does not rewrite or invalidate the existingfeed_articlesrows (including the 27 prod rows recorded withextraction_method='firecrawl', RESEARCH §5 / CV16-extraction-method.md). Their historicalextraction_methodvalues remain readable; the CHECK-enum and CV are only extended (not destructively pruned) by ID-42. Pruningfirecrawlfrom the enum/CV is gated on the corpus-reingest Task (T7), per the Ratified-S237 lockstep rule — ID-42 does not prune.- Acceptance: the pre-existing
feed_articlesrows withextraction_method='firecrawl'are still present and readable after ID-42; thefeed_articles_extraction_method_checkconstraint still admits the legacy values;docs/ontology/16-extraction-method.mdstill lists the legacy values (extension only, no destructive prune in ID-42).
- Acceptance: the pre-existing
-
(Tests verify real behaviour.) New/changed tests for the pullmd path assert real extraction behaviour (non-empty body, correct provenance, gate ordering), not implementation shape, per
docs/reference/test-philosophy.md. The end-to-end ingest proof (Inv-7/8/9) exercises the deployed Service, not a mock.
Open questions (carry to {42.3} TECH — for Liam to ratify, NOT resolved here)
Section titled “Open questions (carry to {42.3} TECH — for Liam to ratify, NOT resolved here)”-
OQ-1 — pullmd Service topology + auth posture (shared vs per-tenant). The existing cocoindex deploy is per-tenant (
phew/kpfloop). pullmd extracts public-web content where cross-tenant cache hits are valuable and ops are simpler as a single shared Service per env (RESEARCH §4.3 / phase-0 eval §8). This diverges from the per-tenant pattern. Question for Liam: deploy one shared pullmd Service per env (recommended), or one per tenant? Coupled sub-question: auth posture —PULLMD_AUTH_MODE=disabledbehindingress: internal+ same VPC as cocoindex (no public exposure, no token), orsingle-admin/multi-userwith aPULLMD_API_TOKEN-style secret added to theCOCOINDEX_SECRETSmount? (Inv-6 holds either way; this decides how the call is accepted.) -
OQ-2 — extraction-method storage: typed column vs JSONB on
source_documents; and whetherfeed_articles.extraction_methodenum extends.source_documentshas noextraction_methodcolumn today (verified ABSENT, RESEARCH §5.1); provenance lives inextraction_metadataJSONB. Question for Liam (two parts):- For the canonical pipeline (
source_documents/content_items), record the pullmd extraction method as a new typed columnsource_documents.extraction_methodwith a CHECK enum (extending the legacy 5 values withpullmd_readability,pullmd_playwright,pullmd_cloudflare,pullmd_reddit,pullmd_trafilatura,docling) — queryable, drift-resistant, indexable — OR keep it in the existingextraction_metadataJSONB ({pullmd:{share_id,x_source,x_quality,fetched_at}}) — zero DDL but not indexable? (The gap-analysis wording “columns onsource_documents” leans typed-column; the typed-column choice also makes Inv-8 directly queryable.) - IF Surface A (sector-intelligence) re-points at pullmd and continues writing
feed_articles.extraction_method, thefeed_articles_extraction_method_checkconstraint must extend to admitpullmd_*values (the live constraint today is exactly{rss_content, fetch, jina_reader, firecrawl, summary_fallback}), and CVdocs/ontology/16-extraction-method.mdbaseline_values must add them. Per the Ratified-S237 lockstep rule,firecrawlis not pruned from the enum/CV until the corpus-reingest Task (T7) drops it — ID-42 extends, never destructively prunes. Confirm this enum-extension-without-prune is the intended ID-42 boundary.
(Both OQs are deferred to {42.3} TECH for resolution with Liam; the invariants above are written to hold regardless of which way each OQ lands — Inv-8 asserts the value is recorded “wherever OQ-2 lands it”, and Inv-6 asserts the call is “accepted, however auth is configured”.)
- For the canonical pipeline (
End of {42.2} PRODUCT. Returns to Orchestrator for ratification. A fresh Planner authors {42.3} TECH against this PRODUCT (Q-PLANNER-2). Two ratifications carried as OQ-1/OQ-2 for Liam; the load-bearing sequencing gate is Inv-10 (no deletion before verified end-to-end ingest).